Fix token usage handling and developer proxy save UX (#704)
This commit is contained in:
committed by
GitHub
Unverified
parent
2668082809
commit
870abb99c4
@@ -36,6 +36,7 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones:
|
|||||||
- **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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
- **UI change validation**: Any user-visible UI change should include or update an Electron E2E spec in the same PR so the interaction is covered by Playwright.
|
||||||
- **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.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface TokenUsageHistoryEntry {
|
|||||||
model?: string;
|
model?: string;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
usageStatus: 'available' | 'missing' | 'error';
|
||||||
inputTokens: number;
|
inputTokens: number;
|
||||||
outputTokens: number;
|
outputTokens: number;
|
||||||
cacheReadTokens: number;
|
cacheReadTokens: number;
|
||||||
@@ -22,6 +23,7 @@ export function extractSessionIdFromTranscriptFileName(fileName: string): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface TranscriptUsageShape {
|
interface TranscriptUsageShape {
|
||||||
|
[key: string]: unknown;
|
||||||
input?: number;
|
input?: number;
|
||||||
output?: number;
|
output?: number;
|
||||||
total?: number;
|
total?: number;
|
||||||
@@ -30,11 +32,169 @@ interface TranscriptUsageShape {
|
|||||||
promptTokens?: number;
|
promptTokens?: number;
|
||||||
completionTokens?: number;
|
completionTokens?: number;
|
||||||
totalTokens?: number;
|
totalTokens?: number;
|
||||||
|
input_tokens?: number;
|
||||||
|
output_tokens?: number;
|
||||||
|
total_tokens?: number;
|
||||||
|
cache_read?: number;
|
||||||
|
cache_write?: number;
|
||||||
|
prompt_tokens?: number;
|
||||||
|
completion_tokens?: number;
|
||||||
|
cache_read_tokens?: number;
|
||||||
|
cache_write_tokens?: number;
|
||||||
|
inputTokenCount?: number;
|
||||||
|
input_token_count?: number;
|
||||||
|
outputTokenCount?: number;
|
||||||
|
output_token_count?: number;
|
||||||
|
promptTokenCount?: number;
|
||||||
|
prompt_token_count?: number;
|
||||||
|
completionTokenCount?: number;
|
||||||
|
completion_token_count?: number;
|
||||||
|
totalTokenCount?: number;
|
||||||
|
total_token_count?: number;
|
||||||
|
cacheReadTokenCount?: number;
|
||||||
|
cacheReadTokens?: number;
|
||||||
|
cache_write_token_count?: number;
|
||||||
cost?: {
|
cost?: {
|
||||||
total?: number;
|
total?: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UsageRecordStatus = 'available' | 'missing' | 'error';
|
||||||
|
|
||||||
|
interface ParsedUsageTokens {
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
cacheReadTokens: number;
|
||||||
|
cacheWriteTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
costUsd?: number;
|
||||||
|
usageStatus: UsageRecordStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUsageNumber(value: unknown): number | undefined {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const parsed = Number(trimmed);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstUsageNumber(usage: TranscriptUsageShape | undefined, candidates: string[]): number | undefined {
|
||||||
|
if (!usage) return undefined;
|
||||||
|
for (const key of candidates) {
|
||||||
|
const value = usage[key];
|
||||||
|
const parsed = normalizeUsageNumber(value);
|
||||||
|
if (parsed !== undefined) return parsed;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUsageFromShape(usage: unknown): ParsedUsageTokens | undefined {
|
||||||
|
if (usage === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usage === null || typeof usage !== 'object' || Array.isArray(usage)) {
|
||||||
|
return {
|
||||||
|
usageStatus: 'error',
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const usageShape = usage as TranscriptUsageShape;
|
||||||
|
|
||||||
|
const inputTokens = firstUsageNumber(usageShape, [
|
||||||
|
'input',
|
||||||
|
'promptTokens',
|
||||||
|
'prompt_tokens',
|
||||||
|
'input_tokens',
|
||||||
|
'inputTokenCount',
|
||||||
|
'input_token_count',
|
||||||
|
'promptTokenCount',
|
||||||
|
'prompt_token_count',
|
||||||
|
]);
|
||||||
|
const outputTokens = firstUsageNumber(usageShape, [
|
||||||
|
'output',
|
||||||
|
'completionTokens',
|
||||||
|
'completion_tokens',
|
||||||
|
'output_tokens',
|
||||||
|
'outputTokenCount',
|
||||||
|
'output_token_count',
|
||||||
|
'completionTokenCount',
|
||||||
|
'completion_token_count',
|
||||||
|
]);
|
||||||
|
const cacheReadTokens = firstUsageNumber(usageShape, [
|
||||||
|
'cacheRead',
|
||||||
|
'cache_read',
|
||||||
|
'cacheReadTokens',
|
||||||
|
'cache_read_tokens',
|
||||||
|
'cacheReadTokenCount',
|
||||||
|
'cache_read_token_count',
|
||||||
|
]);
|
||||||
|
const cacheWriteTokens = firstUsageNumber(usageShape, [
|
||||||
|
'cacheWrite',
|
||||||
|
'cache_write',
|
||||||
|
'cacheWriteTokens',
|
||||||
|
'cache_write_tokens',
|
||||||
|
'cacheWriteTokenCount',
|
||||||
|
'cache_write_token_count',
|
||||||
|
]);
|
||||||
|
const explicitTotalTokens = firstUsageNumber(usageShape, [
|
||||||
|
'total',
|
||||||
|
'totalTokens',
|
||||||
|
'total_tokens',
|
||||||
|
'totalTokenCount',
|
||||||
|
'total_token_count',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hasUsageValue =
|
||||||
|
inputTokens !== undefined
|
||||||
|
|| outputTokens !== undefined
|
||||||
|
|| cacheReadTokens !== undefined
|
||||||
|
|| cacheWriteTokens !== undefined
|
||||||
|
|| explicitTotalTokens !== undefined
|
||||||
|
|| normalizeUsageNumber(usageShape.cost?.total) !== undefined;
|
||||||
|
|
||||||
|
if (!hasUsageValue) {
|
||||||
|
return {
|
||||||
|
usageStatus: 'missing',
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalTokens = explicitTotalTokens ?? (
|
||||||
|
(inputTokens ?? 0)
|
||||||
|
+ (outputTokens ?? 0)
|
||||||
|
+ (cacheReadTokens ?? 0)
|
||||||
|
+ (cacheWriteTokens ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
usageStatus: 'available',
|
||||||
|
inputTokens: inputTokens ?? 0,
|
||||||
|
outputTokens: outputTokens ?? 0,
|
||||||
|
cacheReadTokens: cacheReadTokens ?? 0,
|
||||||
|
cacheWriteTokens: cacheWriteTokens ?? 0,
|
||||||
|
totalTokens,
|
||||||
|
costUsd: normalizeUsageNumber(usageShape.cost?.total),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface TranscriptLineShape {
|
interface TranscriptLineShape {
|
||||||
type?: string;
|
type?: string;
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
@@ -121,17 +281,9 @@ export function parseUsageEntriesFromJsonl(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.role === 'assistant' && message.usage) {
|
if (message.role === 'assistant' && 'usage' in message) {
|
||||||
const usage = message.usage;
|
const usage = parseUsageFromShape(message.usage);
|
||||||
const inputTokens = usage.input ?? usage.promptTokens ?? 0;
|
if (!usage) continue;
|
||||||
const outputTokens = usage.output ?? usage.completionTokens ?? 0;
|
|
||||||
const cacheReadTokens = usage.cacheRead ?? 0;
|
|
||||||
const cacheWriteTokens = usage.cacheWrite ?? 0;
|
|
||||||
const totalTokens = usage.total ?? usage.totalTokens ?? inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
|
|
||||||
|
|
||||||
if (totalTokens <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentText = normalizeUsageContent((message as Record<string, unknown>).content);
|
const contentText = normalizeUsageContent((message as Record<string, unknown>).content);
|
||||||
entries.push({
|
entries.push({
|
||||||
@@ -141,12 +293,7 @@ export function parseUsageEntriesFromJsonl(
|
|||||||
model: message.model ?? message.modelRef,
|
model: message.model ?? message.modelRef,
|
||||||
provider: message.provider,
|
provider: message.provider,
|
||||||
...(contentText ? { content: contentText } : {}),
|
...(contentText ? { content: contentText } : {}),
|
||||||
inputTokens,
|
...usage,
|
||||||
outputTokens,
|
|
||||||
cacheReadTokens,
|
|
||||||
cacheWriteTokens,
|
|
||||||
totalTokens,
|
|
||||||
costUsd: usage.cost?.total,
|
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -156,30 +303,18 @@ export function parseUsageEntriesFromJsonl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const details = message.details;
|
const details = message.details;
|
||||||
if (!details) {
|
if (!details || !('usage' in details)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const usage = details.usage;
|
const usage = parseUsageFromShape(details.usage);
|
||||||
const inputTokens = usage?.input ?? usage?.promptTokens ?? 0;
|
if (!usage) continue;
|
||||||
const outputTokens = usage?.output ?? usage?.completionTokens ?? 0;
|
|
||||||
const cacheReadTokens = usage?.cacheRead ?? 0;
|
|
||||||
const cacheWriteTokens = usage?.cacheWrite ?? 0;
|
|
||||||
const totalTokens = usage?.total ?? usage?.totalTokens ?? inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
|
|
||||||
|
|
||||||
const provider = details.provider ?? details.externalContent?.provider ?? message.provider;
|
const provider = details.provider ?? details.externalContent?.provider ?? message.provider;
|
||||||
const model = details.model ?? message.model ?? message.modelRef;
|
const model = details.model ?? message.model ?? message.modelRef;
|
||||||
const contentText = normalizeUsageContent(details.content)
|
const contentText = normalizeUsageContent(details.content)
|
||||||
?? normalizeUsageContent((message as Record<string, unknown>).content);
|
?? normalizeUsageContent((message as Record<string, unknown>).content);
|
||||||
|
|
||||||
if (!provider && !model) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (totalTokens <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.push({
|
entries.push({
|
||||||
timestamp: parsed.timestamp,
|
timestamp: parsed.timestamp,
|
||||||
sessionId: context.sessionId,
|
sessionId: context.sessionId,
|
||||||
@@ -187,12 +322,7 @@ export function parseUsageEntriesFromJsonl(
|
|||||||
model,
|
model,
|
||||||
provider,
|
provider,
|
||||||
...(contentText ? { content: contentText } : {}),
|
...(contentText ? { content: contentText } : {}),
|
||||||
inputTokens,
|
...usage,
|
||||||
outputTokens,
|
|
||||||
cacheReadTokens,
|
|
||||||
cacheWriteTokens,
|
|
||||||
totalTokens,
|
|
||||||
costUsd: usage?.cost?.total,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,9 @@
|
|||||||
"modelPreview": "Preview",
|
"modelPreview": "Preview",
|
||||||
"modelOverridePlaceholder": "provider/model (for example: openrouter/openai/gpt-5.4)",
|
"modelOverridePlaceholder": "provider/model (for example: openrouter/openai/gpt-5.4)",
|
||||||
"modelOverrideDescription": "Select provider and model ID for this agent.",
|
"modelOverrideDescription": "Select provider and model ID for this agent.",
|
||||||
|
"unsavedChangesTitle": "Unsaved changes",
|
||||||
|
"unsavedChangesMessage": "You have unsaved changes. If you close now, your changes will be discarded.",
|
||||||
|
"closeWithoutSaving": "Close without saving",
|
||||||
"saveModelOverride": "Save model",
|
"saveModelOverride": "Save model",
|
||||||
"useDefaultModel": "Use default model",
|
"useDefaultModel": "Use default model",
|
||||||
"channelsTitle": "Channels",
|
"channelsTitle": "Channels",
|
||||||
|
|||||||
@@ -58,6 +58,8 @@
|
|||||||
"cost": "Cost ${{amount}}",
|
"cost": "Cost ${{amount}}",
|
||||||
"viewContent": "View content",
|
"viewContent": "View content",
|
||||||
"contentDialogTitle": "Usage detail content",
|
"contentDialogTitle": "Usage detail content",
|
||||||
"close": "Close"
|
"close": "Close",
|
||||||
|
"noUsage": "No usage",
|
||||||
|
"usageParseError": "Usage parse error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,6 +39,9 @@
|
|||||||
"modelPreview": "プレビュー",
|
"modelPreview": "プレビュー",
|
||||||
"modelOverridePlaceholder": "provider/model(例: openrouter/openai/gpt-5.4)",
|
"modelOverridePlaceholder": "provider/model(例: openrouter/openai/gpt-5.4)",
|
||||||
"modelOverrideDescription": "この Agent の Provider とモデル ID を選択します。",
|
"modelOverrideDescription": "この Agent の Provider とモデル ID を選択します。",
|
||||||
|
"unsavedChangesTitle": "未保存の変更",
|
||||||
|
"unsavedChangesMessage": "未保存の変更があります。閉じると変更が破棄されます。",
|
||||||
|
"closeWithoutSaving": "保存せずに閉じる",
|
||||||
"saveModelOverride": "モデルを保存",
|
"saveModelOverride": "モデルを保存",
|
||||||
"useDefaultModel": "デフォルトモデルを使用",
|
"useDefaultModel": "デフォルトモデルを使用",
|
||||||
"channelsTitle": "Channels",
|
"channelsTitle": "Channels",
|
||||||
|
|||||||
@@ -58,6 +58,8 @@
|
|||||||
"cost": "コスト ${{amount}}",
|
"cost": "コスト ${{amount}}",
|
||||||
"viewContent": "内容を見る",
|
"viewContent": "内容を見る",
|
||||||
"contentDialogTitle": "使用量詳細の内容",
|
"contentDialogTitle": "使用量詳細の内容",
|
||||||
"close": "閉じる"
|
"close": "閉じる",
|
||||||
|
"noUsage": "使用量なし",
|
||||||
|
"usageParseError": "使用量解析エラー"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,6 +39,9 @@
|
|||||||
"modelPreview": "预览",
|
"modelPreview": "预览",
|
||||||
"modelOverridePlaceholder": "provider/model(例如:openrouter/openai/gpt-5.4)",
|
"modelOverridePlaceholder": "provider/model(例如:openrouter/openai/gpt-5.4)",
|
||||||
"modelOverrideDescription": "为该 Agent 选择 Provider 和模型 ID。",
|
"modelOverrideDescription": "为该 Agent 选择 Provider 和模型 ID。",
|
||||||
|
"unsavedChangesTitle": "未保存的修改",
|
||||||
|
"unsavedChangesMessage": "你有未保存的修改。关闭后这些修改将被丢弃。",
|
||||||
|
"closeWithoutSaving": "不保存并关闭",
|
||||||
"saveModelOverride": "保存模型",
|
"saveModelOverride": "保存模型",
|
||||||
"useDefaultModel": "使用默认模型",
|
"useDefaultModel": "使用默认模型",
|
||||||
"channelsTitle": "频道",
|
"channelsTitle": "频道",
|
||||||
|
|||||||
@@ -58,6 +58,8 @@
|
|||||||
"cost": "费用 ${{amount}}",
|
"cost": "费用 ${{amount}}",
|
||||||
"viewContent": "查看内容",
|
"viewContent": "查看内容",
|
||||||
"contentDialogTitle": "用量明细内容",
|
"contentDialogTitle": "用量明细内容",
|
||||||
"close": "关闭"
|
"close": "关闭",
|
||||||
|
"noUsage": "无用量数据",
|
||||||
|
"usageParseError": "用量解析失败"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,11 +493,22 @@ function AgentSettingsModal({
|
|||||||
const [name, setName] = useState(agent.name);
|
const [name, setName] = useState(agent.name);
|
||||||
const [savingName, setSavingName] = useState(false);
|
const [savingName, setSavingName] = useState(false);
|
||||||
const [showModelModal, setShowModelModal] = useState(false);
|
const [showModelModal, setShowModelModal] = useState(false);
|
||||||
|
const [showCloseConfirm, setShowCloseConfirm] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setName(agent.name);
|
setName(agent.name);
|
||||||
}, [agent.name]);
|
}, [agent.name]);
|
||||||
|
|
||||||
|
const hasNameChanges = name.trim() !== agent.name;
|
||||||
|
|
||||||
|
const handleRequestClose = () => {
|
||||||
|
if (savingName || hasNameChanges) {
|
||||||
|
setShowCloseConfirm(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveName = async () => {
|
const handleSaveName = async () => {
|
||||||
if (!name.trim() || name.trim() === agent.name) return;
|
if (!name.trim() || name.trim() === agent.name) return;
|
||||||
setSavingName(true);
|
setSavingName(true);
|
||||||
@@ -540,7 +551,7 @@ function AgentSettingsModal({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onClose}
|
onClick={handleRequestClose}
|
||||||
className="rounded-full h-8 w-8 -mr-2 -mt-2 text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
className="rounded-full h-8 w-8 -mr-2 -mt-2 text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
@@ -652,6 +663,19 @@ function AgentSettingsModal({
|
|||||||
onClose={() => setShowModelModal(false)}
|
onClose={() => setShowModelModal(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={showCloseConfirm}
|
||||||
|
title={t('settingsDialog.unsavedChangesTitle')}
|
||||||
|
message={t('settingsDialog.unsavedChangesMessage')}
|
||||||
|
confirmLabel={t('settingsDialog.closeWithoutSaving')}
|
||||||
|
cancelLabel={t('common:actions.cancel')}
|
||||||
|
onConfirm={() => {
|
||||||
|
setShowCloseConfirm(false);
|
||||||
|
setName(agent.name);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
onCancel={() => setShowCloseConfirm(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -672,6 +696,7 @@ function AgentModelModal({
|
|||||||
const [selectedRuntimeProviderKey, setSelectedRuntimeProviderKey] = useState('');
|
const [selectedRuntimeProviderKey, setSelectedRuntimeProviderKey] = useState('');
|
||||||
const [modelIdInput, setModelIdInput] = useState('');
|
const [modelIdInput, setModelIdInput] = useState('');
|
||||||
const [savingModel, setSavingModel] = useState(false);
|
const [savingModel, setSavingModel] = useState(false);
|
||||||
|
const [showCloseConfirm, setShowCloseConfirm] = useState(false);
|
||||||
|
|
||||||
const runtimeProviderOptions = useMemo<RuntimeProviderOption[]>(() => {
|
const runtimeProviderOptions = useMemo<RuntimeProviderOption[]>(() => {
|
||||||
const vendorMap = new Map<string, ProviderVendorInfo>(providerVendors.map((vendor) => [vendor.id, vendor]));
|
const vendorMap = new Map<string, ProviderVendorInfo>(providerVendors.map((vendor) => [vendor.id, vendor]));
|
||||||
@@ -740,6 +765,14 @@ function AgentModelModal({
|
|||||||
: null;
|
: null;
|
||||||
const modelChanged = (desiredOverrideModelRef || '') !== currentOverrideModelRef;
|
const modelChanged = (desiredOverrideModelRef || '') !== currentOverrideModelRef;
|
||||||
|
|
||||||
|
const handleRequestClose = () => {
|
||||||
|
if (savingModel || modelChanged) {
|
||||||
|
setShowCloseConfirm(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveModel = async () => {
|
const handleSaveModel = async () => {
|
||||||
if (!selectedRuntimeProviderKey) {
|
if (!selectedRuntimeProviderKey) {
|
||||||
toast.error(t('toast.agentModelProviderRequired'));
|
toast.error(t('toast.agentModelProviderRequired'));
|
||||||
@@ -793,7 +826,7 @@ function AgentModelModal({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onClose}
|
onClick={handleRequestClose}
|
||||||
className="rounded-full h-8 w-8 -mr-2 -mt-2 text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
className="rounded-full h-8 w-8 -mr-2 -mt-2 text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
@@ -854,7 +887,7 @@ function AgentModelModal({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onClose}
|
onClick={handleRequestClose}
|
||||||
className="h-9 text-[13px] font-medium rounded-full px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/80 hover:text-foreground"
|
className="h-9 text-[13px] font-medium rounded-full px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/80 hover:text-foreground"
|
||||||
>
|
>
|
||||||
{t('common:actions.cancel')}
|
{t('common:actions.cancel')}
|
||||||
@@ -873,6 +906,18 @@ function AgentModelModal({
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={showCloseConfirm}
|
||||||
|
title={t('settingsDialog.unsavedChangesTitle')}
|
||||||
|
message={t('settingsDialog.unsavedChangesMessage')}
|
||||||
|
confirmLabel={t('settingsDialog.closeWithoutSaving')}
|
||||||
|
cancelLabel={t('common:actions.cancel')}
|
||||||
|
onConfirm={() => {
|
||||||
|
setShowCloseConfirm(false);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
onCancel={() => setShowCloseConfirm(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,37 @@ export function Models() {
|
|||||||
const [usageWindow, setUsageWindow] = useState<UsageWindow>('7d');
|
const [usageWindow, setUsageWindow] = useState<UsageWindow>('7d');
|
||||||
const [usagePage, setUsagePage] = useState(1);
|
const [usagePage, setUsagePage] = useState(1);
|
||||||
const [selectedUsageEntry, setSelectedUsageEntry] = useState<UsageHistoryEntry | null>(null);
|
const [selectedUsageEntry, setSelectedUsageEntry] = useState<UsageHistoryEntry | null>(null);
|
||||||
|
const HIDDEN_USAGE_SOURCES = new Set([
|
||||||
|
'gateway-injected',
|
||||||
|
'delivery-mirror',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isHiddenUsageSource(source?: string): boolean {
|
||||||
|
if (!source) return false;
|
||||||
|
const normalizedSource = source.trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
HIDDEN_USAGE_SOURCES.has(normalizedSource)
|
||||||
|
|| normalizedSource.includes('gateway-injected')
|
||||||
|
|| normalizedSource.includes('delivery-mirror')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUsageSource(source?: string): string | undefined {
|
||||||
|
if (!source) return undefined;
|
||||||
|
|
||||||
|
if (isHiddenUsageSource(source)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldHideUsageEntry(entry: UsageHistoryEntry): boolean {
|
||||||
|
return (
|
||||||
|
isHiddenUsageSource(entry.provider)
|
||||||
|
|| isHiddenUsageSource(entry.model)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type FetchState = {
|
type FetchState = {
|
||||||
status: 'idle' | 'loading' | 'done';
|
status: 'idle' | 'loading' | 'done';
|
||||||
@@ -181,8 +212,9 @@ export function Models() {
|
|||||||
};
|
};
|
||||||
}, [isGatewayRunning, gatewayStatus.connectedAt, gatewayStatus.pid, usageFetchMaxAttempts]);
|
}, [isGatewayRunning, gatewayStatus.connectedAt, gatewayStatus.pid, usageFetchMaxAttempts]);
|
||||||
|
|
||||||
const usageHistory = fetchState.data;
|
const visibleUsageHistory = isGatewayRunning
|
||||||
const visibleUsageHistory = isGatewayRunning ? usageHistory : [];
|
? fetchState.data.filter((entry) => !shouldHideUsageEntry(entry))
|
||||||
|
: [];
|
||||||
const filteredUsageHistory = filterUsageHistoryByWindow(visibleUsageHistory, usageWindow);
|
const filteredUsageHistory = filterUsageHistoryByWindow(visibleUsageHistory, usageWindow);
|
||||||
const usageGroups = groupUsageHistory(filteredUsageHistory, usageGroupBy);
|
const usageGroups = groupUsageHistory(filteredUsageHistory, usageGroupBy);
|
||||||
const usagePageSize = 5;
|
const usagePageSize = 5;
|
||||||
@@ -313,6 +345,7 @@ export function Models() {
|
|||||||
{pagedUsageHistory.map((entry) => (
|
{pagedUsageHistory.map((entry) => (
|
||||||
<div
|
<div
|
||||||
key={`${entry.sessionId}-${entry.timestamp}`}
|
key={`${entry.sessionId}-${entry.timestamp}`}
|
||||||
|
data-testid="token-usage-entry"
|
||||||
className="rounded-2xl bg-transparent border border-black/10 dark:border-white/10 p-5 hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
className="rounded-2xl bg-transparent border border-black/10 dark:border-white/10 p-5 hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
@@ -321,24 +354,46 @@ export function Models() {
|
|||||||
{entry.model || t('dashboard:recentTokenHistory.unknownModel')}
|
{entry.model || t('dashboard:recentTokenHistory.unknownModel')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[13px] text-muted-foreground truncate mt-0.5">
|
<p className="text-[13px] text-muted-foreground truncate mt-0.5">
|
||||||
{[entry.provider, entry.agentId, entry.sessionId].filter(Boolean).join(' • ')}
|
{[formatUsageSource(entry.provider), formatUsageSource(entry.agentId), entry.sessionId].filter(Boolean).join(' • ')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right shrink-0">
|
<div className="text-right shrink-0">
|
||||||
<p className="font-bold text-[15px]">{formatTokenCount(entry.totalTokens)}</p>
|
<p className={getUsageTotalClass(entry)}>
|
||||||
|
{formatUsageTotal(entry)}
|
||||||
|
</p>
|
||||||
|
{entry.usageStatus === 'missing' && (
|
||||||
|
<p className="text-[12px] text-muted-foreground mt-0.5">
|
||||||
|
{t('dashboard:recentTokenHistory.noUsage')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{entry.usageStatus === 'error' && (
|
||||||
|
<p className="text-[12px] text-red-500 dark:text-red-400 mt-0.5">
|
||||||
|
{t('dashboard:recentTokenHistory.usageParseError')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="text-[12px] text-muted-foreground mt-0.5">
|
<p className="text-[12px] text-muted-foreground mt-0.5">
|
||||||
{formatUsageTimestamp(entry.timestamp)}
|
{formatUsageTimestamp(entry.timestamp)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5 text-[12.5px] font-medium text-muted-foreground">
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5 text-[12.5px] font-medium text-muted-foreground">
|
||||||
<span className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full bg-sky-500"></div>{t('dashboard:recentTokenHistory.input', { value: formatTokenCount(entry.inputTokens) })}</span>
|
{entry.usageStatus === 'available' || entry.usageStatus === undefined ? (
|
||||||
<span className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full bg-violet-500"></div>{t('dashboard:recentTokenHistory.output', { value: formatTokenCount(entry.outputTokens) })}</span>
|
<>
|
||||||
{entry.cacheReadTokens > 0 && (
|
<span className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full bg-sky-500"></div>{t('dashboard:recentTokenHistory.input', { value: formatTokenCount(entry.inputTokens) })}</span>
|
||||||
<span className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full bg-amber-500"></div>{t('dashboard:recentTokenHistory.cacheRead', { value: formatTokenCount(entry.cacheReadTokens) })}</span>
|
<span className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full bg-violet-500"></div>{t('dashboard:recentTokenHistory.output', { value: formatTokenCount(entry.outputTokens) })}</span>
|
||||||
)}
|
{entry.cacheReadTokens > 0 && (
|
||||||
{entry.cacheWriteTokens > 0 && (
|
<span className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full bg-amber-500"></div>{t('dashboard:recentTokenHistory.cacheRead', { value: formatTokenCount(entry.cacheReadTokens) })}</span>
|
||||||
<span className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full bg-amber-500"></div>{t('dashboard:recentTokenHistory.cacheWrite', { value: formatTokenCount(entry.cacheWriteTokens) })}</span>
|
)}
|
||||||
|
{entry.cacheWriteTokens > 0 && (
|
||||||
|
<span className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full bg-amber-500"></div>{t('dashboard:recentTokenHistory.cacheWrite', { value: formatTokenCount(entry.cacheWriteTokens) })}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-[12px]">
|
||||||
|
{entry.usageStatus === 'missing'
|
||||||
|
? t('dashboard:recentTokenHistory.noUsage')
|
||||||
|
: t('dashboard:recentTokenHistory.usageParseError')}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{typeof entry.costUsd === 'number' && Number.isFinite(entry.costUsd) && (
|
{typeof entry.costUsd === 'number' && Number.isFinite(entry.costUsd) && (
|
||||||
<span className="flex items-center gap-1.5 ml-auto text-foreground/80 bg-black/5 dark:bg-white/5 px-2 py-0.5 rounded-md">{t('dashboard:recentTokenHistory.cost', { amount: entry.costUsd.toFixed(4) })}</span>
|
<span className="flex items-center gap-1.5 ml-auto text-foreground/80 bg-black/5 dark:bg-white/5 px-2 py-0.5 rounded-md">{t('dashboard:recentTokenHistory.cost', { amount: entry.costUsd.toFixed(4) })}</span>
|
||||||
@@ -409,6 +464,18 @@ function formatTokenCount(value: number): string {
|
|||||||
return Intl.NumberFormat().format(value);
|
return Intl.NumberFormat().format(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getUsageTotalClass(entry: UsageHistoryEntry): string {
|
||||||
|
if (entry.usageStatus === 'error') return 'font-bold text-[15px] text-red-500 dark:text-red-400';
|
||||||
|
if (entry.usageStatus === 'missing') return 'font-bold text-[15px] text-muted-foreground';
|
||||||
|
return 'font-bold text-[15px]';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUsageTotal(entry: UsageHistoryEntry): string {
|
||||||
|
if (entry.usageStatus === 'error') return '✕';
|
||||||
|
if (entry.usageStatus === 'missing') return '—';
|
||||||
|
return formatTokenCount(entry.totalTokens);
|
||||||
|
}
|
||||||
|
|
||||||
function formatUsageTimestamp(timestamp: string): string {
|
function formatUsageTimestamp(timestamp: string): string {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
if (Number.isNaN(date.getTime())) return timestamp;
|
if (Number.isNaN(date.getTime())) return timestamp;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type UsageHistoryEntry = {
|
|||||||
model?: string;
|
model?: string;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
usageStatus?: 'available' | 'missing' | 'error';
|
||||||
inputTokens: number;
|
inputTokens: number;
|
||||||
outputTokens: number;
|
outputTokens: number;
|
||||||
cacheReadTokens: number;
|
cacheReadTokens: number;
|
||||||
|
|||||||
@@ -323,6 +323,30 @@ export function Settings() {
|
|||||||
setProxyBypassRulesDraft(proxyBypassRules);
|
setProxyBypassRulesDraft(proxyBypassRules);
|
||||||
}, [proxyBypassRules]);
|
}, [proxyBypassRules]);
|
||||||
|
|
||||||
|
const proxySettingsDirty = useMemo(() => {
|
||||||
|
return (
|
||||||
|
proxyEnabledDraft !== proxyEnabled
|
||||||
|
|| proxyServerDraft.trim() !== proxyServer
|
||||||
|
|| proxyHttpServerDraft.trim() !== proxyHttpServer
|
||||||
|
|| proxyHttpsServerDraft.trim() !== proxyHttpsServer
|
||||||
|
|| proxyAllServerDraft.trim() !== proxyAllServer
|
||||||
|
|| proxyBypassRulesDraft.trim() !== proxyBypassRules
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
proxyAllServer,
|
||||||
|
proxyAllServerDraft,
|
||||||
|
proxyBypassRules,
|
||||||
|
proxyBypassRulesDraft,
|
||||||
|
proxyEnabled,
|
||||||
|
proxyEnabledDraft,
|
||||||
|
proxyHttpServer,
|
||||||
|
proxyHttpServerDraft,
|
||||||
|
proxyHttpsServer,
|
||||||
|
proxyHttpsServerDraft,
|
||||||
|
proxyServer,
|
||||||
|
proxyServerDraft,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleSaveProxySettings = async () => {
|
const handleSaveProxySettings = async () => {
|
||||||
setSavingProxy(true);
|
setSavingProxy(true);
|
||||||
try {
|
try {
|
||||||
@@ -612,9 +636,9 @@ export function Settings() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
data-testid="settings-dev-mode-switch"
|
|
||||||
checked={devModeUnlocked}
|
checked={devModeUnlocked}
|
||||||
onCheckedChange={setDevModeUnlocked}
|
onCheckedChange={setDevModeUnlocked}
|
||||||
|
data-testid="settings-dev-mode-switch"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -645,7 +669,7 @@ export function Settings() {
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Gateway Proxy */}
|
{/* Gateway Proxy */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4" data-testid="settings-proxy-section">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-[14px] font-medium text-foreground/80">Gateway Proxy</Label>
|
<Label className="text-[14px] font-medium text-foreground/80">Gateway Proxy</Label>
|
||||||
@@ -656,9 +680,26 @@ export function Settings() {
|
|||||||
<Switch
|
<Switch
|
||||||
checked={proxyEnabledDraft}
|
checked={proxyEnabledDraft}
|
||||||
onCheckedChange={setProxyEnabledDraft}
|
onCheckedChange={setProxyEnabledDraft}
|
||||||
|
data-testid="settings-proxy-toggle"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleSaveProxySettings}
|
||||||
|
disabled={savingProxy || !proxySettingsDirty}
|
||||||
|
data-testid="settings-proxy-save-button"
|
||||||
|
className="rounded-xl h-10 px-5 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${savingProxy ? ' animate-spin' : ''}`} />
|
||||||
|
{savingProxy ? t('common:status.saving') : t('common:actions.save')}
|
||||||
|
</Button>
|
||||||
|
<p className="text-[12px] text-muted-foreground">
|
||||||
|
{t('gateway.proxyRestartNote')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{proxyEnabledDraft && (
|
{proxyEnabledDraft && (
|
||||||
<div className="space-y-4 pt-2">
|
<div className="space-y-4 pt-2">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
@@ -733,20 +774,6 @@ export function Settings() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 pt-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleSaveProxySettings}
|
|
||||||
disabled={savingProxy}
|
|
||||||
className="rounded-xl h-10 px-5 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${savingProxy ? ' animate-spin' : ''}`} />
|
|
||||||
{savingProxy ? t('common:status.saving') : t('common:actions.save')}
|
|
||||||
</Button>
|
|
||||||
<p className="text-[12px] text-muted-foreground">
|
|
||||||
{t('gateway.proxyRestartNote')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
49
tests/e2e/settings-proxy.spec.ts
Normal file
49
tests/e2e/settings-proxy.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { Locator, Page } from '@playwright/test';
|
||||||
|
import { completeSetup, expect, test } from './fixtures/electron';
|
||||||
|
|
||||||
|
async function ensureSwitchState(toggle: Locator, checked: boolean): Promise<void> {
|
||||||
|
const currentState = await toggle.getAttribute('data-state');
|
||||||
|
const isChecked = currentState === 'checked';
|
||||||
|
if (isChecked !== checked) {
|
||||||
|
await toggle.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readProxyEnabled(page: Page): Promise<boolean> {
|
||||||
|
return await page.evaluate(async () => {
|
||||||
|
const settings = await window.electron.ipcRenderer.invoke('settings:getAll');
|
||||||
|
return Boolean(settings?.proxyEnabled);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('ClawX developer proxy settings', () => {
|
||||||
|
test('keeps proxy save available when disabling proxy in developer mode', async ({ page }) => {
|
||||||
|
await completeSetup(page);
|
||||||
|
|
||||||
|
await page.getByTestId('sidebar-nav-settings').click();
|
||||||
|
await expect(page.getByTestId('settings-page')).toBeVisible();
|
||||||
|
|
||||||
|
const devModeToggle = page.getByTestId('settings-dev-mode-switch');
|
||||||
|
await expect(devModeToggle).toBeVisible();
|
||||||
|
await ensureSwitchState(devModeToggle, true);
|
||||||
|
|
||||||
|
const proxySection = page.getByTestId('settings-proxy-section');
|
||||||
|
const proxyToggle = page.getByTestId('settings-proxy-toggle');
|
||||||
|
const proxySaveButton = page.getByTestId('settings-proxy-save-button');
|
||||||
|
|
||||||
|
await expect(proxySection).toBeVisible();
|
||||||
|
await expect(proxyToggle).toBeVisible();
|
||||||
|
await expect(proxySaveButton).toBeVisible();
|
||||||
|
|
||||||
|
await ensureSwitchState(proxyToggle, true);
|
||||||
|
await expect(proxySaveButton).toBeEnabled();
|
||||||
|
await proxySaveButton.click();
|
||||||
|
await expect.poll(async () => await readProxyEnabled(page)).toBe(true);
|
||||||
|
|
||||||
|
await ensureSwitchState(proxyToggle, false);
|
||||||
|
await expect(proxySaveButton).toBeVisible();
|
||||||
|
await expect(proxySaveButton).toBeEnabled();
|
||||||
|
await proxySaveButton.click();
|
||||||
|
await expect.poll(async () => await readProxyEnabled(page)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
198
tests/e2e/token-usage.spec.ts
Normal file
198
tests/e2e/token-usage.spec.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { completeSetup, expect, test } from './fixtures/electron';
|
||||||
|
|
||||||
|
const TEST_AGENT_ID = 'agent';
|
||||||
|
const ZERO_TOKEN_SESSION_ID = 'agent-session-zero-token';
|
||||||
|
const NONZERO_TOKEN_SESSION_ID = 'agent-session-nonzero-token';
|
||||||
|
const GATEWAY_INJECTED_SESSION_ID = 'agent-session-gateway-injected';
|
||||||
|
const DELIVERY_MIRROR_SESSION_ID = 'agent-session-delivery-mirror';
|
||||||
|
|
||||||
|
async function seedTokenUsageTranscripts(homeDir: string): Promise<void> {
|
||||||
|
const sessionDir = join(homeDir, '.openclaw', 'agents', TEST_AGENT_ID, 'sessions');
|
||||||
|
const now = new Date();
|
||||||
|
const zeroTimestamp = new Date(now.getTime() - 20_000).toISOString();
|
||||||
|
const nonzeroTimestamp = now.toISOString();
|
||||||
|
await mkdir(sessionDir, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(sessionDir, `${ZERO_TOKEN_SESSION_ID}.jsonl`),
|
||||||
|
[
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'message',
|
||||||
|
timestamp: zeroTimestamp,
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
model: 'kimi-k2.5',
|
||||||
|
provider: 'kimi',
|
||||||
|
usage: {
|
||||||
|
total_tokens: 0,
|
||||||
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
join(sessionDir, `${NONZERO_TOKEN_SESSION_ID}.jsonl`),
|
||||||
|
[
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'message',
|
||||||
|
timestamp: nonzeroTimestamp,
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
model: 'kimi-k2.5',
|
||||||
|
provider: 'kimi',
|
||||||
|
usage: {
|
||||||
|
total_tokens: 27,
|
||||||
|
input_tokens: 20,
|
||||||
|
output_tokens: 7,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
join(sessionDir, `${GATEWAY_INJECTED_SESSION_ID}.jsonl`),
|
||||||
|
[
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'message',
|
||||||
|
timestamp: new Date(now.getTime() - 10_000).toISOString(),
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
model: 'gateway-injected',
|
||||||
|
usage: {
|
||||||
|
total_tokens: 0,
|
||||||
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
join(sessionDir, `${DELIVERY_MIRROR_SESSION_ID}.jsonl`),
|
||||||
|
[
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'message',
|
||||||
|
timestamp: new Date(now.getTime() - 5_000).toISOString(),
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
model: 'delivery-mirror',
|
||||||
|
usage: {
|
||||||
|
total_tokens: 0,
|
||||||
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('ClawX token usage history', () => {
|
||||||
|
async function waitForGatewayRunning(page: Page): Promise<void> {
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const status = await page.evaluate(async () => {
|
||||||
|
return window.electron.ipcRenderer.invoke('gateway:status');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status?.state === 'running') {
|
||||||
|
return 'running';
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
try {
|
||||||
|
await window.electron.ipcRenderer.invoke('gateway:start');
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await window.electron.ipcRenderer.invoke('gateway:restart');
|
||||||
|
} catch {
|
||||||
|
// Ignore transient e2e startup failures and let the poll retry.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return status?.state ?? 'unknown';
|
||||||
|
}, { timeout: 45_000, intervals: [500, 1000, 1500, 2000] }).toBe('running');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateUsageHistory(page: Page): Promise<void> {
|
||||||
|
const usageHistory = await page.evaluate(async () => {
|
||||||
|
return window.electron.ipcRenderer.invoke('usage:recentTokenHistory', 20);
|
||||||
|
});
|
||||||
|
if (!Array.isArray(usageHistory) || usageHistory.length === 0) {
|
||||||
|
throw new Error('No usage history found in IPC usage:recentTokenHistory');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSeededEntries = usageHistory.some((entry) =>
|
||||||
|
typeof entry?.sessionId === 'string' && (
|
||||||
|
entry.sessionId === ZERO_TOKEN_SESSION_ID
|
||||||
|
|| entry.sessionId === NONZERO_TOKEN_SESSION_ID
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!hasSeededEntries) {
|
||||||
|
throw new Error('Seeded transcript session IDs were not found in IPC usage history');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('displays assistant usage for agent directory with zero and non-zero tokens', async ({ page, homeDir }) => {
|
||||||
|
await seedTokenUsageTranscripts(homeDir);
|
||||||
|
await completeSetup(page);
|
||||||
|
await validateUsageHistory(page);
|
||||||
|
|
||||||
|
const usageHistory = await page.evaluate(async () => {
|
||||||
|
return window.electron.ipcRenderer.invoke('usage:recentTokenHistory', 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
const zeroEntry = usageHistory.find((entry) => entry?.sessionId === ZERO_TOKEN_SESSION_ID);
|
||||||
|
const nonzeroEntry = usageHistory.find((entry) => entry?.sessionId === NONZERO_TOKEN_SESSION_ID);
|
||||||
|
expect(zeroEntry).toBeTruthy();
|
||||||
|
expect(nonzeroEntry).toBeTruthy();
|
||||||
|
expect(nonzeroEntry?.totalTokens).toBe(27);
|
||||||
|
expect(zeroEntry?.totalTokens).toBe(0);
|
||||||
|
expect(zeroEntry?.agentId).toBe(TEST_AGENT_ID);
|
||||||
|
expect(nonzeroEntry?.agentId).toBe(TEST_AGENT_ID);
|
||||||
|
expect(zeroEntry?.provider).toBe('kimi');
|
||||||
|
expect(nonzeroEntry?.provider).toBe('kimi');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hides gateway internal usage rows from the usage list overview', async ({ page, homeDir }) => {
|
||||||
|
await seedTokenUsageTranscripts(homeDir);
|
||||||
|
await completeSetup(page);
|
||||||
|
await waitForGatewayRunning(page);
|
||||||
|
await validateUsageHistory(page);
|
||||||
|
await page.getByTestId('sidebar-nav-models').click();
|
||||||
|
await expect(page.getByTestId('models-page')).toBeVisible();
|
||||||
|
|
||||||
|
const seededSessions = [
|
||||||
|
ZERO_TOKEN_SESSION_ID,
|
||||||
|
NONZERO_TOKEN_SESSION_ID,
|
||||||
|
GATEWAY_INJECTED_SESSION_ID,
|
||||||
|
DELIVERY_MIRROR_SESSION_ID,
|
||||||
|
];
|
||||||
|
const usageEntryRows = page.getByTestId('token-usage-entry');
|
||||||
|
await expect.poll(async () => await usageEntryRows.count()).toBe(2);
|
||||||
|
|
||||||
|
for (const sessionId of seededSessions) {
|
||||||
|
const row = page.locator('[data-testid="token-usage-entry"]', { hasText: sessionId });
|
||||||
|
if (sessionId === GATEWAY_INJECTED_SESSION_ID || sessionId === DELIVERY_MIRROR_SESSION_ID) {
|
||||||
|
await expect(row).toHaveCount(0);
|
||||||
|
} else {
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator('[data-testid="token-usage-entry"]', { hasText: GATEWAY_INJECTED_SESSION_ID })).toHaveCount(0);
|
||||||
|
await expect(page.locator('[data-testid="token-usage-entry"]', { hasText: DELIVERY_MIRROR_SESSION_ID })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,6 +49,7 @@ describe('parseUsageEntriesFromJsonl', () => {
|
|||||||
agentId: 'default',
|
agentId: 'default',
|
||||||
model: 'claude-sonnet',
|
model: 'claude-sonnet',
|
||||||
provider: 'anthropic',
|
provider: 'anthropic',
|
||||||
|
usageStatus: 'available',
|
||||||
inputTokens: 200,
|
inputTokens: 200,
|
||||||
outputTokens: 80,
|
outputTokens: 80,
|
||||||
cacheReadTokens: 25,
|
cacheReadTokens: 25,
|
||||||
@@ -62,6 +63,7 @@ describe('parseUsageEntriesFromJsonl', () => {
|
|||||||
agentId: 'default',
|
agentId: 'default',
|
||||||
model: 'gpt-5',
|
model: 'gpt-5',
|
||||||
provider: 'openai',
|
provider: 'openai',
|
||||||
|
usageStatus: 'available',
|
||||||
inputTokens: 100,
|
inputTokens: 100,
|
||||||
outputTokens: 50,
|
outputTokens: 50,
|
||||||
cacheReadTokens: 0,
|
cacheReadTokens: 0,
|
||||||
@@ -81,7 +83,7 @@ describe('parseUsageEntriesFromJsonl', () => {
|
|||||||
expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([]);
|
expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips tool result entries without positive token usage', () => {
|
it('still skips tool result entries without usage payload', () => {
|
||||||
const jsonl = [
|
const jsonl = [
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
@@ -100,6 +102,111 @@ describe('parseUsageEntriesFromJsonl', () => {
|
|||||||
expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([]);
|
expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps assistant usage entries with zero total tokens when usage is explicitly provided', () => {
|
||||||
|
const jsonl = [
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'message',
|
||||||
|
timestamp: '2026-03-10T03:00:00.000Z',
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
model: 'kimi-k2.5',
|
||||||
|
provider: 'moonshot',
|
||||||
|
usage: {
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([
|
||||||
|
{
|
||||||
|
timestamp: '2026-03-10T03:00:00.000Z',
|
||||||
|
sessionId: 'abc',
|
||||||
|
agentId: 'default',
|
||||||
|
model: 'kimi-k2.5',
|
||||||
|
provider: 'moonshot',
|
||||||
|
usageStatus: 'available',
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
costUsd: undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts usage fields from snake_case provider payloads', () => {
|
||||||
|
const jsonl = [
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'message',
|
||||||
|
timestamp: '2026-03-10T03:10:00.000Z',
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
model: 'kimi-k2.5',
|
||||||
|
provider: 'moonshot',
|
||||||
|
usage: {
|
||||||
|
input_tokens: 12,
|
||||||
|
output_tokens: 3,
|
||||||
|
cache_read: 4,
|
||||||
|
cache_write: 1,
|
||||||
|
total_tokens: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([
|
||||||
|
{
|
||||||
|
timestamp: '2026-03-10T03:10:00.000Z',
|
||||||
|
sessionId: 'abc',
|
||||||
|
agentId: 'default',
|
||||||
|
model: 'kimi-k2.5',
|
||||||
|
provider: 'moonshot',
|
||||||
|
usageStatus: 'available',
|
||||||
|
inputTokens: 12,
|
||||||
|
outputTokens: 3,
|
||||||
|
cacheReadTokens: 4,
|
||||||
|
cacheWriteTokens: 1,
|
||||||
|
totalTokens: 20,
|
||||||
|
costUsd: undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports tool result usage data without explicit provider/model keys', () => {
|
||||||
|
const jsonl = [
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'message',
|
||||||
|
timestamp: '2026-03-10T03:20:00.000Z',
|
||||||
|
message: {
|
||||||
|
role: 'toolResult',
|
||||||
|
details: {
|
||||||
|
usage: {
|
||||||
|
input_tokens: 10,
|
||||||
|
output_tokens: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([
|
||||||
|
{
|
||||||
|
timestamp: '2026-03-10T03:20:00.000Z',
|
||||||
|
sessionId: 'abc',
|
||||||
|
agentId: 'default',
|
||||||
|
usageStatus: 'available',
|
||||||
|
inputTokens: 10,
|
||||||
|
outputTokens: 20,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
totalTokens: 30,
|
||||||
|
costUsd: undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('uses tool result usage when provided', () => {
|
it('uses tool result usage when provided', () => {
|
||||||
const jsonl = [
|
const jsonl = [
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -129,6 +236,7 @@ describe('parseUsageEntriesFromJsonl', () => {
|
|||||||
agentId: 'default',
|
agentId: 'default',
|
||||||
model: 'moonshot-v1-128k',
|
model: 'moonshot-v1-128k',
|
||||||
provider: 'kimi',
|
provider: 'kimi',
|
||||||
|
usageStatus: 'available',
|
||||||
inputTokens: 120,
|
inputTokens: 120,
|
||||||
outputTokens: 30,
|
outputTokens: 30,
|
||||||
cacheReadTokens: 10,
|
cacheReadTokens: 10,
|
||||||
@@ -163,6 +271,7 @@ describe('parseUsageEntriesFromJsonl', () => {
|
|||||||
agentId: 'default',
|
agentId: 'default',
|
||||||
model: 'kimi-k2.5',
|
model: 'kimi-k2.5',
|
||||||
provider: 'moonshot',
|
provider: 'moonshot',
|
||||||
|
usageStatus: 'available',
|
||||||
content: '这是一条测试回复内容。',
|
content: '这是一条测试回复内容。',
|
||||||
inputTokens: 0,
|
inputTokens: 0,
|
||||||
outputTokens: 0,
|
outputTokens: 0,
|
||||||
@@ -200,6 +309,7 @@ describe('parseUsageEntriesFromJsonl', () => {
|
|||||||
agentId: 'default',
|
agentId: 'default',
|
||||||
model: 'moonshot-v1-128k',
|
model: 'moonshot-v1-128k',
|
||||||
provider: 'kimi',
|
provider: 'kimi',
|
||||||
|
usageStatus: 'available',
|
||||||
content: '外部搜索原文内容',
|
content: '外部搜索原文内容',
|
||||||
inputTokens: 0,
|
inputTokens: 0,
|
||||||
outputTokens: 0,
|
outputTokens: 0,
|
||||||
@@ -211,6 +321,70 @@ describe('parseUsageEntriesFromJsonl', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('maps usage object with no recognized fields to missing state', () => {
|
||||||
|
const jsonl = [
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'message',
|
||||||
|
timestamp: '2026-03-10T03:30:00.000Z',
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
model: 'kimi-k2.5',
|
||||||
|
provider: 'moonshot',
|
||||||
|
usage: { notes: 'tool call' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([
|
||||||
|
{
|
||||||
|
timestamp: '2026-03-10T03:30:00.000Z',
|
||||||
|
sessionId: 'abc',
|
||||||
|
agentId: 'default',
|
||||||
|
model: 'kimi-k2.5',
|
||||||
|
provider: 'moonshot',
|
||||||
|
usageStatus: 'missing',
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
costUsd: undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks non-object usage payload as error', () => {
|
||||||
|
const jsonl = [
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'message',
|
||||||
|
timestamp: '2026-03-10T03:40:00.000Z',
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
model: 'kimi-k2.5',
|
||||||
|
provider: 'moonshot',
|
||||||
|
usage: 'invalid',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
expect(parseUsageEntriesFromJsonl(jsonl, { sessionId: 'abc', agentId: 'default' })).toEqual([
|
||||||
|
{
|
||||||
|
timestamp: '2026-03-10T03:40:00.000Z',
|
||||||
|
sessionId: 'abc',
|
||||||
|
agentId: 'default',
|
||||||
|
model: 'kimi-k2.5',
|
||||||
|
provider: 'moonshot',
|
||||||
|
usageStatus: 'error',
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
costUsd: undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns all matching entries when no limit is provided', () => {
|
it('returns all matching entries when no limit is provided', () => {
|
||||||
const jsonl = [
|
const jsonl = [
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
Reference in New Issue
Block a user