chore(docs,scenarios-*): better structure

This commit is contained in:
Neko Ayaka
2026-04-07 14:22:51 +08:00
parent 383da6aa8f
commit d107fadcb9
121 changed files with 661 additions and 209 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 773 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 806 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 755 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 777 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 763 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 806 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 779 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 776 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 757 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 769 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 773 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 783 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 773 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 783 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 808 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 788 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 826 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 775 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 790 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 786 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 807 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 811 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 772 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 816 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 811 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 807 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 799 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 798 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

@@ -1,27 +1,28 @@
# Scenarios - Stage Tamagotchi Electron
Own the raw Electron capture scenarios for stage-tamagotchi.
Own the Electron capture scenarios used to generate tamagotchi docs screenshots.
## Purpose
This package owns the product-specific scenario definitions only. It depends on `@proj-airi/vishot-runner-electron` for:
This package owns product-specific Electron scenario definitions only. It depends on `@proj-airi/vishot-runner-electron` for:
- the `defineScenario()` helper
- the capture context surface
- Electron window and screenshot helpers exposed by the runner package
It does not launch Electron itself and it does not compose final browser-scene exports.
It does not launch Electron itself and it does not own browser-scene composition or shared screenshot staging.
## Workflow
1. Build the Electron app.
2. Run the runner package against one of the scenario modules in this package.
3. Write raw screenshots into `packages/scenarios-stage-tamagotchi-browser/artifacts/raw`.
4. Let the browser scene package consume those raw screenshots for final composition.
2. Run the Electron runner against a section-based scenario entrypoint under `src/scenarios/demo-controls-settings-chat-websocket/index.ts`.
3. Write the scenario's raw working outputs into the scenario-local working directory.
4. Publish the final docs screenshots directly into `docs/content/en/docs/manual/tamagotchi/setup-and-use/assets`.
5. Skip any separate final staging directory for this docs workflow.
```bash
pnpm -F @proj-airi/stage-tamagotchi build
pnpm -F @proj-airi/vishot-runner-electron capture -- packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-controls-settings-chat-websocket.ts --output-dir packages/scenarios-stage-tamagotchi-browser/artifacts/raw
pnpm -F @proj-airi/vishot-runner-electron capture --format avif -- packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-controls-settings-chat-websocket/index.ts --output-dir /tmp/tamagotchi-docs-capture
```
## Scenario Authoring
@@ -45,8 +46,12 @@ export default defineScenario({
})
```
## Scenario Layout
The docs workflow is organized as one section-based scenario module under `src/scenarios/demo-controls-settings-chat-websocket/`. The top-level `index.ts` orchestrates section manifests and writes working outputs into the scenario-local raw directory, so the capture flow stays close to the scenario being authored.
## Notes
- Raw scenario modules live under `src/scenarios`.
- Scenario modules are consumed by the runner package, not by the browser composition package.
- The package stays focused on business capture flows and avoids browser-scene composition concerns.
- Scenario entrypoints should point at `index.ts` when the workflow is organized as a section folder.
- Keep this package focused on Electron capture flows for docs screenshots.
@@ -1,200 +0,0 @@
/* eslint-disable e18e/prefer-static-regex */
import { sleep } from '@moeru/std'
import { defineScenario } from '@proj-airi/vishot-runner-electron'
const airiCardPattern = /sort|排序|upload|上传/i
const providersPattern = /Chat|Speech|Transcription/i
// NOTICE: Must stay unique to /settings/data. The prior alternates `chat` / `聊天`
// also matched the providers page (step 06), which renders a `Chat` / `聊天` provider
// category immediately before this step — so the readiness check could pass on stale
// providers DOM and silently produce a mislabeled screenshot. `打开` was also too
// generic (appears on multiple pages as button labels). `Open app data folder` is
// the desktop-folder section title and is literal English across every locale file.
const dataPattern = /Open app data folder/i
const systemGeneralPattern = /theme|主题|language|语言/i
const systemColorSchemePattern = /RGB|Primary Color|主题颜色|500\/50/i
const modelsPattern = /select model|confirm|缩放与位置|Zoom & Position/i
const modulesPattern = /Consciousness|意识|Speech|发声|Hearing|听觉/i
const hearingPattern = /Audio Input Device|音频输入设备|start monitoring|Start Monitoring|Transcription Result/i
const developerPattern = /Open DevTools|打开|Markdown|Lag|Vision Capture|Screen Capture/i
// NOTICE: Anchor on the consciousness page's always-rendered section description
// (consciousness.vue renders this unconditionally regardless of provider/model state
// or user locale). Previous alternates like `提供商` / `No Providers Configured` did
// not match zh-Hans (which renders `服务来源` / `没有配置服务来源`), and `当前模型` /
// `Current model` only appear when a model is already selected — so fresh environments
// with no providers configured would hang on readiness.
//
// Caveat: vision.vue reuses the same `provider-model-selection.description` i18n key,
// so this pattern also matches on /settings/modules/vision. That is safe here because
// consciousness (step 14) is reached from developer (step 13), which does not render
// either phrase — no stale-DOM collision is possible at this step. If the step order
// ever changes, pick a token unique to consciousness.vue instead.
const consciousnessPattern = /Select the suitable LLM|为意识选择合适/i
const speechPattern = /Hello, my name is AI Assistant|Test voice|Voice|声音|Speech|选择语音合成服务来源/i
const visionPattern = /Capture interval|context|ollama|提供商|Current model|Chat|Vision capture cadence/i
const useWindowMousePattern = /useWindowMouse|\d+,\s*\d+/i
const displaysPattern = /useElectronAllDisplays|@\s*\d+°|Visualize connected displays and cursor position/i
const widgetsCallingPattern = /Widget id is required|Small \(s\)|Spawned widget|Component name/i
const contextFlowPattern = /Active contexts|Prompt projection|Runtime|Context Flow|Filters/i
const relativeMousePattern = /windowX = screenX - windowBounds\.x|Green dot shows current window-relative cursor position|Relative Mouse/i
const beatSyncPattern = /Beat sync driver|Hit beat|Punchy V|Beat Sync Visualizer/i
const websocketInspectorPattern = /Incoming|Outgoing|Filter payload|No messages found|WebSocket Inspector/i
const pluginHostPattern = /Discovered|Enabled|Loaded|Capabilities|Plugin Host Debug/i
const screenCapturePattern = /Applications|Displays|Refetch|Share Window|Share Screen|屏幕捕获|Open system preferences|打开系统偏好设置/i
// NOTICE: Must stay unique to /devtools/vision. Step 25 captures /devtools/screen-capture
// immediately before this, and both pages render `Applications` / `Displays` tab labels, so
// matching against those generics lets the readiness check pass against the stale
// screen-capture DOM and silently produce a mislabeled screenshot. Only use text that does
// not appear on screen-capture.vue.
const visionCapturePattern = /Capture interval|No vision output yet|vision capture/i
const websocketServerAddressPattern = /WebSocket Server Address|WebSocket 服务器地址/i
function normalizeHashPath(hash: string): string {
const withoutHash = hash.startsWith('#')
? hash.slice(1)
: hash
return withoutHash || '/'
}
function isTimeoutLikeError(error: unknown): boolean {
return error instanceof Error && error.name === 'TimeoutError'
}
export default defineScenario({
id: 'demo-controls-settings-chat-websocket',
async run({ capture, controlsIsland, settingsWindow, stageWindows }) {
const mainWindow = await stageWindows.waitFor('main')
await controlsIsland.waitForReady(mainWindow.page)
async function ensureControlsIslandExpanded() {
const chatButton = mainWindow.page
.locator('button')
.filter({
has: mainWindow.page.locator('[i-solar\\:chat-line-line-duotone]'),
})
.first()
const chatButtonVisible = await chatButton.isVisible().catch(() => false)
if (!chatButtonVisible) {
await controlsIsland.expand(mainWindow.page)
await sleep(250)
}
}
await capture('00-stage-tamagotchi', mainWindow.page)
await sleep(500)
await controlsIsland.expand(mainWindow.page)
await sleep(250)
await capture('01-controls-island-expanded', mainWindow.page)
await sleep(250)
await ensureControlsIslandExpanded()
const chatWindowSnapshot = await controlsIsland.openChat(mainWindow.page)
await chatWindowSnapshot.page.getByText(/Chat/i).first().waitFor({ state: 'visible' })
await sleep(1000)
await capture('04-chat-window', chatWindowSnapshot.page)
await sleep(250)
await mainWindow.page.bringToFront()
await controlsIsland.waitForReady(mainWindow.page)
await ensureControlsIslandExpanded()
const settingsWindowSnapshot = await controlsIsland.openSettings(mainWindow.page)
await settingsWindowSnapshot.page.getByText(/connection|websocket|router/i).first().waitFor({ state: 'visible' })
await sleep(1000)
await capture('02-settings-window', settingsWindowSnapshot.page)
async function captureSettingsRoute(name: string, routePath: string, readyPattern: RegExp, waitMs = 250) {
await settingsWindow.goToRoute(settingsWindowSnapshot.page, routePath)
try {
await settingsWindowSnapshot.page.getByText(readyPattern).first().waitFor({ state: 'visible', timeout: 15_000 })
}
catch (error) {
if (!isTimeoutLikeError(error)) {
throw error
}
const currentHashPath = normalizeHashPath(new URL(settingsWindowSnapshot.page.url()).hash)
if (currentHashPath !== routePath) {
throw error
}
// NOTICE: Some settings/devtools pages animate in or hydrate content asynchronously.
// Give known-slow pages one final bounded grace period, but still fail if the target route never becomes ready.
await sleep(1250)
await settingsWindowSnapshot.page.getByText(readyPattern).first().waitFor({ state: 'visible', timeout: 5_000 })
}
await sleep(waitMs)
await capture(name, settingsWindowSnapshot.page)
}
await settingsWindow.goToRoute(settingsWindowSnapshot.page, '/settings/airi-card')
await settingsWindowSnapshot.page.getByText(airiCardPattern).first().waitFor({ state: 'visible' })
await sleep(1000)
await capture('05-airi-card', settingsWindowSnapshot.page)
await settingsWindow.goToRoute(settingsWindowSnapshot.page, '/settings/providers')
await settingsWindowSnapshot.page.getByText(providersPattern).first().waitFor({ state: 'visible' })
await sleep(1000)
await capture('06-providers', settingsWindowSnapshot.page)
await settingsWindow.goToRoute(settingsWindowSnapshot.page, '/settings/data')
await settingsWindowSnapshot.page.getByText(dataPattern).first().waitFor({ state: 'visible' })
await sleep(1000)
await capture('07-data', settingsWindowSnapshot.page)
await settingsWindow.goToRoute(settingsWindowSnapshot.page, '/settings/system/general')
await settingsWindowSnapshot.page.getByText(systemGeneralPattern).first().waitFor({ state: 'visible' })
await sleep(1000)
await capture('08-system-general', settingsWindowSnapshot.page)
await settingsWindow.goToRoute(settingsWindowSnapshot.page, '/settings/system/color-scheme')
await settingsWindowSnapshot.page.getByText(systemColorSchemePattern).first().waitFor({ state: 'visible' })
await sleep(1000)
await capture('09-system-color-scheme', settingsWindowSnapshot.page)
await settingsWindow.goToRoute(settingsWindowSnapshot.page, '/settings/models')
await settingsWindowSnapshot.page.getByText(modelsPattern).first().waitFor({ state: 'visible' })
await sleep(1000)
await capture('10-models', settingsWindowSnapshot.page)
await settingsWindow.goToRoute(settingsWindowSnapshot.page, '/settings/modules')
await settingsWindowSnapshot.page.getByText(modulesPattern).first().waitFor({ state: 'visible' })
await sleep(1000)
await capture('11-modules', settingsWindowSnapshot.page)
await settingsWindow.goToRoute(settingsWindowSnapshot.page, '/settings/modules/hearing')
await settingsWindowSnapshot.page.getByText(hearingPattern).first().waitFor({ state: 'visible' })
await sleep(1000)
await capture('12-hearing', settingsWindowSnapshot.page)
await settingsWindow.goToRoute(settingsWindowSnapshot.page, '/settings/system/developer')
await settingsWindowSnapshot.page.getByText(developerPattern).first().waitFor({ state: 'visible' })
await sleep(1000)
await capture('13-system-developer', settingsWindowSnapshot.page)
await captureSettingsRoute('14-consciousness', '/settings/modules/consciousness', consciousnessPattern)
await captureSettingsRoute('15-speech', '/settings/modules/speech', speechPattern, 500)
await captureSettingsRoute('16-vision', '/settings/modules/vision', visionPattern)
await captureSettingsRoute('17-devtools-use-window-mouse', '/devtools/use-window-mouse', useWindowMousePattern)
await captureSettingsRoute('18-devtools-displays', '/devtools/use-electron-all-displays', displaysPattern)
await captureSettingsRoute('19-devtools-widgets-calling', '/devtools/widgets-calling', widgetsCallingPattern)
await captureSettingsRoute('20-devtools-context-flow', '/devtools/context-flow', contextFlowPattern)
await captureSettingsRoute('21-devtools-relative-mouse', '/devtools/use-electron-relative-mouse', relativeMousePattern)
await captureSettingsRoute('22-devtools-beat-sync', '/devtools/beat-sync', beatSyncPattern)
await captureSettingsRoute('23-devtools-websocket-inspector', '/devtools/websocket-inspector', websocketInspectorPattern)
await captureSettingsRoute('24-devtools-plugin-host', '/devtools/plugin-host', pluginHostPattern)
await captureSettingsRoute('25-devtools-screen-capture', '/devtools/screen-capture', screenCapturePattern, 500)
await captureSettingsRoute('26-devtools-vision-capture', '/devtools/vision', visionCapturePattern, 500)
await settingsWindowSnapshot.page.bringToFront()
await sleep(500)
const websocketSettingsPage = await settingsWindow.goToConnection(settingsWindowSnapshot.page)
await websocketSettingsPage.getByText(websocketServerAddressPattern).waitFor({ state: 'visible' })
await sleep(1000)
await capture('03-websocket-settings', websocketSettingsPage)
},
})
@@ -0,0 +1,34 @@
import type { ManualRuntime } from './shared/types'
import { defineScenario } from '@proj-airi/vishot-runner-electron'
import { manualCaptureSections } from './manifest'
import { formatStepFailure, removePublishedManualAssets, resetScenarioOutputDirectories } from './shared/output'
import { runCaptureStep } from './shared/steps'
export default defineScenario({
id: 'demo-controls-settings-chat-websocket',
async run(context) {
const mainWindow = await context.stageWindows.waitFor('main')
await context.controlsIsland.waitForReady(mainWindow.page)
const runtime: ManualRuntime = {
context,
mainWindow,
}
await removePublishedManualAssets()
await resetScenarioOutputDirectories()
for (const section of manualCaptureSections) {
for (const step of section.steps) {
try {
await runCaptureStep(step, runtime)
}
catch (error) {
throw formatStepFailure(section.id, step.id, error)
}
}
}
},
})
@@ -0,0 +1,13 @@
import { devtoolsSection } from './sections/devtools'
import { overviewSection } from './sections/overview'
import { settingsSection } from './sections/settings'
export const manualCaptureSections = [
overviewSection,
settingsSection,
devtoolsSection,
]
export const manualAssetFileNames = manualCaptureSections.flatMap(section =>
section.steps.map(step => step.docAssetFileName),
)
@@ -0,0 +1,105 @@
import type { ManualCaptureSection } from '../shared/types'
const useWindowMousePattern = /useWindowMouse|\d+,\s*\d+/i
const displaysPattern = /useElectronAllDisplays|@\s*\d+°|Visualize connected displays and cursor position/i
const widgetsCallingPattern = /Widget id is required|Small \(s\)|Spawned widget|Component name/i
const contextFlowPattern = /Active contexts|Prompt projection|Runtime|Context Flow|Filters/i
const relativeMousePattern = /windowX = screenX - windowBounds\.x|Green dot shows current window-relative cursor position|Relative Mouse/i
const beatSyncPattern = /Beat sync driver|Hit beat|Punchy V|Beat Sync Visualizer/i
const websocketInspectorPattern = /Incoming|Outgoing|Filter payload|No messages found|WebSocket Inspector/i
const pluginHostPattern = /Discovered|Enabled|Loaded|Capabilities|Plugin Host Debug/i
const screenCapturePattern = /Applications|Displays|Refetch|Share Window|Share Screen|屏幕捕获|Open system preferences|打开系统偏好设置/i
// NOTICE: Must stay unique to /devtools/vision. The previous step captures
// /devtools/screen-capture and both pages render `Applications` / `Displays`,
// so matching against those generics can pass against stale screen-capture DOM
// and silently produce a mislabeled screenshot.
const visionCapturePattern = /Capture interval|No vision output yet|vision capture/i
export const devtoolsSection: ManualCaptureSection = {
id: 'devtools',
label: 'Developer tools',
steps: [
{
id: 'use-window-mouse',
kind: 'settings-route',
routePath: '/devtools/use-window-mouse',
readyPattern: useWindowMousePattern,
rawCaptureName: '17-devtools-use-window-mouse',
docAssetFileName: 'manual-devtools-use-window-mouse.avif',
},
{
id: 'displays',
kind: 'settings-route',
routePath: '/devtools/use-electron-all-displays',
readyPattern: displaysPattern,
rawCaptureName: '18-devtools-displays',
docAssetFileName: 'manual-devtools-displays.avif',
},
{
id: 'widgets-calling',
kind: 'settings-route',
routePath: '/devtools/widgets-calling',
readyPattern: widgetsCallingPattern,
rawCaptureName: '19-devtools-widgets-calling',
docAssetFileName: 'manual-devtools-widgets-calling.avif',
},
{
id: 'context-flow',
kind: 'settings-route',
routePath: '/devtools/context-flow',
readyPattern: contextFlowPattern,
rawCaptureName: '20-devtools-context-flow',
docAssetFileName: 'manual-devtools-context-flow.avif',
},
{
id: 'relative-mouse',
kind: 'settings-route',
routePath: '/devtools/use-electron-relative-mouse',
readyPattern: relativeMousePattern,
rawCaptureName: '21-devtools-relative-mouse',
docAssetFileName: 'manual-devtools-relative-mouse.avif',
},
{
id: 'beat-sync',
kind: 'settings-route',
routePath: '/devtools/beat-sync',
readyPattern: beatSyncPattern,
rawCaptureName: '22-devtools-beat-sync',
docAssetFileName: 'manual-devtools-beat-sync.avif',
},
{
id: 'websocket-inspector',
kind: 'settings-route',
routePath: '/devtools/websocket-inspector',
readyPattern: websocketInspectorPattern,
rawCaptureName: '23-devtools-websocket-inspector',
docAssetFileName: 'manual-devtools-websocket-inspector.avif',
},
{
id: 'plugin-host',
kind: 'settings-route',
routePath: '/devtools/plugin-host',
readyPattern: pluginHostPattern,
rawCaptureName: '24-devtools-plugin-host',
docAssetFileName: 'manual-devtools-plugin-host.avif',
},
{
id: 'screen-capture',
kind: 'settings-route',
routePath: '/devtools/screen-capture',
readyPattern: screenCapturePattern,
rawCaptureName: '25-devtools-screen-capture',
docAssetFileName: 'manual-devtools-screen-capture.avif',
waitMs: 500,
},
{
id: 'vision-capture',
kind: 'settings-route',
routePath: '/devtools/vision',
readyPattern: visionCapturePattern,
rawCaptureName: '26-devtools-vision-capture',
docAssetFileName: 'manual-devtools-vision-capture.avif',
waitMs: 500,
},
],
}
@@ -0,0 +1,47 @@
import type { ManualCaptureSection } from '../shared/types'
const websocketServerAddressPattern = /WebSocket Server Address|WebSocket 服务器地址/i
export const overviewSection: ManualCaptureSection = {
id: 'overview',
label: 'Interface overview',
steps: [
{
id: 'main-window',
kind: 'main-window',
rawCaptureName: '00-stage-tamagotchi',
docAssetFileName: 'manual-main-window.avif',
},
{
id: 'controls-island-expanded',
kind: 'controls-island',
rawCaptureName: '01-controls-island-expanded',
docAssetFileName: 'manual-controls-island-expanded.avif',
waitMs: 250,
},
{
id: 'chat-window',
kind: 'chat-window',
rawCaptureName: '04-chat-window',
docAssetFileName: 'manual-chat-window.avif',
readyPattern: /Chat/i,
waitMs: 1000,
},
{
id: 'settings-window',
kind: 'settings-overview',
rawCaptureName: '02-settings-window',
docAssetFileName: 'manual-settings-window.avif',
readyPattern: /connection|websocket|router/i,
waitMs: 1000,
},
{
id: 'websocket-settings',
kind: 'connection',
rawCaptureName: '03-websocket-settings',
docAssetFileName: 'manual-websocket-settings.avif',
readyPattern: websocketServerAddressPattern,
waitMs: 1000,
},
],
}
@@ -0,0 +1,142 @@
import type { ManualCaptureSection } from '../shared/types'
const airiCardPattern = /sort|排序|upload|上传/i
const providersPattern = /Chat|Speech|Transcription/i
// NOTICE: Must stay unique to /settings/data. The prior alternates `chat` / `聊天`
// also matched the providers page, which renders a `Chat` / `聊天` provider category
// immediately before this step. That allowed the readiness check to pass against stale
// providers DOM and silently produce a mislabeled screenshot. `打开` was also too
// generic because it appears on multiple pages as button labels. `Open app data folder`
// is the desktop-folder section title and is literal English across every locale file.
const dataPattern = /Open app data folder/i
const systemGeneralPattern = /theme|主题|language|语言/i
const systemColorSchemePattern = /RGB|Primary Color|主题颜色|500\/50/i
const modelsPattern = /select model|confirm|缩放与位置|Zoom & Position/i
const modulesPattern = /Consciousness|意识|Speech|发声|Hearing|听觉/i
const hearingPattern = /Audio Input Device|音频输入设备|start monitoring|Start Monitoring|Transcription Result/i
const developerPattern = /Open DevTools|打开|Markdown|Lag|Vision Capture|Screen Capture/i
// NOTICE: Anchor on the consciousness page's always-rendered section description.
// Previous alternates like `提供商` / `No Providers Configured` did not match zh-Hans,
// and `当前模型` / `Current model` only appear when a model is already selected.
// Fresh environments with no providers configured would hang without this pattern.
//
// Caveat: vision.vue reuses the same i18n key, so this also matches on
// /settings/modules/vision. That remains safe because this step is reached from
// developer, which does not render either phrase. If the step order changes,
// pick a token unique to consciousness.vue instead.
const consciousnessPattern = /Select the suitable LLM|为意识选择合适/i
const speechPattern = /Hello, my name is AI Assistant|Test voice|Voice|声音|Speech|选择语音合成服务来源/i
const visionPattern = /Capture interval|context|ollama|提供商|Current model|Chat|Vision capture cadence/i
export const settingsSection: ManualCaptureSection = {
id: 'settings',
label: 'Settings surfaces',
steps: [
{
id: 'airi-card',
kind: 'settings-route',
routePath: '/settings/airi-card',
readyPattern: airiCardPattern,
rawCaptureName: '05-airi-card',
docAssetFileName: 'manual-airi-card.avif',
waitMs: 1000,
},
{
id: 'providers',
kind: 'settings-route',
routePath: '/settings/providers',
readyPattern: providersPattern,
rawCaptureName: '06-providers',
docAssetFileName: 'manual-providers.avif',
waitMs: 1000,
},
{
id: 'data',
kind: 'settings-route',
routePath: '/settings/data',
readyPattern: dataPattern,
rawCaptureName: '07-data',
docAssetFileName: 'manual-data-settings.avif',
waitMs: 1000,
},
{
id: 'system-general',
kind: 'settings-route',
routePath: '/settings/system/general',
readyPattern: systemGeneralPattern,
rawCaptureName: '08-system-general',
docAssetFileName: 'manual-system-general.avif',
waitMs: 1000,
},
{
id: 'system-color-scheme',
kind: 'settings-route',
routePath: '/settings/system/color-scheme',
readyPattern: systemColorSchemePattern,
rawCaptureName: '09-system-color-scheme',
docAssetFileName: 'manual-system-color-scheme.avif',
waitMs: 1000,
},
{
id: 'models',
kind: 'settings-route',
routePath: '/settings/models',
readyPattern: modelsPattern,
rawCaptureName: '10-models',
docAssetFileName: 'manual-models.avif',
waitMs: 1000,
},
{
id: 'modules',
kind: 'settings-route',
routePath: '/settings/modules',
readyPattern: modulesPattern,
rawCaptureName: '11-modules',
docAssetFileName: 'manual-modules.avif',
waitMs: 1000,
},
{
id: 'hearing',
kind: 'settings-route',
routePath: '/settings/modules/hearing',
readyPattern: hearingPattern,
rawCaptureName: '12-hearing',
docAssetFileName: 'manual-hearing.avif',
waitMs: 1000,
},
{
id: 'system-developer',
kind: 'settings-route',
routePath: '/settings/system/developer',
readyPattern: developerPattern,
rawCaptureName: '13-system-developer',
docAssetFileName: 'manual-system-developer.avif',
waitMs: 1000,
},
{
id: 'consciousness',
kind: 'settings-route',
routePath: '/settings/modules/consciousness',
readyPattern: consciousnessPattern,
rawCaptureName: '14-consciousness',
docAssetFileName: 'manual-consciousness.avif',
},
{
id: 'speech',
kind: 'settings-route',
routePath: '/settings/modules/speech',
readyPattern: speechPattern,
rawCaptureName: '15-speech',
docAssetFileName: 'manual-speech.avif',
waitMs: 500,
},
{
id: 'vision',
kind: 'settings-route',
routePath: '/settings/modules/vision',
readyPattern: visionPattern,
rawCaptureName: '16-vision',
docAssetFileName: 'manual-vision.avif',
},
],
}
@@ -0,0 +1,19 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const scenarioDirectoryPath = fileURLToPath(new URL('..', import.meta.url))
const repoRootPath = fileURLToPath(new URL('../../../../../../', import.meta.url))
export const scenarioRawOutputDir = path.join(scenarioDirectoryPath, 'assets', 'raw')
export const manualDocsAssetsDir = path.join(
repoRootPath,
'docs',
'content',
'en',
'docs',
'manual',
'tamagotchi',
'setup-and-use',
'assets',
)
@@ -0,0 +1,80 @@
import type { VishotArtifact } from '@proj-airi/vishot-runner-electron'
import type { PublishedArtifacts } from './types'
import path from 'node:path'
import { copyFile, mkdir, rm } from 'node:fs/promises'
import { errorMessageFrom } from '@moeru/std'
import { manualAssetFileNames } from '../manifest'
import { manualDocsAssetsDir, scenarioRawOutputDir } from './constants'
function extensionOf(filePath: string): string {
return path.extname(filePath).toLowerCase()
}
export async function resetScenarioOutputDirectories() {
await rm(scenarioRawOutputDir, { recursive: true, force: true })
await mkdir(scenarioRawOutputDir, { recursive: true })
}
export async function removePublishedManualAssets() {
await mkdir(manualDocsAssetsDir, { recursive: true })
await Promise.all(
manualAssetFileNames.map(fileName =>
rm(path.join(manualDocsAssetsDir, fileName), { force: true }),
),
)
}
export async function publishArtifactsToDocs(
artifacts: VishotArtifact[],
targetFileName: string,
): Promise<PublishedArtifacts> {
if (artifacts.length === 0) {
throw new Error(`Expected at least one artifact when publishing "${targetFileName}".`)
}
await mkdir(scenarioRawOutputDir, { recursive: true })
await mkdir(manualDocsAssetsDir, { recursive: true })
const rawArtifactPaths: string[] = []
for (const artifact of artifacts) {
const rawArtifactPath = path.join(scenarioRawOutputDir, path.basename(artifact.filePath))
await copyFile(artifact.filePath, rawArtifactPath)
rawArtifactPaths.push(rawArtifactPath)
}
const primaryArtifact = artifacts[0]
if (extensionOf(primaryArtifact.filePath) !== extensionOf(targetFileName)) {
throw new Error(
[
`Cannot publish "${targetFileName}" from "${path.basename(primaryArtifact.filePath)}".`,
'Run the capture CLI with a matching format, for example `--format avif` for the manual docs flow.',
].join(' '),
)
}
const docsAssetPath = path.join(manualDocsAssetsDir, targetFileName)
await copyFile(primaryArtifact.filePath, docsAssetPath)
await Promise.all(
artifacts.map(artifact => rm(artifact.filePath, { force: true })),
)
return {
docsAssetPath,
rawArtifactPaths,
}
}
export function formatStepFailure(sectionId: string, stepId: string, error: unknown): Error {
const message = errorMessageFrom(error) ?? 'Unknown screenshot automation error'
return new Error(`[${sectionId}/${stepId}] ${message}`, {
cause: error instanceof Error ? error : undefined,
})
}
@@ -0,0 +1,91 @@
import type { ManualRuntime } from './types'
import { sleep } from '@moeru/std'
export function normalizeHashPath(hash: string): string {
const withoutHash = hash.startsWith('#')
? hash.slice(1)
: hash
return withoutHash || '/'
}
export function isTimeoutLikeError(error: unknown): boolean {
return error instanceof Error && error.name === 'TimeoutError'
}
export async function ensureControlsIslandExpanded(runtime: ManualRuntime) {
const { mainWindow, context } = runtime
const chatButton = mainWindow.page
.locator('button')
.filter({
has: mainWindow.page.locator('[i-solar\\:chat-line-line-duotone]'),
})
.first()
const chatButtonVisible = await chatButton.isVisible().catch(() => false)
if (!chatButtonVisible) {
await context.controlsIsland.expand(mainWindow.page)
await sleep(250)
}
}
export async function getChatWindowSnapshot(runtime: ManualRuntime) {
if (runtime.chatWindowSnapshot) {
return runtime.chatWindowSnapshot
}
await ensureControlsIslandExpanded(runtime)
const chatWindowSnapshot = await runtime.context.controlsIsland.openChat(runtime.mainWindow.page)
runtime.chatWindowSnapshot = chatWindowSnapshot
return chatWindowSnapshot
}
export async function getSettingsWindowSnapshot(runtime: ManualRuntime) {
if (runtime.settingsWindowSnapshot) {
return runtime.settingsWindowSnapshot
}
await runtime.mainWindow.page.bringToFront()
await runtime.context.controlsIsland.waitForReady(runtime.mainWindow.page)
await ensureControlsIslandExpanded(runtime)
const settingsWindowSnapshot = await runtime.context.controlsIsland.openSettings(runtime.mainWindow.page)
runtime.settingsWindowSnapshot = settingsWindowSnapshot
return settingsWindowSnapshot
}
export async function waitForRouteReadiness(
runtime: ManualRuntime,
routePath: string,
readyPattern: RegExp,
) {
const settingsWindowSnapshot = await getSettingsWindowSnapshot(runtime)
await runtime.context.settingsWindow.goToRoute(settingsWindowSnapshot.page, routePath)
try {
await settingsWindowSnapshot.page.getByText(readyPattern).first().waitFor({ state: 'visible', timeout: 15_000 })
}
catch (error) {
if (!isTimeoutLikeError(error)) {
throw error
}
const currentHashPath = normalizeHashPath(new URL(settingsWindowSnapshot.page.url()).hash)
if (currentHashPath !== routePath) {
throw error
}
// NOTICE: Some settings and devtools pages animate in or hydrate content
// asynchronously. Give known-slow pages one final bounded grace period.
await sleep(1250)
await settingsWindowSnapshot.page.getByText(readyPattern).first().waitFor({ state: 'visible', timeout: 5_000 })
}
return settingsWindowSnapshot
}
@@ -0,0 +1,78 @@
import type { CaptureExecutionResult, ManualCaptureStep, ManualRuntime } from './types'
import { sleep } from '@moeru/std'
import { publishArtifactsToDocs } from './output'
import { ensureControlsIslandExpanded, getChatWindowSnapshot, getSettingsWindowSnapshot, waitForRouteReadiness } from './runtime'
async function captureStepPage(step: ManualCaptureStep, runtime: ManualRuntime): Promise<CaptureExecutionResult> {
let page
switch (step.kind) {
case 'main-window': {
page = runtime.mainWindow.page
break
}
case 'controls-island': {
await ensureControlsIslandExpanded(runtime)
page = runtime.mainWindow.page
break
}
case 'chat-window': {
const chatWindowSnapshot = await getChatWindowSnapshot(runtime)
if (step.readyPattern) {
await chatWindowSnapshot.page.getByText(step.readyPattern).first().waitFor({ state: 'visible' })
}
page = chatWindowSnapshot.page
break
}
case 'settings-overview': {
const settingsWindowSnapshot = await getSettingsWindowSnapshot(runtime)
if (step.readyPattern) {
await settingsWindowSnapshot.page.getByText(step.readyPattern).first().waitFor({ state: 'visible' })
}
page = settingsWindowSnapshot.page
break
}
case 'settings-route': {
if (!step.routePath || !step.readyPattern) {
throw new Error(`Step "${step.id}" requires both routePath and readyPattern.`)
}
const settingsWindowSnapshot = await waitForRouteReadiness(runtime, step.routePath, step.readyPattern)
page = settingsWindowSnapshot.page
break
}
case 'connection': {
const settingsWindowSnapshot = await getSettingsWindowSnapshot(runtime)
const websocketSettingsPage = await runtime.context.settingsWindow.goToConnection(settingsWindowSnapshot.page)
if (step.readyPattern) {
await websocketSettingsPage.getByText(step.readyPattern).waitFor({ state: 'visible' })
}
page = websocketSettingsPage
break
}
}
if (step.waitMs) {
await sleep(step.waitMs)
}
const artifacts = await runtime.context.capture(step.rawCaptureName, page)
const publishedArtifacts = await publishArtifactsToDocs(artifacts, step.docAssetFileName)
return {
artifacts,
publishedArtifacts,
}
}
export async function runCaptureStep(step: ManualCaptureStep, runtime: ManualRuntime): Promise<CaptureExecutionResult> {
return captureStepPage(step, runtime)
}
@@ -0,0 +1,38 @@
import type { ScenarioContext, VishotArtifact } from '@proj-airi/vishot-runner-electron'
export type ManualSectionId = 'overview' | 'settings' | 'devtools'
export type ManualCaptureStepKind = 'main-window' | 'controls-island' | 'chat-window' | 'settings-overview' | 'settings-route' | 'connection'
export type StageWindowSnapshotLike = Awaited<ReturnType<ScenarioContext['stageWindows']['waitFor']>>
export interface ManualCaptureStep {
docAssetFileName: string
id: string
kind: ManualCaptureStepKind
rawCaptureName: string
readyPattern?: RegExp
routePath?: string
waitMs?: number
}
export interface ManualCaptureSection {
id: ManualSectionId
label: string
steps: ManualCaptureStep[]
}
export interface ManualRuntime {
chatWindowSnapshot?: StageWindowSnapshotLike
context: ScenarioContext
mainWindow: StageWindowSnapshotLike
settingsWindowSnapshot?: StageWindowSnapshotLike
}
export interface PublishedArtifacts {
docsAssetPath: string
rawArtifactPaths: string[]
}
export interface CaptureExecutionResult {
artifacts: VishotArtifact[]
publishedArtifacts: PublishedArtifacts
}