diff --git a/AGENTS.md b/AGENTS.md index 3a97934ac..6c1421cf7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,9 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones: - **Gateway startup**: When running `pnpm dev`, the OpenClaw Gateway process starts automatically on port 18789. It takes ~10-30 seconds to become ready. Gateway readiness is not required for UI development—the app functions without it (shows "connecting" state). - **No database**: The app uses `electron-store` (JSON files) and OS keychain. No database setup is needed. - **AI Provider keys**: Actual AI chat requires at least one provider API key configured via Settings > AI Providers. The app is fully navigable and testable without keys. -- **Token usage history implementation**: Dashboard token usage history is not parsed from console logs. It reads OpenClaw session transcript `.jsonl` files under the local OpenClaw config directory, extracts assistant messages with `message.usage`, and aggregates fields such as input/output/cache/total tokens and cost from those structured records. +- **Token usage history implementation**: Dashboard token usage history is not parsed from console logs. It reads OpenClaw session transcript `.jsonl` files under the local OpenClaw config directory, scans both configured agents and any runtime agent directories found on disk, and treats normal, `.deleted.jsonl`, and `.jsonl.reset.*` transcripts as valid history sources. It extracts assistant/tool usage records with `message.usage` and aggregates fields such as input/output/cache/total tokens and cost from those structured records. +- **Models page aggregation**: The 7-day/30-day filters are relative rolling windows, not calendar-month buckets. When grouped by time, the chart should keep all day buckets in the selected window; only model grouping is intentionally capped to the top entries. +- **OpenClaw Doctor in UI**: In Settings > Advanced > Developer, the app exposes both `Run Doctor` (`openclaw doctor --json`) and `Run Doctor Fix` (`openclaw doctor --fix --yes --non-interactive`) through the host-api. Renderer code should call the host route, not spawn CLI processes directly. - **Renderer/Main API boundary (important)**: - Renderer must use `src/lib/host-api.ts` and `src/lib/api-client.ts` as the single entry for backend calls. - Do not add new direct `window.electron.ipcRenderer.invoke(...)` calls in pages/components; expose them through host-api/api-client instead. diff --git a/README.ja-JP.md b/README.ja-JP.md index 285eeddcd..6bd6ecf17 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -187,6 +187,7 @@ ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネ - 高度なプロキシフィールドが空の場合、ClawXは`プロキシサーバー`にフォールバックします。 - プロキシ設定を保存すると、Electronのネットワーク設定が即座に再適用され、ゲートウェイが自動的に再起動されます。 - ClawXはTelegramが有効な場合、プロキシをOpenClawのTelegramチャネル設定にも同期します。 +- **設定 → 詳細 → 開発者** では **OpenClaw Doctor** を実行でき、`openclaw doctor --json` の診断出力をアプリ内で確認できます。 --- diff --git a/README.md b/README.md index 5aca0710f..853f09db8 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,7 @@ Notes: - If advanced proxy fields are left empty, ClawX falls back to `Proxy Server`. - Saving proxy settings reapplies Electron networking immediately and restarts the Gateway automatically. - ClawX also syncs the proxy to OpenClaw's Telegram channel config when Telegram is enabled. +- In **Settings → Advanced → Developer**, you can run **OpenClaw Doctor** to execute `openclaw doctor --json` and inspect the diagnostic output without leaving the app. --- diff --git a/README.zh-CN.md b/README.zh-CN.md index 38131821f..1498bb3aa 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -191,6 +191,7 @@ ClawX 内置了代理设置,适用于需要通过本地代理客户端访问 - 高级代理项留空时,会自动回退到“代理服务器”。 - 保存代理设置后,Electron 网络层会立即重新应用代理,并自动重启 Gateway。 - 如果启用了 Telegram,ClawX 还会把代理同步到 OpenClaw 的 Telegram 频道配置中。 +- 在 **设置 → 高级 → 开发者** 中,可以直接运行 **OpenClaw Doctor**,执行 `openclaw doctor --json` 并在应用内查看诊断输出。 --- diff --git a/electron/api/routes/app.ts b/electron/api/routes/app.ts index 7d4db9e18..467dc5223 100644 --- a/electron/api/routes/app.ts +++ b/electron/api/routes/app.ts @@ -1,6 +1,8 @@ import type { IncomingMessage, ServerResponse } from 'http'; import type { HostApiContext } from '../context'; -import { setCorsHeaders, sendNoContent } from '../route-utils'; +import { parseJsonBody } from '../route-utils'; +import { setCorsHeaders, sendJson, sendNoContent } from '../route-utils'; +import { runOpenClawDoctor, runOpenClawDoctorFix } from '../../utils/openclaw-doctor'; export async function handleAppRoutes( req: IncomingMessage, @@ -23,6 +25,13 @@ export async function handleAppRoutes( return true; } + if (url.pathname === '/api/app/openclaw-doctor' && req.method === 'POST') { + const body = await parseJsonBody<{ mode?: 'diagnose' | 'fix' }>(req); + const mode = body.mode === 'fix' ? 'fix' : 'diagnose'; + sendJson(res, 200, mode === 'fix' ? await runOpenClawDoctorFix() : await runOpenClawDoctor()); + return true; + } + if (req.method === 'OPTIONS') { sendNoContent(res); return true; diff --git a/electron/main/index.ts b/electron/main/index.ts index ec0a70b40..71d186143 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -13,7 +13,7 @@ import { createMenu } from './menu'; import { appUpdater, registerUpdateHandlers } from './updater'; import { logger } from '../utils/logger'; import { warmupNetworkOptimization } from '../utils/uv-env'; -import { initTelemetry, shutdownTelemetry } from '../utils/telemetry'; +import { initTelemetry } from '../utils/telemetry'; import { ClawHubService } from '../gateway/clawhub'; import { ensureClawXContext, repairClawXOnlyBootstrapFiles } from '../utils/openclaw-workspace'; @@ -392,10 +392,6 @@ app.on('before-quit', () => { setQuitting(); hostEventBus.closeAll(); hostApiServer?.close(); - // Flush telemetry data - void shutdownTelemetry().catch((err) => { - logger.warn('Failed to shutdown telemetry:', err); - }); // Fire-and-forget: do not await gatewayManager.stop() here. // Awaiting inside before-quit can stall Electron's quit sequence. void gatewayManager.stop().catch((err) => { diff --git a/electron/utils/openclaw-doctor.ts b/electron/utils/openclaw-doctor.ts new file mode 100644 index 000000000..a53b183f2 --- /dev/null +++ b/electron/utils/openclaw-doctor.ts @@ -0,0 +1,158 @@ +import { app, utilityProcess } from 'electron'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { getOpenClawDir, getOpenClawEntryPath } from './paths'; +import { logger } from './logger'; +import { getUvMirrorEnv } from './uv-env'; + +const OPENCLAW_DOCTOR_TIMEOUT_MS = 60_000; +const OPENCLAW_DOCTOR_ARGS = ['doctor', '--json']; +const OPENCLAW_DOCTOR_FIX_ARGS = ['doctor', '--fix', '--yes', '--non-interactive']; + +export type OpenClawDoctorMode = 'diagnose' | 'fix'; + +export interface OpenClawDoctorResult { + mode: OpenClawDoctorMode; + success: boolean; + exitCode: number | null; + stdout: string; + stderr: string; + command: string; + cwd: string; + durationMs: number; + timedOut?: boolean; + error?: string; +} + +function getBundledBinPath(): string { + const target = `${process.platform}-${process.arch}`; + return app.isPackaged + ? path.join(process.resourcesPath, 'bin') + : path.join(process.cwd(), 'resources', 'bin', target); +} + +async function runDoctorCommand(mode: OpenClawDoctorMode): Promise { + const openclawDir = getOpenClawDir(); + const entryScript = getOpenClawEntryPath(); + const args = mode === 'fix' ? OPENCLAW_DOCTOR_FIX_ARGS : OPENCLAW_DOCTOR_ARGS; + const command = `openclaw ${args.join(' ')}`; + const startedAt = Date.now(); + + if (!existsSync(entryScript)) { + const error = `OpenClaw entry script not found at ${entryScript}`; + logger.error(`Cannot run OpenClaw doctor: ${error}`); + return { + mode, + success: false, + exitCode: null, + stdout: '', + stderr: '', + command, + cwd: openclawDir, + durationMs: Date.now() - startedAt, + error, + }; + } + + const binPath = getBundledBinPath(); + const binPathExists = existsSync(binPath); + const finalPath = binPathExists + ? `${binPath}${path.delimiter}${process.env.PATH || ''}` + : process.env.PATH || ''; + const uvEnv = await getUvMirrorEnv(); + + logger.info( + `Running OpenClaw doctor (mode=${mode}, entry="${entryScript}", args="${args.join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'})`, + ); + + return await new Promise((resolve) => { + const child = utilityProcess.fork(entryScript, args, { + cwd: openclawDir, + stdio: 'pipe', + env: { + ...process.env, + ...uvEnv, + PATH: finalPath, + OPENCLAW_NO_RESPAWN: '1', + } as NodeJS.ProcessEnv, + }); + + let stdout = ''; + let stderr = ''; + let settled = false; + + const finish = (result: Omit) => { + if (settled) return; + settled = true; + resolve({ + ...result, + durationMs: Date.now() - startedAt, + }); + }; + + const timeout = setTimeout(() => { + logger.error(`OpenClaw doctor timed out after ${OPENCLAW_DOCTOR_TIMEOUT_MS}ms`); + try { + child.kill(); + } catch { + // ignore + } + finish({ + mode, + success: false, + exitCode: null, + stdout, + stderr, + command, + cwd: openclawDir, + timedOut: true, + error: `Timed out after ${OPENCLAW_DOCTOR_TIMEOUT_MS}ms`, + }); + }, OPENCLAW_DOCTOR_TIMEOUT_MS); + + child.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + child.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('error', (error) => { + clearTimeout(timeout); + logger.error('Failed to spawn OpenClaw doctor process:', error); + finish({ + mode, + success: false, + exitCode: null, + stdout, + stderr, + command, + cwd: openclawDir, + error: error instanceof Error ? error.message : String(error), + }); + }); + + child.on('exit', (code) => { + clearTimeout(timeout); + logger.info(`OpenClaw doctor exited with code ${code ?? 'null'}`); + finish({ + mode, + success: code === 0, + exitCode: code, + stdout, + stderr, + command, + cwd: openclawDir, + }); + }); + }); +} + +export async function runOpenClawDoctor(): Promise { + return await runDoctorCommand('diagnose'); +} + +export async function runOpenClawDoctorFix(): Promise { + return await runDoctorCommand('fix'); +} diff --git a/electron/utils/telemetry.ts b/electron/utils/telemetry.ts index ae11bae79..3405e1157 100644 --- a/electron/utils/telemetry.ts +++ b/electron/utils/telemetry.ts @@ -6,10 +6,32 @@ import { logger } from './logger'; const POSTHOG_API_KEY = 'phc_aGNegeJQP5FzNiF2rEoKqQbkuCpiiETMttplibXpB0n'; const POSTHOG_HOST = 'https://us.i.posthog.com'; +const TELEMETRY_SHUTDOWN_TIMEOUT_MS = 1500; let posthogClient: PostHog | null = null; let distinctId: string = ''; +function isIgnorablePostHogShutdownError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + const message = `${error.name} ${error.message}`.toLowerCase(); + if ( + message.includes('posthogfetchnetworkerror') || + message.includes('network error while fetching posthog') || + message.includes('timeouterror') || + message.includes('aborted due to timeout') || + message.includes('fetch failed') + ) { + return true; + } + + return 'cause' in error && error.cause !== error + ? isIgnorablePostHogShutdownError(error.cause) + : false; +} + /** * Initialize PostHog telemetry */ @@ -65,15 +87,51 @@ export async function initTelemetry(): Promise { } /** - * Ensure PostHog flushes all pending events before shutting down + * Best-effort telemetry shutdown that never blocks app exit on network issues. */ export async function shutdownTelemetry(): Promise { - if (posthogClient) { - try { - await posthogClient.shutdown(); - logger.debug('Flushed telemetry events on shutdown'); - } catch (error) { - logger.error('Error shutting down telemetry:', error); + const client = posthogClient; + posthogClient = null; + distinctId = ''; + + if (!client) { + return; + } + + let didTimeout = false; + let timeoutHandle: ReturnType | null = null; + const shutdownPromise = client.shutdown().catch((error) => { + if (isIgnorablePostHogShutdownError(error)) { + logger.debug('Ignored telemetry shutdown network error:', error); + return; } + throw error; + }); + + try { + await Promise.race([ + shutdownPromise, + new Promise((resolve) => { + timeoutHandle = setTimeout(() => { + didTimeout = true; + resolve(); + }, TELEMETRY_SHUTDOWN_TIMEOUT_MS); + }), + ]); + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + + if (didTimeout) { + logger.debug(`Skipped waiting for telemetry shutdown after ${TELEMETRY_SHUTDOWN_TIMEOUT_MS}ms`); + return; + } + + logger.debug('Flushed telemetry events on shutdown'); + } catch (error) { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + logger.error('Error shutting down telemetry:', error); } } diff --git a/electron/utils/token-usage-core.ts b/electron/utils/token-usage-core.ts index 08fb5a024..304c5243b 100644 --- a/electron/utils/token-usage-core.ts +++ b/electron/utils/token-usage-core.ts @@ -16,7 +16,7 @@ export interface TokenUsageHistoryEntry { export function extractSessionIdFromTranscriptFileName(fileName: string): string | undefined { if (!fileName.endsWith('.jsonl') && !fileName.includes('.jsonl.reset.')) return undefined; return fileName - .replace(/\.jsonl\.reset\..+$/, '') + .replace(/\.reset\..+$/, '') .replace(/\.deleted\.jsonl$/, '') .replace(/\.jsonl$/, ''); } diff --git a/electron/utils/token-usage.ts b/electron/utils/token-usage.ts index 62ffe1c81..a63a365d5 100644 --- a/electron/utils/token-usage.ts +++ b/electron/utils/token-usage.ts @@ -15,12 +15,45 @@ export { type TokenUsageHistoryEntry, } from './token-usage-core'; +async function listAgentIdsWithSessionDirs(): Promise { + const openclawDir = getOpenClawConfigDir(); + const agentsDir = join(openclawDir, 'agents'); + const agentIds = new Set(); + + try { + for (const agentId of await listConfiguredAgentIds()) { + const normalized = agentId.trim(); + if (normalized) { + agentIds.add(normalized); + } + } + } catch { + // Ignore config discovery failures and fall back to disk scan. + } + + try { + const agentEntries = await readdir(agentsDir, { withFileTypes: true }); + for (const entry of agentEntries) { + if (entry.isDirectory()) { + const normalized = entry.name.trim(); + if (normalized) { + agentIds.add(normalized); + } + } + } + } catch { + // Ignore disk discovery failures and return whatever we already found. + } + + return [...agentIds]; +} + async function listRecentSessionFiles(): Promise> { const openclawDir = getOpenClawConfigDir(); const agentsDir = join(openclawDir, 'agents'); try { - const agentEntries = await listConfiguredAgentIds(); + const agentEntries = await listAgentIdsWithSessionDirs(); const files: Array<{ filePath: string; sessionId: string; agentId: string; mtimeMs: number }> = []; for (const agentId of agentEntries) { diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 4244de91f..6551063e6 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -219,6 +219,29 @@ "cliPowershell": "PowerShell command.", "cmdUnavailable": "Command unavailable", "cmdCopied": "CLI command copied", + "doctor": "OpenClaw Doctor", + "doctorDesc": "Run `openclaw doctor --json` and inspect the raw diagnostic output.", + "runDoctor": "Run Doctor", + "runDoctorFix": "Run Doctor Fix", + "doctorSucceeded": "OpenClaw doctor completed", + "doctorFailed": "OpenClaw doctor reported issues", + "doctorRunFailed": "Failed to run OpenClaw doctor", + "doctorFixSucceeded": "OpenClaw doctor fix completed", + "doctorFixFailed": "OpenClaw doctor fix reported issues", + "doctorFixRunFailed": "Failed to run OpenClaw doctor fix", + "doctorCopied": "Doctor output copied", + "doctorOk": "Healthy", + "doctorIssue": "Issues Found", + "doctorFixOk": "Fixed", + "doctorFixIssue": "Fix Failed", + "doctorExitCode": "Exit code", + "doctorDuration": "Duration", + "doctorCommand": "Command", + "doctorWorkingDir": "Working dir", + "doctorError": "Error", + "doctorStdout": "Stdout", + "doctorStderr": "Stderr", + "doctorOutputEmpty": "(empty)", "wsDiagnostic": "WS Diagnostic Mode", "wsDiagnosticDesc": "Temporarily enable WS/HTTP fallback chain for gateway RPC debugging.", "wsDiagnosticEnabled": "WS diagnostic mode enabled", diff --git a/src/i18n/locales/ja/settings.json b/src/i18n/locales/ja/settings.json index 46d2b9607..91d84e2cf 100644 --- a/src/i18n/locales/ja/settings.json +++ b/src/i18n/locales/ja/settings.json @@ -216,6 +216,29 @@ "cliPowershell": "PowerShell コマンド。", "cmdUnavailable": "コマンドが利用できません", "cmdCopied": "CLI コマンドをコピーしました", + "doctor": "OpenClaw Doctor", + "doctorDesc": "`openclaw doctor --json` を実行して診断の生出力を確認します。", + "runDoctor": "Doctor を実行", + "runDoctorFix": "Doctor 修復を実行", + "doctorSucceeded": "OpenClaw doctor が完了しました", + "doctorFailed": "OpenClaw doctor が問題を報告しました", + "doctorRunFailed": "OpenClaw doctor の実行に失敗しました", + "doctorFixSucceeded": "OpenClaw doctor 修復が完了しました", + "doctorFixFailed": "OpenClaw doctor 修復後も問題が残っています", + "doctorFixRunFailed": "OpenClaw doctor 修復の実行に失敗しました", + "doctorCopied": "診断出力をコピーしました", + "doctorOk": "正常", + "doctorIssue": "問題あり", + "doctorFixOk": "修復済み", + "doctorFixIssue": "修復失敗", + "doctorExitCode": "終了コード", + "doctorDuration": "所要時間", + "doctorCommand": "コマンド", + "doctorWorkingDir": "作業ディレクトリ", + "doctorError": "エラー", + "doctorStdout": "標準出力", + "doctorStderr": "標準エラー", + "doctorOutputEmpty": "(空)", "wsDiagnostic": "WS 診断モード", "wsDiagnosticDesc": "Gateway RPC デバッグのため一時的に WS/HTTP フォールバックを有効化します。", "wsDiagnosticEnabled": "WS 診断モードを有効化しました", diff --git a/src/i18n/locales/zh/settings.json b/src/i18n/locales/zh/settings.json index ce40901db..ee854a7f6 100644 --- a/src/i18n/locales/zh/settings.json +++ b/src/i18n/locales/zh/settings.json @@ -219,6 +219,29 @@ "cliPowershell": "PowerShell 命令。", "cmdUnavailable": "命令不可用", "cmdCopied": "CLI 命令已复制", + "doctor": "OpenClaw Doctor 诊断", + "doctorDesc": "运行 `openclaw doctor --json` 并查看原始诊断输出。", + "runDoctor": "运行 Doctor", + "runDoctorFix": "运行 Doctor 并修复", + "doctorSucceeded": "OpenClaw doctor 已完成", + "doctorFailed": "OpenClaw doctor 检测到问题", + "doctorRunFailed": "运行 OpenClaw doctor 失败", + "doctorFixSucceeded": "OpenClaw doctor 修复已完成", + "doctorFixFailed": "OpenClaw doctor 修复后仍有问题", + "doctorFixRunFailed": "运行 OpenClaw doctor 修复失败", + "doctorCopied": "诊断输出已复制", + "doctorOk": "正常", + "doctorIssue": "发现问题", + "doctorFixOk": "已修复", + "doctorFixIssue": "修复失败", + "doctorExitCode": "退出码", + "doctorDuration": "耗时", + "doctorCommand": "命令", + "doctorWorkingDir": "工作目录", + "doctorError": "错误", + "doctorStdout": "标准输出", + "doctorStderr": "标准错误", + "doctorOutputEmpty": "(空)", "wsDiagnostic": "WS 诊断模式", "wsDiagnosticDesc": "临时启用 WS/HTTP 回退链,用于网关 RPC 调试。", "wsDiagnosticEnabled": "已启用 WS 诊断模式", diff --git a/src/pages/Models/index.tsx b/src/pages/Models/index.tsx index 55950f74b..a57291d5c 100644 --- a/src/pages/Models/index.tsx +++ b/src/pages/Models/index.tsx @@ -12,25 +12,15 @@ import { hostApiFetch } from '@/lib/host-api'; import { trackUiEvent } from '@/lib/telemetry'; import { ProvidersSettings } from '@/components/settings/ProvidersSettings'; import { FeedbackState } from '@/components/common/FeedbackState'; - -type UsageHistoryEntry = { - timestamp: string; - sessionId: string; - agentId: string; - model?: string; - provider?: string; - content?: string; - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheWriteTokens: number; - totalTokens: number; - costUsd?: number; -}; - -type UsageWindow = '7d' | '30d' | 'all'; -type UsageGroupBy = 'model' | 'day'; -const USAGE_FETCH_MAX_ATTEMPTS = 6; +import { + filterUsageHistoryByWindow, + groupUsageHistory, + type UsageGroupBy, + type UsageHistoryEntry, + type UsageWindow, +} from './usage-history'; +const DEFAULT_USAGE_FETCH_MAX_ATTEMPTS = 6; +const WINDOWS_USAGE_FETCH_MAX_ATTEMPTS = 10; const USAGE_FETCH_RETRY_DELAY_MS = 1500; export function Models() { @@ -38,6 +28,9 @@ export function Models() { const gatewayStatus = useGatewayStore((state) => state.status); const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked); const isGatewayRunning = gatewayStatus.state === 'running'; + const usageFetchMaxAttempts = window.electron.platform === 'win32' + ? WINDOWS_USAGE_FETCH_MAX_ATTEMPTS + : DEFAULT_USAGE_FETCH_MAX_ATTEMPTS; const [usageHistory, setUsageHistory] = useState([]); const [usageGroupBy, setUsageGroupBy] = useState('model'); @@ -87,7 +80,7 @@ export function Models() { restartMarker, }); - if (normalized.length === 0 && attempt < USAGE_FETCH_MAX_ATTEMPTS) { + if (normalized.length === 0 && attempt < usageFetchMaxAttempts) { trackUiEvent('models.token_usage_fetch_retry_scheduled', { generation, attempt, @@ -113,7 +106,7 @@ export function Models() { restartMarker, message: error instanceof Error ? error.message : String(error), }); - if (attempt < USAGE_FETCH_MAX_ATTEMPTS) { + if (attempt < usageFetchMaxAttempts) { trackUiEvent('models.token_usage_fetch_retry_scheduled', { generation, attempt, @@ -143,7 +136,7 @@ export function Models() { usageFetchTimerRef.current = null; } }; - }, [isGatewayRunning, gatewayStatus.connectedAt, gatewayStatus.pid]); + }, [isGatewayRunning, gatewayStatus.connectedAt, gatewayStatus.pid, usageFetchMaxAttempts]); const visibleUsageHistory = isGatewayRunning ? usageHistory : []; const filteredUsageHistory = filterUsageHistoryByWindow(visibleUsageHistory, usageWindow); @@ -383,84 +376,6 @@ function formatUsageTimestamp(timestamp: string): string { }).format(date); } -function groupUsageHistory( - entries: UsageHistoryEntry[], - groupBy: UsageGroupBy, -): Array<{ - label: string; - totalTokens: number; - inputTokens: number; - outputTokens: number; - cacheTokens: number; - sortKey: number | string; -}> { - const grouped = new Map(); - - for (const entry of entries) { - const label = groupBy === 'model' - ? (entry.model || 'Unknown') - : formatUsageDay(entry.timestamp); - const current = grouped.get(label) ?? { - label, - totalTokens: 0, - inputTokens: 0, - outputTokens: 0, - cacheTokens: 0, - sortKey: groupBy === 'day' ? getUsageDaySortKey(entry.timestamp) : label.toLowerCase(), - }; - current.totalTokens += entry.totalTokens; - current.inputTokens += entry.inputTokens; - current.outputTokens += entry.outputTokens; - current.cacheTokens += entry.cacheReadTokens + entry.cacheWriteTokens; - grouped.set(label, current); - } - - return Array.from(grouped.values()) - .sort((a, b) => { - if (groupBy === 'day') { - return Number(a.sortKey) - Number(b.sortKey); - } - return b.totalTokens - a.totalTokens; - }) - .slice(0, 8); -} - -function formatUsageDay(timestamp: string): string { - const date = new Date(timestamp); - if (Number.isNaN(date.getTime())) return timestamp; - return new Intl.DateTimeFormat(undefined, { - month: 'short', - day: 'numeric', - }).format(date); -} - -function getUsageDaySortKey(timestamp: string): number { - const date = new Date(timestamp); - if (Number.isNaN(date.getTime())) return 0; - date.setHours(0, 0, 0, 0); - return date.getTime(); -} - -function filterUsageHistoryByWindow(entries: UsageHistoryEntry[], window: UsageWindow): UsageHistoryEntry[] { - if (window === 'all') return entries; - - const now = Date.now(); - const days = window === '7d' ? 7 : 30; - const cutoff = now - days * 24 * 60 * 60 * 1000; - - return entries.filter((entry) => { - const timestamp = Date.parse(entry.timestamp); - return Number.isFinite(timestamp) && timestamp >= cutoff; - }); -} - function UsageBarChart({ groups, emptyLabel, diff --git a/src/pages/Models/usage-history.ts b/src/pages/Models/usage-history.ts new file mode 100644 index 000000000..a535fa275 --- /dev/null +++ b/src/pages/Models/usage-history.ts @@ -0,0 +1,93 @@ +export type UsageHistoryEntry = { + timestamp: string; + sessionId: string; + agentId: string; + model?: string; + provider?: string; + content?: string; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + totalTokens: number; + costUsd?: number; +}; + +export type UsageWindow = '7d' | '30d' | 'all'; +export type UsageGroupBy = 'model' | 'day'; + +export type UsageGroup = { + label: string; + totalTokens: number; + inputTokens: number; + outputTokens: number; + cacheTokens: number; + sortKey: number | string; +}; + +export function formatUsageDay(timestamp: string): string { + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) return timestamp; + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + }).format(date); +} + +export function getUsageDaySortKey(timestamp: string): number { + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) return 0; + date.setHours(0, 0, 0, 0); + return date.getTime(); +} + +export function groupUsageHistory( + entries: UsageHistoryEntry[], + groupBy: UsageGroupBy, +): UsageGroup[] { + const grouped = new Map(); + + for (const entry of entries) { + const label = groupBy === 'model' + ? (entry.model || 'Unknown') + : formatUsageDay(entry.timestamp); + const current = grouped.get(label) ?? { + label, + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheTokens: 0, + sortKey: groupBy === 'day' ? getUsageDaySortKey(entry.timestamp) : label.toLowerCase(), + }; + current.totalTokens += entry.totalTokens; + current.inputTokens += entry.inputTokens; + current.outputTokens += entry.outputTokens; + current.cacheTokens += entry.cacheReadTokens + entry.cacheWriteTokens; + grouped.set(label, current); + } + + const sorted = Array.from(grouped.values()).sort((a, b) => { + if (groupBy === 'day') { + return Number(a.sortKey) - Number(b.sortKey); + } + return b.totalTokens - a.totalTokens; + }); + + return groupBy === 'model' ? sorted.slice(0, 8) : sorted; +} + +export function filterUsageHistoryByWindow( + entries: UsageHistoryEntry[], + window: UsageWindow, + now = Date.now(), +): UsageHistoryEntry[] { + if (window === 'all') return entries; + + const days = window === '7d' ? 7 : 30; + const cutoff = now - days * 24 * 60 * 60 * 1000; + + return entries.filter((entry) => { + const timestamp = Date.parse(entry.timestamp); + return Number.isFinite(timestamp) && timestamp >= cutoff; + }); +} diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx index 3dc01be56..06c0901a1 100644 --- a/src/pages/Settings/index.tsx +++ b/src/pages/Settings/index.tsx @@ -100,6 +100,19 @@ export function Settings() { const showCliTools = true; const [showLogs, setShowLogs] = useState(false); const [logContent, setLogContent] = useState(''); + const [doctorRunningMode, setDoctorRunningMode] = useState<'diagnose' | 'fix' | null>(null); + const [doctorResult, setDoctorResult] = useState<{ + mode: 'diagnose' | 'fix'; + success: boolean; + exitCode: number | null; + stdout: string; + stderr: string; + command: string; + cwd: string; + durationMs: number; + timedOut?: boolean; + error?: string; + } | null>(null); const handleShowLogs = async () => { try { @@ -123,6 +136,72 @@ export function Settings() { } }; + const handleRunOpenClawDoctor = async (mode: 'diagnose' | 'fix') => { + setDoctorRunningMode(mode); + try { + const result = await hostApiFetch<{ + mode: 'diagnose' | 'fix'; + success: boolean; + exitCode: number | null; + stdout: string; + stderr: string; + command: string; + cwd: string; + durationMs: number; + timedOut?: boolean; + error?: string; + }>('/api/app/openclaw-doctor', { + method: 'POST', + body: JSON.stringify({ mode }), + }); + setDoctorResult(result); + if (result.success) { + toast.success(mode === 'fix' ? t('developer.doctorFixSucceeded') : t('developer.doctorSucceeded')); + } else { + toast.error(result.error || (mode === 'fix' ? t('developer.doctorFixFailed') : t('developer.doctorFailed'))); + } + } catch (error) { + const message = toUserMessage(error) || (mode === 'fix' ? t('developer.doctorFixRunFailed') : t('developer.doctorRunFailed')); + toast.error(message); + setDoctorResult({ + mode, + success: false, + exitCode: null, + stdout: '', + stderr: '', + command: 'openclaw doctor --json', + cwd: '', + durationMs: 0, + error: message, + }); + } finally { + setDoctorRunningMode(null); + } + }; + + const handleCopyDoctorOutput = async () => { + if (!doctorResult) return; + const payload = [ + `command: ${doctorResult.command}`, + `cwd: ${doctorResult.cwd}`, + `exitCode: ${doctorResult.exitCode ?? 'null'}`, + `durationMs: ${doctorResult.durationMs}`, + '', + '[stdout]', + doctorResult.stdout.trim() || '(empty)', + '', + '[stderr]', + doctorResult.stderr.trim() || '(empty)', + ].join('\n'); + + try { + await navigator.clipboard.writeText(payload); + toast.success(t('developer.doctorCopied')); + } catch (error) { + toast.error(`Failed to copy doctor output: ${String(error)}`); + } + }; + const refreshControlUiInfo = async () => { @@ -737,6 +816,86 @@ export function Settings() { )} +
+
+
+ +

+ {t('developer.doctorDesc')} +

+
+
+ + + +
+
+ + {doctorResult && ( +
+
+ + {doctorResult.mode === 'fix' + ? (doctorResult.success ? t('developer.doctorFixOk') : t('developer.doctorFixIssue')) + : (doctorResult.success ? t('developer.doctorOk') : t('developer.doctorIssue'))} + + + {t('developer.doctorExitCode')}: {doctorResult.exitCode ?? 'null'} + + + {t('developer.doctorDuration')}: {Math.round(doctorResult.durationMs)}ms + +
+
+

{t('developer.doctorCommand')}: {doctorResult.command}

+

{t('developer.doctorWorkingDir')}: {doctorResult.cwd || '-'}

+ {doctorResult.error &&

{t('developer.doctorError')}: {doctorResult.error}

} +
+
+
+

{t('developer.doctorStdout')}

+
+                              {doctorResult.stdout.trim() || t('developer.doctorOutputEmpty')}
+                            
+
+
+

{t('developer.doctorStderr')}

+
+                              {doctorResult.stderr.trim() || t('developer.doctorOutputEmpty')}
+                            
+
+
+
+ )} +
+
diff --git a/tests/unit/app-routes.test.ts b/tests/unit/app-routes.test.ts new file mode 100644 index 000000000..0cdfdeccd --- /dev/null +++ b/tests/unit/app-routes.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IncomingMessage, ServerResponse } from 'http'; + +const runOpenClawDoctorMock = vi.fn(); +const runOpenClawDoctorFixMock = vi.fn(); +const sendJsonMock = vi.fn(); +const sendNoContentMock = vi.fn(); + +vi.mock('@electron/utils/openclaw-doctor', () => ({ + runOpenClawDoctor: (...args: unknown[]) => runOpenClawDoctorMock(...args), + runOpenClawDoctorFix: (...args: unknown[]) => runOpenClawDoctorFixMock(...args), +})); + +vi.mock('@electron/api/route-utils', () => ({ + setCorsHeaders: vi.fn(), + parseJsonBody: vi.fn().mockResolvedValue({}), + sendJson: (...args: unknown[]) => sendJsonMock(...args), + sendNoContent: (...args: unknown[]) => sendNoContentMock(...args), +})); + +describe('handleAppRoutes', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('runs openclaw doctor through the host api', async () => { + runOpenClawDoctorMock.mockResolvedValueOnce({ success: true, exitCode: 0 }); + const { handleAppRoutes } = await import('@electron/api/routes/app'); + + const handled = await handleAppRoutes( + { method: 'POST' } as IncomingMessage, + {} as ServerResponse, + new URL('http://127.0.0.1:3210/api/app/openclaw-doctor'), + {} as never, + ); + + expect(handled).toBe(true); + expect(runOpenClawDoctorMock).toHaveBeenCalledTimes(1); + expect(sendJsonMock).toHaveBeenCalledWith(expect.anything(), 200, { success: true, exitCode: 0 }); + }); + + it('runs openclaw doctor fix when requested', async () => { + const { parseJsonBody } = await import('@electron/api/route-utils'); + vi.mocked(parseJsonBody).mockResolvedValueOnce({ mode: 'fix' }); + runOpenClawDoctorFixMock.mockResolvedValueOnce({ success: false, exitCode: 1 }); + const { handleAppRoutes } = await import('@electron/api/routes/app'); + + const handled = await handleAppRoutes( + { method: 'POST' } as IncomingMessage, + {} as ServerResponse, + new URL('http://127.0.0.1:3210/api/app/openclaw-doctor'), + {} as never, + ); + + expect(handled).toBe(true); + expect(runOpenClawDoctorFixMock).toHaveBeenCalledTimes(1); + expect(sendJsonMock).toHaveBeenCalledWith(expect.anything(), 200, { success: false, exitCode: 1 }); + }); +}); diff --git a/tests/unit/models-usage-history.test.ts b/tests/unit/models-usage-history.test.ts new file mode 100644 index 000000000..2907fdc75 --- /dev/null +++ b/tests/unit/models-usage-history.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { + filterUsageHistoryByWindow, + groupUsageHistory, + type UsageHistoryEntry, +} from '@/pages/Models/usage-history'; + +function createEntry(day: number, totalTokens: number): UsageHistoryEntry { + return { + timestamp: `2026-03-${String(day).padStart(2, '0')}T12:00:00.000Z`, + sessionId: `session-${day}`, + agentId: 'main', + model: 'gpt-5', + inputTokens: totalTokens, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens, + }; +} + +describe('models usage history helpers', () => { + it('keeps all day buckets instead of truncating to the first eight', () => { + const entries = Array.from({ length: 12 }, (_, index) => createEntry(index + 1, index + 1)); + + const groups = groupUsageHistory(entries, 'day'); + + expect(groups).toHaveLength(12); + expect(groups[0]?.totalTokens).toBe(1); + expect(groups[11]?.totalTokens).toBe(12); + }); + + it('limits model buckets to the top eight by total tokens', () => { + const entries = Array.from({ length: 10 }, (_, index) => ({ + ...createEntry(index + 1, index + 1), + model: `model-${index + 1}`, + })); + + const groups = groupUsageHistory(entries, 'model'); + + expect(groups).toHaveLength(8); + expect(groups[0]?.label).toBe('model-10'); + expect(groups[7]?.label).toBe('model-3'); + }); + + it('filters the last 30 days relative to now instead of calendar month boundaries', () => { + const now = Date.parse('2026-03-12T12:00:00.000Z'); + const entries = [ + { + ...createEntry(12, 12), + timestamp: '2026-03-12T12:00:00.000Z', + }, + { + ...createEntry(11, 11), + timestamp: '2026-02-11T12:00:00.000Z', + }, + { + ...createEntry(10, 10), + timestamp: '2026-02-10T11:59:59.000Z', + }, + ]; + + const filtered = filterUsageHistoryByWindow(entries, '30d', now); + + expect(filtered).toHaveLength(2); + expect(filtered.map((entry) => entry.totalTokens)).toEqual([12, 11]); + }); +}); diff --git a/tests/unit/telemetry.test.ts b/tests/unit/telemetry.test.ts new file mode 100644 index 000000000..d74481fbd --- /dev/null +++ b/tests/unit/telemetry.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + shutdownMock, + captureMock, + getSettingMock, + setSettingMock, + loggerDebugMock, + loggerErrorMock, +} = vi.hoisted(() => ({ + shutdownMock: vi.fn(), + captureMock: vi.fn(), + getSettingMock: vi.fn(), + setSettingMock: vi.fn(), + loggerDebugMock: vi.fn(), + loggerErrorMock: vi.fn(), +})); + +vi.mock('posthog-node', () => ({ + PostHog: vi.fn(function PostHogMock() { + return { + capture: captureMock, + shutdown: shutdownMock, + }; + }), +})); + +vi.mock('@electron/utils/store', () => ({ + getSetting: getSettingMock, + setSetting: setSettingMock, +})); + +vi.mock('@electron/utils/logger', () => ({ + logger: { + debug: loggerDebugMock, + error: loggerErrorMock, + info: vi.fn(), + warn: vi.fn(), + }, +})); + +vi.mock('electron', () => ({ + app: { + getVersion: () => '0.2.1', + }, +})); + +vi.mock('node-machine-id', () => ({ + machineIdSync: () => 'machine-id-1', +})); + +describe('main telemetry shutdown', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + getSettingMock.mockImplementation(async (key: string) => { + switch (key) { + case 'telemetryEnabled': + return true; + case 'machineId': + return 'existing-machine-id'; + case 'hasReportedInstall': + return true; + default: + return undefined; + } + }); + setSettingMock.mockResolvedValue(undefined); + captureMock.mockReturnValue(undefined); + }); + + it('ignores PostHog network timeout errors during shutdown', async () => { + shutdownMock.mockRejectedValueOnce( + Object.assign(new Error('Network error while fetching PostHog'), { + name: 'PostHogFetchNetworkError', + cause: Object.assign(new Error('The operation was aborted due to timeout'), { + name: 'TimeoutError', + }), + }), + ); + + const { initTelemetry, shutdownTelemetry } = await import('@electron/utils/telemetry'); + await initTelemetry(); + await shutdownTelemetry(); + + expect(loggerErrorMock).not.toHaveBeenCalled(); + expect(loggerDebugMock).toHaveBeenCalledWith( + 'Ignored telemetry shutdown network error:', + expect.objectContaining({ name: 'PostHogFetchNetworkError' }), + ); + }); +}); diff --git a/tests/unit/token-usage-files.test.ts b/tests/unit/token-usage-files.test.ts index f202605c0..4ec0f6b66 100644 --- a/tests/unit/token-usage-files.test.ts +++ b/tests/unit/token-usage-files.test.ts @@ -14,6 +14,10 @@ describe('extractSessionIdFromTranscriptFileName', () => { expect(extractSessionIdFromTranscriptFileName('abc-123.jsonl.reset.2026-03-09T03-01-29.968Z')).toBe('abc-123'); }); + it('parses deleted reset transcript names', () => { + expect(extractSessionIdFromTranscriptFileName('abc-123.deleted.jsonl.reset.2026-03-09T03-01-29.968Z')).toBe('abc-123'); + }); + it('returns undefined for non-transcript files', () => { expect(extractSessionIdFromTranscriptFileName('sessions.json')).toBeUndefined(); expect(extractSessionIdFromTranscriptFileName('abc-123.log')).toBeUndefined(); diff --git a/tests/unit/token-usage-scan.test.ts b/tests/unit/token-usage-scan.test.ts new file mode 100644 index 000000000..be9d1b05b --- /dev/null +++ b/tests/unit/token-usage-scan.test.ts @@ -0,0 +1,89 @@ +import { mkdir, rm, writeFile } from 'fs/promises'; +import { join } from 'path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { testHome, testUserData } = vi.hoisted(() => { + const suffix = Math.random().toString(36).slice(2); + return { + testHome: `/tmp/clawx-token-usage-${suffix}`, + testUserData: `/tmp/clawx-token-usage-user-data-${suffix}`, + }; +}); + +vi.mock('os', async () => { + const actual = await vi.importActual('os'); + const mocked = { + ...actual, + homedir: () => testHome, + }; + return { + ...mocked, + default: mocked, + }; +}); + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: () => testUserData, + getVersion: () => '0.0.0-test', + }, +})); + +describe('token usage session scan', () => { + beforeEach(async () => { + vi.resetModules(); + vi.restoreAllMocks(); + await rm(testHome, { recursive: true, force: true }); + await rm(testUserData, { recursive: true, force: true }); + }); + + it('includes transcripts from agent directories that exist on disk but are not configured', async () => { + const openclawDir = join(testHome, '.openclaw'); + await mkdir(openclawDir, { recursive: true }); + await writeFile(join(openclawDir, 'openclaw.json'), JSON.stringify({ + agents: { + list: [ + { id: 'main', name: 'Main', default: true }, + ], + }, + }, null, 2), 'utf8'); + + const diskOnlySessionsDir = join(openclawDir, 'agents', 'custom-custom25', 'sessions'); + await mkdir(diskOnlySessionsDir, { recursive: true }); + await writeFile( + join(diskOnlySessionsDir, 'f8e66f77-0125-4e2f-b750-9c4de01e8f5a.jsonl'), + [ + JSON.stringify({ + type: 'message', + timestamp: '2026-03-12T12:19:00.000Z', + message: { + role: 'assistant', + model: 'gpt-5.2-2025-12-11', + provider: 'openai', + usage: { + input: 17649, + output: 107, + total: 17756, + }, + }, + }), + ].join('\n'), + 'utf8', + ); + + const { getRecentTokenUsageHistory } = await import('@electron/utils/token-usage'); + const entries = await getRecentTokenUsageHistory(); + + expect(entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + agentId: 'custom-custom25', + sessionId: 'f8e66f77-0125-4e2f-b750-9c4de01e8f5a', + model: 'gpt-5.2-2025-12-11', + totalTokens: 17756, + }), + ]), + ); + }); +});