diff --git a/desktop/renderer/src/browser-openclaw.ts b/desktop/renderer/src/browser-openclaw.ts index 9cfd18f..6655ab7 100644 --- a/desktop/renderer/src/browser-openclaw.ts +++ b/desktop/renderer/src/browser-openclaw.ts @@ -179,7 +179,57 @@ export function createBrowserOpenClawMock(): OpenClawAPI { }, }, usage: { - getStats: async () => ({ totalSpend: 0, totalTokens: 0 }), + getStats: async () => ({ + totalSpend: 6.82, + maxBudget: 12, + modelSpend: { + "gpt-4.1": 2.41, + "claude-sonnet-4": 3.78, + "gpt-4.1-mini": 0.63, + }, + keyName: "browser-dev-preview", + budgetDuration: "month", + budgetResetAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 14).toISOString(), + totalPromptTokens: 184320, + totalCompletionTokens: 76240, + totalTokens: 260560, + totalRequests: 428, + modelBreakdown: { + "gpt-4.1": { + requests: 136, + promptTokens: 68210, + completionTokens: 21940, + spend: 2.41, + }, + "claude-sonnet-4": { + requests: 204, + promptTokens: 91360, + completionTokens: 41680, + spend: 3.78, + }, + "gpt-4.1-mini": { + requests: 88, + promptTokens: 24750, + completionTokens: 12620, + spend: 0.63, + }, + }, + dailySpend: { + "2026-08-06": 0.72, + "2026-08-07": 0.94, + "2026-08-08": 1.16, + "2026-08-09": 0.83, + "2026-08-10": 1.49, + "2026-08-11": 1.68, + }, + hasDetailedLogs: true, + cacheReadTokens: 38420, + cacheWriteTokens: 9210, + sessionCount: 37, + toolCalls: 96, + exchangeRate: 7.2, + currency: "CNY", + }), getDetailedStats: async () => ({}), getExchangeRate: async () => ({ rate: 7.2, diff --git a/desktop/renderer/src/i18n/en-US.ts b/desktop/renderer/src/i18n/en-US.ts index 826b4ca..9703ba4 100644 --- a/desktop/renderer/src/i18n/en-US.ts +++ b/desktop/renderer/src/i18n/en-US.ts @@ -346,10 +346,12 @@ export default { "settings.gatewayLogsExported": "Gateway logs exported.", "settings.gatewayLogsExportFailed": "Could not export Gateway logs: {error}", "settings.webSearchDesc": - "MicroClaw uses the configured provider for web search. The gateway restarts automatically after you save.", + "MicroClaw uses the configured provider for web search. Changes are saved automatically.", "settings.noApiKeyRequired": "No API key required", "settings.getApiKey": "Get API key", "settings.save": "Save", + "settings.saving": "Saving...", + "settings.saved": "Saved", "settings.configSaveFailed": "Failed to save configuration: {error}", "settings.modelSwitched": "Model switched to {model}", "settings.customModelAdded": "Custom model added", @@ -364,6 +366,9 @@ export default { "settings.searchConfigured": "Configured", "settings.searchNotConfigured": "Not configured", "settings.searchDefault": "Default", + "settings.searchApiKeyRequired": "API key required", + "settings.searchApiKeyFormatInvalid": "Check the key format", + "settings.searchSaveFailed": "Failed to save", "settings.saveFailed": "Save failed: {error}", "settings.clearHistoryConfirm": "Are you sure you want to clear all chat history? This action cannot be undone.", diff --git a/desktop/renderer/src/i18n/zh-CN.ts b/desktop/renderer/src/i18n/zh-CN.ts index 3bc0396..3a290aa 100644 --- a/desktop/renderer/src/i18n/zh-CN.ts +++ b/desktop/renderer/src/i18n/zh-CN.ts @@ -325,10 +325,12 @@ export default { "settings.export": "导出", "settings.gatewayLogsExported": "网关日志已导出。", "settings.gatewayLogsExportFailed": "无法导出网关日志:{error}", - "settings.webSearchDesc": "MicroClaw 使用已配置的提供商进行联网搜索。保存后网关将自动重启生效。", + "settings.webSearchDesc": "MicroClaw 使用已配置的提供商进行联网搜索。更改会自动保存。", "settings.noApiKeyRequired": "无需 API 密钥", "settings.getApiKey": "获取 API 密钥", "settings.save": "保存", + "settings.saving": "正在保存...", + "settings.saved": "已保存", "settings.configSaveFailed": "配置保存失败:{error}", "settings.modelSwitched": "模型已切换为 {model}", "settings.customModelAdded": "自定义模型已添加", @@ -342,6 +344,9 @@ export default { "settings.searchConfigured": "已配置", "settings.searchNotConfigured": "未配置", "settings.searchDefault": "默认", + "settings.searchApiKeyRequired": "需要 API 密钥", + "settings.searchApiKeyFormatInvalid": "请检查密钥格式", + "settings.searchSaveFailed": "保存失败", "settings.saveFailed": "保存失败:{error}", "settings.clearHistoryConfirm": "确定要清除所有聊天记录吗?此操作不可撤销。", "settings.confirm": "确认", diff --git a/desktop/renderer/src/views/SettingsView.vue b/desktop/renderer/src/views/SettingsView.vue index 4de3800..fe4964b 100644 --- a/desktop/renderer/src/views/SettingsView.vue +++ b/desktop/renderer/src/views/SettingsView.vue @@ -311,21 +311,20 @@
{{ t("settings.webSearch") }}
- {{ t("settings.save") }} + + {{ searchSaveStatusText }} +
-
+
{{ t("settings.provider") }} - +
@@ -343,12 +342,10 @@ show-password :placeholder="activeSearchProvider.placeholder" class="provider-key-input" + @blur="saveSearchKeyIfReady" + @keyup.enter="saveSearchKeyIfReady" />
-
- {{ t("settings.apiKey") }} - {{ t("settings.noApiKeyRequired") }} -
@@ -1271,10 +1268,20 @@ const savedSearchKeys = reactive>({ brave: "", tavily: "", }); -const searchSavingAll = ref(false); -const searchDirty = computed(() => { - if (searchProvider.value !== savedSearchProvider.value) return true; - return searchProviders.some((p) => searchKeys[p.id].trim() !== savedSearchKeys[p.id]); +type SearchSaveStatus = "idle" | "saving" | "saved" | "error" | "invalid"; +const searchSaveStatus = ref("idle"); +const searchSaveError = ref(""); +const searchSaveStatusClass = computed(() => { + if (searchSaveStatus.value === "saved") return "status-indicator--ok"; + if (searchSaveStatus.value === "saving") return "status-indicator--muted"; + return "status-indicator--error"; +}); +const searchSaveStatusText = computed(() => { + if (searchSaveStatus.value === "saving") return t("settings.saving"); + if (searchSaveStatus.value === "saved") return t("settings.saved"); + if (searchSaveStatus.value === "invalid") return searchSaveError.value; + if (searchSaveStatus.value === "error") return t("settings.searchSaveFailed"); + return ""; }); // --- Usage state --- @@ -1763,6 +1770,24 @@ function snapshotSearchConfig(): void { for (const p of searchProviders) savedSearchKeys[p.id] = searchKeys[p.id].trim(); } +function validateActiveSearchKey(): string | null { + if (!activeSearchProvider.value.requiresKey) return null; + const key = searchKeys[searchProvider.value].trim(); + if (!key) return t("settings.searchApiKeyRequired"); + if (searchProvider.value === "brave" && !key.startsWith("BSA")) { + return t("settings.searchApiKeyFormatInvalid"); + } + if (searchProvider.value === "tavily" && !key.startsWith("tvly-")) { + return t("settings.searchApiKeyFormatInvalid"); + } + return null; +} + +function setSearchValidationError(message: string): void { + searchSaveError.value = message; + searchSaveStatus.value = "invalid"; +} + // Writes only the active provider into tools.web.search — the single object the gateway // consumes. The gateway schema rejects any extra keys under tools.web, so no per-provider map // is persisted; unsaved keys for other providers live only in-memory for quick switching. @@ -1785,18 +1810,36 @@ async function persistSearchConfig(): Promise { // Single save for the Web search section: commits both provider keys and the selected default // provider in one action. The gateway hot-reloads the tools config on write. async function saveSearchSettings(): Promise { - searchSavingAll.value = true; + searchSaveStatus.value = "saving"; try { await persistSearchConfig(); snapshotSearchConfig(); - ElMessage.success(t("settings.searchSettingsSaved")); + searchSaveError.value = ""; + searchSaveStatus.value = "saved"; } catch (err: any) { + searchSaveStatus.value = "error"; ElMessage.error(t("settings.saveFailed", { error: err.message || err })); - } finally { - searchSavingAll.value = false; } } +async function handleSearchProviderChange(): Promise { + const validationError = validateActiveSearchKey(); + if (validationError) { + setSearchValidationError(validationError); + return; + } + await saveSearchSettings(); +} + +async function saveSearchKeyIfReady(): Promise { + const validationError = validateActiveSearchKey(); + if (validationError) { + setSearchValidationError(validationError); + return; + } + await saveSearchSettings(); +} + function openExternal(url: string) { window.openclaw.shell.openExternal(url); }