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
@@ -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<UsageHistoryEntry[]>([]);
|
||||
const [usageGroupBy, setUsageGroupBy] = useState<UsageGroupBy>('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<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({
|
||||
groups,
|
||||
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 [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() {
|
||||
</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="flex items-center justify-between rounded-2xl border border-black/10 dark:border-white/10 p-5 bg-transparent">
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user