chore: cleanup and lint fix

This commit is contained in:
RainbowBird
2026-06-07 20:31:22 +08:00
parent 4de2aef746
commit 9382a12137
121 changed files with 846 additions and 264 deletions
@@ -19,7 +19,7 @@ Auth`authGuard` + `adminGuard``ADMIN_EMAILS` allowlist + 验证邮箱)
Body
```ts
```text
{
description: string, // 1..500 chars; 写入 flux_transaction.metadata.description
amount: number, // 1..MAX_GRANT_AMOUNT_PER_USER (10_000), 单人发放数量
@@ -33,13 +33,13 @@ Body
dry-run 响应:
```ts
```text
{ preview: { totalEmails, willGrant, willSkip: { notFound, userDeleted, duplicateInInput }, totalFluxToIssue, samples } }
```
实发响应:
```ts
```text
{
summary: { totalEmails, willGrant, willSkip, totalFluxToIssue, samples },
result: {
@@ -208,7 +208,7 @@ reads `keys[0]`, so a write replaces the active key).
The file `/tmp/smoke-streaming-tts.mjs` used in the smoke run:
```js
import WebSocket from '<airi-root>/node_modules/.pnpm/ws@*/node_modules/ws/wrapper.mjs'
import WebSocket from 'ws'
const URL = 'ws://localhost:5933/v1/audio/speech/stream'
+13 -13
View File
@@ -43,7 +43,7 @@ async function main() {
// the router is actually sending when E2E fails. Remove after E2E passes.
const debugFetch: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
console.log(` fetch → POST ${url}`)
console.info(` fetch → POST ${url}`)
if (init?.headers) {
const hdrs = init.headers as Record<string, string>
const auth = hdrs.authorization || hdrs.Authorization
@@ -51,16 +51,16 @@ async function main() {
// Never log credential substrings: a 30-char prefix of an OpenRouter
// key (`sk-or-v1-bb1a38505a7309...`) is enough to identify the account.
// Print presence only. Source: codex review 2026-05-15 #10.
console.log(` auth = ${auth ? '<set>' : '<none>'}`)
console.info(` auth = ${auth ? '<set>' : '<none>'}`)
}
if (init?.body) {
console.log(` body = ${String(init.body).slice(0, 200)}`)
console.info(` body = ${String(init.body).slice(0, 200)}`)
}
const res = await fetch(input as any, init as any)
if (!res.ok) {
const clone = res.clone()
const text = await clone.text().catch(() => '<unreadable>')
console.log(`${res.status} body: ${text.slice(0, 300)}`)
console.info(`${res.status} body: ${text.slice(0, 300)}`)
}
return res
}
@@ -72,7 +72,7 @@ async function main() {
fetchImpl: debugFetch,
})
console.log('→ calling router.route() with model=chat-default')
console.info('→ calling router.route() with model=chat-default')
const start = Date.now()
let response: Response
try {
@@ -94,7 +94,7 @@ async function main() {
}
const elapsed = Date.now() - start
console.log(`← status ${response.status} (${elapsed}ms)`)
console.info(`← status ${response.status} (${elapsed}ms)`)
if (!response.ok) {
const text = await response.text()
@@ -109,11 +109,11 @@ async function main() {
model?: string
}
const content = payload.choices?.[0]?.message?.content
console.log()
console.log('Assistant response:')
console.log(` model: ${payload.model ?? '<unknown>'}`)
console.log(` text: ${JSON.stringify(content)}`)
console.log(` tokens: prompt=${payload.usage?.prompt_tokens ?? '?'} completion=${payload.usage?.completion_tokens ?? '?'}`)
console.info()
console.info('Assistant response:')
console.info(` model: ${payload.model ?? '<unknown>'}`)
console.info(` text: ${JSON.stringify(content)}`)
console.info(` tokens: prompt=${payload.usage?.prompt_tokens ?? '?'} completion=${payload.usage?.completion_tokens ?? '?'}`)
if (!content) {
console.error('error: response.choices[0].message.content was empty')
@@ -121,8 +121,8 @@ async function main() {
exit(1)
}
console.log()
console.log('E2E PASS — router service successfully called OpenRouter and returned a usable response.')
console.info()
console.info('E2E PASS — router service successfully called OpenRouter and returned a usable response.')
await redis.quit()
}
@@ -1,3 +1,4 @@
import { Buffer } from 'node:buffer'
import { randomBytes } from 'node:crypto'
import { describe, expect, it } from 'vitest'
@@ -1,3 +1,4 @@
import type { Buffer } from 'node:buffer'
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
import { spawn } from 'node:child_process'
@@ -8,6 +9,8 @@ import { dirname, resolve } from 'node:path'
import { env, exit, kill as killProcess } from 'node:process'
import { fileURLToPath } from 'node:url'
import { errorMessageFromValue } from '@proj-airi/stage-shared'
import { desktopOverlayPollHeartbeatMarker } from '../src/shared/desktop-overlay-heartbeat'
import { selectDesktopOverlaySmokeCandidateId } from '../src/shared/desktop-overlay-live-window-smoke'
@@ -478,14 +481,14 @@ async function main() {
waitForRemoteDebug(debugPort),
stageExited,
]).catch((error) => {
throw new Error(`APP_START_FAILED: ${error instanceof Error ? error.message : String(error)}`)
throw new Error(`APP_START_FAILED: ${errorMessageFromValue(error)}`)
})
overlayClient = await Promise.race([
connectOverlayClient(debugPort),
stageExited,
]).catch((error) => {
throw new Error(`APP_START_FAILED: ${error instanceof Error ? error.message : String(error)}`)
throw new Error(`APP_START_FAILED: ${errorMessageFromValue(error)}`)
})
// NOTICE:
@@ -499,7 +502,7 @@ async function main() {
connectOverlayClient(debugPort),
stageExited,
]).catch((error) => {
throw new Error(`APP_START_FAILED: ${error instanceof Error ? error.message : String(error)}`)
throw new Error(`APP_START_FAILED: ${errorMessageFromValue(error)}`)
})
const readiness = await overlayClient.evaluate<{ state: 'booting' | 'ready' | 'degraded', error?: string }>('window.__AIRI_DESKTOP_OVERLAY_SMOKE__.getReadiness()')
@@ -531,13 +534,13 @@ async function main() {
assert(pointerIntent.candidateId === candidateId, `lastPointerIntent candidate mismatch: expected ${candidateId}, got ${String(pointerIntent.candidateId)}`)
}
catch (error) {
throw new Error(`MCP_CALL_FAILED: ${error instanceof Error ? error.message : String(error)}`)
throw new Error(`MCP_CALL_FAILED: ${errorMessageFromValue(error)}`)
}
const heartbeat = await waitFor('overlay poll heartbeat', () => {
return heartbeatLines.find(line => line.includes('snapshotId=') && line.includes('pointerIntent=yes'))
}, 30_000, 250).catch((error) => {
throw new Error(`HEARTBEAT_TIMEOUT: ${error instanceof Error ? error.message : String(error)}`)
throw new Error(`HEARTBEAT_TIMEOUT: ${errorMessageFromValue(error)}`)
})
console.info(JSON.stringify({
@@ -556,7 +559,7 @@ async function main() {
if (import.meta.main) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
console.error(errorMessageFromValue(error))
console.error(`stage log: ${stageLogPath}`)
exit(1)
})
@@ -25,6 +25,7 @@
import { mkdir, open, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { errorMessageFromValue } from '@proj-airi/stage-shared'
import { app } from 'electron'
// ============================================================================
@@ -71,7 +72,7 @@ export const nullFileLoggerHandle: FileLoggerHandle = {
* Extracts a human-readable error message from an unknown error object.
*/
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
return errorMessageFromValue(error)
}
/**
@@ -12,11 +12,7 @@ import { createHash } from 'node:crypto'
import { useLogg } from '@guiiai/logg'
import { defineInvokeHandler } from '@moeru/eventa'
import { errorMessageFrom } from '@moeru/std'
import {
artistryGenerateHeadless,
artistrySyncConfig,
artistryTestComfyUIConnection,
} from '@proj-airi/stage-shared'
import { artistryGenerateHeadless, artistrySyncConfig, artistryTestComfyUIConnection, errorMessageFromValue } from '@proj-airi/stage-shared'
import { injeca } from 'injeca'
import { ComfyUIProvider } from './providers/comfyui'
@@ -256,7 +252,7 @@ export async function generateHeadless(params: {
return await executionPromise
}
catch (err) {
return { error: errorMessageFrom(err) ?? String(err) }
return { error: errorMessageFromValue(err) }
}
finally {
// Remove from map after completion so it can be re-triggered later
@@ -16,7 +16,8 @@ import semver from 'semver'
import { is } from '@electron-toolkit/utils'
import { useLogg } from '@guiiai/logg'
import { defineInvokeHandler } from '@moeru/eventa'
import { errorMessageFrom, tryCatch } from '@moeru/std'
import { tryCatch } from '@moeru/std'
import { errorMessageFromValue } from '@proj-airi/stage-shared'
import { committerDate } from '~build/git'
import { app } from 'electron'
import { Semaphore } from 'es-toolkit'
@@ -335,7 +336,7 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
function broadcastUpdaterError(error: unknown, reason: string) {
broadcast({
status: 'error',
error: { message: errorMessageFrom(error) ?? String(error) },
error: { message: errorMessageFromValue(error) },
})
log.withError(error).error(reason)
}
@@ -24,6 +24,7 @@ import type { ServerChannel } from '../../services/airi/channel-server'
import type { McpStdioManager } from '../../services/airi/mcp-servers'
import { join, resolve } from 'node:path'
import { env } from 'node:process'
import { BrowserWindow, screen } from 'electron'
@@ -38,7 +39,7 @@ import {
/** Whether the desktop overlay feature is enabled */
export function isDesktopOverlayEnabled(): boolean {
return process.env.AIRI_DESKTOP_OVERLAY === '1'
return env.AIRI_DESKTOP_OVERLAY === '1'
}
/**
@@ -47,7 +48,7 @@ export function isDesktopOverlayEnabled(): boolean {
* mount the in-page smoke bridge.
*/
export function isDesktopOverlayPollHeartbeatEnabled(): boolean {
return process.env.AIRI_DESKTOP_OVERLAY_POLL_HEARTBEAT === '1'
return env.AIRI_DESKTOP_OVERLAY_POLL_HEARTBEAT === '1'
}
let overlayWindow: BrowserWindow | null = null
@@ -18,6 +18,7 @@ import type { DesktopOverlayReadiness } from './contracts'
import { defineInvokeHandler } from '@moeru/eventa'
import { createContext } from '@moeru/eventa/adapters/electron/main'
import { errorMessageFromValue } from '@proj-airi/stage-shared'
import { ipcMain } from 'electron'
import { getDesktopOverlayReadinessContract } from '../../../../shared/eventa'
@@ -51,7 +52,7 @@ export async function setupDesktopOverlayElectronInvokes(params: {
catch (error) {
readiness = {
state: 'degraded',
error: error instanceof Error ? error.message : String(error),
error: errorMessageFromValue(error),
}
// We intentionally don't throw here so the window still opens and
// the renderer gracefully detects the degraded state via polling.
@@ -47,7 +47,12 @@ const blockingOverlays = reactive(new Set<string>())
const isBlocked = computed(() => blockingOverlays.size > 0)
function setOverlay(key: string, active: boolean) {
active ? blockingOverlays.add(key) : blockingOverlays.delete(key)
if (active) {
blockingOverlays.add(key)
return
}
blockingOverlays.delete(key)
}
// Expose for parent (e.g. to disable click-through when a dialog is open)
@@ -7,6 +7,8 @@
import type { McpCallToolResult } from '@proj-airi/stage-ui/stores/mcp-tool-bridge'
import { errorMessageFromValue } from '@proj-airi/stage-shared'
import { desktopOverlayPollHeartbeatMarker, desktopOverlayPollHeartbeatQueryParam } from '../../shared/desktop-overlay-heartbeat'
// ---------------------------------------------------------------------------
@@ -265,7 +267,7 @@ export function createOverlayPollController(config: OverlayPollConfig): OverlayP
}
catch (e) {
currentBootstrapState = 'degraded'
currentBootstrapError = e instanceof Error ? e.message : String(e)
currentBootstrapError = errorMessageFromValue(e)
}
if (!running)
@@ -173,7 +173,9 @@ const rippleStyle = computed(() => {
watch(pointerPhase, (newPhase) => {
if (newPhase === 'completed') {
showRipple.value = true
setTimeout(() => { showRipple.value = false }, 600)
setTimeout(() => {
showRipple.value = false
}, 600)
}
})
@@ -4,6 +4,7 @@ import type { ChatSessionMeta } from '@proj-airi/stage-ui/types/chat-session'
import type { ChatProvider } from '@xsai-ext/providers/utils'
import { errorMessageFrom } from '@moeru/std'
import { errorMessageFromValue } from '@proj-airi/stage-shared'
import { useChatOrchestratorStore } from '@proj-airi/stage-ui/stores/chat'
import { useChatMaintenanceStore } from '@proj-airi/stage-ui/stores/chat/maintenance'
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
@@ -153,7 +154,7 @@ function logChatSyncError(message: string, error: unknown, details: Record<strin
console.error(`[chat-sync] ${message}`, {
...details,
error,
errorMessage: errorMessageFrom(error) ?? String(error),
errorMessage: errorMessageFromValue(error),
})
}
@@ -4,7 +4,7 @@ import type { JsonSchema } from 'xsschema'
import { defineInvoke } from '@moeru/eventa'
import { createContext } from '@moeru/eventa/adapters/electron/renderer'
import { artistryGenerateHeadless } from '@proj-airi/stage-shared'
import { artistryGenerateHeadless, errorMessageFromValue } from '@proj-airi/stage-shared'
import { useBackgroundStore } from '@proj-airi/stage-ui/stores/background'
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
import { resolveArtistryConfigFromStore, useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry'
@@ -171,7 +171,7 @@ async function executeCreateImageJournalEntry(params: { prompt?: string, title?:
}
catch (e) {
console.error('[ImageJournalTool] Failed to create entry', e)
return `Error: ${e instanceof Error ? e.message : String(e)}`
return `Error: ${errorMessageFromValue(e)}`
}
}
@@ -210,7 +210,7 @@ async function executeSetAsBackground(params: { query?: string }) {
return `Background set to "${entry.title}".`
}
catch (e) {
return `Error applying "${entry.title}": ${e instanceof Error ? e.message : String(e)}`
return `Error applying "${entry.title}": ${errorMessageFromValue(e)}`
}
}
@@ -112,7 +112,7 @@ async function handleSetAsBackground() {
extension.airi.modules.activeBackgroundId = entry.id
await cardStore.updateCard(cardId, { ...card, extensions: extension })
console.log(`[ComfyWidget] Set activeBackgroundId to ${entry.id} for ${cardId}`)
console.info(`[ComfyWidget] Set activeBackgroundId to ${entry.id} for ${cardId}`)
}
}
catch (e) {
@@ -3,6 +3,7 @@ import type { PerceptionState, VrmPoseTargets } from '@proj-airi/model-driver-me
import type { Vector3Like } from 'three'
import { createMediaPipeBackend, createMocapEngine, createVrmPoseApplier, drawOverlay, poseToVrmTargets } from '@proj-airi/model-driver-mediapipe'
import { errorMessageFromValue } from '@proj-airi/stage-shared'
import { ThreeScene } from '@proj-airi/stage-ui-three'
import { animations } from '@proj-airi/stage-ui-three/assets/vrm'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
@@ -130,7 +131,7 @@ async function startCamera() {
}
catch (err) {
status.value = 'error'
errorMessage.value = err instanceof Error ? err.message : String(err)
errorMessage.value = errorMessageFromValue(err)
console.error('Failed to start camera or pipeline:', err)
syncingToggleState.value = true
@@ -204,7 +205,7 @@ async function startPipeline() {
return
}
errorMessage.value = err instanceof Error ? err.message : String(err)
errorMessage.value = errorMessageFromValue(err)
// Ensure resources are released, but keep the error status visible.
stop()
status.value = 'error'
+1 -1
View File
@@ -113,7 +113,7 @@ describe('verification: flux-unbilled-exploit-fix', () => {
)
expect(responses.filter(r => r.status === 402)).toHaveLength(5)
const ledger = await ctx.db.query.fluxTransaction.findMany({ where: ... })
const ledger = await ctx.db.query.fluxTransaction.findMany({ where: { userId: 'u1' } })
expect(ledger).toHaveLength(0)
const metrics = await ctx.scrapeMetrics()
@@ -0,0 +1,283 @@
---
title: "feat: Full-flow observability diagnostics"
status: active
date: 2026-06-07
type: feat
---
# feat: Full-flow observability diagnostics
## Summary
Add a shared server-side diagnostics layer for OpenAI-compatible chat, HTTP TTS, streaming TTS WebSocket, router attempts, upstream adapters, billing, request logs, product events, logs, traces, and metrics. The immediate acceptance sample is the CosyVoice incident where AIRI returned 502 while UnSpeech/DashScope returned 400, but the implementation should cover the full generation flow rather than only TTS errors.
## Problem Frame
During the TTS incident, Grafana showed a concentrated burst of `POST /api/v1/audio/speech` 502 responses for `alibaba/cosyvoice-v1` while Tempo exposed an internal `POST https://unspeech-production.up.railway.app/v1/audio/speech` span returning upstream HTTP 400. Product events recorded `speech_failed` with final `http_status: 502` and reason `BAD_GATEWAY`, but Loki did not contain structured fields such as upstream HTTP status, upstream provider, upstream error code, response body snippet, or request input snippet.
The result is an operational dead end: we can identify that one user repeatedly triggered the failure, but we cannot answer why DashScope returned 400 without replaying, guessing, or obtaining upstream-side logs. This plan turns each generation request into a correlated diagnostic record that survives across logs, traces, product events, and request logs.
---
## Requirements
**Correlation**
- R1. Every chat, HTTP TTS, and TTS WebSocket request must carry a stable `requestId` through route logs, spans, product events, request logs, router attempts, adapter failures, billing, and final response handling.
- R2. Operators must be able to start from any one of `requestId`, `trace_id`, `userId`, product event row, or request log row and reconstruct the generation flow.
- R3. Diagnostic records must include route-level context: user id, session id when available, source, trigger, feature, action, final HTTP status, model, voice for TTS, input character count, request duration, and billing outcome.
**Upstream Failure Detail**
- R4. Router and adapter failures must preserve structured upstream diagnostics: upstream service, provider host, upstream URL or route name, upstream HTTP status, upstream error code, upstream error message, bounded upstream response body snippet, key id, upstream index, attempt count, and fallback decision.
- R5. The specific non-fallback TTS 400 case must be logged before the router breaks out of the fallback loop; a raw upstream 400 must not disappear just because it is not in `fallbackHttpCodes`.
- R6. AIRI may continue mapping upstream 4xx/5xx failures to client-safe 502/503 responses, but server-side diagnostics must retain the original upstream status and response details.
**Input Diagnostics**
- R7. Failure diagnostics must include a bounded request input snippet or payload summary for chat, HTTP TTS, and TTS WebSocket text input.
- R8. Raw input snippets and upstream response snippets must not become Prometheus labels. They belong in structured logs, trace attributes/events, product event metadata, or request-log diagnostics where cardinality and payload size are controlled.
- R9. Diagnostic snippets must be bounded by code-level defaults and environment-configurable caps to prevent large auto-TTS loops from creating unbounded log volume.
**Storage And Metrics**
- R10. `llm_request_log` must become drilldown-capable by storing request id, operation/source, provider, reason, input length, upstream status, and structured diagnostics for failures.
- R11. `product_events` metadata must receive scalar drilldown fields for failure diagnosis while respecting the current primitive metadata type.
- R12. Metrics must stay low-cardinality: no user ids, request ids, raw input, error messages, or response body snippets in Prometheus labels.
- R13. Observability docs must name the destination rules for logs, traces, product events, request logs, and metrics so future instrumentation does not drift again.
---
## Key Technical Decisions
- **Create one diagnostic envelope module.** A shared module should normalize route context, input summaries, upstream attempts, final status, and destination-specific projections. This prevents chat, HTTP TTS, and TTS WebSocket from each inventing field names.
- **Keep final response safety separate from server diagnostics.** `mapUpstreamError` can still return client-safe 502/503 errors, while `ApiError.cause`, structured logs, spans, product events, and request logs keep the upstream 400/429/500 details.
- **Use bounded snippets, not unbounded prompt dumps.** Default caps should be explicit, such as 512 characters for request input snippets and 2048 bytes for upstream body snippets, with env overrides. This is primarily a log-volume and storage-control boundary.
- **Store full attempts where JSON is natural, flatten where query speed matters.** Structured logs and request-log diagnostics can carry an attempts array. Product event metadata should store scalar fields such as `upstream_attempt_count`, `upstream_http_status`, `upstream_error_code`, and `input_snippet` because `ProductEventMetadata` currently allows only primitive values.
- **Route lifecycle logs are first-class, not only global error fallback.** `app.ts` global `onError` remains a safety net, but each route should emit request started, blocked, upstream failed, billing failed, succeeded, and failed events with the same diagnostic envelope.
- **Prometheus remains aggregate-only.** Counters and histograms should use low-cardinality labels such as operation, provider, model, final status, upstream status class, and fallback decision. User-level drilldown belongs in Postgres, Loki, and Tempo.
- **Fix misleading fallback accounting while adding diagnostics.** The current TTS router increments `fallbackCount` before knowing whether a status will actually fallback. The implementation should either move the increment behind the fallback decision or add a distinct attempt-failure counter so dashboards do not call non-fallback 400s "fallbacks".
---
## High-Level Technical Design
```mermaid
flowchart TB
REQ[Route receives generation request] --> CTX[Create DiagnosticContext]
CTX --> START[Log and product event: requested]
CTX --> ROUTER[LLM/TTS router]
ROUTER --> ADAPTER[Provider adapter]
ADAPTER --> UPSTREAM[UnSpeech / DashScope / other upstream]
UPSTREAM -->|non-2xx / error| ATTEMPT[Build UpstreamDiagnostic]
ATTEMPT --> ROUTER
ROUTER -->|exhausted / non-fallback| FINAL[Build final DiagnosticEnvelope]
FINAL --> LOGS[Loki structured logs]
FINAL --> TRACE[Tempo span attrs/events]
FINAL --> PRODUCT[product_events scalar metadata]
FINAL --> REQLOG[llm_request_log diagnostics jsonb]
FINAL --> METRICS[Prometheus low-cardinality metrics]
FINAL --> CLIENT[Client-safe response]
```
```mermaid
sequenceDiagram
participant Route as Route handler
participant Diag as Diagnostics module
participant Router as LLM/TTS router
participant Adapter as Adapter
participant Upstream as Upstream API
participant Sinks as Logs/Trace/DB/Metrics
Route->>Diag: newContext(requestId, userId, model, source, inputSummary)
Route->>Sinks: requested event
Route->>Router: route with DiagnosticContext
Router->>Adapter: dispatch attempt
Adapter->>Upstream: HTTP request
Upstream-->>Adapter: HTTP 400 with JSON body
Adapter-->>Router: UpstreamDiagnostic(status, code, message, bodySnippet)
Router-->>Diag: attempt failed, fallback decision
Diag->>Sinks: upstream_failed diagnostics
Router-->>Route: ApiError 502 with diagnostic cause
Route->>Sinks: failed event + request log + span attrs
Route-->>Client: sanitized 502
```
---
## Implementation Units
### U1. Diagnostic envelope and field conventions
- **Goal:** Define one shared representation for correlation, input summaries, upstream attempts, billing outcomes, and destination-specific projections.
- **Requirements:** R1, R2, R3, R7, R8, R9, R12, R13
- **Files:**
- `apps/server/src/services/domain/observability-diagnostics.ts` or `apps/server/src/services/domain/observability-diagnostics/index.ts`
- `apps/server/src/services/domain/observability-diagnostics.test.ts`
- `apps/server/src/utils/observability.ts`
- `apps/server/docs/ai-context/observability-conventions.md`
- **Approach:** Add types such as `DiagnosticContext`, `InputDiagnostic`, `UpstreamDiagnostic`, `GenerationFailureDiagnostic`, and projection helpers for logs, span attributes, product event metadata, request-log diagnostics, and metric labels. Keep destination rules in code, not scattered at call sites.
- **Patterns to follow:** `apps/server/src/utils/observability.ts` for existing `airi.*` attribute naming; `apps/server/docs/ai-context/observability-conventions.md` for low-cardinality rules.
- **Test scenarios:**
- Input snippets are truncated to the configured cap and preserve `input_chars`.
- Upstream body snippets are truncated independently from input snippets.
- Product event projection contains only primitive metadata values.
- Metric projection excludes `userId`, `requestId`, raw input, error message, and body snippet.
- Log/request-log projection retains diagnostic fields needed for incident drilldown.
### U2. Structured upstream diagnostics in router and adapters
- **Goal:** Preserve upstream status, parsed error code/message, body snippet, and fallback decision through router failures.
- **Requirements:** R4, R5, R6, R12
- **Files:**
- `apps/server/src/services/adapters/tts/unspeech.ts`
- `apps/server/src/services/domain/llm-router/router.ts`
- `apps/server/src/services/domain/llm-router/error-mapping.ts`
- `apps/server/src/services/domain/llm-router/tests/router.test.ts`
- **Approach:** Replace string-only TTS adapter errors with structured diagnostic fields attached to the thrown error or returned attempt failure. Parse `UnSpeechAPIError.responseBody` as JSON when possible and extract provider error code/message. Keep raw `bodySnippet` bounded. Ensure non-fallback 400s are logged and recorded before the router breaks. Revisit `fallbackCount` so it records real fallback decisions rather than all failed attempts.
- **Patterns to follow:** Existing chat non-2xx handling in `apps/server/src/services/domain/llm-router/router.ts`, which already reads `bodySnippet`; existing `UpstreamAttempt` cause shape in `apps/server/src/services/domain/llm-router/error-mapping.ts`.
- **Test scenarios:**
- UnSpeech/DashScope 400 JSON body becomes `upstream_http_status: 400`, parsed `upstream_error_code`, parsed `upstream_error_message`, and bounded `upstream_body_snippet`.
- TTS 400 that is not in `fallbackHttpCodes` still emits an upstream failure log and attaches the attempt to `ApiError.cause`.
- TTS 429 still records fallback decision and remains distinguishable from non-fallback 400.
- Chat upstream non-2xx continues preserving `bodySnippet` and now projects the same diagnostic field names.
- Metrics do not receive high-cardinality diagnostic payloads.
### U3. Unified lifecycle diagnostics for OpenAI chat and HTTP TTS
- **Goal:** Make non-streaming chat and HTTP TTS emit the same request lifecycle shape across logs, spans, product events, request logs, and metrics.
- **Requirements:** R1, R2, R3, R6, R7, R10, R11
- **Files:**
- `apps/server/src/routes/openai/v1/middlewares/telemetry.ts`
- `apps/server/src/routes/openai/v1/operations/chat-completions/index.ts`
- `apps/server/src/routes/openai/v1/operations/speech-generation/index.ts`
- `apps/server/src/services/domain/openai-speech/index.ts`
- `apps/server/src/routes/openai/v1/route.test.ts`
- **Approach:** Extend `createRouteTelemetry` so both chat and speech can create a `DiagnosticContext`, record lifecycle events, and write failure request logs. Move duplicated TTS analytics fields into the shared helper where practical. Preserve existing success accounting and billing semantics.
- **Patterns to follow:** Current `createRouteTelemetry` in `apps/server/src/routes/openai/v1/middlewares/telemetry.ts`; current TTS product event sequence in `apps/server/src/services/domain/openai-speech/index.ts`.
- **Test scenarios:**
- HTTP TTS upstream 400 produces `speech_failed` metadata with request id, input chars, input snippet, upstream provider, upstream status, error code/message, body snippet, final status 502, and duration.
- Chat router exhaustion produces `completion_failed` metadata with request id, model, input summary, upstream status/body snippet, final status, and duration.
- Billing block/failure logs request id and does not pretend an upstream call happened.
- Successful chat and TTS requests keep existing request-log and product-event behavior while adding request id/source/provider fields.
- Client responses remain sanitized and do not include upstream body snippets.
### U4. Streaming TTS WebSocket diagnostics
- **Goal:** Bring `routes/audio-speech-ws` to the same diagnostic standard as HTTP TTS.
- **Requirements:** R1, R2, R3, R7, R10, R11
- **Files:**
- `apps/server/src/routes/audio-speech-ws/session.ts`
- `apps/server/src/routes/audio-speech-ws/types.ts`
- `apps/server/src/routes/audio-speech-ws/route.test.ts`
- **Approach:** Thread `requestId` into start, upstream dial, upstream control event, upstream error, billing failure, close, success, product event, and request-log paths. Accumulate a bounded input snippet from text frames and record input character counts. Map upstream control errors into the shared diagnostic envelope.
- **Patterns to follow:** Existing WebSocket product event writes in `apps/server/src/routes/audio-speech-ws/session.ts`; existing request-log success write near the end of the session lifecycle.
- **Test scenarios:**
- Upstream WebSocket error records request id, user id, model, voice, input chars, input snippet, upstream code/message, and final close status.
- Upstream control error produces `speech_failed` product metadata with diagnostic fields.
- Billing failure includes request id, units, reason, and source.
- Success path writes request log with request id and operation/source.
- Input snippet cap is respected for long streaming text.
### U5. Drilldown-capable request logs and product event metadata
- **Goal:** Store enough persistent diagnostic data to query incidents after volatile logs age out.
- **Requirements:** R2, R3, R10, R11
- **Files:**
- `apps/server/src/schemas/llm-request-log.ts`
- `apps/server/src/services/domain/request-log.ts`
- `apps/server/drizzle/0016_*.sql`
- `apps/server/drizzle/meta/_journal.json`
- `apps/server/drizzle/meta/0016_snapshot.json`
- `apps/server/src/schemas/product-events.ts`
- `apps/server/src/routes/openai/v1/route.test.ts`
- `apps/server/src/routes/audio-speech-ws/route.test.ts`
- **Approach:** Add request-log columns such as `request_id`, `operation`, `source`, `provider`, `reason`, `input_chars`, `upstream_status`, and `diagnostics` jsonb. Add indexes for `request_id`, `(user_id, created_at)`, and `(provider, upstream_status, created_at)` if query plans warrant them. Keep `product_events` schema stable unless type widening is needed; write scalar diagnostic metadata through U1 projections.
- **Patterns to follow:** Existing Drizzle table definitions in `apps/server/src/schemas/*.ts`; existing migration numbering under `apps/server/drizzle/`.
- **Test scenarios:**
- Failed HTTP TTS writes request log with request id, operation, provider, final status, upstream status, reason, input chars, and diagnostics jsonb.
- Failed chat writes equivalent request-log fields.
- Successful requests still write existing flux/token fields.
- Product event metadata remains primitive and query-friendly.
- Migration applies cleanly to an existing table without requiring historical rows to have request ids.
### U6. Metrics and documentation update
- **Goal:** Make dashboards and future instrumentation use the new diagnostic contract correctly.
- **Requirements:** R8, R12, R13
- **Files:**
- `apps/server/src/otel/index.ts`
- `apps/server/src/utils/observability.ts`
- `apps/server/docs/ai-context/observability-conventions.md`
- `apps/server/docs/ai-context/observability-metrics.md`
- `apps/server/src/services/domain/llm-router/tests/router.test.ts`
- **Approach:** Add or revise counters for upstream attempt failures, real fallback decisions, and final route failures using low-cardinality labels. Document Loki, Tempo, Postgres, and Prometheus query patterns for request-level drilldown. Update metric docs to explain why user ids and snippets are excluded from Prometheus.
- **Patterns to follow:** Current `GatewayMetrics` in `apps/server/src/otel/index.ts`; existing metric naming conventions in `apps/server/src/utils/observability.ts`.
- **Test scenarios:**
- Upstream attempt failure increments an attempt-failure counter with provider/model/status-class labels.
- Real fallback increments fallback counter only when the router actually proceeds to another key/upstream.
- Non-fallback 400 does not appear as a fallback.
- Metric attribute helpers reject or omit high-cardinality fields.
### U7. Incident runbook acceptance queries
- **Goal:** Make the next incident answerable from Grafana/Loki/Tempo/Postgres without code spelunking.
- **Requirements:** R2, R13
- **Files:**
- `apps/server/docs/ai-context/observability-runbook.md`
- `apps/server/docs/ai-context/observability-conventions.md`
- **Approach:** Document concrete query shapes: from user id to recent failed requests, from request id to Loki logs, from trace id to upstream span, from product event to request log, and from provider/status to aggregate Prometheus trends. Include the TTS 400-to-502 incident as the worked example.
- **Patterns to follow:** Existing server docs under `apps/server/docs/ai-context/`.
- **Test scenarios:** Documentation-only unit; verify manually during implementation by running the queries against a staging or production time window after deployment.
---
## Acceptance Examples
- AE1. Given DashScope returns a JSON 400 through UnSpeech during HTTP TTS, when AIRI returns client-safe 502, then Loki, Tempo, `product_events`, and `llm_request_log` expose request id, user id, source, trigger, model, voice, input chars, input snippet, upstream provider, upstream HTTP 400, parsed upstream code/message, body snippet, final 502, and duration.
- AE2. Given a chat completion upstream returns non-2xx with a response body, when the router exhausts, then `completion_failed` and request logs preserve upstream diagnostics while the client response stays sanitized.
- AE3. Given TTS WebSocket text frames are sent and the upstream control channel reports an error, then the session logs and product event include request id, input snippet, upstream code/message, close status, and billing outcome.
- AE4. Given a billing block happens before any upstream call, then diagnostics show billing reason and final status but do not fabricate upstream fields.
- AE5. Given an operator starts with a high-frequency `userId`, then they can query product events/request logs for request ids, jump to Loki by request id, and jump to Tempo by trace id without relying on raw application memory.
---
## Scope Boundaries
- In scope: server-side logs, traces, metrics, product events, request logs, router/adapters, HTTP chat, HTTP TTS, TTS WebSocket, and documentation/runbook.
- In scope: bounded failure-time input snippets and bounded upstream body snippets.
- Out of scope: front-end product analytics UI, admin dashboards, replay tooling, long-term data retention policy, and full prompt capture for every successful request.
- Out of scope: changing the client-facing error response contract except where tests need to confirm diagnostics remain server-side.
---
## System-Wide Impact
This change touches the generation hot path, observability conventions, Postgres schema, and dashboard semantics. It also changes the meaning or interpretation of fallback metrics if `fallbackCount` is corrected. The implementation should update metric docs in the same unit as metric behavior to avoid confusing existing dashboards.
The request-log migration must be backward compatible with existing rows. New columns should be nullable unless there is a safe default. Indexes should be chosen for incident queries, not for every possible metadata field.
---
## Risks & Dependencies
- **Log volume:** Auto-TTS loops can generate hundreds of failures in minutes. Caps, failure-only snippets, and destination projections are required.
- **Metric cardinality:** Accidentally placing user ids, request ids, snippets, or raw upstream messages in labels would harm Prometheus. U1 and U6 tests should catch this.
- **Security material:** Do not log API keys, Authorization headers, encrypted key ciphertext, or full request headers. This remains a security boundary even when request text snippets are allowed.
- **Schema churn:** `llm_request_log` changes require Drizzle migration files and test updates across HTTP and WebSocket routes.
- **Partial instrumentation drift:** Implementing only TTS would leave chat and WebSocket incidents with the same blind spots. U3 and U4 should land before the plan is considered complete.
---
## Sources / Research
- `apps/server/src/app.ts` currently has global `onError` logging, but route-level upstream diagnostics are not guaranteed.
- `apps/server/src/services/domain/openai-speech/index.ts` already emits TTS request logs and product events, but failure metadata only carries final status/duration/trigger.
- `apps/server/src/routes/openai/v1/operations/chat-completions/index.ts` emits chat lifecycle product events, but router failures do not expose upstream diagnostics in product metadata.
- `apps/server/src/routes/audio-speech-ws/session.ts` has WebSocket product events and request logs, but upstream errors do not consistently include request id or input diagnostics.
- `apps/server/src/services/domain/llm-router/router.ts` already captures chat upstream `bodySnippet`; the TTS path mostly collapses adapter errors into strings and can skip logging non-fallback 400s.
- `apps/server/src/services/adapters/tts/unspeech.ts` sees `UnSpeechAPIError.responseBody`, but does not expose parsed upstream code/message as structured fields.
- `apps/server/src/services/domain/llm-router/error-mapping.ts` keeps upstream attempts server-side in `ApiError.cause`, which is the right place to preserve detail while sanitizing client responses.
- `apps/server/src/schemas/product-events.ts` stores product event metadata as primitive jsonb values and already has indexes for feature/action/time and user/time queries.
- `apps/server/src/schemas/llm-request-log.ts` is currently too thin for incident drilldown: no request id, operation/source, provider, reason, upstream status, or diagnostics jsonb.
- `apps/server/src/utils/observability.ts`, `apps/server/src/otel/index.ts`, and `apps/server/docs/ai-context/observability-conventions.md` define the existing OTel and metric conventions this plan should extend.
+3 -1
View File
@@ -4,6 +4,8 @@ import LibsamplerateWorkletURL from '@alexanderolsen/libsamplerate-js/dist/libsa
import ProcessorWorkletURL from './processor.worklet?worker&url'
import { errorMessageFromValue } from '../utils/error-message'
let context: AudioContext | undefined
let sampleRate: number = 48000 // High quality base sample rate
let isReady: boolean = false
@@ -128,7 +130,7 @@ export async function initializeAudioContext(requestedSampleRate: number = 48000
return context
}
catch (err) {
error = err instanceof Error ? err.message : String(err)
error = errorMessageFromValue(err)
isReady = false
workletLoaded = false
notifyListeners()
@@ -4,6 +4,8 @@ import type { ConverterTypeValue } from '@alexanderolsen/libsamplerate-js/dist/c
import { ConverterType, create } from '@alexanderolsen/libsamplerate-js'
import { errorMessageFromValue } from '../utils/error-message'
interface ProcessorOptions {
inputSampleRate: number
outputSampleRate: number
@@ -68,7 +70,7 @@ class ResamplingAudioWorkletProcessor extends AudioWorkletProcessor {
this.port.postMessage({
type: 'initialized',
success: false,
error: error instanceof Error ? error.message : String(error),
error: errorMessageFromValue(error),
})
}
}
@@ -142,7 +144,7 @@ class ResamplingAudioWorkletProcessor extends AudioWorkletProcessor {
this.port.postMessage({
type: 'error',
error: error instanceof Error ? error.message : String(error),
error: errorMessageFromValue(error),
})
// Pass through original data on error
+17
View File
@@ -0,0 +1,17 @@
import { errorMessageFrom } from '@moeru/std'
/**
* Returns an error message while preserving JavaScript string fallback.
*
* Use when:
* - Audio runtime diagnostics need the previous `String(error)` fallback.
*
* Expects:
* - `error` may be any thrown value.
*
* Returns:
* - The extracted error message, else `String(error)`.
*/
export function errorMessageFromValue(error: unknown): string {
return errorMessageFrom(error) ?? String(error)
}
+1
View File
@@ -40,6 +40,7 @@
"vite": "^7.0.0 || ^8.0.0-beta.0"
},
"dependencies": {
"@moeru/std": "catalog:",
"cac": "catalog:",
"tinyexec": "catalog:"
}
+2 -1
View File
@@ -4,6 +4,7 @@ import process from 'node:process'
import { runCapVite } from '..'
import { getCapViteCliHelpText, parseCapViteCliArgs } from '../cli'
import { errorMessageFromValue } from '../utils/error-message'
async function main() {
const parsed = parseCapViteCliArgs(process.argv.slice(2))
@@ -19,6 +20,6 @@ async function main() {
}
void main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
process.stderr.write(`${errorMessageFromValue(error)}\n`)
process.exit(1)
})
@@ -0,0 +1,17 @@
import { errorMessageFrom } from '@moeru/std'
/**
* Returns an error message while preserving JavaScript string fallback.
*
* Use when:
* - CLI and Vite plugin diagnostics need a message for arbitrary thrown values.
*
* Expects:
* - `error` may be any thrown value.
*
* Returns:
* - The extracted error message, else `String(error)`.
*/
export function errorMessageFromValue(error: unknown): string {
return errorMessageFrom(error) ?? String(error)
}
+2 -1
View File
@@ -12,6 +12,7 @@ import * as readline from 'node:readline'
import { x } from 'tinyexec'
import { parseCapacitorPlatform, pickServerUrl, resolveCapRunArgs, shouldRestartForNativeChange } from './native'
import { errorMessageFromValue } from './utils/error-message'
export interface CapVitePluginOptions {
capArgs: string[]
@@ -166,7 +167,7 @@ export function capVitePlugin(options: CapVitePluginOptions): Plugin {
}
}
catch (error) {
logger.error(`[cap-vite] ${error instanceof Error ? error.message : String(error)}`)
logger.error(`[cap-vite] ${errorMessageFromValue(error)}`)
await shutdown()
}
finally {
@@ -3,10 +3,11 @@ import type { Message, Tool } from '@xsai/shared-chat'
import type { StreamFromOptions, StreamOptions } from '../types/llm'
import { errorMessageFrom } from '@moeru/std'
import { stepCountAtLeast } from '@xsai/shared-chat'
import { streamText } from '@xsai/stream-text'
import { errorMessageFromValue } from '../utils/error-message'
/**
* Normalize chat messages so they match the wire format the active provider
* actually accepts, flattening content-part arrays back to plain strings when
@@ -109,7 +110,7 @@ function isAbortError(error: unknown): boolean {
}
function createCapturedToolErrorResult(toolName: string, error: unknown): string {
return `Tool call error for "${toolName}": ${errorMessageFrom(error) ?? String(error)}`
return `Tool call error for "${toolName}": ${errorMessageFromValue(error)}`
}
function withCapturedToolErrors(
@@ -0,0 +1,17 @@
import { errorMessageFrom } from '@moeru/std'
/**
* Returns an error message while preserving JavaScript string fallback.
*
* Use when:
* - Agent runtime diagnostics need a message for arbitrary thrown values.
*
* Expects:
* - `error` may be any thrown value.
*
* Returns:
* - The extracted error message, else `String(error)`.
*/
export function errorMessageFromValue(error: unknown): string {
return errorMessageFrom(error) ?? String(error)
}
+2 -2
View File
@@ -64,8 +64,8 @@ dialogs:
stateGranted: 許可済み
stateNotGranted: 許可されていません
bug-report:
title: バク報告 (´;ω;`)ヾ(・∀・`)
subtitle: "予期せぬエラーが発生しました \n何が起こったか報告してくれませんか?"
title: バク報告 (´;ω;`)ヾ(・∀・`)
subtitle: "予期せぬエラーが発生しました\n何が起こったか報告してくれませんか?"
trigger-label: バグを報告
submit-label: バグ報告を送信
triage-description: この問題の解決のためにページ内容とスクリーンショットを含める。
@@ -0,0 +1,17 @@
import { errorMessageFrom } from '@moeru/std'
/**
* Returns an error message while preserving JavaScript string fallback.
*
* Use when:
* - Task scripts need a message for arbitrary thrown values.
*
* Expects:
* - `error` may be any thrown value.
*
* Returns:
* - The extracted error message, else `String(error)`.
*/
export function errorMessageFromValue(error: unknown): string {
return errorMessageFrom(error) ?? String(error)
}
@@ -12,6 +12,7 @@ import { withRetry } from '@moeru/std'
import { attemptAsync } from 'es-toolkit'
import { ofetch } from 'ofetch'
import { errorMessageFromValue } from './error-message'
import { visionTaskAssets } from './tasks'
const taskSources: Record<keyof VisionTaskAssets, string> = {
@@ -62,7 +63,7 @@ async function downloadAsset(key: string, url: string, outputPath: string) {
}
}, {
onError: (error) => {
const message = error instanceof Error ? error.message : String(error)
const message = errorMessageFromValue(error)
console.warn(`Failed to download MediaPipe vision task asset for ${key} (attempt ${attempt}): ${message}`)
},
})
@@ -6,6 +6,8 @@ import type {
PlaybackStartEvent,
} from '../types'
import { errorMessageFrom } from '@moeru/std'
export type OverflowPolicy = 'queue' | 'reject' | 'steal-oldest' | 'steal-lowest-priority'
export type OwnerOverflowPolicy = 'reject' | 'steal-oldest'
@@ -141,7 +143,7 @@ export function createPlaybackManager<TAudio>(options: PlaybackManagerOptions<TA
if (!active.has(item.id))
return
active.delete(item.id)
emitInterrupt(item, err instanceof Error ? err.message : 'playback-error')
emitInterrupt(item, errorMessageFrom(err) ?? 'playback-error')
void tryStartWaiting()
})
}
@@ -0,0 +1,17 @@
import { errorMessageFrom } from '@moeru/std'
/**
* Returns an error message while preserving JavaScript string fallback.
*
* Use when:
* - Pipeline diagnostics need a message for arbitrary thrown values.
*
* Expects:
* - `error` may be any thrown value.
*
* Returns:
* - The extracted error message, else `String(error)`.
*/
export function errorMessageFromValue(error: unknown): string {
return errorMessageFrom(error) ?? String(error)
}
+1
View File
@@ -41,6 +41,7 @@
},
"dependencies": {
"@moeru/eventa": "catalog:",
"@moeru/std": "catalog:",
"@proj-airi/plugin-protocol": "workspace:*",
"@proj-airi/server-shared": "workspace:*",
"nanoid": "catalog:",
+3 -2
View File
@@ -32,6 +32,7 @@ import type { PluginTransport } from './transports'
import { cwd } from 'node:process'
import { defineInvokeHandler } from '@moeru/eventa'
import { errorMessageFrom } from '@moeru/std'
import {
errorPermission,
moduleAnnounce,
@@ -1320,7 +1321,7 @@ export class PluginHost {
session.channels.host.emit(moduleStatus, {
identity: session.identity,
phase: 'failed',
reason: error instanceof Error ? error.message : 'Failed to load plugin.',
reason: errorMessageFrom(error) ?? 'Failed to load plugin.',
})
throw error
@@ -1568,7 +1569,7 @@ export class PluginHost {
session.channels.host.emit(moduleStatus, {
identity: session.identity,
phase: 'failed',
reason: error instanceof Error ? error.message : 'Plugin host initialization failed.',
reason: errorMessageFrom(error) ?? 'Plugin host initialization failed.',
})
this.cleanupSession(session)
@@ -0,0 +1,17 @@
import { errorMessageFrom } from '@moeru/std'
/**
* Returns an error message while preserving JavaScript string fallback.
*
* Use when:
* - Plugin host diagnostics need a message for arbitrary thrown values.
*
* Expects:
* - `error` may be any thrown value.
*
* Returns:
* - The extracted error message, else `String(error)`.
*/
export function errorMessageFromValue(error: unknown): string {
return errorMessageFrom(error) ?? String(error)
}
@@ -20,7 +20,9 @@ const dropdownRef = ref(null)
// which looks worse than the explicit placeholder we already ship.
// Reset on URL change so a fixed URL re-attempts loading.
const avatarLoadError = ref(false)
watch(userAvatar, () => { avatarLoadError.value = false })
watch(userAvatar, () => {
avatarLoadError.value = false
})
const formattedCredits = computed(() => credits.value.toLocaleString())
@@ -4,6 +4,7 @@ import type {
PluginManifestSummary,
} from '@proj-airi/stage-ui/stores/devtools/plugin-host-debug'
import { errorMessageFrom } from '@moeru/std'
import { Section } from '@proj-airi/stage-ui/components'
import { usePluginHostInspectorStore } from '@proj-airi/stage-ui/stores/devtools/plugin-host-debug'
import { Button, Callout, Input } from '@proj-airi/ui'
@@ -93,7 +94,7 @@ async function refresh() {
await store.refreshAll()
}
catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to refresh plugin host debug state.')
toast.error(errorMessageFrom(error) ?? 'Failed to refresh plugin host debug state.')
}
}
@@ -102,7 +103,7 @@ async function loadEnabled() {
await store.loadEnabled()
}
catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to load enabled plugins.')
toast.error(errorMessageFrom(error) ?? 'Failed to load enabled plugins.')
}
}
@@ -114,7 +115,7 @@ async function setAutoReload(plugin: PluginManifestSummary, enabled: boolean) {
})
}
catch (error) {
toast.error(error instanceof Error ? error.message : `Failed to update auto-reload state for ${plugin.name}.`)
toast.error(errorMessageFrom(error) ?? `Failed to update auto-reload state for ${plugin.name}.`)
}
}
@@ -127,7 +128,7 @@ async function setEnabled(plugin: PluginManifestSummary, enabled: boolean) {
})
}
catch (error) {
toast.error(error instanceof Error ? error.message : `Failed to update enabled state for ${plugin.name}.`)
toast.error(errorMessageFrom(error) ?? `Failed to update enabled state for ${plugin.name}.`)
}
}
@@ -136,7 +137,7 @@ async function loadPlugin(plugin: PluginManifestSummary) {
await store.load({ name: plugin.name })
}
catch (error) {
toast.error(error instanceof Error ? error.message : `Failed to load plugin ${plugin.name}.`)
toast.error(errorMessageFrom(error) ?? `Failed to load plugin ${plugin.name}.`)
}
}
@@ -145,7 +146,7 @@ async function unloadPlugin(plugin: PluginManifestSummary) {
await store.unload({ name: plugin.name })
}
catch (error) {
toast.error(error instanceof Error ? error.message : `Failed to unload plugin ${plugin.name}.`)
toast.error(errorMessageFrom(error) ?? `Failed to unload plugin ${plugin.name}.`)
}
}
@@ -160,7 +161,7 @@ async function loadSelectedPlugin() {
await store.load({ name })
}
catch (error) {
toast.error(error instanceof Error ? error.message : `Failed to load plugin ${name}.`)
toast.error(errorMessageFrom(error) ?? `Failed to load plugin ${name}.`)
}
}
@@ -3,6 +3,7 @@ import type { ServerEvent, ServerEvents } from '@proj-airi/stage-ui/stores/provi
import vadWorkletUrl from '@proj-airi/stage-ui/workers/vad/process.worklet?worker&url'
import { errorMessageFromValue } from '@proj-airi/stage-shared'
import { createAliyunNLSProvider, streamAliyunTranscription } from '@proj-airi/stage-ui/stores/providers/aliyun/stream-transcription'
import { Button, FieldCombobox, FieldInput } from '@proj-airi/ui'
import { computed, nextTick, onBeforeUnmount, reactive, ref, shallowRef, watch } from 'vue'
@@ -177,7 +178,7 @@ async function startRecording() {
},
onSessionTerminated: (error) => {
if (error) {
appendLog(`Session terminated: ${error instanceof Error ? error.message : String(error)}`, 'error')
appendLog(`Session terminated: ${errorMessageFromValue(error)}`, 'error')
isTranscribing.value = false
}
},
@@ -199,7 +200,7 @@ async function startRecording() {
if (error instanceof DOMException && error.name === 'AbortError')
appendLog('Transcription aborted by user')
else
appendLog(`Transcription failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
appendLog(`Transcription failed: ${errorMessageFromValue(error)}`, 'error')
})
.finally(() => {
isTranscribing.value = false
@@ -229,7 +230,7 @@ async function startRecording() {
}
catch (error) {
console.error(error)
appendLog(`Failed to start recording: ${error instanceof Error ? error.message : String(error)}`, 'error')
appendLog(`Failed to start recording: ${errorMessageFromValue(error)}`, 'error')
audioStreamController.value?.error(error instanceof Error ? error : new Error(String(error)))
audioStreamController.value = undefined
abortTranscription()
@@ -45,7 +45,9 @@ const gravatarProfileUrl = computed(() => {
// instead of rendering an alt-text overflow inside the circle. Resets when
// the URL changes so a fixed URL re-attempts loading.
const avatarLoadError = ref(false)
watch(userAvatar, () => { avatarLoadError.value = false })
watch(userAvatar, () => {
avatarLoadError.value = false
})
// Locale-aware thousand separator. Bare 56 digit numbers are noisy to scan
// (e.g. "44965" reads as one block); Intl.NumberFormat respects user locale
@@ -316,7 +316,7 @@ function saveCard(card: Card): boolean {
throw new Error('Not an object')
}
}
catch (e) {
catch {
showError.value = true
errorMessage.value = t('settings.pages.card.creation.errors.invalid_artistry_json')
return false
@@ -213,8 +213,19 @@ async function handleSetAsBackground(entry: any) {
activeBackgroundId.value = entry.id
}
function requestDeleteConfirmation(message: string): boolean {
// NOTICE:
// Native confirm is the existing guard for this destructive gallery action.
// Root cause: `no-alert` rejects direct `confirm(...)` calls before this page
// has a shared confirmation-dialog primitive wired into the card settings flow.
// Source/context: this component already used native confirm for journal delete.
// Removal condition: replace with the shared modal confirmation component.
const confirmAction = globalThis.confirm.bind(globalThis)
return confirmAction(message)
}
async function handleDeleteEntry(id: string) {
if (confirm('Are you sure you want to delete this image from the journal?')) {
if (requestDeleteConfirmation('Are you sure you want to delete this image from the journal?')) {
await backgroundStore.removeBackground(id)
}
}
@@ -1,6 +1,7 @@
<script setup lang="ts">
import workletUrl from '@proj-airi/stage-ui/workers/vad/process.worklet?worker&url'
import { errorMessageFromValue } from '@proj-airi/stage-shared'
import { Alert, ErrorContainer, LevelMeter, RadioCardManySelect, RadioCardSimple, TestDummyMarker, ThresholdMeter, TimeSeriesChart } from '@proj-airi/stage-ui/components'
import { useAnalytics, useAudioAnalyzer, useAudioRecorder } from '@proj-airi/stage-ui/composables'
import { useVAD } from '@proj-airi/stage-ui/stores/ai/models/vad'
@@ -176,7 +177,7 @@ async function setupAudioMonitoring() {
}
catch (error) {
console.error('Error setting up audio monitoring:', error)
vadModelError.value = error instanceof Error ? error.message : String(error)
vadModelError.value = errorMessageFromValue(error)
}
}
@@ -284,7 +285,7 @@ onStopRecord(async (recording) => {
}
}
catch (err) {
testTranscriptionError.value = err instanceof Error ? err.message : String(err)
testTranscriptionError.value = errorMessageFromValue(err)
testStatusMessage.value = `Error: ${testTranscriptionError.value}`
console.error('STT test transcription error:', err)
}
@@ -402,7 +403,7 @@ async function startSTTTest() {
}
}
catch (err) {
testTranscriptionError.value = err instanceof Error ? err.message : String(err)
testTranscriptionError.value = errorMessageFromValue(err)
testStatusMessage.value = `Error: ${testTranscriptionError.value}`
isTranscribing.value = false
isTestingSTT.value = false
@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import { errorMessageFromValue } from '@proj-airi/stage-shared'
import {
ProviderAdvancedSettings,
ProviderBaseUrlInput,
@@ -110,7 +111,7 @@ async function refetch() {
}
catch (error) {
validationMessage.value = t('settings.dialogs.onboarding.validationError', {
error: error instanceof Error ? error.message : String(error),
error: errorMessageFromValue(error),
})
}
}
@@ -61,6 +61,13 @@ const modelOptions = computed(() => {
}))
})
const model = computed({
get: () => config.value?.model || defaultModel,
set: (value) => {
ensureProviderConfig().model = value
},
})
const availableVoices = computed(() => speechStore.availableVoices[providerId] || [])
const isVoiceDesignModel = computed(() => model.value === 'mimo-v2.5-tts-voicedesign')
@@ -85,13 +92,6 @@ const stylePromptDescription = computed(() => {
return 'Natural-language control sent as the user message. You can leave it empty for a neutral delivery.'
})
const model = computed({
get: () => config.value?.model || defaultModel,
set: (value) => {
ensureProviderConfig().model = value
},
})
const stylePrompt = computed({
get: () => config.value?.stylePrompt || '',
set: (value) => {
@@ -6,6 +6,7 @@ import type { TranscriptionProviderWithExtraOptions } from '@xsai-ext/providers/
import vadWorkletUrl from '@proj-airi/stage-ui/workers/vad/process.worklet?worker&url'
import { errorMessageFromValue } from '@proj-airi/stage-shared'
import {
Alert,
ProviderBasicSettings,
@@ -240,7 +241,7 @@ async function startStreaming() {
},
onSessionTerminated: async (error?: unknown) => {
if (error)
errorMessage.value = error instanceof Error ? error.message : String(error)
errorMessage.value = errorMessageFromValue(error)
isStreaming.value = false
transcriptionAbortController.value = undefined
},
@@ -259,7 +260,7 @@ async function startStreaming() {
activeTranscription.value = result
transcriptionTextPromise.value = result.text
.catch((error) => {
errorMessage.value = error instanceof Error ? error.message : String(error)
errorMessage.value = errorMessageFromValue(error)
throw error
})
@@ -283,7 +284,7 @@ async function startStreaming() {
isStreaming.value = true
}
catch (error) {
errorMessage.value = error instanceof Error ? error.message : String(error)
errorMessage.value = errorMessageFromValue(error)
await stopStreaming()
}
}
@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import { errorMessageFromValue } from '@proj-airi/stage-shared'
import {
Alert,
ErrorContainer,
@@ -230,7 +231,7 @@ async function startSTTTest() {
isTranscribing.value = false // Not actively transcribing yet, just listening
}
catch (err) {
testTranscriptionError.value = err instanceof Error ? err.message : String(err)
testTranscriptionError.value = errorMessageFromValue(err)
testStatusMessage.value = `Error: ${testTranscriptionError.value}`
isTranscribing.value = false
isTestingSTT.value = false
@@ -50,8 +50,19 @@ function setAsBackground(id: string) {
activeBackgroundId.value = id
}
function requestDeleteConfirmation(message: string): boolean {
// NOTICE:
// Native confirm is the existing guard for this destructive scene action.
// Root cause: `no-alert` rejects direct `confirm(...)` calls before this page
// has a shared confirmation-dialog primitive wired into the scene gallery flow.
// Source/context: this component already used native confirm for scene delete.
// Removal condition: replace with the shared modal confirmation component.
const confirmAction = globalThis.confirm.bind(globalThis)
return confirmAction(message)
}
function removeBackground(id: string) {
if (confirm(t('settings.pages.scene.gallery.delete_confirm', 'Are you sure you want to delete this background?'))) {
if (requestDeleteConfirmation(t('settings.pages.scene.gallery.delete_confirm', 'Are you sure you want to delete this background?'))) {
backgroundStore.removeBackground(id)
}
}
@@ -17,3 +17,21 @@ import { errorMessageFrom } from '@moeru/std'
export function errorMessageFromUnknown(error: unknown, unknownMessage?: string): string {
return errorMessageFrom(error) ?? unknownMessage ?? 'Unknown error'
}
/**
* Returns a human-readable message while preserving JavaScript string fallback.
*
* Use when:
* - Existing code intentionally falls back to `String(error)`.
* - Callers need a message for logs, diagnostics, or protocol payloads.
*
* Expects:
* - `error` may be any thrown value.
*
* Returns:
* - The first non-empty message extracted by {@link errorMessageFrom},
* else the JavaScript string conversion of the original value.
*/
export function errorMessageFromValue(error: unknown): string {
return errorMessageFrom(error) ?? String(error)
}
@@ -1,6 +1,8 @@
import fs from 'node:fs'
import path from 'node:path'
import { argv, exit } from 'node:process'
import JSZip from 'jszip'
/**
@@ -8,13 +10,13 @@ import JSZip from 'jszip'
*/
async function generateReport(zipPath: string) {
console.log(`\n================================================================`)
console.log(`LIVE2D STRUCTURE REPORT: ${path.basename(zipPath)}`)
console.log(`================================================================\n`)
console.info(`\n================================================================`)
console.info(`LIVE2D STRUCTURE REPORT: ${path.basename(zipPath)}`)
console.info(`================================================================\n`)
if (!fs.existsSync(zipPath)) {
console.error(`Error: File not found at ${zipPath}`)
process.exit(1)
exit(1)
}
const data = fs.readFileSync(zipPath)
@@ -40,9 +42,9 @@ async function generateReport(zipPath: string) {
}
// 1. Enumerate Files and Check Non-ASCII
console.log(`[1] Enumerating ${allFiles.length} files...`)
console.info(`[1] Enumerating ${allFiles.length} files...`)
allFiles.forEach((f) => {
if (/[^\x00-\x7F]/.test(f)) {
if (Array.from(f).some(char => char.charCodeAt(0) > 0x7F)) {
report.issues.push(`Non-ASCII filename detected: "${f}" (Ensure middleware handles this)`)
}
})
@@ -166,26 +168,26 @@ async function generateReport(zipPath: string) {
report.checks.push(`Total Motions found: ${report.metadata.motions.length}`)
// Final Summary
console.log(`[2] SUMMARY`)
console.log(` Type: ${report.structureType}`)
console.log(` Status: ${report.issues.length === 0 ? 'VALID' : 'INVALID'}`)
console.info(`[2] SUMMARY`)
console.info(` Type: ${report.structureType}`)
console.info(` Status: ${report.issues.length === 0 ? 'VALID' : 'INVALID'}`)
if (report.checks.length > 0) {
console.log(`\n[3] CHECKS PASSED:`)
report.checks.forEach(c => console.log(` [V] ${c}`))
console.info(`\n[3] CHECKS PASSED:`)
report.checks.forEach(c => console.info(` [V] ${c}`))
}
if (report.issues.length > 0) {
console.log(`\n[4] ISSUES FOUND:`)
report.issues.forEach(i => console.log(` [X] ${i}`))
console.info(`\n[4] ISSUES FOUND:`)
report.issues.forEach(i => console.info(` [X] ${i}`))
}
console.log(`\n================================================================\n`)
console.info(`\n================================================================\n`)
}
const target = process.argv[2]
const target = argv[2]
if (!target) {
console.log('Usage: node_modules/.bin/tsx packages/stage-ui-live2d/src/utils/live2d-structure-report.ts <zip-path>')
console.info('Usage: node_modules/.bin/tsx packages/stage-ui-live2d/src/utils/live2d-structure-report.ts <zip-path>')
}
else {
generateReport(target).catch(console.error)
@@ -161,7 +161,7 @@ async function loadModel() {
if (!detectedVersion)
detectedVersion = '4.2'
const spine = await loadSpineRuntime(detectedVersion)
console.log(`[Spine] Detected skeleton version: ${detectedVersion}`)
console.info(`[Spine] Detected skeleton version: ${detectedVersion}`)
if (isUnmounted) {
assetCleanup?.()
@@ -131,11 +131,12 @@ export async function loadSpineModelPreview(file: File): Promise<string | undefi
config: { app: import('@esotericsoftware/spine-webgl').SpineCanvasApp, pathPrefix?: string, webglConfig?: WebGLContextAttributes },
) => import('@esotericsoftware/spine-webgl').SpineCanvas
new SpineCanvasCtor(canvas!, {
const spineCanvas = new SpineCanvasCtor(canvas!, {
app,
pathPrefix: '',
webglConfig: { alpha: true, premultipliedAlpha: false, preserveDrawingBuffer: true },
})
void spineCanvas
}
catch (err) {
console.error('[Spine] Preview generation failed:', err)
@@ -4,8 +4,7 @@ import type { ComponentPublicInstance } from 'vue'
import type { ChatActionMenuAction } from '.'
import { errorMessageFrom } from '@moeru/std'
import { isStageCapacitor, isStageWeb } from '@proj-airi/stage-shared'
import { errorMessageFromValue, isStageCapacitor, isStageWeb } from '@proj-airi/stage-shared'
import { useElementVisibility, useIntervalFn } from '@vueuse/core'
import { createTimeline } from 'animejs'
import { clamp } from 'es-toolkit'
@@ -264,7 +263,7 @@ async function handleAction(action: ChatActionMenuAction) {
triggerCopyFeedbackReset()
}
catch (error) {
console.error('Failed to copy text:', errorMessageFrom(error) ?? String(error))
console.error('Failed to copy text:', errorMessageFromValue(error))
}
return
@@ -7,14 +7,14 @@ import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } f
import { DrawerContent, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot } from 'vaul-vue'
import { onMounted } from 'vue'
const props = defineProps<{
defineProps<{
report: Live2DValidationReport | null
}>()
const emits = defineEmits<{
(e: 'close'): void
(e: 'confirm'): void
(e: 'fix-error', error: string): void
(e: 'fixError', error: string): void
}>()
const showDialog = defineModel<boolean>('open', { default: false })
@@ -41,7 +41,7 @@ function canFixError(err: string) {
}
function handleFix(err: string) {
emits('fix-error', err)
emits('fixError', err)
}
</script>
@@ -108,11 +108,6 @@ async function saveProviderConfiguration(data: ProviderConfigData) {
}
}
async function handleSave() {
capturePosthogEvent('onboarding_step_completed', { step: currentStep.value?.id ?? 'unknown' })
emit('configured')
}
const allSteps = computed<OnboardingStep[]>(() => {
const coreSteps: OnboardingStep[] = [
{
@@ -163,6 +158,11 @@ const currentStep = computed(() => allSteps.value[step.value] ?? null)
const isLastStep = computed(() => step.value === allSteps.value.length - 1)
const currentStepProps = computed(() => currentStep.value?.props?.() ?? {})
async function handleSave() {
capturePosthogEvent('onboarding_step_completed', { step: currentStep.value?.id ?? 'unknown' })
emit('configured')
}
async function canPassGuard(guard?: OnboardingStepGuard) {
if (!guard)
return true
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { errorMessageFrom } from '@moeru/std'
import { FieldCheckbox, FieldInput } from '@proj-airi/ui'
import { computed, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -71,7 +72,7 @@ async function handleGenerateTestSpeech() {
}
catch (error) {
console.error('Error generating speech:', error)
errorMessage.value = error instanceof Error ? error.message : 'An unknown error occurred'
errorMessage.value = errorMessageFrom(error) ?? 'An unknown error occurred'
}
finally {
isGenerating.value = false
@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { VoiceInfo } from '../../../stores/providers'
import { errorMessageFrom } from '@moeru/std'
import { FieldCheckbox, FieldCombobox } from '@proj-airi/ui'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -80,7 +81,7 @@ async function handleGenerateTestSpeech() {
}
catch (error) {
console.error('Error generating speech:', error)
errorMessage.value = error instanceof Error ? error.message : 'An unknown error occurred'
errorMessage.value = errorMessageFrom(error) ?? 'An unknown error occurred'
}
finally {
isGenerating.value = false
@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { HearingTranscriptionResult } from '../../../stores/modules/hearing'
import { errorMessageFromValue } from '@proj-airi/stage-shared'
import { Button, FieldCombobox, FieldRange } from '@proj-airi/ui'
import { until } from '@vueuse/core'
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
@@ -75,7 +76,7 @@ async function setupAudioMonitoring() {
}
catch (error) {
console.error('Error setting up audio monitoring:', error)
errorMessage.value = error instanceof Error ? error.message : String(error)
errorMessage.value = errorMessageFromValue(error)
}
}
@@ -118,7 +119,7 @@ onStopRecord(async (recording) => {
}
}
catch (err) {
errorMessage.value = err instanceof Error ? err.message : String(err)
errorMessage.value = errorMessageFromValue(err)
console.error('Error generating transcription:', errorMessage.value)
}
})
@@ -1,3 +1,4 @@
import { errorMessageFromValue } from '@proj-airi/stage-shared'
import { onUnmounted, ref } from 'vue'
const amplification = 3 // Amplification factor for volume visualization
@@ -71,7 +72,7 @@ export function useAudioAnalyzer() {
}
catch (err) {
console.error('Error setting up audio monitoring:', err)
error.value = err instanceof Error ? err.message : String(err)
error.value = errorMessageFromValue(err)
}
}
+2 -1
View File
@@ -2,6 +2,7 @@ import type { WhisperEvent } from '../libs/inference/adapters/whisper'
import type { ProgressPayload } from '../libs/inference/protocol'
import { merge } from '@moeru/std'
import { errorMessageFromValue } from '@proj-airi/stage-shared'
import { onUnmounted, ref } from 'vue'
import { createWhisperAdapter } from '../libs/inference/adapters/whisper'
@@ -104,7 +105,7 @@ export function useWhisper(url: string, options?: Partial<UseWhisperOptions>) {
}).catch((err) => {
console.error('Whisper transcription error:', err)
transcribing.value = false
opts.onError?.(err instanceof Error ? err.message : String(err))
opts.onError?.(errorMessageFromValue(err))
})
},
status,
+1 -1
View File
@@ -9,7 +9,7 @@ function getEnvStatus() {
return { isAndroidNative: false, isNative: false }
}
// @ts-ignore
// @ts-expect-error Capacitor is injected by the native runtime when available.
const capacitor = window.Capacitor
const isAndroidNative = !!(capacitor?.getPlatform?.() === 'android')
const isNative = !!capacitor || isAndroidNative
@@ -111,7 +111,7 @@ export function createBackgroundRemovalAdapter(): BackgroundRemovalAdapter {
let timeoutId: ReturnType<typeof setTimeout> | undefined
let abortListener: (() => void) | null = null
const cleanup = (): void => {
function cleanup(): void {
if (timeoutId !== undefined)
clearTimeout(timeoutId)
w.removeEventListener('message', handler)
@@ -119,7 +119,7 @@ export function createBackgroundRemovalAdapter(): BackgroundRemovalAdapter {
signal.removeEventListener('abort', abortListener)
}
const handler = (event: MessageEvent): void => {
function handler(event: MessageEvent): void {
if (event.data.requestId !== requestId)
return
@@ -149,7 +149,7 @@ function waitForWorkerMessage<T = any>(
let timeoutId: ReturnType<typeof setTimeout> | undefined
let abortListener: (() => void) | null = null
const cleanup = (): void => {
function cleanup(): void {
if (timeoutId !== undefined)
clearTimeout(timeoutId)
worker.removeEventListener('message', handler)
@@ -157,7 +157,7 @@ function waitForWorkerMessage<T = any>(
signal.removeEventListener('abort', abortListener)
}
const handler = (event: MessageEvent): void => {
function handler(event: MessageEvent): void {
if (event.data.requestId !== requestId)
return
@@ -215,7 +215,7 @@ export function createWhisperAdapter(workerUrl: string | URL): WhisperAdapter {
let timeoutId: ReturnType<typeof setTimeout> | undefined
let abortListener: (() => void) | null = null
const cleanup = (): void => {
function cleanup(): void {
if (timeoutId !== undefined)
clearTimeout(timeoutId)
w.removeEventListener('message', handler)
@@ -223,7 +223,7 @@ export function createWhisperAdapter(workerUrl: string | URL): WhisperAdapter {
signal.removeEventListener('abort', abortListener)
}
const handler = (event: MessageEvent): void => {
function handler(event: MessageEvent): void {
if (event.data.requestId !== requestId)
return
@@ -1,3 +1,4 @@
import { errorMessageFromValue } from '@proj-airi/stage-shared'
/**
* Unified inference worker message protocol.
*
@@ -199,7 +200,7 @@ const DEVICE_LOSS_PATTERNS = [
* determines whether the code is `LOAD_FAILED` or `INFERENCE_FAILED`.
*/
export function classifyError(error: unknown, phase?: 'load' | 'inference'): InferenceErrorCode {
const msg = error instanceof Error ? error.message : String(error)
const msg = errorMessageFromValue(error)
const lower = msg.toLowerCase()
if (lower.includes('out of memory') || lower.includes('allocation failed'))
@@ -235,7 +236,7 @@ export function classifyDeviceLossReason(error: unknown): DeviceLossReason {
return 'unknown'
}
const msg = error instanceof Error ? error.message : String(error)
const msg = errorMessageFromValue(error)
const lower = msg.toLowerCase()
if (lower.includes('destroyed'))
return 'destroyed'
@@ -169,9 +169,7 @@ export function createInferenceWorkerManager(
}
function handleWorkerError(event: ErrorEvent | Error): void {
const message = event instanceof Error
? event.message
: (event as ErrorEvent).message ?? 'Unknown worker error'
const message = errorMessageFrom(event) ?? 'Unknown worker error'
lastError = {
code: 'UNKNOWN',
+3 -2
View File
@@ -31,6 +31,7 @@ import {
TextStreamer,
WhisperForConditionalGeneration,
} from '@huggingface/transformers'
import { errorMessageFromValue } from '@proj-airi/stage-shared'
import { MODEL_IDS, MODEL_NAMES } from '../inference/constants'
import { classifyError, isRecoverable } from '../inference/protocol'
@@ -133,7 +134,7 @@ class AutomaticSpeechRecognitionPipeline {
catch (error) {
console.warn(
'[Whisper Worker] fp16 encoder failed, falling back to fp32:',
error instanceof Error ? error.message : error,
errorMessageFromValue(error),
)
return await WhisperForConditionalGeneration.from_pretrained(this.model_id!, {
dtype: {
@@ -225,7 +226,7 @@ function sendProgress(requestId: string, phase: 'download' | 'compile' | 'warmup
}
function sendError(requestId: string, error: unknown, phase?: 'load' | 'inference'): void {
const message = error instanceof Error ? error.message : String(error)
const message = errorMessageFromValue(error)
const code = classifyError(error, phase)
const msg: ErrorResponse = {
type: 'error',
@@ -3,6 +3,7 @@ import type { MaybeRefOrGetter } from 'vue'
import type { BaseVADConfig } from '../../../libs/audio/vad'
import { merge } from '@moeru/std'
import { errorMessageFromValue } from '@proj-airi/stage-shared'
import { ref, toRef, watch } from 'vue'
import { createVAD, createVADStates } from '../../../workers/vad'
@@ -111,7 +112,7 @@ export function useVAD(workerUrl: string, options?: UseVADOptions) {
loaded.value = true
}
catch (error) {
inferenceError.value = error instanceof Error ? error.message : String(error)
inferenceError.value = errorMessageFromValue(error)
}
finally {
loading.value = false
@@ -1,3 +1,4 @@
import { errorMessageFrom } from '@moeru/std'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
@@ -146,7 +147,7 @@ export const usePluginHostInspectorStore = defineStore('devtools:plugin-host-deb
return await run(bridge.value)
}
catch (cause) {
error.value = cause instanceof Error ? cause.message : 'Plugin host debug request failed.'
error.value = errorMessageFrom(cause) ?? 'Plugin host debug request failed.'
throw cause
}
finally {
@@ -189,7 +189,7 @@ LATEST ${target === 'assistant' ? 'COMPANION RESPONSE' : 'USER INPUT'}:
// 3. Parse and analyze
// Handle potential markdown fences: ```json ... ```
let jsonContent = rawContent
const fenceMatch = rawContent.match(/```(?:json)?\s*([\s\S]*?)```/)
const fenceMatch = rawContent.match(/```(?:json)?\n?([\s\S]*?)```/)
if (fenceMatch) {
jsonContent = fenceMatch[1].trim()
artistLog('Extracted JSON from fences:', jsonContent)
@@ -4,7 +4,7 @@ import type { WithUnknown } from '@xsai/shared'
import type { StreamTranscriptionResult, StreamTranscriptionOptions as XSAIStreamTranscriptionOptions } from '@xsai/stream-transcription'
import { errorMessageFrom, tryCatch } from '@moeru/std'
import { IOAttributes, IOEvents, IOSpanNames, IOSubsystems } from '@proj-airi/stage-shared'
import { errorMessageFromValue, IOAttributes, IOEvents, IOSpanNames, IOSubsystems } from '@proj-airi/stage-shared'
import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
import { refManualReset } from '@vueuse/core'
import { generateTranscription } from '@xsai/generate-transcription'
@@ -20,7 +20,7 @@ import { streamAliyunTranscription } from '../providers/aliyun/stream-transcript
import { streamWebSpeechAPITranscription } from '../providers/web-speech-api'
function errorMessage(err: unknown): string {
const msg = errorMessageFrom(err) ?? String(err)
const msg = errorMessageFromValue(err)
// Browsers hide the real reason (CORS, timeout, DNS, …) behind this generic string.
if (msg === 'Failed to fetch' || msg === 'Load failed') {
return `${msg} — check the browser console (Network tab) for the exact reason (e.g. CORS, network timeout, DNS failure).`
@@ -1,6 +1,8 @@
import type { TranscriptionProviderWithExtraOptions } from '@xsai-ext/providers/utils'
import type { StreamTranscriptionDelta, StreamTranscriptionResult } from '@xsai/stream-transcription'
import { errorMessageFromValue } from '@proj-airi/stage-shared'
// NOTICE: Copied/adapted from @xsai/stream-transcription delayed promise helper.
// Ref: @xsai/stream-transcription@0.4.0-beta.8 (dist/index.js DelayedPromise usage).
function createDeferred<T>() {
@@ -306,7 +308,7 @@ export function streamWebSpeechAPITranscription(
}
catch (newErr) {
console.error('Web Speech API failed to create new instance:', newErr)
const error = new Error(`Failed to restart recognition: ${newErr instanceof Error ? newErr.message : String(newErr)}`)
const error = new Error(`Failed to restart recognition: ${errorMessageFromValue(newErr)}`)
fullStreamCtrl?.error(error)
textStreamCtrl?.error(error)
deferredText.reject(error)
+2 -2
View File
@@ -1,6 +1,6 @@
import type { Tool } from '@xsai/shared-chat'
import { errorMessageFrom } from '@moeru/std'
import { errorMessageFromValue } from '@proj-airi/stage-shared'
import { tool } from '@xsai/tool'
import { z } from 'zod'
@@ -117,7 +117,7 @@ export function createMcpTools(runtime: McpToolRuntime): Array<Promise<Tool>> {
catch (error) {
return {
isError: true,
content: [{ type: 'text', text: errorMessageFrom(error) ?? String(error) }],
content: [{ type: 'text', text: errorMessageFromValue(error) }],
}
}
},
@@ -18,6 +18,7 @@ import type {
} from '../../libs/inference/protocol'
import { AutoModel, AutoProcessor, env, RawImage } from '@huggingface/transformers'
import { errorMessageFromValue } from '@proj-airi/stage-shared'
import { MODEL_IDS, MODEL_NAMES } from '../../libs/inference/constants'
import { classifyError, isRecoverable } from '../../libs/inference/protocol'
@@ -61,7 +62,7 @@ function sendProgress(requestId: string, percent: number, message?: string): voi
}
function sendError(requestId: string, error: unknown, phase?: 'load' | 'inference'): void {
const message = error instanceof Error ? error.message : String(error)
const message = errorMessageFromValue(error)
const code = classifyError(error, phase)
const msg: ErrorResponse = {
type: 'error',
@@ -16,6 +16,7 @@ import type {
} from '../../libs/inference/protocol'
import type { VoiceKey, Voices } from './types'
import { errorMessageFromValue } from '@proj-airi/stage-shared'
import { KokoroTTS } from 'kokoro-js'
import { MODEL_IDS, MODEL_NAMES } from '../../libs/inference/constants'
@@ -102,7 +103,7 @@ function clearCancelled(requestId: string): void {
}
function sendError(requestId: string, error: unknown, phase?: 'load' | 'inference'): void {
const message = error instanceof Error ? error.message : String(error)
const message = errorMessageFromValue(error)
const code = classifyError(error, phase)
const msg: ErrorResponse = {
type: 'error',
@@ -204,7 +205,7 @@ async function loadModel(request: LoadModelRequest): Promise<void> {
lastError = error
console.warn(
`[Kokoro Worker] Failed with dtype=${attempt.dtype} device=${attempt.device}, trying next fallback...`,
error instanceof Error ? error.message : error,
errorMessageFromValue(error),
)
}
}
+1
View File
@@ -27,6 +27,7 @@
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@moeru/std": "catalog:",
"@proj-airi/font-departure-mono": "workspace:^",
"@rive-app/canvas-lite": "catalog:",
"@vueuse/core": "catalog:",
@@ -9,6 +9,8 @@ import CircleFadeInAnimation from './assets/circle_blink_in_-_loading_(@proj-air
import CRT from './CRT.vue'
import CRTLine from './CRTLine.vue'
import { errorMessageFromValue } from '../../utils/error-message'
interface WriteLineOptions {
renderSpeed?: number
pending?: boolean
@@ -360,7 +362,7 @@ async function writeLine<T extends any[]>(
}
catch (error) {
currentEntry.status = 'error'
currentEntry.error = error instanceof Error ? error.message : String(error)
currentEntry.error = errorMessageFromValue(error)
currentEntry.content = `${fullLine} [ ERROR ]`
}
}
@@ -0,0 +1,17 @@
import { errorMessageFrom } from '@moeru/std'
/**
* Returns an error message while preserving JavaScript string fallback.
*
* Use when:
* - Loading-screen diagnostics need a message for arbitrary thrown values.
*
* Expects:
* - `error` may be any thrown value.
*
* Returns:
* - The extracted error message, else `String(error)`.
*/
export function errorMessageFromValue(error: unknown): string {
return errorMessageFrom(error) ?? String(error)
}
@@ -18,6 +18,7 @@
"@iconify-json/solar": "catalog:",
"@iconify-json/svg-spinners": "catalog:",
"@moeru/eventa": "catalog:",
"@moeru/std": "catalog:",
"@proj-airi/server-sdk": "workspace:^",
"@proj-airi/ui": "workspace:^",
"@unocss/reset": "catalog:",
@@ -7,6 +7,8 @@ import { nanoid } from 'nanoid'
import packageJSON from '../../package.json'
import { errorMessageFromValue } from '../utils/error-message'
const PLUGIN_NAME = 'proj-airi:plugin-web-extension'
export interface ClientState {
@@ -72,7 +74,7 @@ export async function ensureClient(state: ClientState, settings: ExtensionSettin
autoReconnect: true,
onError: (error) => {
state.connected = false
state.lastError = error instanceof Error ? error.message : String(error)
state.lastError = errorMessageFromValue(error)
},
onClose: () => {
state.connected = false
@@ -88,7 +90,7 @@ export async function ensureClient(state: ClientState, settings: ExtensionSettin
}
catch (error) {
state.connected = false
state.lastError = error instanceof Error ? error.message : String(error)
state.lastError = errorMessageFromValue(error)
}
}
@@ -0,0 +1,17 @@
import { errorMessageFrom } from '@moeru/std'
/**
* Returns an error message while preserving JavaScript string fallback.
*
* Use when:
* - Web extension state needs a message for arbitrary thrown values.
*
* Expects:
* - `error` may be any thrown value.
*
* Returns:
* - The extracted error message, else `String(error)`.
*/
export function errorMessageFromValue(error: unknown): string {
return errorMessageFrom(error) ?? String(error)
}
+12
View File
@@ -3312,6 +3312,9 @@ importers:
'@capacitor/cli':
specifier: ^8.0.0
version: 8.3.1
'@moeru/std':
specifier: 'catalog:'
version: 0.1.0-beta.17
cac:
specifier: 'catalog:'
version: 7.0.0
@@ -3586,6 +3589,9 @@ importers:
'@moeru/eventa':
specifier: 'catalog:'
version: 1.0.0-beta.8(electron@41.2.1)(h3@2.0.1-rc.20(crossws@0.4.5(srvx@0.11.15)))(hono@4.12.2)
'@moeru/std':
specifier: 'catalog:'
version: 0.1.0-beta.17
'@proj-airi/plugin-protocol':
specifier: workspace:*
version: link:../plugin-protocol
@@ -4693,6 +4699,9 @@ importers:
packages/ui-loading-screens:
dependencies:
'@moeru/std':
specifier: 'catalog:'
version: 0.1.0-beta.17
'@proj-airi/font-departure-mono':
specifier: workspace:^
version: link:../font-departure-mono
@@ -4977,6 +4986,9 @@ importers:
'@moeru/eventa':
specifier: 'catalog:'
version: 1.0.0-beta.8(electron@41.2.1)(h3@2.0.1-rc.20(crossws@0.4.5(srvx@0.11.15)))(hono@4.12.2)
'@moeru/std':
specifier: 'catalog:'
version: 0.1.0-beta.17
'@proj-airi/server-sdk':
specifier: workspace:^
version: link:../../packages/server-sdk
@@ -1,3 +1,5 @@
/* global chrome */
/**
* msg_bridge.js ISOLATED world message bridge
*
@@ -1,13 +1,15 @@
/**
* Demo: use computer-use-mcp's terminal_exec tool via MCP client
* to create a Python hello-world project and run it.
*/
import { dirname, resolve } from 'node:path'
import { env, exit } from 'node:process'
import { fileURLToPath } from 'node:url'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
/**
* Demo: use computer-use-mcp's terminal_exec tool via MCP client
* to create a Python hello-world project and run it.
*/
import { errorMessageFromValue } from '../utils/error-message'
const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const homeDir = env.HOME || '$HOME'
@@ -153,6 +155,6 @@ function printResult(label: string, result: unknown) {
}
main().catch((err) => {
console.error('❌ Fatal:', err instanceof Error ? err.message : String(err))
console.error('❌ Fatal:', errorMessageFromValue(err))
exit(1)
})
@@ -23,6 +23,7 @@ import {
prioritizeInspectableAiriTargets,
} from '../e2e/debug-targets'
import { getProviderBootstrapConfig } from '../e2e/provider-bootstrap'
import { errorMessageFromValue } from '../utils/error-message'
interface DebugTarget {
id: string
@@ -639,7 +640,7 @@ async function main() {
chatSurfaceMode = 'same-window-route'
addTimeline('chat-open-fallback', {
mode: 'same-window-route',
reason: error instanceof Error ? error.message : String(error),
reason: errorMessageFromValue(error),
})
await mainTargetClient.evaluate(`window.__AIRI_DEBUG__.navigateTo('/chat')`)
@@ -985,7 +986,7 @@ async function main() {
}
catch (error) {
report.status = 'failed'
report.error = error instanceof Error ? error.stack || error.message : String(error)
report.error = errorMessageFromValue(error)
addTimeline('failure', { error: report.error })
await writeReport()
console.error(report.error)
@@ -20,6 +20,7 @@ import {
prioritizeInspectableAiriTargets,
} from '../e2e/debug-targets'
import { getProviderBootstrapConfig, resolvePreferredChatProviderId } from '../e2e/provider-bootstrap'
import { errorMessageFromValue } from '../utils/error-message'
interface DebugTarget extends DebugTargetLike {
webSocketDebuggerUrl?: string
@@ -748,7 +749,7 @@ async function main() {
catch (error) {
addTimeline('chat-open-fallback', {
mode: 'same-window-route',
reason: error instanceof Error ? error.message : String(error),
reason: errorMessageFromValue(error),
})
await mainTargetClient.close().catch(() => {})
@@ -1207,7 +1208,7 @@ async function main() {
}
catch (error) {
report.status = 'failed'
report.error = error instanceof Error ? error.stack || error.message : String(error)
report.error = errorMessageFromValue(error)
addTimeline('failure', { error: report.error })
await writeReport()
console.error(report.error)
@@ -22,6 +22,7 @@ import {
prioritizeInspectableAiriTargets,
} from '../e2e/debug-targets'
import { getProviderBootstrapConfig } from '../e2e/provider-bootstrap'
import { errorMessageFromValue } from '../utils/error-message'
interface DebugTarget {
id: string
@@ -870,7 +871,7 @@ async function main() {
chatSurfaceMode = 'same-window-route'
addTimeline('chat-open-fallback', {
mode: 'same-window-route',
reason: error instanceof Error ? error.message : String(error),
reason: errorMessageFromValue(error),
})
await mainTargetClient.evaluate(`window.__AIRI_DEBUG__.navigateTo('/chat')`)
@@ -1287,7 +1288,7 @@ async function main() {
report.discord.bot = {
...discordRuntimeState,
}
report.error = error instanceof Error ? error.stack || error.message : String(error)
report.error = errorMessageFromValue(error)
addTimeline('failure', { error: report.error })
await writeReport()
console.error(report.error)
@@ -1322,7 +1323,7 @@ async function main() {
main()
.catch((error) => {
const message = error instanceof Error ? error.stack || error.message : String(error)
const message = errorMessageFromValue(error)
console.error(message)
exitCode = 1
})
@@ -19,6 +19,7 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import {
prioritizeInspectableAiriTargets,
} from '../e2e/debug-targets'
import { errorMessageFromValue } from '../utils/error-message'
interface DebugTarget {
id: string
@@ -961,7 +962,7 @@ async function main() {
}
catch (error) {
addTimeline('discord-client-open-skipped', {
error: error instanceof Error ? error.message : String(error),
error: errorMessageFromValue(error),
})
}
}
@@ -1027,7 +1028,7 @@ async function main() {
report.discord.bot = {
...discordRuntimeState,
}
report.error = error instanceof Error ? error.stack || error.message : String(error)
report.error = errorMessageFromValue(error)
addTimeline('failure', { error: report.error })
await writeReport()
console.error(report.error)
+4 -3
View File
@@ -5,6 +5,7 @@ import process from 'node:process'
import { createInterface } from 'node:readline'
import { LinuxX11RunnerService } from '../runner/service'
import { errorMessageFromValue } from '../utils/error-message'
const runner = new LinuxX11RunnerService()
const rl = createInterface({
@@ -120,7 +121,7 @@ async function handleRequest(request: RunnerRequest) {
id: request.id,
ok: false,
error: {
message: error instanceof Error ? error.message : String(error),
message: errorMessageFromValue(error),
},
})
}
@@ -134,7 +135,7 @@ function enqueueRequest(request: RunnerRequest) {
id: request.id,
ok: false,
error: {
message: error instanceof Error ? error.message : String(error),
message: errorMessageFromValue(error),
},
})
})
@@ -150,7 +151,7 @@ rl.on('line', (line) => {
enqueueRequest(request)
}
catch (error) {
process.stderr.write(`invalid runner request: ${error instanceof Error ? error.message : String(error)}\n`)
process.stderr.write(`invalid runner request: ${errorMessageFromValue(error)}\n`)
}
})
@@ -1,4 +1,4 @@
import { errorMessageFrom } from '@moeru/std'
import { errorMessageFromValue } from '../utils/error-message'
export interface BrowserRepairSuggestion {
/** The matched error pattern. */
@@ -74,7 +74,7 @@ export function diagnoseBrowserActionError(
selector: string,
actionKind: string,
): BrowserRepairSuggestion | null {
const message = errorMessageFrom(error) ?? String(error)
const message = errorMessageFromValue(error)
for (const { pattern, build } of ERROR_PATTERNS) {
if (pattern.test(message)) {
@@ -14,6 +14,8 @@
import { WebSocket } from 'ws'
import { errorMessageFromValue } from '../utils/error-message'
export interface CdpBridgeConfig {
/** CDP endpoint URL, e.g. http://localhost:9222 */
cdpUrl: string
@@ -176,7 +178,7 @@ export class CdpBridge {
if (this.socket && this.socket !== socket)
return
const message = error instanceof Error ? error.message : String(error)
const message = errorMessageFromValue(error)
if (!this.socket && connectionSettled)
return
@@ -456,7 +458,7 @@ export class CdpBridge {
this.awaitingHeartbeatPong = true
}
catch (error) {
const message = error instanceof Error ? error.message : String(error)
const message = errorMessageFromValue(error)
this.teardownAfterHeartbeatFailure(message)
}
}, intervalMs)
@@ -131,7 +131,6 @@ describe('buildTargetCandidates', () => {
// Should have the chrome candidate (preferred) and the AX should be deduped
const chromeCount = candidates.filter(c => c.source === 'chrome_dom').length
const axCount = candidates.filter(c => c.source === 'ax').length
expect(chromeCount).toBe(1)
// AX candidate may or may not be deduped depending on exact IoU
})
@@ -45,7 +45,12 @@ let nextSnapshotId = 1
* If Chrome browser surfaces are available (and `includeChrome` is not false),
* also captures Chrome semantic data.
*
* @param params - Capture parameters (config, executor, input, bridges)
* @param params - Capture parameters.
* @param params.config - Runtime configuration for platform-specific capture.
* @param params.executor - Desktop executor used for screenshots and windows.
* @param params.input - Optional capture flags from the tool request.
* @param params.extensionBridge - Optional browser extension DOM bridge.
* @param params.cdpBridge - Optional Chrome DevTools Protocol bridge.
* @returns Unified desktop grounding snapshot
*/
export async function captureDesktopGrounding(params: {
@@ -77,34 +82,15 @@ export async function captureDesktopGrounding(params: {
// are requested. The generic top-N window snapshot is often dominated by
// system UI and can miss Chrome entirely, which would prevent chrome_dom
// candidates from being mapped to screen coordinates.
let chromeWindowBounds = findChromeWindowBounds(windowObs)
let chromeWindowBounds = findChromeWindowBounds(windowObs, foregroundApp)
let chromeWindowObservation: WindowObservation | undefined
if (shouldCaptureChrome && !chromeWindowBounds) {
try {
chromeWindowObservation = await executor.observeWindows({
app: 'Google Chrome',
app: foregroundApp.toLowerCase().includes('chrome') ? foregroundApp : 'Google Chrome',
limit: 12,
})
chromeWindowBounds = findChromeWindowBounds(chromeWindowObservation)
}
catch {
// Best-effort only. Fall back to AX-only candidates if filtered window
// enumeration fails.
}
}
// If Chrome is foreground, ask the executor for a Chrome-filtered window list.
// The generic top-N window snapshot is often dominated by system UI and can
// miss Chrome entirely, which would prevent chrome_dom candidates from being
// mapped to screen coordinates.
let chromeWindowBounds = findChromeWindowBounds(windowObs, foregroundApp)
if (isChromeInFront && !chromeWindowBounds) {
try {
const chromeWindows = await executor.observeWindows({
app: foregroundApp,
limit: 12,
})
chromeWindowBounds = findChromeWindowBounds(chromeWindows, foregroundApp)
chromeWindowBounds = findChromeWindowBounds(chromeWindowObservation, foregroundApp)
}
catch {
// Best-effort only. Fall back to AX-only candidates if filtered window
@@ -13,6 +13,7 @@ import type {
} from '../types'
import { RemoteRunnerClient } from '../runner/client'
import { errorMessageFromValue } from '../utils/error-message'
import { writeScreenshotArtifact } from '../utils/screenshot'
export interface LinuxX11ExecutorOptions extends RemoteRunnerClientOptions {
@@ -45,7 +46,7 @@ export function createLinuxX11Executor(config: ComputerUseConfig, options: Linux
return await client.getForegroundContext()
}
catch (error) {
return unavailableContext(error instanceof Error ? error.message : String(error))
return unavailableContext(errorMessageFromValue(error))
}
},
getDisplayInfo: () => client.getDisplayInfo(),
@@ -24,6 +24,7 @@ import { join } from 'node:path'
import { appNamesMatch, getKnownAppLaunchNames } from '../app-aliases'
import { probeDisplayInfo, probePermissionInfo } from '../runtime-probes'
import { errorMessageFromValue } from '../utils/error-message'
import { runProcess } from '../utils/process'
import { captureScreenshotArtifact } from '../utils/screenshot'
import { runSwiftScript } from '../utils/swift'
@@ -524,7 +525,7 @@ export function createMacOSLocalExecutor(config: ComputerUseConfig): DesktopExec
return observationToForegroundContext(await observeWindows(config, { limit: 8 }))
}
catch (error) {
return fallbackContext(error instanceof Error ? error.message : String(error))
return fallbackContext(errorMessageFromValue(error))
}
},
getDisplayInfo: () => probeDisplayInfo(config),
@@ -32,6 +32,8 @@ import { spawn } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { createInterface } from 'node:readline'
import { errorMessageFromValue } from '../utils/error-message'
function buildSshTarget(config: ComputerUseConfig) {
if (!config.remoteSshHost || !config.remoteSshUser) {
throw new Error('linux-x11 executor requires COMPUTER_USE_REMOTE_SSH_HOST and COMPUTER_USE_REMOTE_SSH_USER')
@@ -304,13 +306,13 @@ export class RemoteRunnerClient {
}
catch (error) {
if (options.mutating) {
this.taintedReason = error instanceof Error ? error.message : String(error)
this.taintedReason = errorMessageFromValue(error)
}
if (this.currentTarget) {
this.currentTarget = this.applyPersistentTaint({
...this.currentTarget,
note: error instanceof Error ? error.message : String(error),
note: errorMessageFromValue(error),
})
}
throw error
@@ -31,6 +31,7 @@ import { createServer } from 'node:http'
import { homedir, tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { errorMessageFromValue } from '../utils/error-message'
import { runProcess, sanitizeFileSegment } from '../utils/process'
const sessionDisplayStart = 90
@@ -242,7 +243,7 @@ export class LinuxX11RunnerService {
return {
available: false,
platform: 'linux',
unavailableReason: error instanceof Error ? error.message : String(error),
unavailableReason: errorMessageFromValue(error),
}
}
}
@@ -14,6 +14,7 @@ import { basename } from 'node:path'
import { argv, pid, platform, ppid, title } from 'node:process'
import { enumerateDisplays } from './display'
import { errorMessageFromValue } from './utils/error-message'
import { runProcess } from './utils/process'
import { runSwiftScript } from './utils/swift'
@@ -108,7 +109,7 @@ export async function probeDisplayInfo(config: ComputerUseConfig): Promise<Displ
return {
available: false,
platform,
note: error instanceof Error ? error.message : String(error),
note: errorMessageFromValue(error),
}
}
}
@@ -141,7 +142,7 @@ print(AXIsProcessTrusted() ? "granted" : "missing")
status: 'unknown',
target: resolveLaunchContext(config).launchHostProcess,
checkedBy: 'AXIsProcessTrusted',
note: error instanceof Error ? error.message : String(error),
note: errorMessageFromValue(error),
}
}
}
@@ -175,7 +176,7 @@ print(CGPreflightScreenCaptureAccess() ? "granted" : "missing")
status: 'unknown',
target: resolveLaunchContext(config).launchHostProcess,
checkedBy: 'CGPreflightScreenCaptureAccess',
note: error instanceof Error ? error.message : String(error),
note: errorMessageFromValue(error),
}
}
}
@@ -210,7 +211,7 @@ async function probeAutomation(config: ComputerUseConfig): Promise<PermissionPro
status: 'missing',
target: `${launchContext.launchHostProcess} -> System Events`,
checkedBy: 'osascript/System Events foreground probe',
note: error instanceof Error ? error.message : String(error),
note: errorMessageFromValue(error),
}
}
}

Some files were not shown because too many files have changed in this diff Show More