mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
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:
co-authored by
Claude Sonnet 4.6
parent
ff91552356
commit
ad61472a77
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user