Fix token usage handling and developer proxy save UX (#704)

This commit is contained in:
Lingxuan Zuo
2026-03-28 21:13:56 +08:00
committed by GitHub
Unverified
parent 2668082809
commit 870abb99c4
15 changed files with 782 additions and 75 deletions

View File

@@ -39,6 +39,9 @@
"modelPreview": "Preview",
"modelOverridePlaceholder": "provider/model (for example: openrouter/openai/gpt-5.4)",
"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",
"useDefaultModel": "Use default model",
"channelsTitle": "Channels",

View File

@@ -58,6 +58,8 @@
"cost": "Cost ${{amount}}",
"viewContent": "View content",
"contentDialogTitle": "Usage detail content",
"close": "Close"
"close": "Close",
"noUsage": "No usage",
"usageParseError": "Usage parse error"
}
}
}

View File

@@ -39,6 +39,9 @@
"modelPreview": "プレビュー",
"modelOverridePlaceholder": "provider/model例: openrouter/openai/gpt-5.4",
"modelOverrideDescription": "この Agent の Provider とモデル ID を選択します。",
"unsavedChangesTitle": "未保存の変更",
"unsavedChangesMessage": "未保存の変更があります。閉じると変更が破棄されます。",
"closeWithoutSaving": "保存せずに閉じる",
"saveModelOverride": "モデルを保存",
"useDefaultModel": "デフォルトモデルを使用",
"channelsTitle": "Channels",

View File

@@ -58,6 +58,8 @@
"cost": "コスト ${{amount}}",
"viewContent": "内容を見る",
"contentDialogTitle": "使用量詳細の内容",
"close": "閉じる"
"close": "閉じる",
"noUsage": "使用量なし",
"usageParseError": "使用量解析エラー"
}
}
}

View File

@@ -39,6 +39,9 @@
"modelPreview": "预览",
"modelOverridePlaceholder": "provider/model例如openrouter/openai/gpt-5.4",
"modelOverrideDescription": "为该 Agent 选择 Provider 和模型 ID。",
"unsavedChangesTitle": "未保存的修改",
"unsavedChangesMessage": "你有未保存的修改。关闭后这些修改将被丢弃。",
"closeWithoutSaving": "不保存并关闭",
"saveModelOverride": "保存模型",
"useDefaultModel": "使用默认模型",
"channelsTitle": "频道",

View File

@@ -58,6 +58,8 @@
"cost": "费用 ${{amount}}",
"viewContent": "查看内容",
"contentDialogTitle": "用量明细内容",
"close": "关闭"
"close": "关闭",
"noUsage": "无用量数据",
"usageParseError": "用量解析失败"
}
}
}

View File

@@ -493,11 +493,22 @@ function AgentSettingsModal({
const [name, setName] = useState(agent.name);
const [savingName, setSavingName] = useState(false);
const [showModelModal, setShowModelModal] = useState(false);
const [showCloseConfirm, setShowCloseConfirm] = useState(false);
useEffect(() => {
setName(agent.name);
}, [agent.name]);
const hasNameChanges = name.trim() !== agent.name;
const handleRequestClose = () => {
if (savingName || hasNameChanges) {
setShowCloseConfirm(true);
return;
}
onClose();
};
const handleSaveName = async () => {
if (!name.trim() || name.trim() === agent.name) return;
setSavingName(true);
@@ -540,7 +551,7 @@ function AgentSettingsModal({
<Button
variant="ghost"
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"
>
<X className="h-4 w-4" />
@@ -652,6 +663,19 @@ function AgentSettingsModal({
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>
);
}
@@ -672,6 +696,7 @@ function AgentModelModal({
const [selectedRuntimeProviderKey, setSelectedRuntimeProviderKey] = useState('');
const [modelIdInput, setModelIdInput] = useState('');
const [savingModel, setSavingModel] = useState(false);
const [showCloseConfirm, setShowCloseConfirm] = useState(false);
const runtimeProviderOptions = useMemo<RuntimeProviderOption[]>(() => {
const vendorMap = new Map<string, ProviderVendorInfo>(providerVendors.map((vendor) => [vendor.id, vendor]));
@@ -740,6 +765,14 @@ function AgentModelModal({
: null;
const modelChanged = (desiredOverrideModelRef || '') !== currentOverrideModelRef;
const handleRequestClose = () => {
if (savingModel || modelChanged) {
setShowCloseConfirm(true);
return;
}
onClose();
};
const handleSaveModel = async () => {
if (!selectedRuntimeProviderKey) {
toast.error(t('toast.agentModelProviderRequired'));
@@ -793,7 +826,7 @@ function AgentModelModal({
<Button
variant="ghost"
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"
>
<X className="h-4 w-4" />
@@ -854,7 +887,7 @@ function AgentModelModal({
</Button>
<Button
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"
>
{t('common:actions.cancel')}
@@ -873,6 +906,18 @@ function AgentModelModal({
</div>
</CardContent>
</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>
);
}

View File

@@ -36,6 +36,37 @@ export function Models() {
const [usageWindow, setUsageWindow] = useState<UsageWindow>('7d');
const [usagePage, setUsagePage] = useState(1);
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 = {
status: 'idle' | 'loading' | 'done';
@@ -181,8 +212,9 @@ export function Models() {
};
}, [isGatewayRunning, gatewayStatus.connectedAt, gatewayStatus.pid, usageFetchMaxAttempts]);
const usageHistory = fetchState.data;
const visibleUsageHistory = isGatewayRunning ? usageHistory : [];
const visibleUsageHistory = isGatewayRunning
? fetchState.data.filter((entry) => !shouldHideUsageEntry(entry))
: [];
const filteredUsageHistory = filterUsageHistoryByWindow(visibleUsageHistory, usageWindow);
const usageGroups = groupUsageHistory(filteredUsageHistory, usageGroupBy);
const usagePageSize = 5;
@@ -313,6 +345,7 @@ export function Models() {
{pagedUsageHistory.map((entry) => (
<div
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"
>
<div className="flex items-start justify-between gap-3">
@@ -321,24 +354,46 @@ export function Models() {
{entry.model || t('dashboard:recentTokenHistory.unknownModel')}
</p>
<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>
</div>
<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">
{formatUsageTimestamp(entry.timestamp)}
</p>
</div>
</div>
<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>
<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-amber-500"></div>{t('dashboard:recentTokenHistory.cacheRead', { value: formatTokenCount(entry.cacheReadTokens) })}</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>
{entry.usageStatus === 'available' || entry.usageStatus === undefined ? (
<>
<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-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-amber-500"></div>{t('dashboard:recentTokenHistory.cacheRead', { value: formatTokenCount(entry.cacheReadTokens) })}</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) && (
<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);
}
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 {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return timestamp;

View File

@@ -5,6 +5,7 @@ export type UsageHistoryEntry = {
model?: string;
provider?: string;
content?: string;
usageStatus?: 'available' | 'missing' | 'error';
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;

View File

@@ -323,6 +323,30 @@ export function Settings() {
setProxyBypassRulesDraft(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 () => {
setSavingProxy(true);
try {
@@ -612,9 +636,9 @@ export function Settings() {
</p>
</div>
<Switch
data-testid="settings-dev-mode-switch"
checked={devModeUnlocked}
onCheckedChange={setDevModeUnlocked}
data-testid="settings-dev-mode-switch"
/>
</div>
@@ -645,7 +669,7 @@ export function Settings() {
</h2>
<div className="space-y-8">
{/* 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>
<Label className="text-[14px] font-medium text-foreground/80">Gateway Proxy</Label>
@@ -656,9 +680,26 @@ export function Settings() {
<Switch
checked={proxyEnabledDraft}
onCheckedChange={setProxyEnabledDraft}
data-testid="settings-proxy-toggle"
/>
</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 && (
<div className="space-y-4 pt-2">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -733,20 +774,6 @@ export function Settings() {
</p>
</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>