mirror of
https://github.com/moeru-ai/airi.git
synced 2026-10-30 19:34:10 +08:00
refactor(stage-tamagotchi): persist local IO traces (#2774)
## What users can do - Turn on **Record IO traces automatically** in Settings > System > Developer in Tamagotchi. - Let a coding agent read the saved files to see which step was slow or failed. ## How it works - The main window sends each ended IO span to the main process over Eventa. - The main process appends one JSON line per span to `<userData>/io-traces/<start time>.jsonl`. A new file starts each time recording starts. - Files older than seven days are removed when recording starts. - The setting is saved and restored at launch. - The IO Tracer window is unchanged. `use-io-tracer.ts` now has a subscriber set next to the existing panel callback, so both receive spans. - A new skill, `analyze-io-traces`, tells agents where the files are and gives `jq` examples. ## Why this shape The goal is to let an agent read local traces. An agent reads files well, so this needs no server, database, token, or CLI. This replaces #2717, which stored traces behind an OTLP server and DuckDB. That design was larger than the goal needs. ## Verification - `pnpm -F @proj-airi/stage-tamagotchi exec vitest run src/main/services/airi/io-trace-recording/index.test.ts`: 2 tests passed, using the real file system. - `pnpm -F @proj-airi/stage-ui exec vitest run --project browser src/composables/use-io-tracer.browser.test.ts`: 1 test passed. - `typecheck` passed for `@proj-airi/stage-tamagotchi` and `@proj-airi/stage-ui`. - ESLint on the changed files: 0 errors. - Not tested: a real Electron run of the toggle, IPC, and file writes. ## Limits - Spans are saved when they end. A call that never ends is not in the file, so live view is not included. - Only the main window forwards spans. - Spans hold timing, counts, and status. They do not hold prompts or replies. - Web (`stage-web`) is not supported, because a browser cannot write local files. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
autofix-ci[bot]
Claude Sonnet 5.5
parent
146c04294c
commit
a4da7edd73
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: analyze-io-traces
|
||||
description: Read and analyze AIRI IO traces that Tamagotchi saved to local files. Use when the user asks why a chat, speech, or audio turn was slow, failed, or behaved wrongly, and recording is enabled in Settings > System > Developer.
|
||||
---
|
||||
|
||||
# Analyze IO traces
|
||||
|
||||
Tamagotchi saves each ended IO span as one JSON line when **Record IO traces automatically** is on. The setting is under Settings > System > Developer.
|
||||
|
||||
## Find the files
|
||||
|
||||
Files are named `<start time>.jsonl`. A new file starts each time recording starts. Files older than seven days are deleted.
|
||||
|
||||
```sh
|
||||
ls -t "$HOME/Library/Application Support/ai.moeru.airi/io-traces"/*.jsonl | head -3
|
||||
ls -t "$HOME/Library/Application Support/@proj-airi/stage-tamagotchi/io-traces"/*.jsonl | head -3
|
||||
```
|
||||
|
||||
The first path is the packaged app on macOS. The second is `pnpm dev` on macOS. If `APP_USER_DATA_PATH` is set, use `$APP_USER_DATA_PATH/io-traces`. If recording is off or nothing ran yet, the folder does not exist. Ask the user to turn it on and reproduce the problem.
|
||||
|
||||
## Record shape
|
||||
|
||||
Each line is one span with `traceId`, `spanId`, `parentSpanId`, `name`, `startTimeNano`, `endTimeNano`, `status.code` (2 means error), `attributes`, and `events`. Time values are decimal strings in nanoseconds. A trace is one interaction turn. Subsystems are in `attributes["ai.moeru.airi.io.subsystem"]`.
|
||||
|
||||
## Common queries
|
||||
|
||||
Set `F` to the newest file first.
|
||||
|
||||
```sh
|
||||
# Spans by name, slowest first, in milliseconds
|
||||
jq -r '[.name, ((.endTimeNano|tonumber)-(.startTimeNano|tonumber))/1e6] | @tsv' "$F" | sort -t$'\t' -k2 -nr | head -20
|
||||
|
||||
# Spans with an error status
|
||||
jq -c 'select(.status.code == 2) | {name, traceId, message: .status.message}' "$F"
|
||||
|
||||
# All spans of one trace in start order
|
||||
jq -c 'select(.traceId == "TRACE_ID")' "$F" | jq -s 'sort_by(.startTimeNano|tonumber)'
|
||||
|
||||
# Time to first token for each LLM span
|
||||
jq -c 'select(.events | any(.name | endswith("llm.first_token"))) | {traceId, events}' "$F"
|
||||
```
|
||||
|
||||
## Limits
|
||||
|
||||
Spans are saved only after they end. A call that never ends is not in the file. Spans record timing and counts, not prompts or replies. Say so when the cause needs content.
|
||||
@@ -150,6 +150,7 @@ Skills are in `.agents/skills/<name>/SKILL.md`. Read the listed file before you
|
||||
| Write or change a string that users see, or a glossary term | [`packages/i18n/AGENTS.md`](packages/i18n/AGENTS.md) |
|
||||
| Work under `server/` | [`server/AGENTS.md`](server/AGENTS.md). For `server/apps/api`, also read [`server/apps/api/AGENTS.md`](server/apps/api/AGENTS.md). |
|
||||
| Write documentation, code comments, commit messages, or PR and issue text | [`simple-english`](.agents/skills/simple-english/SKILL.md) |
|
||||
| Analyze why a chat, speech, or audio turn was slow or failed, using saved IO traces | [`analyze-io-traces`](.agents/skills/analyze-io-traces/SKILL.md) |
|
||||
| Create or prepare a pull request | [`create-pr`](.agents/skills/create-pr/SKILL.md) |
|
||||
| Upload a local file through a file input or a file chooser in a web or Electron app | [`use-agent-browser-with-input-file`](.agents/skills/use-agent-browser-with-input-file/SKILL.md) and [`agent-browser`](.agents/skills/agent-browser/SKILL.md). For Electron, also read [`agent-browser-electron`](.agents/skills/agent-browser-electron/SKILL.md). |
|
||||
| Test Live2D, VRM, or MMD import and rendering in stage-web, stage-tamagotchi, or stage-pocket | [`use-agent-browser-for-airi`](.agents/skills/use-agent-browser-for-airi/SKILL.md) |
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { boolean, object } from 'valibot'
|
||||
|
||||
import { createConfig } from '../libs/electron/persistence'
|
||||
|
||||
export function createIOTraceRecordingConfig() {
|
||||
const config = createConfig('io-trace-recording', 'options.json', object({ enabled: boolean() }), {
|
||||
default: { enabled: false },
|
||||
autoHeal: true,
|
||||
})
|
||||
config.setup()
|
||||
return config
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import type { FileLoggerHandle } from './app/file-logger'
|
||||
|
||||
import process, { env, platform } from 'node:process'
|
||||
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import messages from '@proj-airi/i18n/locales'
|
||||
@@ -26,6 +26,7 @@ import { resolveIsWayland } from './app/ozone'
|
||||
import { installSingleInstanceGuard } from './app/single-instance'
|
||||
import { createArtistryConfig } from './configs/artistry'
|
||||
import { createGlobalAppConfig } from './configs/global'
|
||||
import { createIOTraceRecordingConfig } from './configs/io-trace-recording'
|
||||
import { emitAppBeforeQuit, emitAppWindowAllClosed } from './libs/bootkit/lifecycle'
|
||||
import { getElectronMainDirname, setElectronMainDirname } from './libs/electron/location'
|
||||
import { createI18n } from './libs/i18n'
|
||||
@@ -35,6 +36,7 @@ import { setupServerChannel } from './services/airi/channel-server'
|
||||
import { setupComputerUse } from './services/airi/computer-use'
|
||||
import { setupGodotStageManager } from './services/airi/godot-stage'
|
||||
import { setupBuiltInServer } from './services/airi/http-server'
|
||||
import { IOTraceRecordingService } from './services/airi/io-trace-recording'
|
||||
import { setupMcpStdioManager } from './services/airi/mcp-servers'
|
||||
import { setupExtensionHost } from './services/airi/plugins'
|
||||
import { setupArtistryBridge } from './services/airi/widgets/artistry-bridge'
|
||||
@@ -178,6 +180,7 @@ app.whenReady().then(async () => {
|
||||
|
||||
const appConfig = injeca.provide('configs:app', () => createGlobalAppConfig())
|
||||
const artistryConfig = injeca.provide('configs:artistry', () => createArtistryConfig())
|
||||
const ioTraceRecordingConfig = injeca.provide('configs:io-trace-recording', () => createIOTraceRecordingConfig())
|
||||
const electronApp = injeca.provide('host:electron:app', () => app)
|
||||
const autoUpdater = injeca.provide('services:auto-updater', {
|
||||
dependsOn: { appConfig },
|
||||
@@ -228,6 +231,27 @@ app.whenReady().then(async () => {
|
||||
build: async () => setupMcpStdioManager(),
|
||||
})
|
||||
|
||||
const ioTraceRecording = injeca.provide('services:io-trace-recording', {
|
||||
dependsOn: { config: ioTraceRecordingConfig, lifecycle },
|
||||
build: ({ dependsOn }) => {
|
||||
const service = new IOTraceRecordingService({
|
||||
directory: join(app.getPath('userData'), 'io-traces'),
|
||||
getStoredEnabled: () => dependsOn.config.get()?.enabled ?? false,
|
||||
setStoredEnabled: enabled => dependsOn.config.update({ enabled }),
|
||||
})
|
||||
dependsOn.lifecycle.appHooks.onStart(async () => {
|
||||
try {
|
||||
await service.restore()
|
||||
}
|
||||
catch (error) {
|
||||
log.withError(error).error('Failed to restore IO trace recording')
|
||||
}
|
||||
})
|
||||
dependsOn.lifecycle.appHooks.onStop(() => service.dispose())
|
||||
return service
|
||||
},
|
||||
})
|
||||
|
||||
const widgetsManager = injeca.provide('windows:widgets', {
|
||||
dependsOn: { serverChannel, i18n },
|
||||
build: ({ dependsOn }) => setupWidgetsWindowManager(dependsOn),
|
||||
@@ -286,7 +310,7 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
|
||||
const settingsWindow = injeca.provide('windows:settings', {
|
||||
dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsWindow: devtoolsMarkdownStressWindow, serverChannel, godotStageManager, mcpStdioManager, i18n, globalShortcut, spotlightWindow },
|
||||
dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsWindow: devtoolsMarkdownStressWindow, serverChannel, godotStageManager, mcpStdioManager, i18n, globalShortcut, spotlightWindow, ioTraceRecording },
|
||||
build: async ({ dependsOn }) =>
|
||||
setupSettingsWindowReusableFunc({
|
||||
...dependsOn,
|
||||
@@ -304,7 +328,7 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
|
||||
const mainWindow = injeca.provide('windows:main', {
|
||||
dependsOn: { editorWindow, settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater, serverChannel, godotStageManager, mcpStdioManager, i18n, onboardingWindowManager, appleSpeechTranscription, appleVision },
|
||||
dependsOn: { editorWindow, settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater, serverChannel, godotStageManager, mcpStdioManager, i18n, onboardingWindowManager, appleSpeechTranscription, appleVision, ioTraceRecording },
|
||||
build: async ({ dependsOn }) => setupMainWindow({
|
||||
...dependsOn,
|
||||
onWindowCreated: (window) => {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { SerializedIOSpan } from '@proj-airi/stage-shared/types/io-trace'
|
||||
|
||||
import { mkdtemp, readdir, readFile, rm, utimes, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { IOTraceRecordingService } from '.'
|
||||
|
||||
const directories: string[] = []
|
||||
|
||||
function span(spanId: string): SerializedIOSpan {
|
||||
return {
|
||||
attributes: {},
|
||||
ended: true,
|
||||
endTimeNano: '3000000',
|
||||
events: [],
|
||||
kind: 0,
|
||||
name: 'llm.stream',
|
||||
parentSpanId: '',
|
||||
spanId,
|
||||
startTimeNano: '1000000',
|
||||
status: { code: 1, message: '' },
|
||||
traceId: 'trace-1',
|
||||
}
|
||||
}
|
||||
|
||||
async function createService(initiallyEnabled = false) {
|
||||
const root = await mkdtemp(join(tmpdir(), 'airi-io-trace-'))
|
||||
directories.push(root)
|
||||
const directory = join(root, 'io-traces')
|
||||
let stored = initiallyEnabled
|
||||
const service = new IOTraceRecordingService({
|
||||
directory,
|
||||
getStoredEnabled: () => stored,
|
||||
setStoredEnabled: (enabled) => {
|
||||
stored = enabled
|
||||
},
|
||||
})
|
||||
return { directory, service, stored: () => stored }
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(directories.splice(0).map(path => rm(path, { force: true, recursive: true })))
|
||||
})
|
||||
|
||||
describe('iOTraceRecordingService', () => {
|
||||
it('appends ended spans as JSON Lines only while recording and keeps the setting on dispose', async () => {
|
||||
const { directory, service, stored } = await createService()
|
||||
|
||||
await service.recordSpan(span('before'))
|
||||
const started = await service.setEnabled(true)
|
||||
await Promise.all([service.recordSpan(span('first')), service.recordSpan(span('second'))])
|
||||
await service.setEnabled(false)
|
||||
await service.recordSpan(span('after'))
|
||||
|
||||
expect(started.filePath).toBeDefined()
|
||||
const lines = (await readFile(started.filePath!, 'utf8')).trim().split('\n').map(line => JSON.parse(line))
|
||||
expect(lines.map(line => line.spanId)).toEqual(['first', 'second'])
|
||||
expect(await readdir(directory)).toHaveLength(1)
|
||||
expect(stored()).toBe(false)
|
||||
|
||||
await service.setEnabled(true)
|
||||
await service.dispose()
|
||||
expect(stored()).toBe(true)
|
||||
expect(service.getState().enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('restores the stored setting and removes files older than seven days', async () => {
|
||||
const { directory, service } = await createService()
|
||||
await service.setEnabled(true)
|
||||
await service.dispose()
|
||||
const stale = join(directory, 'stale.jsonl')
|
||||
await writeFile(stale, '{}\n')
|
||||
const old = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000)
|
||||
await utimes(stale, old, old)
|
||||
|
||||
await service.restore()
|
||||
|
||||
expect(service.getState().enabled).toBe(true)
|
||||
expect(await readdir(directory)).not.toContain('stale.jsonl')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { IOTraceRecordingState, SerializedIOSpan } from '@proj-airi/stage-shared/types/io-trace'
|
||||
|
||||
import { appendFile, mkdir, readdir, rm, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { Mutex } from 'async-mutex'
|
||||
|
||||
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
interface IOTraceRecordingServiceOptions {
|
||||
directory: string
|
||||
getStoredEnabled: () => boolean
|
||||
setStoredEnabled: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
export class IOTraceRecordingService {
|
||||
private readonly listeners = new Set<(state: IOTraceRecordingState) => void>()
|
||||
private readonly mutex = new Mutex()
|
||||
private filePath: string | undefined
|
||||
private error: string | undefined
|
||||
|
||||
constructor(private readonly options: IOTraceRecordingServiceOptions) {}
|
||||
|
||||
getState(): IOTraceRecordingState {
|
||||
return {
|
||||
directory: this.options.directory,
|
||||
enabled: this.filePath !== undefined,
|
||||
error: this.error,
|
||||
filePath: this.filePath,
|
||||
}
|
||||
}
|
||||
|
||||
onStateChange(listener: (state: IOTraceRecordingState) => void): () => void {
|
||||
this.listeners.add(listener)
|
||||
return () => this.listeners.delete(listener)
|
||||
}
|
||||
|
||||
async restore(): Promise<void> {
|
||||
if (this.options.getStoredEnabled())
|
||||
await this.setEnabled(true, false)
|
||||
}
|
||||
|
||||
async setEnabled(enabled: boolean, persist = true): Promise<IOTraceRecordingState> {
|
||||
return await this.mutex.runExclusive(async () => {
|
||||
if (enabled)
|
||||
await this.start()
|
||||
else
|
||||
this.filePath = undefined
|
||||
|
||||
if (persist)
|
||||
this.options.setStoredEnabled(enabled)
|
||||
const state = this.getState()
|
||||
for (const listener of this.listeners)
|
||||
listener(state)
|
||||
return state
|
||||
})
|
||||
}
|
||||
|
||||
async recordSpan(span: SerializedIOSpan): Promise<void> {
|
||||
await this.mutex.runExclusive(async () => {
|
||||
if (!this.filePath)
|
||||
return
|
||||
await appendFile(this.filePath, `${JSON.stringify(span)}\n`, { mode: 0o600 })
|
||||
})
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
await this.mutex.runExclusive(() => {
|
||||
this.filePath = undefined
|
||||
})
|
||||
}
|
||||
|
||||
private async start(): Promise<void> {
|
||||
if (this.filePath)
|
||||
return
|
||||
|
||||
try {
|
||||
await mkdir(this.options.directory, { mode: 0o700, recursive: true })
|
||||
await this.pruneExpiredFiles()
|
||||
this.filePath = join(this.options.directory, `${new Date().toISOString().replace(/[:.]/g, '-')}.jsonl`)
|
||||
this.error = undefined
|
||||
}
|
||||
catch (error) {
|
||||
this.error = errorMessageFrom(error) ?? 'Failed to start IO trace recording.'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async pruneExpiredFiles(): Promise<void> {
|
||||
const cutoff = Date.now() - RETENTION_MS
|
||||
for (const name of await readdir(this.options.directory)) {
|
||||
if (!name.endsWith('.jsonl'))
|
||||
continue
|
||||
const path = join(this.options.directory, name)
|
||||
if ((await stat(path)).mtimeMs < cutoff)
|
||||
await rm(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { InvocableEventContext } from '@moeru/eventa'
|
||||
|
||||
import type { IOTraceRecordingService } from '.'
|
||||
|
||||
import { defineInvokeHandler } from '@moeru/eventa'
|
||||
|
||||
import {
|
||||
ioTraceRecordingChanged,
|
||||
ioTraceRecordingGet,
|
||||
ioTraceRecordingRecordSpan,
|
||||
ioTraceRecordingSetEnabled,
|
||||
} from '../../../../shared/eventa'
|
||||
|
||||
export function registerIOTraceRecording(
|
||||
context: InvocableEventContext<any, any>,
|
||||
service: IOTraceRecordingService,
|
||||
): () => void {
|
||||
const disposers = [
|
||||
defineInvokeHandler(context, ioTraceRecordingGet, () => service.getState()),
|
||||
defineInvokeHandler(context, ioTraceRecordingSetEnabled, ({ enabled }) => service.setEnabled(enabled)),
|
||||
defineInvokeHandler(context, ioTraceRecordingRecordSpan, span => service.recordSpan(span)),
|
||||
service.onStateChange((state) => {
|
||||
void context.emit(ioTraceRecordingChanged, state)
|
||||
}),
|
||||
]
|
||||
|
||||
return () => disposers.forEach(dispose => dispose())
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { InferOutput } from 'valibot'
|
||||
import type { I18n } from '../../libs/i18n'
|
||||
import type { ServerChannel } from '../../services/airi/channel-server'
|
||||
import type { GodotStageManager } from '../../services/airi/godot-stage'
|
||||
import type { IOTraceRecordingService } from '../../services/airi/io-trace-recording'
|
||||
import type { McpStdioManager } from '../../services/airi/mcp-servers'
|
||||
import type { AutoUpdater } from '../../services/electron/auto-updater'
|
||||
import type { ChatWindowManager } from '../chat'
|
||||
@@ -61,6 +62,7 @@ export async function setupMainWindow(params: {
|
||||
mcpStdioManager: McpStdioManager
|
||||
i18n: I18n
|
||||
onboardingWindowManager: OnboardingWindowManager
|
||||
ioTraceRecording: IOTraceRecordingService
|
||||
}) {
|
||||
const {
|
||||
setup: setupConfig,
|
||||
@@ -185,6 +187,7 @@ export async function setupMainWindow(params: {
|
||||
mcpStdioManager: params.mcpStdioManager,
|
||||
i18n: params.i18n,
|
||||
onboardingWindowManager: params.onboardingWindowManager,
|
||||
ioTraceRecording: params.ioTraceRecording,
|
||||
})
|
||||
|
||||
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/', {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { BrowserWindow } from 'electron'
|
||||
import type { I18n } from '../../../libs/i18n'
|
||||
import type { ServerChannel } from '../../../services/airi/channel-server'
|
||||
import type { GodotStageManager } from '../../../services/airi/godot-stage'
|
||||
import type { IOTraceRecordingService } from '../../../services/airi/io-trace-recording'
|
||||
import type { McpStdioManager } from '../../../services/airi/mcp-servers'
|
||||
import type { AutoUpdater } from '../../../services/electron/auto-updater'
|
||||
import type { ChatWindowManager } from '../../chat'
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
} from '../../../../shared/eventa'
|
||||
import { createAuthService } from '../../../services/airi/auth'
|
||||
import { createGodotStageService } from '../../../services/airi/godot-stage'
|
||||
import { registerIOTraceRecording } from '../../../services/airi/io-trace-recording/register'
|
||||
import { createMcpServersService } from '../../../services/airi/mcp-servers'
|
||||
import { createOnboardingService } from '../../../services/airi/onboarding'
|
||||
import { createWidgetsService } from '../../../services/airi/widgets'
|
||||
@@ -48,6 +50,7 @@ export async function setupMainWindowElectronInvokes(params: {
|
||||
mcpStdioManager: McpStdioManager
|
||||
i18n: I18n
|
||||
onboardingWindowManager: OnboardingWindowManager
|
||||
ioTraceRecording: IOTraceRecordingService
|
||||
}) {
|
||||
// TODO: once we refactored eventa to support window-namespaced contexts,
|
||||
// we can remove the setMaxListeners call below since eventa will be able to dispatch and
|
||||
@@ -63,6 +66,8 @@ export async function setupMainWindowElectronInvokes(params: {
|
||||
createGodotStageService({ context, manager: params.godotStageManager, window: params.window })
|
||||
createOnboardingService({ context, onboardingWindowManager: params.onboardingWindowManager, mainWindow: params.window })
|
||||
createAuthService({ context, window: params.window })
|
||||
const stopIOTraceRecording = registerIOTraceRecording(context, params.ioTraceRecording)
|
||||
params.window.once('closed', stopIOTraceRecording)
|
||||
|
||||
defineInvokeHandler(context, electronCenterMainWindow, () => centerWindowOnDisplay(params.window))
|
||||
defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' }))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { I18n } from '../../libs/i18n'
|
||||
import type { ServerChannel } from '../../services/airi/channel-server'
|
||||
import type { GodotStageManager } from '../../services/airi/godot-stage'
|
||||
import type { IOTraceRecordingService } from '../../services/airi/io-trace-recording'
|
||||
import type { McpStdioManager } from '../../services/airi/mcp-servers'
|
||||
import type { AutoUpdater } from '../../services/electron/auto-updater'
|
||||
import type { GlobalShortcutService } from '../../services/electron/global-shortcut'
|
||||
@@ -38,6 +39,7 @@ export function setupSettingsWindowReusableFunc(params: {
|
||||
i18n: I18n
|
||||
globalShortcut: GlobalShortcutService
|
||||
spotlightWindow: SpotlightWindowManager
|
||||
ioTraceRecording: IOTraceRecordingService
|
||||
}): SettingsWindowManager {
|
||||
const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer'))
|
||||
const defaultRoute = '/settings'
|
||||
@@ -76,6 +78,7 @@ export function setupSettingsWindowReusableFunc(params: {
|
||||
i18n: params.i18n,
|
||||
globalShortcut: params.globalShortcut,
|
||||
spotlightWindow: params.spotlightWindow,
|
||||
ioTraceRecording: params.ioTraceRecording,
|
||||
})
|
||||
|
||||
await load(window, withHashRoute(rendererBase, currentRoute, {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { BrowserWindow } from 'electron'
|
||||
import type { I18n } from '../../../libs/i18n'
|
||||
import type { ServerChannel } from '../../../services/airi/channel-server'
|
||||
import type { GodotStageManager } from '../../../services/airi/godot-stage'
|
||||
import type { IOTraceRecordingService } from '../../../services/airi/io-trace-recording'
|
||||
import type { McpStdioManager } from '../../../services/airi/mcp-servers'
|
||||
import type { AutoUpdater } from '../../../services/electron/auto-updater'
|
||||
import type { GlobalShortcutService } from '../../../services/electron/global-shortcut'
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
} from '../../../../shared/eventa'
|
||||
import { createAuthService } from '../../../services/airi/auth'
|
||||
import { createGodotStageService } from '../../../services/airi/godot-stage'
|
||||
import { registerIOTraceRecording } from '../../../services/airi/io-trace-recording/register'
|
||||
import { createMcpServersService } from '../../../services/airi/mcp-servers'
|
||||
import { createWidgetsService } from '../../../services/airi/widgets'
|
||||
import { createAutoUpdaterService } from '../../../services/electron'
|
||||
@@ -41,6 +43,7 @@ export async function setupSettingsWindowInvokes(params: {
|
||||
i18n: I18n
|
||||
globalShortcut: GlobalShortcutService
|
||||
spotlightWindow: SpotlightWindowManager
|
||||
ioTraceRecording: IOTraceRecordingService
|
||||
}) {
|
||||
// TODO: once we refactored eventa to support window-namespaced contexts,
|
||||
// we can remove the setMaxListeners call below since eventa will be able to dispatch and
|
||||
@@ -56,6 +59,8 @@ export async function setupSettingsWindowInvokes(params: {
|
||||
createMcpServersService({ context, manager: params.mcpStdioManager })
|
||||
createGodotStageService({ context, manager: params.godotStageManager, window: params.settingsWindow })
|
||||
createAuthService({ context, window: params.settingsWindow })
|
||||
const stopIOTraceRecording = registerIOTraceRecording(context, params.ioTraceRecording)
|
||||
params.settingsWindow.once('closed', stopIOTraceRecording)
|
||||
|
||||
// Register the global shortcut service for the settings window.
|
||||
params.globalShortcut.registerWindow({ context, window: params.settingsWindow })
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
} from '../shared/eventa/plugin/host'
|
||||
import { electronPluginToolsChanged } from '../shared/eventa/plugin/tools'
|
||||
import { initializeElectronAuthCallbackBridge } from './bridges/electron-auth-callback'
|
||||
import { initializeIOTraceRecordingBridge } from './bridges/io-trace-recording'
|
||||
import { initializeStageThreeRuntimeTraceBridge } from './bridges/stage-three-runtime-trace'
|
||||
import { useLanguage } from './composables/use-language'
|
||||
import { useServerChannelSettingsStore } from './stores/settings/server-channel'
|
||||
@@ -94,6 +95,9 @@ const isSpotlightWindow = initialRoutePath === '/spotlight'
|
||||
// The floating chat resizes from its own grip, which keeps the corner beside the character in place.
|
||||
const isFloatingChatWindow = initialRoutePath === '/chat-floating'
|
||||
const isSettingsWindow = initialRoutePath === '/settings' || initialRoutePath.startsWith('/settings/')
|
||||
const stopIOTraceRecordingBridge = initialRoutePath === '/'
|
||||
? initializeIOTraceRecordingBridge(context.value)
|
||||
: undefined
|
||||
|
||||
async function refreshPluginRuntimeTools() {
|
||||
try {
|
||||
@@ -369,6 +373,7 @@ watch(themeColorsHueDynamic, () => {
|
||||
}, { immediate: true })
|
||||
|
||||
onUnmounted(() => {
|
||||
stopIOTraceRecordingBridge?.()
|
||||
stopLeadershipListener?.()
|
||||
chatStore.dispose()
|
||||
fullStageRuntime?.dispose()
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { EventContext } from '@moeru/eventa'
|
||||
|
||||
import { useLogg } from '@guiiai/logg'
|
||||
import { defineInvoke } from '@moeru/eventa'
|
||||
import { serializeSpan, subscribeIOSpan } from '@proj-airi/stage-ui/composables/use-io-tracer'
|
||||
|
||||
import {
|
||||
ioTraceRecordingChanged,
|
||||
ioTraceRecordingGet,
|
||||
ioTraceRecordingRecordSpan,
|
||||
} from '../../shared/eventa'
|
||||
|
||||
const log = useLogg('electron:io-trace-recording').useGlobalConfig()
|
||||
|
||||
export function initializeIOTraceRecordingBridge(context: EventContext<any, any>): () => void {
|
||||
const recordSpan = defineInvoke(context, ioTraceRecordingRecordSpan)
|
||||
let enabled = false
|
||||
|
||||
const stopStateListener = context.on(ioTraceRecordingChanged, (event) => {
|
||||
enabled = event.body?.enabled ?? false
|
||||
})
|
||||
void defineInvoke(context, ioTraceRecordingGet)()
|
||||
.then((state) => {
|
||||
enabled = state?.enabled ?? false
|
||||
})
|
||||
.catch(error => log.withError(error).error('Failed to load IO trace recording state'))
|
||||
|
||||
const stopSpanListener = subscribeIOSpan((span) => {
|
||||
if (enabled && span.ended)
|
||||
void recordSpan(serializeSpan(span)).catch(error => log.withError(error).error('Failed to record IO trace span'))
|
||||
})
|
||||
|
||||
return () => {
|
||||
stopStateListener()
|
||||
stopSpanListener()
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
|
||||
import type { IOTraceRecordingState } from '@proj-airi/stage-shared/types/io-trace'
|
||||
|
||||
import { useElectronEventaContext, useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
|
||||
import { ButtonBar, CheckBar, IconItem } from '@proj-airi/stage-ui/components'
|
||||
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { electronOpenDevtoolsWindow, electronOpenEditor, electronOpenMainDevtools } from '../../../../shared/eventa'
|
||||
import { electronOpenDevtoolsWindow, electronOpenEditor, electronOpenMainDevtools, ioTraceRecordingChanged, ioTraceRecordingGet, ioTraceRecordingSetEnabled } from '../../../../shared/eventa'
|
||||
|
||||
const { t } = useI18n()
|
||||
const settings = useSettings()
|
||||
const router = useRouter()
|
||||
const eventaContext = useElectronEventaContext()
|
||||
const getIOTraceRecording = useElectronEventaInvoke(ioTraceRecordingGet)
|
||||
const setIOTraceRecordingEnabled = useElectronEventaInvoke(ioTraceRecordingSetEnabled)
|
||||
const ioTraceRecording = ref<IOTraceRecordingState>()
|
||||
const ioTraceRecordingBusy = ref(false)
|
||||
|
||||
const stopIOTraceRecordingListener = eventaContext.value.on(ioTraceRecordingChanged, (event) => {
|
||||
ioTraceRecording.value = event.body
|
||||
})
|
||||
onMounted(async () => {
|
||||
ioTraceRecording.value = await getIOTraceRecording()
|
||||
})
|
||||
onUnmounted(stopIOTraceRecordingListener)
|
||||
|
||||
async function updateIOTraceRecording(enabled: boolean | undefined) {
|
||||
ioTraceRecordingBusy.value = true
|
||||
try {
|
||||
ioTraceRecording.value = await setIOTraceRecordingEnabled({ enabled: enabled === true })
|
||||
}
|
||||
finally {
|
||||
ioTraceRecordingBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const menu = computed(() => [
|
||||
{
|
||||
@@ -157,6 +182,17 @@ const openEditor = useElectronEventaInvoke(electronOpenEditor)
|
||||
>
|
||||
{{ t('tamagotchi.settings.devtools.pages.io-tracer.title') }}
|
||||
</ButtonBar>
|
||||
<CheckBar
|
||||
:model-value="ioTraceRecording?.enabled ?? false"
|
||||
:disabled="ioTraceRecording === undefined || ioTraceRecordingBusy"
|
||||
:class="['mb-2']"
|
||||
icon-on="i-solar:record-circle-bold-duotone"
|
||||
icon-off="i-solar:record-circle-line-duotone"
|
||||
text="tamagotchi.settings.devtools.pages.io-tracer.recording.title"
|
||||
description="tamagotchi.settings.devtools.pages.io-tracer.recording.description"
|
||||
transition="all ease-in-out duration-250"
|
||||
@update:model-value="updateIOTraceRecording"
|
||||
/>
|
||||
<ButtonBar
|
||||
:class="['mb-2', 'transition-all duration-250 ease-in-out']"
|
||||
icon="i-solar:chart-square-bold-duotone"
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
StageViewSnapshotPayload,
|
||||
} from '@proj-airi/stage-shared/godot-stage'
|
||||
import type { ServerChannelQrPayload } from '@proj-airi/stage-shared/server-channel-qr'
|
||||
import type { IOTraceRecordingState, SerializedIOSpan } from '@proj-airi/stage-shared/types/io-trace'
|
||||
import type {
|
||||
ThreeHitTestReadTracePayload,
|
||||
ThreeSceneRenderInfoTracePayload,
|
||||
@@ -41,6 +42,11 @@ export const electronOpenSettings = defineInvokeEventa<void, { route?: string }>
|
||||
export const electronSettingsNavigate = defineEventa<{ route: string }>('eventa:event:electron:windows:settings:navigate')
|
||||
export const electronOpenChat = defineInvokeEventa('eventa:invoke:electron:windows:chat:open')
|
||||
|
||||
export const ioTraceRecordingChanged = defineEventa<IOTraceRecordingState>('eventa:event:electron:io-trace-recording:changed')
|
||||
export const ioTraceRecordingGet = defineInvokeEventa<IOTraceRecordingState>('eventa:invoke:electron:io-trace-recording:get')
|
||||
export const ioTraceRecordingSetEnabled = defineInvokeEventa<IOTraceRecordingState, { enabled: boolean }>('eventa:invoke:electron:io-trace-recording:set-enabled')
|
||||
export const ioTraceRecordingRecordSpan = defineInvokeEventa<void, SerializedIOSpan>('eventa:invoke:electron:io-trace-recording:record-span')
|
||||
|
||||
/**
|
||||
* Which window the Controls Island chat button opens.
|
||||
*
|
||||
|
||||
@@ -533,6 +533,9 @@ devtools:
|
||||
description: Stress markdown parsing and rendering with heavy chat payloads
|
||||
io-tracer:
|
||||
title: IO Tracer
|
||||
recording:
|
||||
title: Record IO traces automatically
|
||||
description: Save ended IO spans as JSON Lines in the app data folder so coding agents can analyze them. Files older than seven days are removed.
|
||||
widgets-calling:
|
||||
title: Widget Calling
|
||||
|
||||
|
||||
@@ -148,6 +148,10 @@ devtools:
|
||||
markdown-stress:
|
||||
title: Markdown 压力测试
|
||||
description: 用大量聊天负载来测试 Markdown 的解析和渲染能力
|
||||
io-tracer:
|
||||
recording:
|
||||
title: 自动记录 IO Trace
|
||||
description: 把已结束的 IO span 以 JSON Lines 保存在应用数据目录,供 Agent 分析。七天前的文件会被自动删除。
|
||||
widgets-calling:
|
||||
title: 小组件调用
|
||||
screen-capture:
|
||||
|
||||
@@ -15,3 +15,11 @@ export interface SerializedIOSpan {
|
||||
status: { code: number, message: string }
|
||||
ended: boolean
|
||||
}
|
||||
|
||||
/** Runtime state for Electron-owned IO trace recording. */
|
||||
export interface IOTraceRecordingState {
|
||||
directory: string
|
||||
enabled: boolean
|
||||
error?: string
|
||||
filePath?: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
|
||||
import { onIOSpan, serializeSpan, startSpan, subscribeIOSpan } from './use-io-tracer'
|
||||
|
||||
afterEach(() => onIOSpan(undefined))
|
||||
|
||||
it('delivers one ended span to the panel callback and every subscriber', () => {
|
||||
const panelNames: string[] = []
|
||||
const recordedSpans: ReturnType<typeof serializeSpan>[] = []
|
||||
onIOSpan(span => panelNames.push(span.name))
|
||||
const unsubscribe = subscribeIOSpan(span => recordedSpans.push(serializeSpan(span)))
|
||||
|
||||
startSpan('before-unsubscribe').end()
|
||||
unsubscribe()
|
||||
startSpan('after-unsubscribe').end()
|
||||
|
||||
expect(panelNames).toEqual(['before-unsubscribe', 'after-unsubscribe'])
|
||||
expect(recordedSpans).toHaveLength(1)
|
||||
expect(recordedSpans[0]).toMatchObject({ ended: true, name: 'before-unsubscribe' })
|
||||
})
|
||||
@@ -55,7 +55,7 @@ export function deserializeSpan(s: SerializedIOSpan): ReadableSpan {
|
||||
}
|
||||
}
|
||||
|
||||
function serializeSpan(span: ReadableSpan): SerializedIOSpan {
|
||||
export function serializeSpan(span: ReadableSpan): SerializedIOSpan {
|
||||
const ctx = span.spanContext()
|
||||
const parentCtx = span.parentSpanContext
|
||||
return {
|
||||
@@ -79,6 +79,7 @@ function serializeSpan(span: ReadableSpan): SerializedIOSpan {
|
||||
|
||||
let provider: BasicTracerProvider | undefined
|
||||
let spanCallback: SpanCallback | undefined
|
||||
const spanSubscribers = new Set<SpanCallback>()
|
||||
let broadcastChannel: BroadcastChannel | undefined
|
||||
|
||||
export function createCallbackSpanExporter(): SpanExporter {
|
||||
@@ -86,6 +87,8 @@ export function createCallbackSpanExporter(): SpanExporter {
|
||||
export: (spans, resultCallback) => {
|
||||
for (const span of spans) {
|
||||
spanCallback?.(span)
|
||||
for (const subscriber of spanSubscribers)
|
||||
subscriber(span)
|
||||
|
||||
broadcastChannel?.postMessage({
|
||||
span: serializeSpan(span),
|
||||
@@ -118,6 +121,11 @@ export function initIOTracer() {
|
||||
trace.setGlobalTracerProvider(provider)
|
||||
}
|
||||
|
||||
export function subscribeIOSpan(subscriber: SpanCallback): () => void {
|
||||
spanSubscribers.add(subscriber)
|
||||
return () => spanSubscribers.delete(subscriber)
|
||||
}
|
||||
|
||||
export function onIOSpan(cb: SpanCallback | undefined) {
|
||||
spanCallback = cb
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user