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 <noreply@anthropic.com>
This commit is contained in:
coso
2026-03-02 17:52:39 +08:00
co-authored by Claude Sonnet 4.6
parent ff91552356
commit ad61472a77
8 changed files with 880 additions and 36 deletions
+213
View File
@@ -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) {
)}
</div>
{/* 观测面板(对标 ClawRouter) */}
<div className="rounded-lg border bg-card p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">观测面板</h3>
<span className="text-xs text-muted-foreground">
实时统计(每 3 秒刷新)
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="rounded-md border bg-muted/30 px-3 py-2">
<p className="text-xs text-muted-foreground">最近1分钟错误率</p>
<p className="text-sm font-medium">
{((status?.error_rate_1m ?? 0) * 100).toFixed(1)}%
</p>
</div>
<div className="rounded-md border bg-muted/30 px-3 py-2">
<p className="text-xs text-muted-foreground">
最近1分钟 P95 延迟
</p>
<p className="text-sm font-medium">
{status?.p95_latency_ms_1m != null
? `${status.p95_latency_ms_1m}ms`
: "-"}
</p>
</div>
<div className="rounded-md border bg-muted/30 px-3 py-2">
<p className="text-xs text-muted-foreground">
活跃请求(近似)
</p>
<p className="text-sm font-medium">
{status?.active_requests ?? 0}
</p>
</div>
<div className="rounded-md border bg-muted/30 px-3 py-2">
<p className="text-xs text-muted-foreground">
熔断上游数(近似)
</p>
<p className="text-sm font-medium">
{status?.open_circuit_count ?? 0}
</p>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-3">
<div className="rounded-md border p-3 space-y-2">
<p className="text-xs text-muted-foreground">
响应缓存(非流式)
</p>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>命中:{status?.response_cache?.hits ?? 0}</div>
<div>未命中:{status?.response_cache?.misses ?? 0}</div>
<div>缓存条目:{status?.response_cache?.size ?? 0}</div>
<div>淘汰次数:{status?.response_cache?.evictions ?? 0}</div>
</div>
<p className="text-xs text-muted-foreground">
缓存命中率:{cacheHitRate.toFixed(1)}%
</p>
</div>
<div className="rounded-md border p-3 space-y-2">
<p className="text-xs text-muted-foreground">请求去重</p>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
新请求:
{status?.request_dedup?.check_new_total ?? 0}
</div>
<div>
In-Flight 等待:
{status?.request_dedup?.check_in_progress_total ?? 0}
</div>
<div>
直接回放:
{status?.request_dedup?.check_completed_total ?? 0}
</div>
<div>
等待回放成功:
{status?.request_dedup?.wait_success_total ?? 0}
</div>
<div>
等待超时:
{status?.request_dedup?.wait_timeout_total ?? 0}
</div>
<div>
当前 In-Flight:
{status?.request_dedup?.inflight_size ?? 0}
</div>
</div>
<p className="text-xs text-muted-foreground">
去重回放率:{requestDedupReplayRate.toFixed(1)}%
</p>
</div>
<div className="rounded-md border p-3 space-y-2">
<p className="text-xs text-muted-foreground">幂等闭环</p>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
新键:
{status?.idempotency?.check_new_total ?? 0}
</div>
<div>
处理中冲突:
{status?.idempotency?.check_in_progress_total ?? 0}
</div>
<div>
已完成回放:
{status?.idempotency?.check_completed_total ?? 0}
</div>
<div>
完成写入:
{status?.idempotency?.complete_total ?? 0}
</div>
<div>
当前 In-Progress:
{status?.idempotency?.in_progress_size ?? 0}
</div>
<div>
当前 Completed:
{status?.idempotency?.completed_size ?? 0}
</div>
</div>
<p className="text-xs text-muted-foreground">
幂等回放率:{idempotencyReplayRate.toFixed(1)}%
</p>
</div>
<div className="rounded-md border p-3 space-y-2">
<p className="text-xs text-muted-foreground">能力路由与回退</p>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
过滤评估:
{status?.capability_routing?.filter_eval_total ?? 0}
</div>
<div>
过滤排除:
{status?.capability_routing?.filter_excluded_total ?? 0}
</div>
<div>
Provider 回退:
{status?.capability_routing?.provider_fallback_total ?? 0}
</div>
<div>
模型回退:
{status?.capability_routing?.model_fallback_total ?? 0}
</div>
<div>
候选全排空:
{status?.capability_routing
?.all_candidates_excluded_total ?? 0}
</div>
<div>
原因样本:
{capabilityExcludedReasonTotal}
</div>
</div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>
tools:{" "}
{status?.capability_routing?.filter_excluded_tools_total ??
0}
</span>
<span>
vision:{" "}
{status?.capability_routing?.filter_excluded_vision_total ??
0}
</span>
<span>
context:{" "}
{status?.capability_routing
?.filter_excluded_context_total ?? 0}
</span>
</div>
</div>
</div>
</div>
{/* Default Provider - 动态显示有凭证的 Provider */}
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center justify-between mb-3">
+7 -2
View File
@@ -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<SettingsTabs>(
initialTab || SettingsTabs.Appearance,
);
const contentContainerRef = useRef<HTMLElement | null>(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({
{/* 设置内容 */}
<LayoutContainer>
<SettingsSidebar activeTab={activeTab} onTabChange={setActiveTab} />
<ContentContainer>
<ContentContainer ref={contentContainerRef}>
<ContentWrapper $wide={WIDE_CONTENT_TABS.has(activeTab)}>
{renderSettingsContent(activeTab)}
</ContentWrapper>
@@ -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<DownloadResult | null>(
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<VersionInfo>("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("下载更新", "下载更新")}
</button>
{versionInfo.downloadUrl && (
<a
href={versionInfo.downloadUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border text-sm hover:bg-muted"
>
<ExternalLink className="h-4 w-4" />
{t("网页下载", "网页下载")}
</a>
)}
<a
href={manualDownloadUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border text-sm hover:bg-muted"
>
<ExternalLink className="h-4 w-4" />
{t("网页下载", "网页下载")}
</a>
</>
)}
</div>
@@ -239,14 +249,14 @@ export function AboutSection() {
)}
<div className="flex-1">
<p>{downloadResult.message}</p>
{!downloadResult.success && versionInfo.downloadUrl && (
{!downloadResult.success && (
<a
href={versionInfo.downloadUrl}
href={manualDownloadUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 mt-2 underline hover:no-underline"
>
<ExternalLink className="h-3 w-3" />
className="inline-flex items-center gap-1 mt-2 underline hover:no-underline"
>
<ExternalLink className="h-3 w-3" />
{t("前往网页下载", "前往网页下载")}
</a>
)}
@@ -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 (
<div className="space-y-6 max-w-2xl">
{/* 标题 */}
@@ -100,6 +171,8 @@ export function DeveloperSettings() {
</div>
)}
{showClipboardGuide && <ClipboardPermissionGuideCard />}
{/* 组件视图调试 */}
<div className="border rounded-lg p-4">
<div className="flex items-center justify-between">
@@ -155,6 +228,17 @@ export function DeveloperSettings() {
>
复制诊断信息
</button>
<button
type="button"
onClick={() => void handleCopyDiagnosticJson()}
disabled={diagnosticBusy}
className={cn(
"rounded-md border px-3 py-1.5 text-xs transition-colors",
diagnosticBusy && "opacity-50 cursor-not-allowed",
)}
>
复制纯 JSON
</button>
<button
type="button"
onClick={() => void handleExportDiagnostic()}
@@ -166,6 +250,17 @@ export function DeveloperSettings() {
>
导出诊断 JSON
</button>
<button
type="button"
onClick={() => void handleOpenDownloadDirectory()}
disabled={diagnosticBusy}
className={cn(
"rounded-md border px-3 py-1.5 text-xs transition-colors",
diagnosticBusy && "opacity-50 cursor-not-allowed",
)}
>
打开下载目录
</button>
</div>
</div>
</div>
@@ -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() {
</div>
)}
{showClipboardGuide && <ClipboardPermissionGuideCard />}
{/* 截图对话功能 */}
<div className="rounded-lg border p-4 space-y-4">
<div className="flex items-start justify-between">
@@ -562,6 +636,28 @@ export function ExperimentalSettings() {
>
导出诊断 JSON
</button>
<button
type="button"
onClick={() => void copyCrashDiagnosticJson()}
disabled={saving || diagnosticBusy}
className={cn(
"rounded-md border px-3 py-1.5 text-xs transition-colors",
(saving || diagnosticBusy) && "opacity-50 cursor-not-allowed",
)}
>
复制纯 JSON
</button>
<button
type="button"
onClick={() => void openCrashDownloadDirectory()}
disabled={saving || diagnosticBusy}
className={cn(
"rounded-md border px-3 py-1.5 text-xs transition-colors",
(saving || diagnosticBusy) && "opacity-50 cursor-not-allowed",
)}
>
打开下载目录
</button>
<button
type="button"
onClick={handleSaveCrashConfig}
@@ -1,15 +1,72 @@
import { useState, useEffect } from "react";
import {
Eye,
EyeOff,
Copy,
Check,
RefreshCw,
} from "lucide-react";
import { Eye, EyeOff, Copy, Check, RefreshCw } from "lucide-react";
import { getConfig, saveConfig, Config } from "@/hooks/useTauri";
const DEFAULT_RESPONSE_CACHE_SETTINGS = {
enabled: true,
ttl_secs: 600,
max_entries: 200,
max_body_bytes: 1_048_576,
cacheable_status_codes: [200],
};
function normalizeResponseCacheSettings(config: Config): Config {
const rawSettings = config.server.response_cache;
const mergedCodes =
rawSettings?.cacheable_status_codes
?.filter((code) => Number.isInteger(code) && code >= 100 && code <= 599)
.map((code) => Math.trunc(code)) ?? [];
const normalizedCodes =
mergedCodes.length > 0
? Array.from(new Set(mergedCodes))
: DEFAULT_RESPONSE_CACHE_SETTINGS.cacheable_status_codes;
return {
...config,
server: {
...config.server,
response_cache: {
...DEFAULT_RESPONSE_CACHE_SETTINGS,
...(rawSettings ?? {}),
cacheable_status_codes: normalizedCodes,
},
},
};
}
function parseStatusCodesInput(input: string): {
codes: number[];
invalidTokens: string[];
} {
const tokens = input
.split(",")
.map((token) => token.trim())
.filter((token) => token.length > 0);
const invalidTokens: string[] = [];
const validCodes = tokens
.map((token) => {
const parsed = Number(token);
const isValid =
Number.isInteger(parsed) && parsed >= 100 && parsed <= 599;
if (!isValid) {
invalidTokens.push(token);
}
return parsed;
})
.filter((code) => Number.isInteger(code) && code >= 100 && code <= 599)
.map((code) => Math.trunc(code));
return {
codes: Array.from(new Set(validCodes)),
invalidTokens,
};
}
export function ProxySettings() {
const [config, setConfig] = useState<Config | null>(null);
const [cacheableStatusCodesInput, setCacheableStatusCodesInput] =
useState("");
const [showApiKey, setShowApiKey] = useState(false);
const [copied, setCopied] = useState(false);
const [saving, setSaving] = useState(false);
@@ -25,7 +82,13 @@ export function ProxySettings() {
const loadConfig = async () => {
try {
const c = await getConfig();
setConfig(c);
const normalizedConfig = normalizeResponseCacheSettings(c);
setConfig(normalizedConfig);
setCacheableStatusCodesInput(
normalizedConfig.server.response_cache?.cacheable_status_codes.join(
", ",
) ?? "200",
);
} catch (e) {
console.error(e);
}
@@ -33,10 +96,41 @@ export function ProxySettings() {
const handleSave = async () => {
if (!config) return;
const { codes, invalidTokens } = parseStatusCodesInput(
cacheableStatusCodesInput,
);
if (invalidTokens.length > 0) {
setMessage({
type: "error",
text: `状态码格式错误: ${invalidTokens.join(", ")}(范围 100-599)`,
});
return;
}
const normalizedConfig = normalizeResponseCacheSettings(config);
const nextConfig: Config = {
...normalizedConfig,
server: {
...normalizedConfig.server,
response_cache: {
...normalizedConfig.server.response_cache,
cacheable_status_codes:
codes.length > 0
? codes
: DEFAULT_RESPONSE_CACHE_SETTINGS.cacheable_status_codes,
},
},
};
setSaving(true);
setMessage(null);
try {
await saveConfig(config);
await saveConfig(nextConfig);
setConfig(nextConfig);
setCacheableStatusCodesInput(
nextConfig.server.response_cache?.cacheable_status_codes.join(", ") ??
"200",
);
setMessage({ type: "success", text: "设置已保存" });
setTimeout(() => setMessage(null), 3000);
} catch (e: unknown) {
@@ -180,6 +274,169 @@ export function ProxySettings() {
</div>
</div>
{/* 响应缓存配置 */}
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium">响应缓存配置</h3>
<p className="text-xs text-muted-foreground">
对齐 ClawRouter:默认仅缓存状态码 200
</p>
</div>
<div className="space-y-4 p-4 rounded-lg border">
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium">启用响应缓存</label>
<p className="text-xs text-muted-foreground mt-1">
仅对非流式请求生效
</p>
</div>
<input
type="checkbox"
checked={config.server.response_cache?.enabled ?? true}
onChange={(event) =>
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"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1.5">
TTL(秒)
</label>
<input
type="number"
min={1}
value={config.server.response_cache?.ttl_secs ?? 600}
onChange={(event) =>
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"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1.5">
最大条目数
</label>
<input
type="number"
min={1}
value={config.server.response_cache?.max_entries ?? 200}
onChange={(event) =>
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"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1.5">
单响应最大缓存字节数
</label>
<input
type="number"
min={1}
value={config.server.response_cache?.max_body_bytes ?? 1_048_576}
onChange={(event) =>
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"
/>
<p className="text-xs text-muted-foreground mt-1">
超过此大小的响应会跳过缓存
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1.5">
可缓存状态码
</label>
<input
type="text"
value={cacheableStatusCodesInput}
onChange={(event) =>
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"
/>
<p className="text-xs text-muted-foreground mt-1">
使用英文逗号分隔;默认仅 200
</p>
</div>
</div>
</div>
</div>
);
}
@@ -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<string | null>(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 (
<div
className={cn(
"rounded-lg border border-amber-200 bg-amber-50/80 p-3 text-sm dark:border-amber-900/40 dark:bg-amber-950/20",
className,
)}
>
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-600 dark:text-amber-400" />
<div className="min-w-0 flex-1 space-y-2">
<p className="font-medium text-amber-900 dark:text-amber-300">
{guide.title}
</p>
<ol className="list-decimal space-y-1 pl-4 text-amber-800 dark:text-amber-400">
{guide.steps.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
{guide.settingsUrl && (
<button
type="button"
onClick={() => void handleOpenSettings()}
className="inline-flex items-center gap-1 rounded-md border border-amber-300 px-2.5 py-1 text-xs text-amber-900 transition-colors hover:bg-amber-100 dark:border-amber-700 dark:text-amber-200 dark:hover:bg-amber-900/40"
>
打开系统设置
<ExternalLink className="h-3.5 w-3.5" />
</button>
)}
{openError && (
<p className="text-xs text-destructive">
打开系统设置失败:{openError}
</p>
)}
</div>
</div>
</div>
);
}
+94
View File
@@ -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<string> {
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<WorkspaceEnsureResult> {
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<LogEntry[]> {
}
}
export async function getPersistedLogsTail(lines = 200): Promise<LogEntry[]> {
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<void> {
try {
await safeInvoke("clear_logs");