fix: 修复所有测试和代码质量问题

- 修复 6 个失败的 Rust 测试
  - test_bundled_social_post_with_cover_skill_contract: 支持 SKILL.md 中的中文引号
  - workspace_commands_roundtrip: 使用驼峰命名 workspaceType
  - should_embed_social_image_tool_contract_in_default_skill: 更新为 **配图说明**
  - 修复 normalize 相关测试中的配图说明断言

- 修复 clippy 警告
  - 为多个枚举添加 #[derive(Default)]
  - 实现 std::str::FromStr trait 替代自定义 from_str
  - 修复不必要的 unwrap 调用
  - 使用 vec![] 宏替代 vec init then push

- 修复前端 ESLint 错误
  - ThemeWorkbenchSidebar: 20+ 个未使用变量加下划线前缀
  - useConfiguredProviders: 修复 React hooks 依赖项

- 所有核心测试通过 (328 passed; 0 failed)
- npm lint 通过
- 代码格式化通过
This commit is contained in:
coso
2026-03-09 09:44:45 +08:00
parent 4cbd13e84e
commit ef8ca1dce4
30 changed files with 535 additions and 292 deletions
@@ -0,0 +1,113 @@
import styled from "styled-components";
import { A2UIRenderer } from "@/components/content-creator/a2ui";
import type {
A2UIFormData,
A2UIResponse,
} from "@/components/content-creator/a2ui/types";
interface A2UIFloatingFormProps {
response: A2UIResponse;
onSubmit: (formData: A2UIFormData) => void;
}
const Card = styled.div`
position: relative;
margin-bottom: 10px;
padding: 12px;
background: hsl(var(--background) / 0.97);
border: 1px solid hsl(var(--border) / 0.95);
border-radius: 12px;
max-width: 100%;
max-height: min(44vh, 420px);
overflow-y: auto;
overscroll-behavior: contain;
box-shadow:
0 14px 36px hsl(var(--foreground) / 0.10),
0 0 0 1px hsl(var(--background) / 0.72);
backdrop-filter: blur(14px);
scrollbar-width: thin;
scrollbar-color: hsl(var(--border)) transparent;
&::after {
content: "";
position: sticky;
display: block;
left: 0;
right: 0;
bottom: -12px;
height: 16px;
margin: 0 -12px -12px;
pointer-events: none;
background: linear-gradient(
180deg,
hsl(var(--background) / 0) 0%,
hsl(var(--background) / 0.9) 100%
);
}
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-thumb {
background: hsl(var(--border));
border-radius: 999px;
}
.a2ui-container {
display: flex;
flex-direction: column;
gap: 10px;
font-size: 13px;
line-height: 1.4;
}
.a2ui-container > * + * {
margin-top: 0;
}
.a2ui-container .text-sm,
.a2ui-container label,
.a2ui-container [class*="text-sm"] {
font-size: 13px;
line-height: 1.35;
}
.a2ui-container .text-xs,
.a2ui-container p,
.a2ui-container [class*="text-xs"] {
font-size: 12px;
line-height: 1.3;
}
.a2ui-container input,
.a2ui-container textarea {
padding: 7px 9px;
font-size: 12px;
line-height: 1.35;
border-color: hsl(var(--border) / 0.95);
background: hsl(var(--background));
}
.a2ui-container textarea {
min-height: 72px;
}
.a2ui-container button {
padding: 6px 10px;
font-size: 12px;
line-height: 1.3;
box-shadow: 0 1px 0 hsl(var(--background) / 0.35);
}
`;
export function A2UIFloatingForm({
response,
onSubmit,
}: A2UIFloatingFormProps) {
return (
<Card>
<A2UIRenderer response={response} onSubmit={onSubmit} />
</Card>
);
}
@@ -7,11 +7,11 @@ import type { MessageImage } from "../../types";
import type { Character } from "@/lib/api/memory";
import type { Skill } from "@/lib/api/skills";
import { TaskFileList, type TaskFile } from "../TaskFiles";
import { A2UIRenderer } from "@/components/content-creator/a2ui";
import {
A2UISubmissionNotice,
type A2UISubmissionNoticeData,
} from "./components/A2UISubmissionNotice";
import { A2UIFloatingForm } from "./components/A2UIFloatingForm";
import type { A2UIResponse, A2UIFormData } from "@/components/content-creator/a2ui/types";
import {
FolderOpen,
@@ -46,99 +46,6 @@ const TaskFilesArea = styled.div`
margin: 0;
`;
// A2UI Form 卡片容器(在输入框上方)
const A2UIFormCard = styled.div`
position: relative;
margin-bottom: 10px;
padding: 12px;
background: hsl(var(--background) / 0.97);
border: 1px solid hsl(var(--border) / 0.95);
border-radius: 12px;
max-width: 100%;
max-height: min(44vh, 420px);
overflow-y: auto;
overscroll-behavior: contain;
box-shadow:
0 14px 36px hsl(var(--foreground) / 0.10),
0 0 0 1px hsl(var(--background) / 0.72);
backdrop-filter: blur(14px);
scrollbar-width: thin;
scrollbar-color: hsl(var(--border)) transparent;
&::after {
content: "";
position: sticky;
display: block;
left: 0;
right: 0;
bottom: -12px;
height: 16px;
margin: 0 -12px -12px;
pointer-events: none;
background: linear-gradient(
180deg,
hsl(var(--background) / 0) 0%,
hsl(var(--background) / 0.9) 100%
);
}
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-thumb {
background: hsl(var(--border));
border-radius: 999px;
}
.a2ui-container {
display: flex;
flex-direction: column;
gap: 10px;
font-size: 13px;
line-height: 1.4;
}
.a2ui-container > * + * {
margin-top: 0;
}
.a2ui-container .text-sm,
.a2ui-container label,
.a2ui-container [class*="text-sm"] {
font-size: 13px;
line-height: 1.35;
}
.a2ui-container .text-xs,
.a2ui-container p,
.a2ui-container [class*="text-xs"] {
font-size: 12px;
line-height: 1.3;
}
.a2ui-container input,
.a2ui-container textarea {
padding: 7px 9px;
font-size: 12px;
line-height: 1.35;
border-color: hsl(var(--border) / 0.95);
background: hsl(var(--background));
}
.a2ui-container textarea {
min-height: 72px;
}
.a2ui-container button {
padding: 6px 10px;
font-size: 12px;
line-height: 1.3;
box-shadow: 0 1px 0 hsl(var(--background) / 0.35);
}
`;
// 按钮和面板的包装容器
const TaskFilesWrapper = styled.div`
position: relative;
@@ -1283,9 +1190,10 @@ export const Inputbar: React.FC<InputbarProps> = ({
/>
) : null}
{pendingA2UIForm && onA2UISubmit ? (
<A2UIFormCard>
<A2UIRenderer response={pendingA2UIForm} onSubmit={onA2UISubmit} />
</A2UIFormCard>
<A2UIFloatingForm
response={pendingA2UIForm}
onSubmit={onA2UISubmit}
/>
) : null}
<input
ref={fileInputRef}
@@ -219,7 +219,7 @@ export function DeveloperSettings() {
<div>
<h4 className="font-medium">崩溃诊断日志(开发协作)</h4>
<p className="text-sm text-muted-foreground">
用于定位 Windows 闪退与前端异常,包含最近 30 条 FrontendCrash 日志(DSN 自动脱敏)
用于定位 Windows 闪退与前端异常,包含 FrontendCrash、失败命令以及最近调用轨迹(DSN 自动脱敏)
</p>
</div>
</div>
+30
View File
@@ -9,6 +9,7 @@ import {
getClipboardPermissionGuide,
sanitizeDiagnosticSceneTag,
} from "./crashDiagnostic";
import { clearInvokeTraceBuffer } from "./dev-bridge/safeInvoke";
import {
clearWorkspaceRepairHistory,
recordWorkspaceRepair,
@@ -37,6 +38,7 @@ describe("copyCrashDiagnosticToClipboard", () => {
afterEach(() => {
vi.restoreAllMocks();
clearWorkspaceRepairHistory();
clearInvokeTraceBuffer();
});
it("应支持复制纯 JSON", async () => {
@@ -166,6 +168,7 @@ describe("diagnostic export file name", () => {
describe("buildCrashDiagnosticPayload", () => {
afterEach(() => {
clearWorkspaceRepairHistory();
clearInvokeTraceBuffer();
});
it("应注入 workspace 自动修复记录", () => {
@@ -191,4 +194,31 @@ describe("buildCrashDiagnosticPayload", () => {
"workspace_refresh",
);
});
it("摘要应包含最近调用轨迹条数", () => {
window.localStorage.setItem(
"proxycast_invoke_trace_buffer_v1",
JSON.stringify([
{
timestamp: "2026-03-09T01:02:03.000Z",
command: "get_config",
transport: "tauri-ipc",
status: "success",
duration_ms: 12,
},
]),
);
const diagnostic = buildCrashDiagnosticPayload({
crashConfig: payload.crash_reporting,
logs: payload.frontend_crash_logs,
appVersion: payload.app_version,
platform: payload.platform,
userAgent: payload.user_agent,
});
const text = buildCrashDiagnosticClipboardText(diagnostic);
expect(diagnostic.invoke_trace_buffer?.length).toBe(1);
expect(text).toContain("最近调用轨迹条数:1");
});
});
+8
View File
@@ -4,8 +4,10 @@ import type {
} from "@/hooks/useTauri";
import {
getInvokeErrorBuffer,
getInvokeTraceBuffer,
safeInvoke,
type InvokeErrorBufferEntry,
type InvokeTraceBufferEntry,
} from "@/lib/dev-bridge";
import { getRuntimeAppVersion } from "@/lib/appVersion";
import {
@@ -35,6 +37,7 @@ export interface CrashDiagnosticPayload {
frontend_crash_logs: LogEntry[];
frontend_crash_buffer?: FrontendCrashBufferEntry[];
invoke_error_buffer?: InvokeErrorBufferEntry[];
invoke_trace_buffer?: InvokeTraceBufferEntry[];
persisted_log_tail?: LogEntry[];
workspace_repair_history?: WorkspaceRepairRecord[];
theme_workbench_document_state?: ThemeWorkbenchDocumentState | null;
@@ -117,6 +120,7 @@ interface BuildCrashDiagnosticPayloadParams {
userAgent: string;
maxCrashLogs?: number;
maxInvokeErrors?: number;
maxInvokeTraces?: number;
maxPersistedLogs?: number;
maxWorkspaceRepairs?: number;
themeWorkbenchDocumentState?: ThemeWorkbenchDocumentState | null;
@@ -135,6 +139,7 @@ export function buildCrashDiagnosticPayload(
userAgent,
maxCrashLogs = 30,
maxInvokeErrors = 40,
maxInvokeTraces = 80,
maxPersistedLogs = 200,
maxWorkspaceRepairs = 50,
themeWorkbenchDocumentState = null,
@@ -158,6 +163,7 @@ export function buildCrashDiagnosticPayload(
frontend_crash_logs: pickFrontendCrashLogs(logs, maxCrashLogs),
frontend_crash_buffer: getFrontendCrashBuffer(maxCrashLogs),
invoke_error_buffer: getInvokeErrorBuffer(maxInvokeErrors),
invoke_trace_buffer: getInvokeTraceBuffer(maxInvokeTraces),
persisted_log_tail: persistedLogTail.slice(-maxPersistedLogs),
workspace_repair_history: getWorkspaceRepairHistory(maxWorkspaceRepairs),
theme_workbench_document_state: themeWorkbenchDocumentState,
@@ -269,6 +275,7 @@ function buildDiagnosticSummary(payload: CrashDiagnosticPayload): string {
const crashLogCount = payload.frontend_crash_logs.length;
const localCrashCount = payload.frontend_crash_buffer?.length ?? 0;
const invokeErrorCount = payload.invoke_error_buffer?.length ?? 0;
const invokeTraceCount = payload.invoke_trace_buffer?.length ?? 0;
const persistedLogCount = payload.persisted_log_tail?.length ?? 0;
const workspaceRepairCount = payload.workspace_repair_history?.length ?? 0;
const versionCount = payload.theme_workbench_document_state?.version_count ?? 0;
@@ -281,6 +288,7 @@ function buildDiagnosticSummary(payload: CrashDiagnosticPayload): string {
`- 崩溃日志条数:${crashLogCount}`,
`- 本地崩溃缓存条数:${localCrashCount}`,
`- 命令调用失败缓存条数:${invokeErrorCount}`,
`- 最近调用轨迹条数:${invokeTraceCount}`,
`- 持久化日志尾部行数:${persistedLogCount}`,
`- Workspace 自动修复记录条数:${workspaceRepairCount}`,
`- 主题工作台文稿版本数:${versionCount}`,
+6 -1
View File
@@ -23,6 +23,11 @@ export {
safeListen,
safeEmit,
getInvokeErrorBuffer,
getInvokeTraceBuffer,
clearInvokeErrorBuffer,
clearInvokeTraceBuffer,
} from "./safeInvoke";
export type {
InvokeErrorBufferEntry,
InvokeTraceBufferEntry,
} from "./safeInvoke";
export type { InvokeErrorBufferEntry } from "./safeInvoke";
+36 -1
View File
@@ -33,7 +33,13 @@ vi.mock("./mockPriorityCommands", () => ({
shouldPreferMockInBrowser: vi.fn(() => false),
}));
import { safeInvoke } from "./safeInvoke";
import {
clearInvokeErrorBuffer,
clearInvokeTraceBuffer,
getInvokeErrorBuffer,
getInvokeTraceBuffer,
safeInvoke,
} from "./safeInvoke";
import { shouldPreferMockInBrowser } from "./mockPriorityCommands";
describe("safeInvoke", () => {
@@ -41,6 +47,8 @@ describe("safeInvoke", () => {
vi.clearAllMocks();
mocks.isDevBridgeAvailable.mockReturnValue(true);
window.localStorage.clear();
clearInvokeErrorBuffer();
clearInvokeTraceBuffer();
delete (window as any).__TAURI__;
});
@@ -52,6 +60,14 @@ describe("safeInvoke", () => {
expect(result).toEqual({ ok: true });
expect(mocks.invokeViaHttp).toHaveBeenCalledWith("workspace_list", undefined);
expect(mocks.baseInvoke).not.toHaveBeenCalled();
expect(getInvokeTraceBuffer()).toEqual([
expect.objectContaining({
command: "workspace_list",
transport: "http-bridge",
status: "success",
}),
]);
});
it("HTTP bridge 失败时会回退到 mock/baseInvoke", async () => {
@@ -62,6 +78,25 @@ describe("safeInvoke", () => {
expect(mocks.normalizeDevBridgeError).toHaveBeenCalled();
expect(mocks.baseInvoke).toHaveBeenCalledWith("workspace_list", undefined);
expect(getInvokeErrorBuffer()).toEqual([
expect.objectContaining({
command: "workspace_list",
transport: "http-bridge",
}),
]);
expect(getInvokeTraceBuffer()).toEqual([
expect.objectContaining({
command: "workspace_list",
transport: "http-bridge",
status: "error",
}),
expect.objectContaining({
command: "workspace_list",
transport: "fallback-invoke",
status: "success",
}),
]);
});
it("mock 优先命令会直接走 fallback invoke", async () => {
+149 -5
View File
@@ -26,8 +26,20 @@ export interface InvokeErrorBufferEntry {
args_preview?: Record<string, unknown>;
}
export interface InvokeTraceBufferEntry {
timestamp: string;
command: string;
transport: "tauri-ipc" | "tauri-legacy" | "http-bridge" | "fallback-invoke";
status: "success" | "error";
duration_ms: number;
error?: string;
args_preview?: Record<string, unknown>;
}
const INVOKE_ERROR_BUFFER_KEY = "proxycast_invoke_error_buffer_v1";
const INVOKE_ERROR_BUFFER_LIMIT = 120;
const INVOKE_TRACE_BUFFER_KEY = "proxycast_invoke_trace_buffer_v1";
const INVOKE_TRACE_BUFFER_LIMIT = 240;
const INVOKE_ERROR_TEXT_LIMIT = 800;
const SECRET_PATTERNS: Array<[RegExp, string]> = [
@@ -127,6 +139,50 @@ function writeInvokeErrorBuffer(items: InvokeErrorBufferEntry[]): void {
}
}
function readInvokeTraceBuffer(): InvokeTraceBufferEntry[] {
if (typeof window === "undefined") {
return [];
}
try {
const raw = window.localStorage.getItem(INVOKE_TRACE_BUFFER_KEY);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed
.filter(
(item): item is InvokeTraceBufferEntry =>
item &&
typeof item === "object" &&
typeof item.timestamp === "string" &&
typeof item.command === "string" &&
typeof item.transport === "string" &&
(item.status === "success" || item.status === "error") &&
typeof item.duration_ms === "number",
)
.slice(-INVOKE_TRACE_BUFFER_LIMIT);
} catch {
return [];
}
}
function writeInvokeTraceBuffer(items: InvokeTraceBufferEntry[]): void {
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.setItem(
INVOKE_TRACE_BUFFER_KEY,
JSON.stringify(items.slice(-INVOKE_TRACE_BUFFER_LIMIT)),
);
} catch {
// ignore
}
}
function recordInvokeError(
command: string,
args: Record<string, unknown> | undefined,
@@ -147,6 +203,30 @@ function recordInvokeError(
writeInvokeErrorBuffer(current);
}
function recordInvokeTrace(
command: string,
args: Record<string, unknown> | undefined,
transport: InvokeTraceBufferEntry["transport"],
status: InvokeTraceBufferEntry["status"],
startedAt: number,
error?: unknown,
): void {
const current = readInvokeTraceBuffer();
const entry: InvokeTraceBufferEntry = {
timestamp: new Date().toISOString(),
command: sanitizeText(command),
transport,
status,
duration_ms: Math.max(0, Date.now() - startedAt),
error: error ? toErrorMessage(error) : undefined,
args_preview: args
? (sanitizeValue(args) as Record<string, unknown>)
: undefined,
};
current.push(entry);
writeInvokeTraceBuffer(current);
}
export function getInvokeErrorBuffer(limit = 50): InvokeErrorBufferEntry[] {
const safeLimit = Number.isFinite(limit)
? Math.min(200, Math.max(1, Math.floor(limit)))
@@ -154,6 +234,13 @@ export function getInvokeErrorBuffer(limit = 50): InvokeErrorBufferEntry[] {
return readInvokeErrorBuffer().slice(-safeLimit);
}
export function getInvokeTraceBuffer(limit = 80): InvokeTraceBufferEntry[] {
const safeLimit = Number.isFinite(limit)
? Math.min(300, Math.max(1, Math.floor(limit)))
: 80;
return readInvokeTraceBuffer().slice(-safeLimit);
}
export function clearInvokeErrorBuffer(): void {
if (typeof window === "undefined") {
return;
@@ -165,6 +252,17 @@ export function clearInvokeErrorBuffer(): void {
}
}
export function clearInvokeTraceBuffer(): void {
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.removeItem(INVOKE_TRACE_BUFFER_KEY);
} catch {
// ignore
}
}
/**
* 安全的 Tauri invoke 封装
* 支持三种模式:Tauri IPC → HTTP Bridge → Mock。
@@ -174,15 +272,20 @@ export async function safeInvoke<T = any>(
cmd: string,
args?: Record<string, unknown>,
): Promise<T> {
const startedAt = Date.now();
// 1. 优先使用 Tauri IPC (生产环境或 Tauri webview 可用时)
if (
typeof window !== "undefined" &&
(window as any).__TAURI__?.core?.invoke
) {
try {
return await (window as any).__TAURI__.core.invoke(cmd, args);
const result = await (window as any).__TAURI__.core.invoke(cmd, args);
recordInvokeTrace(cmd, args, "tauri-ipc", "success", startedAt);
return result;
} catch (error) {
recordInvokeError(cmd, args, error, "tauri-ipc");
recordInvokeTrace(cmd, args, "tauri-ipc", "error", startedAt, error);
throw error;
}
}
@@ -190,9 +293,12 @@ export async function safeInvoke<T = any>(
// Legacy check for older Tauri versions
if (typeof window !== "undefined" && (window as any).__TAURI__?.invoke) {
try {
return await (window as any).__TAURI__.invoke(cmd, args);
const result = await (window as any).__TAURI__.invoke(cmd, args);
recordInvokeTrace(cmd, args, "tauri-legacy", "success", startedAt);
return result;
} catch (error) {
recordInvokeError(cmd, args, error, "tauri-legacy");
recordInvokeTrace(cmd, args, "tauri-legacy", "error", startedAt, error);
throw error;
}
}
@@ -200,9 +306,19 @@ export async function safeInvoke<T = any>(
// 2. 浏览器开发模式下,部分原生/非关键命令直接优先走 mock。
if (isDevBridgeAvailable() && shouldPreferMockInBrowser(cmd)) {
try {
return await baseInvoke(cmd, args);
const result = await baseInvoke(cmd, args);
recordInvokeTrace(cmd, args, "fallback-invoke", "success", startedAt);
return result;
} catch (error) {
recordInvokeError(cmd, args, error, "fallback-invoke");
recordInvokeTrace(
cmd,
args,
"fallback-invoke",
"error",
startedAt,
error,
);
throw error;
}
}
@@ -211,15 +327,40 @@ export async function safeInvoke<T = any>(
if (isDevBridgeAvailable()) {
try {
const result = await invokeViaHttp(cmd, args);
recordInvokeTrace(cmd, args, "http-bridge", "success", startedAt);
return result as T;
} catch (error) {
const normalizedError = normalizeDevBridgeError(cmd, error);
recordInvokeError(cmd, args, normalizedError, "http-bridge");
recordInvokeTrace(
cmd,
args,
"http-bridge",
"error",
startedAt,
normalizedError,
);
try {
return await baseInvoke(cmd, args);
const result = await baseInvoke(cmd, args);
recordInvokeTrace(
cmd,
args,
"fallback-invoke",
"success",
startedAt,
);
return result;
} catch (fallbackError) {
recordInvokeError(cmd, args, fallbackError, "fallback-invoke");
recordInvokeTrace(
cmd,
args,
"fallback-invoke",
"error",
startedAt,
fallbackError,
);
throw normalizedError;
}
}
@@ -227,9 +368,12 @@ export async function safeInvoke<T = any>(
// 4. Fallback 到 mock(Vite alias 会替换 @tauri-apps 导入)
try {
return await baseInvoke(cmd, args);
const result = await baseInvoke(cmd, args);
recordInvokeTrace(cmd, args, "fallback-invoke", "success", startedAt);
return result;
} catch (error) {
recordInvokeError(cmd, args, error, "fallback-invoke");
recordInvokeTrace(cmd, args, "fallback-invoke", "error", startedAt, error);
throw error;
}
}