/**
* 生成 README 用的设置页截图。
*
* 在隔离浏览器里加载**已构建的** client 产物,宿主 API 全部走内存 mock,
* 不接触任何真实 profile、凭据或用户配置。截图里的项目路径、提供商与模型
* 全部是合成演示数据,不代表任何实际环境。
*/
import { mkdir, readFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
const require = createRequire(import.meta.url)
const viteEntry = require.resolve('vite', { paths: [require.resolve('vitest/package.json')] })
const { createServer } = await import(pathToFileURL(viteEntry).href)
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE
? pathToFileURL(resolve(process.env.PLAYWRIGHT_MODULE)).href
: 'playwright')
const output = resolve('docs/images')
await mkdir(output, { recursive: true })
const server = await createServer({ root: process.cwd(), configFile: false, server: { middlewareMode: true } })
const fixtureHtml = [
'
subagent-routes',
'',
'',
].join('')
/** 合成演示快照:提供商名称与模型均为公开通用示例,路径为明显的占位符。 */
const DEMO_TABLE = [
'# 子代理工作区路由表',
'# 最长前缀匹配优先;未匹配项目回落至全局兜底配置(若有)。',
'',
'# 全局兜底配置(未匹配项目时使用)',
'default:',
' provider: openai',
' model: gpt-4.1',
' reasoningEffort: medium',
'',
'# 项目专属路由规则',
'routes:',
' - path: /Users/you/Code/frontend-app',
' provider: anthropic',
' model: claude-sonnet-4',
' reasoningEffort: high',
' - path: /Users/you/Code/deep-research',
' provider: deepseek',
' model: deepseek-reasoner',
' reasoningEffort: max',
' - path: /Users/you/Code/scratch',
' reasoningEffort: low',
'',
].join('\n')
const DEMO_PARSED = {
fallback: { provider: 'openai', model: 'gpt-4.1', reasoningEffort: 'medium' },
routes: [
{ path: '/Users/you/Code/frontend-app', provider: 'anthropic', model: 'claude-sonnet-4', reasoningEffort: 'high' },
{ path: '/Users/you/Code/deep-research', provider: 'deepseek', model: 'deepseek-reasoner', reasoningEffort: 'max' },
{ path: '/Users/you/Code/scratch', reasoningEffort: 'low' },
],
}
const DEMO_CATALOG = [
{
id: 'openai',
name: 'OpenAI',
models: [
{
id: 'gpt-4.1',
name: 'GPT-4.1',
reasoning: {
efforts: [
{ id: 'low', name: 'Low', description: '快速响应' },
{ id: 'medium', name: 'Medium', description: '平衡' },
{ id: 'high', name: 'High', description: '深度推理' },
],
defaultEffort: 'medium',
},
},
{ id: 'gpt-4.1-mini', name: 'GPT-4.1 mini' },
],
},
{
id: 'anthropic',
name: 'Anthropic',
models: [
{
id: 'claude-sonnet-4',
name: 'Claude Sonnet 4',
reasoning: {
efforts: [
{ id: 'low', name: 'Low' },
{ id: 'high', name: 'High' },
{ id: 'max', name: 'Max', description: '极限思考' },
],
defaultEffort: 'high',
},
},
{ id: 'claude-haiku', name: 'Claude Haiku' },
],
},
{
id: 'deepseek',
name: 'DeepSeek',
models: [
{
id: 'deepseek-reasoner',
name: 'DeepSeek Reasoner',
reasoning: { efforts: [{ id: 'low', name: 'Low' }, { id: 'max', name: 'Max' }], defaultEffort: 'max' },
},
{ id: 'deepseek-chat', name: 'DeepSeek Chat' },
],
},
]
const DEMO_WORKSPACES = [
{ path: '/Users/you/Code/frontend-app', title: 'frontend-app' },
{ path: '/Users/you/Code/deep-research', title: 'deep-research' },
{ path: '/Users/you/Code/scratch', title: 'scratch' },
{ path: '/Users/you/Code/docs-site', title: 'docs-site' },
]
const snapshot = {
config: { routesFile: '', enabled: true, preflight: true },
configRevision: 'demo-config',
canEditConfig: true,
table: {
file: '/Users/you/.dsh/subagent-routes.yaml',
exists: true,
text: DEMO_TABLE,
revision: 'demo-table',
parsed: DEMO_PARSED,
},
catalog: DEMO_CATALOG,
workspaces: DEMO_WORKSPACES,
}
const browser = await chromium.launch({ headless: true })
const context = await browser.newContext({
viewport: { width: 1000, height: 1200 },
deviceScaleFactor: 2,
})
try {
await context.route('http://settings.test/**', async route => {
const url = new URL(route.request().url())
if (url.pathname === '/api/subagent-routes/settings') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(snapshot),
})
}
if (url.pathname === '/fixture') return route.fulfill({ contentType: 'text/html', body: fixtureHtml })
if (url.pathname === '/lib/client.js') {
return route.fulfill({ contentType: 'application/javascript', body: await readFile('lib/client.js', 'utf8') })
}
const result = await server.transformRequest(url.pathname + url.search)
if (!result) return route.fulfill({ status: 404, body: 'not found' })
return route.fulfill({ contentType: 'application/javascript', body: result.code })
})
const page = await context.newPage()
page.on('pageerror', error => { throw new Error(`浏览器运行时错误:${error}`) })
await page.goto('http://settings.test/fixture')
await page.getByRole('heading', { name: '子代理工作区路由' }).waitFor()
await page.addStyleTag({
content: [
'body{margin:0;padding:32px;background:#f8fafc;font-family:system-ui}',
'#mount{max-width:860px;margin:0 auto}',
'.sr-settings{max-width:100%}',
].join(''),
})
/** 切换主题并按需裁切:`clip` 用元素外框,保证图片只包含相关区块。 */
const shot = async (name, mode, selector) => {
await page.evaluate(next => {
document.documentElement.toggleAttribute('data-ds-dark-theme', next === 'dark')
document.body.style.background = next === 'dark' ? '#141a23' : '#f8fafc'
}, mode)
const target = selector === undefined ? page : page.locator(selector)
await target.screenshot({ path: `${output}/${name}.png`, animations: 'disabled' })
console.log(`captured ${name}.png`)
}
const suffix = (mode) => (mode === 'dark' ? 'dark' : 'light')
for (const mode of ['light', 'dark']) {
await shot(`settings-overview-${suffix(mode)}`, mode, '.sr-settings')
}
// 模型选择器联动特写:全局兜底卡片
await page.evaluate(() => document.documentElement.removeAttribute('data-ds-dark-theme'))
const fallbackCard = page.locator('.sr-card').first()
await fallbackCard.screenshot({ path: `${output}/fallback-card.png`, animations: 'disabled' })
console.log('captured fallback-card.png')
// 规则编辑器:工作区下拉 + 模型下拉 + 思考等级联动
await page.getByRole('button', { name: '+ 新增规则' }).click()
await page.getByRole('heading', { name: '新建项目专属规则' }).waitFor()
// 0 = 工作区路径,1 = Provider,2 = Model,3 = 思考等级
await page.locator('.sr-editor-box select').nth(0).selectOption(DEMO_WORKSPACES[0].path)
await page.locator('.sr-editor-box select').nth(2).selectOption({ label: 'Claude Sonnet 4 (claude-sonnet-4)' })
await page.locator('.sr-editor-box').screenshot({ path: `${output}/rule-editor.png`, animations: 'disabled' })
console.log('captured rule-editor.png')
// 列表总览:规则数量多时的卡片形态
await page.locator('.sr-card').last().screenshot({ path: `${output}/rule-list.png`, animations: 'disabled' })
console.log('captured rule-list.png')
} finally {
await browser.close()
await server.close()
}