From ad61472a77c52157974582e75a99befb4ac75c3b Mon Sep 17 00:00:00 2001 From: coso Date: Mon, 2 Mar 2026 17:52:39 +0800 Subject: [PATCH] feat: add observability panel, response cache config UI, and clipboard permission guide - ApiServerPage: add observability panel with cache hit rate, dedup replay rate, idempotency rate, capability routing exclusion metrics, latency/error/circuit stats (refreshes every 3s) - Settings proxy page: add ResponseCacheSettings configuration UI (enabled, TTL, max entries, etc.) - Settings developer page: add clipboard permission denied detection + ClipboardPermissionGuideCard, add "Copy JSON" button alongside existing markdown export, integrate persisted log tail in payload - Settings about page: add fallback GitHub tags URL for manual download when no download URL available - Settings layout: scroll content panel to top when switching tabs - Add ClipboardPermissionGuideCard shared component with platform-specific step instructions - useTauri: add CapabilityRoutingMetricsSnapshot, ResponseCacheStats, RequestDedupStats, IdempotencyStats types and integrate into ServerStatus Co-Authored-By: Claude Sonnet 4.6 --- src/components/api-server/ApiServerPage.tsx | 213 ++++++++++++++ src/components/settings-v2/_layout/index.tsx | 9 +- .../settings-v2/system/about/index.tsx | 46 +-- .../settings-v2/system/developer/index.tsx | 103 ++++++- .../settings-v2/system/experimental/index.tsx | 102 ++++++- .../settings-v2/system/proxy/index.tsx | 275 +++++++++++++++++- .../shared/ClipboardPermissionGuideCard.tsx | 74 +++++ src/hooks/useTauri.ts | 94 ++++++ 8 files changed, 880 insertions(+), 36 deletions(-) create mode 100644 src/components/settings-v2/system/shared/ClipboardPermissionGuideCard.tsx diff --git a/src/components/api-server/ApiServerPage.tsx b/src/components/api-server/ApiServerPage.tsx index 5989a69b8..0b5009291 100644 --- a/src/components/api-server/ApiServerPage.tsx +++ b/src/components/api-server/ApiServerPage.tsx @@ -902,6 +902,43 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) { ), [status, editHost], ); + const cacheHitRate = useMemo(() => { + const hits = status?.response_cache?.hits ?? 0; + const misses = status?.response_cache?.misses ?? 0; + const total = hits + misses; + if (total === 0) return 0; + return (hits / total) * 100; + }, [status]); + const capabilityExcludedReasonTotal = useMemo(() => { + const capability = status?.capability_routing; + if (!capability) return 0; + return ( + capability.filter_excluded_tools_total + + capability.filter_excluded_vision_total + + capability.filter_excluded_context_total + ); + }, [status]); + const requestDedupReplayRate = useMemo(() => { + const dedup = status?.request_dedup; + if (!dedup) return 0; + const totalChecks = + dedup.check_new_total + + dedup.check_in_progress_total + + dedup.check_completed_total; + const replayCount = dedup.check_completed_total + dedup.wait_success_total; + if (totalChecks === 0) return 0; + return (replayCount / totalChecks) * 100; + }, [status]); + const idempotencyReplayRate = useMemo(() => { + const idempotency = status?.idempotency; + if (!idempotency) return 0; + const totalChecks = + idempotency.check_new_total + + idempotency.check_in_progress_total + + idempotency.check_completed_total; + if (totalChecks === 0) return 0; + return (idempotency.check_completed_total / totalChecks) * 100; + }, [status]); const handleGatewayModeChange = async (mode: GatewayMode) => { const nextHost = mode === "local" ? "127.0.0.1" : "0.0.0.0"; @@ -1313,6 +1350,182 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) { )} + {/* 观测面板(对标 ClawRouter) */} +
+
+

观测面板

+ + 实时统计(每 3 秒刷新) + +
+ +
+
+

最近1分钟错误率

+

+ {((status?.error_rate_1m ?? 0) * 100).toFixed(1)}% +

+
+
+

+ 最近1分钟 P95 延迟 +

+

+ {status?.p95_latency_ms_1m != null + ? `${status.p95_latency_ms_1m}ms` + : "-"} +

+
+
+

+ 活跃请求(近似) +

+

+ {status?.active_requests ?? 0} +

+
+
+

+ 熔断上游数(近似) +

+

+ {status?.open_circuit_count ?? 0} +

+
+
+ +
+
+

+ 响应缓存(非流式) +

+
+
命中:{status?.response_cache?.hits ?? 0}
+
未命中:{status?.response_cache?.misses ?? 0}
+
缓存条目:{status?.response_cache?.size ?? 0}
+
淘汰次数:{status?.response_cache?.evictions ?? 0}
+
+

+ 缓存命中率:{cacheHitRate.toFixed(1)}% +

+
+ +
+

请求去重

+
+
+ 新请求: + {status?.request_dedup?.check_new_total ?? 0} +
+
+ In-Flight 等待: + {status?.request_dedup?.check_in_progress_total ?? 0} +
+
+ 直接回放: + {status?.request_dedup?.check_completed_total ?? 0} +
+
+ 等待回放成功: + {status?.request_dedup?.wait_success_total ?? 0} +
+
+ 等待超时: + {status?.request_dedup?.wait_timeout_total ?? 0} +
+
+ 当前 In-Flight: + {status?.request_dedup?.inflight_size ?? 0} +
+
+

+ 去重回放率:{requestDedupReplayRate.toFixed(1)}% +

+
+ +
+

幂等闭环

+
+
+ 新键: + {status?.idempotency?.check_new_total ?? 0} +
+
+ 处理中冲突: + {status?.idempotency?.check_in_progress_total ?? 0} +
+
+ 已完成回放: + {status?.idempotency?.check_completed_total ?? 0} +
+
+ 完成写入: + {status?.idempotency?.complete_total ?? 0} +
+
+ 当前 In-Progress: + {status?.idempotency?.in_progress_size ?? 0} +
+
+ 当前 Completed: + {status?.idempotency?.completed_size ?? 0} +
+
+

+ 幂等回放率:{idempotencyReplayRate.toFixed(1)}% +

+
+ +
+

能力路由与回退

+
+
+ 过滤评估: + {status?.capability_routing?.filter_eval_total ?? 0} +
+
+ 过滤排除: + {status?.capability_routing?.filter_excluded_total ?? 0} +
+
+ Provider 回退: + {status?.capability_routing?.provider_fallback_total ?? 0} +
+
+ 模型回退: + {status?.capability_routing?.model_fallback_total ?? 0} +
+
+ 候选全排空: + {status?.capability_routing + ?.all_candidates_excluded_total ?? 0} +
+
+ 原因样本: + {capabilityExcludedReasonTotal} +
+
+
+ + tools:{" "} + {status?.capability_routing?.filter_excluded_tools_total ?? + 0} + + + vision:{" "} + {status?.capability_routing?.filter_excluded_vision_total ?? + 0} + + + context:{" "} + {status?.capability_routing + ?.filter_excluded_context_total ?? 0} + +
+
+
+
+ {/* Default Provider - 动态显示有凭证的 Provider */}
diff --git a/src/components/settings-v2/_layout/index.tsx b/src/components/settings-v2/_layout/index.tsx index 87786dbc3..bbcaae238 100644 --- a/src/components/settings-v2/_layout/index.tsx +++ b/src/components/settings-v2/_layout/index.tsx @@ -5,7 +5,7 @@ * 参考成熟产品的设置布局设计 */ -import { useState, ReactNode, useEffect } from "react"; +import { useState, ReactNode, useEffect, useRef } from "react"; import styled from "styled-components"; import { SettingsSidebar } from "./SettingsSidebar"; import { SettingsTabs } from "@/types/settings"; @@ -323,6 +323,7 @@ export function SettingsLayoutV2({ const [activeTab, setActiveTab] = useState( initialTab || SettingsTabs.Appearance, ); + const contentContainerRef = useRef(null); useEffect(() => { if (initialTab) { @@ -330,6 +331,10 @@ export function SettingsLayoutV2({ } }, [initialTab]); + useEffect(() => { + contentContainerRef.current?.scrollTo({ top: 0, behavior: "auto" }); + }, [activeTab]); + const handleBackToHome = () => { if (onNavigate) { onNavigate("agent", buildHomeAgentParams()); @@ -345,7 +350,7 @@ export function SettingsLayoutV2({ {/* 设置内容 */} - + {renderSettingsContent(activeTab)} diff --git a/src/components/settings-v2/system/about/index.tsx b/src/components/settings-v2/system/about/index.tsx index 9e5822cc5..5fae40a28 100644 --- a/src/components/settings-v2/system/about/index.tsx +++ b/src/components/settings-v2/system/about/index.tsx @@ -23,6 +23,8 @@ interface DownloadResult { filePath?: string; } +const FALLBACK_TAGS_URL = "https://github.com/aiclientproxy/proxycast/tags"; + const CREATIVE_THEMES = [ "通用对话", "社媒内容", @@ -63,6 +65,7 @@ export function AboutSection() { const [downloadResult, setDownloadResult] = useState( null, ); + const manualDownloadUrl = versionInfo.downloadUrl || FALLBACK_TAGS_URL; // 加载当前版本号(从后端获取,确保与 Cargo.toml 同步) useEffect(() => { @@ -73,9 +76,14 @@ export function AboutSection() { setVersionInfo((prev) => ({ ...prev, current: result.current, + downloadUrl: result.downloadUrl || FALLBACK_TAGS_URL, })); } catch (error) { console.error("Failed to load version:", error); + setVersionInfo((prev) => ({ + ...prev, + downloadUrl: prev.downloadUrl || FALLBACK_TAGS_URL, + })); } }; loadCurrentVersion(); @@ -86,12 +94,16 @@ export function AboutSection() { setDownloadResult(null); try { const result = await safeInvoke("check_for_updates"); - setVersionInfo(result); + setVersionInfo({ + ...result, + downloadUrl: result.downloadUrl || FALLBACK_TAGS_URL, + }); } catch (error) { console.error("Failed to check for updates:", error); setVersionInfo((prev) => ({ ...prev, error: t("检查更新失败", "检查更新失败"), + downloadUrl: prev.downloadUrl || FALLBACK_TAGS_URL, })); } finally { setChecking(false); @@ -124,7 +136,7 @@ export function AboutSection() { console.error("Failed to download update:", error); setDownloadResult({ success: false, - message: t("下载失败,请手动下载", "下载失败,请手动下载"), + message: t("下载失败,请手动下载最新版", "下载失败,请手动下载最新版"), filePath: undefined, }); } finally { @@ -207,17 +219,15 @@ export function AboutSection() { : t("下载更新", "下载更新")} - {versionInfo.downloadUrl && ( - - - {t("网页下载", "网页下载")} - - )} + + + {t("网页下载", "网页下载")} + )}
@@ -239,14 +249,14 @@ export function AboutSection() { )}

{downloadResult.message}

- {!downloadResult.success && versionInfo.downloadUrl && ( + {!downloadResult.success && ( - + className="inline-flex items-center gap-1 mt-2 underline hover:no-underline" + > + {t("前往网页下载", "前往网页下载")} )} diff --git a/src/components/settings-v2/system/developer/index.tsx b/src/components/settings-v2/system/developer/index.tsx index f0187b264..61548ee95 100644 --- a/src/components/settings-v2/system/developer/index.tsx +++ b/src/components/settings-v2/system/developer/index.tsx @@ -6,14 +6,18 @@ import { useCallback, useState } from "react"; import { Bug, Code2, Eye } from "lucide-react"; import { Switch } from "@/components/ui/switch"; import { useComponentDebug } from "@/contexts/ComponentDebugContext"; -import { getConfig, getLogs } from "@/hooks/useTauri"; +import { getConfig, getLogs, getPersistedLogsTail } from "@/hooks/useTauri"; import { buildCrashDiagnosticPayload, copyCrashDiagnosticToClipboard, + copyCrashDiagnosticJsonToClipboard, exportCrashDiagnosticToJson, + isClipboardPermissionDeniedError, normalizeCrashReportingConfig, + openCrashDiagnosticDownloadDirectory, } from "@/lib/crashDiagnostic"; import { cn } from "@/lib/utils"; +import { ClipboardPermissionGuideCard } from "../shared/ClipboardPermissionGuideCard"; export function DeveloperSettings() { const { enabled, setEnabled } = useComponentDebug(); @@ -22,12 +26,18 @@ export function DeveloperSettings() { type: "success" | "error"; text: string; } | null>(null); + const [showClipboardGuide, setShowClipboardGuide] = useState(false); const buildDiagnosticPayload = useCallback(async () => { - const [config, logs] = await Promise.all([getConfig(), getLogs()]); + const [config, logs, persistedLogs] = await Promise.all([ + getConfig(), + getLogs(), + getPersistedLogsTail(200), + ]); return buildCrashDiagnosticPayload({ crashConfig: normalizeCrashReportingConfig(config.crash_reporting), logs, + persistedLogTail: persistedLogs, appVersion: import.meta.env.VITE_APP_VERSION, platform: navigator.platform, userAgent: navigator.userAgent, @@ -37,6 +47,7 @@ export function DeveloperSettings() { const handleCopyDiagnostic = useCallback(async () => { setDiagnosticBusy(true); setMessage(null); + setShowClipboardGuide(false); try { const payload = await buildDiagnosticPayload(); await copyCrashDiagnosticToClipboard(payload); @@ -47,6 +58,8 @@ export function DeveloperSettings() { setTimeout(() => setMessage(null), 2500); } catch (err) { console.error("复制诊断信息失败:", err); + const isPermissionDenied = isClipboardPermissionDeniedError(err); + setShowClipboardGuide(isPermissionDenied); setMessage({ type: "error", text: err instanceof Error ? err.message : "复制诊断信息失败", @@ -56,15 +69,52 @@ export function DeveloperSettings() { } }, [buildDiagnosticPayload]); + const handleCopyDiagnosticJson = useCallback(async () => { + setDiagnosticBusy(true); + setMessage(null); + setShowClipboardGuide(false); + try { + const payload = await buildDiagnosticPayload(); + await copyCrashDiagnosticJsonToClipboard(payload); + setMessage({ + type: "success", + text: "纯 JSON 诊断信息已复制", + }); + setTimeout(() => setMessage(null), 2500); + } catch (err) { + console.error("复制纯 JSON 失败:", err); + const isPermissionDenied = isClipboardPermissionDeniedError(err); + setShowClipboardGuide(isPermissionDenied); + setMessage({ + type: "error", + text: err instanceof Error ? err.message : "复制纯 JSON 失败", + }); + } finally { + setDiagnosticBusy(false); + } + }, [buildDiagnosticPayload]); + const handleExportDiagnostic = useCallback(async () => { setDiagnosticBusy(true); setMessage(null); + setShowClipboardGuide(false); try { const payload = await buildDiagnosticPayload(); - exportCrashDiagnosticToJson(payload); + const result = exportCrashDiagnosticToJson(payload, { + sceneTag: "settings-developer", + }); + let openedPath: string | null = null; + try { + const opened = await openCrashDiagnosticDownloadDirectory(); + openedPath = opened.openedPath; + } catch { + openedPath = null; + } setMessage({ type: "success", - text: "诊断文件已导出,可发送给开发者", + text: openedPath + ? `诊断文件已导出:${result.fileName},并已打开目录:${openedPath}` + : `诊断文件已导出:${result.fileName}(位置:${result.locationHint})`, }); setTimeout(() => setMessage(null), 2500); } catch (err) { @@ -78,6 +128,27 @@ export function DeveloperSettings() { } }, [buildDiagnosticPayload]); + const handleOpenDownloadDirectory = useCallback(async () => { + setDiagnosticBusy(true); + setMessage(null); + try { + const result = await openCrashDiagnosticDownloadDirectory(); + setMessage({ + type: "success", + text: `已打开下载目录:${result.openedPath}`, + }); + setTimeout(() => setMessage(null), 2500); + } catch (err) { + console.error("打开下载目录失败:", err); + setMessage({ + type: "error", + text: err instanceof Error ? err.message : "打开下载目录失败", + }); + } finally { + setDiagnosticBusy(false); + } + }, []); + return (
{/* 标题 */} @@ -100,6 +171,8 @@ export function DeveloperSettings() {
)} + {showClipboardGuide && } + {/* 组件视图调试 */}
@@ -155,6 +228,17 @@ export function DeveloperSettings() { > 复制诊断信息 + +
diff --git a/src/components/settings-v2/system/experimental/index.tsx b/src/components/settings-v2/system/experimental/index.tsx index 2c6b0c7d2..a0aa6720f 100644 --- a/src/components/settings-v2/system/experimental/index.tsx +++ b/src/components/settings-v2/system/experimental/index.tsx @@ -19,6 +19,7 @@ import { saveConfig, type Config, getLogs, + getPersistedLogsTail, type CrashReportingConfig, } from "@/hooks/useTauri"; import { ShortcutSettings } from "@/components/smart-input/ShortcutSettings"; @@ -32,11 +33,15 @@ import { import { applyCrashReportingSettings } from "@/lib/crashReporting"; import { buildCrashDiagnosticPayload, + copyCrashDiagnosticJsonToClipboard, copyCrashDiagnosticToClipboard, DEFAULT_CRASH_REPORTING_CONFIG, exportCrashDiagnosticToJson, + isClipboardPermissionDeniedError, normalizeCrashReportingConfig, + openCrashDiagnosticDownloadDirectory, } from "@/lib/crashDiagnostic"; +import { ClipboardPermissionGuideCard } from "../shared/ClipboardPermissionGuideCard"; // ============================================================ // 组件 @@ -57,6 +62,7 @@ export function ExperimentalSettings() { type: "success" | "error"; text: string; } | null>(null); + const [showClipboardGuide, setShowClipboardGuide] = useState(false); // 检测是否为 macOS(使用 userAgentData 或 userAgent 替代已弃用的 platform) const isMacOS = navigator.userAgent.includes("Mac"); @@ -253,10 +259,14 @@ export function ExperimentalSettings() { }, [crashConfig, persistCrashConfig]); const buildDiagnosticPayload = useCallback(async () => { - const logs = await getLogs(); + const [logs, persistedLogs] = await Promise.all([ + getLogs(), + getPersistedLogsTail(200), + ]); return buildCrashDiagnosticPayload({ crashConfig, logs, + persistedLogTail: persistedLogs, appVersion: import.meta.env.VITE_APP_VERSION, platform: navigator.platform, userAgent: navigator.userAgent, @@ -266,6 +276,7 @@ export function ExperimentalSettings() { const copyCrashDiagnostic = useCallback(async () => { setDiagnosticBusy(true); setMessage(null); + setShowClipboardGuide(false); try { const payload = await buildDiagnosticPayload(); await copyCrashDiagnosticToClipboard(payload); @@ -276,6 +287,8 @@ export function ExperimentalSettings() { setTimeout(() => setMessage(null), 2500); } catch (err) { console.error("复制诊断信息失败:", err); + const isPermissionDenied = isClipboardPermissionDeniedError(err); + setShowClipboardGuide(isPermissionDenied); setMessage({ type: "error", text: err instanceof Error ? err.message : "复制诊断信息失败", @@ -285,15 +298,52 @@ export function ExperimentalSettings() { } }, [buildDiagnosticPayload]); + const copyCrashDiagnosticJson = useCallback(async () => { + setDiagnosticBusy(true); + setMessage(null); + setShowClipboardGuide(false); + try { + const payload = await buildDiagnosticPayload(); + await copyCrashDiagnosticJsonToClipboard(payload); + setMessage({ + type: "success", + text: "纯 JSON 诊断信息已复制", + }); + setTimeout(() => setMessage(null), 2500); + } catch (err) { + console.error("复制纯 JSON 失败:", err); + const isPermissionDenied = isClipboardPermissionDeniedError(err); + setShowClipboardGuide(isPermissionDenied); + setMessage({ + type: "error", + text: err instanceof Error ? err.message : "复制纯 JSON 失败", + }); + } finally { + setDiagnosticBusy(false); + } + }, [buildDiagnosticPayload]); + const exportCrashDiagnostic = useCallback(async () => { setDiagnosticBusy(true); setMessage(null); + setShowClipboardGuide(false); try { const payload = await buildDiagnosticPayload(); - exportCrashDiagnosticToJson(payload); + const result = exportCrashDiagnosticToJson(payload, { + sceneTag: "settings-experimental", + }); + let openedPath: string | null = null; + try { + const opened = await openCrashDiagnosticDownloadDirectory(); + openedPath = opened.openedPath; + } catch { + openedPath = null; + } setMessage({ type: "success", - text: "诊断文件已导出,可发送给开发者", + text: openedPath + ? `诊断文件已导出:${result.fileName},并已打开目录:${openedPath}` + : `诊断文件已导出:${result.fileName}(位置:${result.locationHint})`, }); setTimeout(() => setMessage(null), 2500); } catch (err) { @@ -307,6 +357,28 @@ export function ExperimentalSettings() { } }, [buildDiagnosticPayload]); + const openCrashDownloadDirectory = useCallback(async () => { + setDiagnosticBusy(true); + setMessage(null); + setShowClipboardGuide(false); + try { + const result = await openCrashDiagnosticDownloadDirectory(); + setMessage({ + type: "success", + text: `已打开下载目录:${result.openedPath}`, + }); + setTimeout(() => setMessage(null), 2500); + } catch (err) { + console.error("打开下载目录失败:", err); + setMessage({ + type: "error", + text: err instanceof Error ? err.message : "打开下载目录失败", + }); + } finally { + setDiagnosticBusy(false); + } + }, []); + // 加载中状态 if (loading) { return ( @@ -362,6 +434,8 @@ export function ExperimentalSettings() {
)} + {showClipboardGuide && } + {/* 截图对话功能 */}
@@ -562,6 +636,28 @@ export function ExperimentalSettings() { > 导出诊断 JSON + +
+ {/* 响应缓存配置 */} +
+
+

响应缓存配置

+

+ 对齐 ClawRouter:默认仅缓存状态码 200 +

+
+ +
+
+
+ +

+ 仅对非流式请求生效 +

+
+ + setConfig((previous) => { + if (!previous) return previous; + const normalizedConfig = + normalizeResponseCacheSettings(previous); + return { + ...normalizedConfig, + server: { + ...normalizedConfig.server, + response_cache: { + ...normalizedConfig.server.response_cache, + enabled: event.target.checked, + }, + }, + }; + }) + } + className="h-4 w-4 rounded border" + /> +
+ +
+
+ + + setConfig((previous) => { + if (!previous) return previous; + const normalizedConfig = + normalizeResponseCacheSettings(previous); + const parsed = Number.parseInt(event.target.value, 10); + return { + ...normalizedConfig, + server: { + ...normalizedConfig.server, + response_cache: { + ...normalizedConfig.server.response_cache, + ttl_secs: + Number.isFinite(parsed) && parsed > 0 + ? parsed + : 600, + }, + }, + }; + }) + } + className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" + /> +
+
+ + + setConfig((previous) => { + if (!previous) return previous; + const normalizedConfig = + normalizeResponseCacheSettings(previous); + const parsed = Number.parseInt(event.target.value, 10); + return { + ...normalizedConfig, + server: { + ...normalizedConfig.server, + response_cache: { + ...normalizedConfig.server.response_cache, + max_entries: + Number.isFinite(parsed) && parsed > 0 + ? parsed + : 200, + }, + }, + }; + }) + } + className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" + /> +
+
+ +
+ + + setConfig((previous) => { + if (!previous) return previous; + const normalizedConfig = + normalizeResponseCacheSettings(previous); + const parsed = Number.parseInt(event.target.value, 10); + return { + ...normalizedConfig, + server: { + ...normalizedConfig.server, + response_cache: { + ...normalizedConfig.server.response_cache, + max_body_bytes: + Number.isFinite(parsed) && parsed > 0 + ? parsed + : 1_048_576, + }, + }, + }; + }) + } + className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" + /> +

+ 超过此大小的响应会跳过缓存 +

+
+ +
+ + + setCacheableStatusCodesInput(event.target.value) + } + placeholder="200, 201" + className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" + /> +

+ 使用英文逗号分隔;默认仅 200 +

+
+
+
); } diff --git a/src/components/settings-v2/system/shared/ClipboardPermissionGuideCard.tsx b/src/components/settings-v2/system/shared/ClipboardPermissionGuideCard.tsx new file mode 100644 index 000000000..6b129b2b3 --- /dev/null +++ b/src/components/settings-v2/system/shared/ClipboardPermissionGuideCard.tsx @@ -0,0 +1,74 @@ +import { useMemo, useState } from "react"; +import { AlertTriangle, ExternalLink } from "lucide-react"; +import { open as openExternal } from "@tauri-apps/plugin-shell"; +import { cn } from "@/lib/utils"; +import { getClipboardPermissionGuide } from "@/lib/crashDiagnostic"; + +interface ClipboardPermissionGuideCardProps { + className?: string; +} + +export function ClipboardPermissionGuideCard({ + className, +}: ClipboardPermissionGuideCardProps) { + const [openError, setOpenError] = useState(null); + + const guide = useMemo( + () => getClipboardPermissionGuide(navigator.platform, navigator.userAgent), + [], + ); + + const handleOpenSettings = async () => { + if (!guide.settingsUrl) return; + setOpenError(null); + try { + await openExternal(guide.settingsUrl); + } catch (error) { + try { + window.open(guide.settingsUrl, "_blank"); + } catch { + setOpenError( + error instanceof Error ? error.message : "打开系统设置失败", + ); + } + } + }; + + return ( +
+
+ +
+

+ {guide.title} +

+
    + {guide.steps.map((step) => ( +
  1. {step}
  2. + ))} +
+ {guide.settingsUrl && ( + + )} + {openError && ( +

+ 打开系统设置失败:{openError} +

+ )} +
+
+
+ ); +} diff --git a/src/hooks/useTauri.ts b/src/hooks/useTauri.ts index 3f0daa2ac..0be55eca8 100644 --- a/src/hooks/useTauri.ts +++ b/src/hooks/useTauri.ts @@ -7,6 +7,56 @@ export interface ServerStatus { port: number; requests: number; uptime_secs: number; + error_rate_1m: number; + p95_latency_ms_1m: number | null; + open_circuit_count: number; + active_requests: number; + capability_routing: CapabilityRoutingMetricsSnapshot; + response_cache: ResponseCacheStats; + request_dedup: RequestDedupStats; + idempotency: IdempotencyStats; +} + +export interface CapabilityRoutingMetricsSnapshot { + filter_eval_total: number; + filter_excluded_total: number; + filter_excluded_tools_total: number; + filter_excluded_vision_total: number; + filter_excluded_context_total: number; + provider_fallback_total: number; + model_fallback_total: number; + all_candidates_excluded_total: number; +} + +export interface ResponseCacheStats { + size: number; + hits: number; + misses: number; + evictions: number; +} + +export interface RequestDedupStats { + inflight_size: number; + completed_size: number; + check_new_total: number; + check_in_progress_total: number; + check_completed_total: number; + wait_success_total: number; + wait_timeout_total: number; + wait_no_result_total: number; + complete_total: number; + remove_total: number; +} + +export interface IdempotencyStats { + entries_size: number; + in_progress_size: number; + completed_size: number; + check_new_total: number; + check_in_progress_total: number; + check_completed_total: number; + complete_total: number; + remove_total: number; } // TLS Configuration @@ -16,6 +66,15 @@ export interface TlsConfig { key_path: string | null; } +// Response Cache Configuration +export interface ResponseCacheConfig { + enabled: boolean; + ttl_secs: number; + max_entries: number; + max_body_bytes: number; + cacheable_status_codes: number[]; +} + // Remote Management Configuration export interface RemoteManagementConfig { allow_remote: boolean; @@ -381,6 +440,7 @@ export interface Config { port: number; api_key: string; tls: TlsConfig; + response_cache: ResponseCacheConfig; }; providers: { kiro: { @@ -479,6 +539,29 @@ export async function setDefaultProvider(provider: string): Promise { return safeInvoke("set_default_provider", { provider }); } +export interface WorkspaceEnsureResult { + workspaceId: string; + rootPath: string; + existed: boolean; + created: boolean; + repaired: boolean; + relocated?: boolean; + previousRootPath?: string | null; + warning?: string | null; +} + +export async function workspaceEnsureReady( + id: string, +): Promise { + return safeInvoke("workspace_ensure_ready", { id }); +} + +export async function workspaceEnsureDefaultReady(): Promise< + WorkspaceEnsureResult | null +> { + return safeInvoke("workspace_ensure_default_ready"); +} + /** * 更新 Provider 的环境变量 * @@ -517,6 +600,17 @@ export async function getLogs(): Promise { } } +export async function getPersistedLogsTail(lines = 200): Promise { + const safeLines = Number.isFinite(lines) + ? Math.min(1000, Math.max(20, Math.floor(lines))) + : 200; + try { + return await safeInvoke("get_persisted_logs_tail", { lines: safeLines }); + } catch { + return []; + } +} + export async function clearLogs(): Promise { try { await safeInvoke("clear_logs");