Fix telemetry shutdown noise and improve token usage diagnostics (#444)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
01adc828b5
commit
995a7f070d
@@ -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).
|
- **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.
|
- **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.
|
- **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/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.
|
- 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.
|
- Do not add new direct `window.electron.ipcRenderer.invoke(...)` calls in pages/components; expose them through host-api/api-client instead.
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネ
|
|||||||
- 高度なプロキシフィールドが空の場合、ClawXは`プロキシサーバー`にフォールバックします。
|
- 高度なプロキシフィールドが空の場合、ClawXは`プロキシサーバー`にフォールバックします。
|
||||||
- プロキシ設定を保存すると、Electronのネットワーク設定が即座に再適用され、ゲートウェイが自動的に再起動されます。
|
- プロキシ設定を保存すると、Electronのネットワーク設定が即座に再適用され、ゲートウェイが自動的に再起動されます。
|
||||||
- ClawXはTelegramが有効な場合、プロキシをOpenClawのTelegramチャネル設定にも同期します。
|
- ClawXはTelegramが有効な場合、プロキシをOpenClawのTelegramチャネル設定にも同期します。
|
||||||
|
- **設定 → 詳細 → 開発者** では **OpenClaw Doctor** を実行でき、`openclaw doctor --json` の診断出力をアプリ内で確認できます。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ Notes:
|
|||||||
- If advanced proxy fields are left empty, ClawX falls back to `Proxy Server`.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ ClawX 内置了代理设置,适用于需要通过本地代理客户端访问
|
|||||||
- 高级代理项留空时,会自动回退到“代理服务器”。
|
- 高级代理项留空时,会自动回退到“代理服务器”。
|
||||||
- 保存代理设置后,Electron 网络层会立即重新应用代理,并自动重启 Gateway。
|
- 保存代理设置后,Electron 网络层会立即重新应用代理,并自动重启 Gateway。
|
||||||
- 如果启用了 Telegram,ClawX 还会把代理同步到 OpenClaw 的 Telegram 频道配置中。
|
- 如果启用了 Telegram,ClawX 还会把代理同步到 OpenClaw 的 Telegram 频道配置中。
|
||||||
|
- 在 **设置 → 高级 → 开发者** 中,可以直接运行 **OpenClaw Doctor**,执行 `openclaw doctor --json` 并在应用内查看诊断输出。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from 'http';
|
import type { IncomingMessage, ServerResponse } from 'http';
|
||||||
import type { HostApiContext } from '../context';
|
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(
|
export async function handleAppRoutes(
|
||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
@@ -23,6 +25,13 @@ export async function handleAppRoutes(
|
|||||||
return true;
|
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') {
|
if (req.method === 'OPTIONS') {
|
||||||
sendNoContent(res);
|
sendNoContent(res);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { createMenu } from './menu';
|
|||||||
import { appUpdater, registerUpdateHandlers } from './updater';
|
import { appUpdater, registerUpdateHandlers } from './updater';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { warmupNetworkOptimization } from '../utils/uv-env';
|
import { warmupNetworkOptimization } from '../utils/uv-env';
|
||||||
import { initTelemetry, shutdownTelemetry } from '../utils/telemetry';
|
import { initTelemetry } from '../utils/telemetry';
|
||||||
|
|
||||||
import { ClawHubService } from '../gateway/clawhub';
|
import { ClawHubService } from '../gateway/clawhub';
|
||||||
import { ensureClawXContext, repairClawXOnlyBootstrapFiles } from '../utils/openclaw-workspace';
|
import { ensureClawXContext, repairClawXOnlyBootstrapFiles } from '../utils/openclaw-workspace';
|
||||||
@@ -392,10 +392,6 @@ app.on('before-quit', () => {
|
|||||||
setQuitting();
|
setQuitting();
|
||||||
hostEventBus.closeAll();
|
hostEventBus.closeAll();
|
||||||
hostApiServer?.close();
|
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.
|
// Fire-and-forget: do not await gatewayManager.stop() here.
|
||||||
// Awaiting inside before-quit can stall Electron's quit sequence.
|
// Awaiting inside before-quit can stall Electron's quit sequence.
|
||||||
void gatewayManager.stop().catch((err) => {
|
void gatewayManager.stop().catch((err) => {
|
||||||
|
|||||||
158
electron/utils/openclaw-doctor.ts
Normal file
158
electron/utils/openclaw-doctor.ts
Normal file
@@ -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<OpenClawDoctorResult> {
|
||||||
|
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<OpenClawDoctorResult>((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<OpenClawDoctorResult, 'durationMs'>) => {
|
||||||
|
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<OpenClawDoctorResult> {
|
||||||
|
return await runDoctorCommand('diagnose');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runOpenClawDoctorFix(): Promise<OpenClawDoctorResult> {
|
||||||
|
return await runDoctorCommand('fix');
|
||||||
|
}
|
||||||
@@ -6,10 +6,32 @@ import { logger } from './logger';
|
|||||||
|
|
||||||
const POSTHOG_API_KEY = 'phc_aGNegeJQP5FzNiF2rEoKqQbkuCpiiETMttplibXpB0n';
|
const POSTHOG_API_KEY = 'phc_aGNegeJQP5FzNiF2rEoKqQbkuCpiiETMttplibXpB0n';
|
||||||
const POSTHOG_HOST = 'https://us.i.posthog.com';
|
const POSTHOG_HOST = 'https://us.i.posthog.com';
|
||||||
|
const TELEMETRY_SHUTDOWN_TIMEOUT_MS = 1500;
|
||||||
|
|
||||||
let posthogClient: PostHog | null = null;
|
let posthogClient: PostHog | null = null;
|
||||||
let distinctId: string = '';
|
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
|
* Initialize PostHog telemetry
|
||||||
*/
|
*/
|
||||||
@@ -65,15 +87,51 @@ export async function initTelemetry(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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<void> {
|
export async function shutdownTelemetry(): Promise<void> {
|
||||||
if (posthogClient) {
|
const client = posthogClient;
|
||||||
try {
|
posthogClient = null;
|
||||||
await posthogClient.shutdown();
|
distinctId = '';
|
||||||
logger.debug('Flushed telemetry events on shutdown');
|
|
||||||
} catch (error) {
|
if (!client) {
|
||||||
logger.error('Error shutting down telemetry:', error);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let didTimeout = false;
|
||||||
|
let timeoutHandle: ReturnType<typeof setTimeout> | 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<void>((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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export interface TokenUsageHistoryEntry {
|
|||||||
export function extractSessionIdFromTranscriptFileName(fileName: string): string | undefined {
|
export function extractSessionIdFromTranscriptFileName(fileName: string): string | undefined {
|
||||||
if (!fileName.endsWith('.jsonl') && !fileName.includes('.jsonl.reset.')) return undefined;
|
if (!fileName.endsWith('.jsonl') && !fileName.includes('.jsonl.reset.')) return undefined;
|
||||||
return fileName
|
return fileName
|
||||||
.replace(/\.jsonl\.reset\..+$/, '')
|
.replace(/\.reset\..+$/, '')
|
||||||
.replace(/\.deleted\.jsonl$/, '')
|
.replace(/\.deleted\.jsonl$/, '')
|
||||||
.replace(/\.jsonl$/, '');
|
.replace(/\.jsonl$/, '');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,45 @@ export {
|
|||||||
type TokenUsageHistoryEntry,
|
type TokenUsageHistoryEntry,
|
||||||
} from './token-usage-core';
|
} from './token-usage-core';
|
||||||
|
|
||||||
|
async function listAgentIdsWithSessionDirs(): Promise<string[]> {
|
||||||
|
const openclawDir = getOpenClawConfigDir();
|
||||||
|
const agentsDir = join(openclawDir, 'agents');
|
||||||
|
const agentIds = new Set<string>();
|
||||||
|
|
||||||
|
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<Array<{ filePath: string; sessionId: string; agentId: string; mtimeMs: number }>> {
|
async function listRecentSessionFiles(): Promise<Array<{ filePath: string; sessionId: string; agentId: string; mtimeMs: number }>> {
|
||||||
const openclawDir = getOpenClawConfigDir();
|
const openclawDir = getOpenClawConfigDir();
|
||||||
const agentsDir = join(openclawDir, 'agents');
|
const agentsDir = join(openclawDir, 'agents');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const agentEntries = await listConfiguredAgentIds();
|
const agentEntries = await listAgentIdsWithSessionDirs();
|
||||||
const files: Array<{ filePath: string; sessionId: string; agentId: string; mtimeMs: number }> = [];
|
const files: Array<{ filePath: string; sessionId: string; agentId: string; mtimeMs: number }> = [];
|
||||||
|
|
||||||
for (const agentId of agentEntries) {
|
for (const agentId of agentEntries) {
|
||||||
|
|||||||
@@ -219,6 +219,29 @@
|
|||||||
"cliPowershell": "PowerShell command.",
|
"cliPowershell": "PowerShell command.",
|
||||||
"cmdUnavailable": "Command unavailable",
|
"cmdUnavailable": "Command unavailable",
|
||||||
"cmdCopied": "CLI command copied",
|
"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",
|
"wsDiagnostic": "WS Diagnostic Mode",
|
||||||
"wsDiagnosticDesc": "Temporarily enable WS/HTTP fallback chain for gateway RPC debugging.",
|
"wsDiagnosticDesc": "Temporarily enable WS/HTTP fallback chain for gateway RPC debugging.",
|
||||||
"wsDiagnosticEnabled": "WS diagnostic mode enabled",
|
"wsDiagnosticEnabled": "WS diagnostic mode enabled",
|
||||||
|
|||||||
@@ -216,6 +216,29 @@
|
|||||||
"cliPowershell": "PowerShell コマンド。",
|
"cliPowershell": "PowerShell コマンド。",
|
||||||
"cmdUnavailable": "コマンドが利用できません",
|
"cmdUnavailable": "コマンドが利用できません",
|
||||||
"cmdCopied": "CLI コマンドをコピーしました",
|
"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 診断モード",
|
"wsDiagnostic": "WS 診断モード",
|
||||||
"wsDiagnosticDesc": "Gateway RPC デバッグのため一時的に WS/HTTP フォールバックを有効化します。",
|
"wsDiagnosticDesc": "Gateway RPC デバッグのため一時的に WS/HTTP フォールバックを有効化します。",
|
||||||
"wsDiagnosticEnabled": "WS 診断モードを有効化しました",
|
"wsDiagnosticEnabled": "WS 診断モードを有効化しました",
|
||||||
|
|||||||
@@ -219,6 +219,29 @@
|
|||||||
"cliPowershell": "PowerShell 命令。",
|
"cliPowershell": "PowerShell 命令。",
|
||||||
"cmdUnavailable": "命令不可用",
|
"cmdUnavailable": "命令不可用",
|
||||||
"cmdCopied": "CLI 命令已复制",
|
"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 诊断模式",
|
"wsDiagnostic": "WS 诊断模式",
|
||||||
"wsDiagnosticDesc": "临时启用 WS/HTTP 回退链,用于网关 RPC 调试。",
|
"wsDiagnosticDesc": "临时启用 WS/HTTP 回退链,用于网关 RPC 调试。",
|
||||||
"wsDiagnosticEnabled": "已启用 WS 诊断模式",
|
"wsDiagnosticEnabled": "已启用 WS 诊断模式",
|
||||||
|
|||||||
@@ -12,25 +12,15 @@ import { hostApiFetch } from '@/lib/host-api';
|
|||||||
import { trackUiEvent } from '@/lib/telemetry';
|
import { trackUiEvent } from '@/lib/telemetry';
|
||||||
import { ProvidersSettings } from '@/components/settings/ProvidersSettings';
|
import { ProvidersSettings } from '@/components/settings/ProvidersSettings';
|
||||||
import { FeedbackState } from '@/components/common/FeedbackState';
|
import { FeedbackState } from '@/components/common/FeedbackState';
|
||||||
|
import {
|
||||||
type UsageHistoryEntry = {
|
filterUsageHistoryByWindow,
|
||||||
timestamp: string;
|
groupUsageHistory,
|
||||||
sessionId: string;
|
type UsageGroupBy,
|
||||||
agentId: string;
|
type UsageHistoryEntry,
|
||||||
model?: string;
|
type UsageWindow,
|
||||||
provider?: string;
|
} from './usage-history';
|
||||||
content?: string;
|
const DEFAULT_USAGE_FETCH_MAX_ATTEMPTS = 6;
|
||||||
inputTokens: number;
|
const WINDOWS_USAGE_FETCH_MAX_ATTEMPTS = 10;
|
||||||
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;
|
|
||||||
const USAGE_FETCH_RETRY_DELAY_MS = 1500;
|
const USAGE_FETCH_RETRY_DELAY_MS = 1500;
|
||||||
|
|
||||||
export function Models() {
|
export function Models() {
|
||||||
@@ -38,6 +28,9 @@ export function Models() {
|
|||||||
const gatewayStatus = useGatewayStore((state) => state.status);
|
const gatewayStatus = useGatewayStore((state) => state.status);
|
||||||
const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked);
|
const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked);
|
||||||
const isGatewayRunning = gatewayStatus.state === 'running';
|
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<UsageHistoryEntry[]>([]);
|
const [usageHistory, setUsageHistory] = useState<UsageHistoryEntry[]>([]);
|
||||||
const [usageGroupBy, setUsageGroupBy] = useState<UsageGroupBy>('model');
|
const [usageGroupBy, setUsageGroupBy] = useState<UsageGroupBy>('model');
|
||||||
@@ -87,7 +80,7 @@ export function Models() {
|
|||||||
restartMarker,
|
restartMarker,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (normalized.length === 0 && attempt < USAGE_FETCH_MAX_ATTEMPTS) {
|
if (normalized.length === 0 && attempt < usageFetchMaxAttempts) {
|
||||||
trackUiEvent('models.token_usage_fetch_retry_scheduled', {
|
trackUiEvent('models.token_usage_fetch_retry_scheduled', {
|
||||||
generation,
|
generation,
|
||||||
attempt,
|
attempt,
|
||||||
@@ -113,7 +106,7 @@ export function Models() {
|
|||||||
restartMarker,
|
restartMarker,
|
||||||
message: error instanceof Error ? error.message : String(error),
|
message: error instanceof Error ? error.message : String(error),
|
||||||
});
|
});
|
||||||
if (attempt < USAGE_FETCH_MAX_ATTEMPTS) {
|
if (attempt < usageFetchMaxAttempts) {
|
||||||
trackUiEvent('models.token_usage_fetch_retry_scheduled', {
|
trackUiEvent('models.token_usage_fetch_retry_scheduled', {
|
||||||
generation,
|
generation,
|
||||||
attempt,
|
attempt,
|
||||||
@@ -143,7 +136,7 @@ export function Models() {
|
|||||||
usageFetchTimerRef.current = null;
|
usageFetchTimerRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [isGatewayRunning, gatewayStatus.connectedAt, gatewayStatus.pid]);
|
}, [isGatewayRunning, gatewayStatus.connectedAt, gatewayStatus.pid, usageFetchMaxAttempts]);
|
||||||
|
|
||||||
const visibleUsageHistory = isGatewayRunning ? usageHistory : [];
|
const visibleUsageHistory = isGatewayRunning ? usageHistory : [];
|
||||||
const filteredUsageHistory = filterUsageHistoryByWindow(visibleUsageHistory, usageWindow);
|
const filteredUsageHistory = filterUsageHistoryByWindow(visibleUsageHistory, usageWindow);
|
||||||
@@ -383,84 +376,6 @@ function formatUsageTimestamp(timestamp: string): string {
|
|||||||
}).format(date);
|
}).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<string, {
|
|
||||||
label: string;
|
|
||||||
totalTokens: number;
|
|
||||||
inputTokens: number;
|
|
||||||
outputTokens: number;
|
|
||||||
cacheTokens: number;
|
|
||||||
sortKey: number | string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
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({
|
function UsageBarChart({
|
||||||
groups,
|
groups,
|
||||||
emptyLabel,
|
emptyLabel,
|
||||||
|
|||||||
93
src/pages/Models/usage-history.ts
Normal file
93
src/pages/Models/usage-history.ts
Normal file
@@ -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<string, UsageGroup>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -100,6 +100,19 @@ export function Settings() {
|
|||||||
const showCliTools = true;
|
const showCliTools = true;
|
||||||
const [showLogs, setShowLogs] = useState(false);
|
const [showLogs, setShowLogs] = useState(false);
|
||||||
const [logContent, setLogContent] = useState('');
|
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 () => {
|
const handleShowLogs = async () => {
|
||||||
try {
|
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 () => {
|
const refreshControlUiInfo = async () => {
|
||||||
@@ -737,6 +816,86 @@ export function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[14px] font-medium text-foreground">{t('developer.doctor')}</Label>
|
||||||
|
<p className="text-[13px] text-muted-foreground mt-1">
|
||||||
|
{t('developer.doctorDesc')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void handleRunOpenClawDoctor('diagnose')}
|
||||||
|
disabled={doctorRunningMode !== null}
|
||||||
|
className="rounded-xl h-10 px-4 bg-transparent border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-2${doctorRunningMode === 'diagnose' ? ' animate-spin' : ''}`} />
|
||||||
|
{doctorRunningMode === 'diagnose' ? t('common:status.running') : t('developer.runDoctor')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void handleRunOpenClawDoctor('fix')}
|
||||||
|
disabled={doctorRunningMode !== null}
|
||||||
|
className="rounded-xl h-10 px-4 bg-transparent border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-2${doctorRunningMode === 'fix' ? ' animate-spin' : ''}`} />
|
||||||
|
{doctorRunningMode === 'fix' ? t('common:status.running') : t('developer.runDoctorFix')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCopyDoctorOutput}
|
||||||
|
disabled={!doctorResult}
|
||||||
|
className="rounded-xl h-10 px-4 bg-transparent border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
{t('common:actions.copy')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{doctorResult && (
|
||||||
|
<div className="space-y-3 rounded-2xl border border-black/10 dark:border-white/10 p-5 bg-black/5 dark:bg-white/5">
|
||||||
|
<div className="flex flex-wrap gap-2 text-[12px]">
|
||||||
|
<Badge variant={doctorResult.success ? 'secondary' : 'destructive'} className="rounded-full px-3 py-1">
|
||||||
|
{doctorResult.mode === 'fix'
|
||||||
|
? (doctorResult.success ? t('developer.doctorFixOk') : t('developer.doctorFixIssue'))
|
||||||
|
: (doctorResult.success ? t('developer.doctorOk') : t('developer.doctorIssue'))}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="rounded-full px-3 py-1">
|
||||||
|
{t('developer.doctorExitCode')}: {doctorResult.exitCode ?? 'null'}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="rounded-full px-3 py-1">
|
||||||
|
{t('developer.doctorDuration')}: {Math.round(doctorResult.durationMs)}ms
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-[12px] text-muted-foreground font-mono break-all">
|
||||||
|
<p>{t('developer.doctorCommand')}: {doctorResult.command}</p>
|
||||||
|
<p>{t('developer.doctorWorkingDir')}: {doctorResult.cwd || '-'}</p>
|
||||||
|
{doctorResult.error && <p>{t('developer.doctorError')}: {doctorResult.error}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[12px] font-semibold text-foreground/80">{t('developer.doctorStdout')}</p>
|
||||||
|
<pre className="max-h-72 overflow-auto rounded-xl border border-black/10 dark:border-white/10 bg-white dark:bg-card p-3 text-[11px] font-mono whitespace-pre-wrap break-words">
|
||||||
|
{doctorResult.stdout.trim() || t('developer.doctorOutputEmpty')}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[12px] font-semibold text-foreground/80">{t('developer.doctorStderr')}</p>
|
||||||
|
<pre className="max-h-72 overflow-auto rounded-xl border border-black/10 dark:border-white/10 bg-white dark:bg-card p-3 text-[11px] font-mono whitespace-pre-wrap break-words">
|
||||||
|
{doctorResult.stderr.trim() || t('developer.doctorOutputEmpty')}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between rounded-2xl border border-black/10 dark:border-white/10 p-5 bg-transparent">
|
<div className="flex items-center justify-between rounded-2xl border border-black/10 dark:border-white/10 p-5 bg-transparent">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
59
tests/unit/app-routes.test.ts
Normal file
59
tests/unit/app-routes.test.ts
Normal file
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
68
tests/unit/models-usage-history.test.ts
Normal file
68
tests/unit/models-usage-history.test.ts
Normal file
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
92
tests/unit/telemetry.test.ts
Normal file
92
tests/unit/telemetry.test.ts
Normal file
@@ -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' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,10 @@ describe('extractSessionIdFromTranscriptFileName', () => {
|
|||||||
expect(extractSessionIdFromTranscriptFileName('abc-123.jsonl.reset.2026-03-09T03-01-29.968Z')).toBe('abc-123');
|
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', () => {
|
it('returns undefined for non-transcript files', () => {
|
||||||
expect(extractSessionIdFromTranscriptFileName('sessions.json')).toBeUndefined();
|
expect(extractSessionIdFromTranscriptFileName('sessions.json')).toBeUndefined();
|
||||||
expect(extractSessionIdFromTranscriptFileName('abc-123.log')).toBeUndefined();
|
expect(extractSessionIdFromTranscriptFileName('abc-123.log')).toBeUndefined();
|
||||||
|
|||||||
89
tests/unit/token-usage-scan.test.ts
Normal file
89
tests/unit/token-usage-scan.test.ts
Normal file
@@ -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<typeof import('os')>('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,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user