diff --git a/src/App.tsx b/src/App.tsx index 8238d1c76..5d5c10c59 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import React, { useState, useEffect, useCallback } from "react"; import styled from "styled-components"; +import { invoke } from "@tauri-apps/api/core"; import { withI18nPatch } from "./i18n/withI18nPatch"; import { SplashScreen } from "./components/SplashScreen"; import { AppSidebar } from "./components/AppSidebar"; @@ -60,6 +61,7 @@ import { } from "./types/page"; import { SettingsTabs } from "./types/settings"; import { toast } from "sonner"; +import { recordWorkspaceRepair } from "@/lib/workspaceHealthTelemetry"; const AppContainer = styled.div` display: flex; @@ -327,6 +329,32 @@ function AppContent() { } }, [registryError]); + useEffect(() => { + void invoke<{ + workspaceId: string; + rootPath: string; + created: boolean; + repaired: boolean; + relocated?: boolean; + } | null>("workspace_ensure_default_ready") + .then((result) => { + if (result?.repaired) { + recordWorkspaceRepair({ + workspaceId: result.workspaceId, + rootPath: result.rootPath, + source: "app_startup", + }); + console.info( + "[App] 启动时检测到默认工作区目录缺失,已自动修复:", + result.rootPath, + ); + } + }) + .catch((error) => { + console.warn("[App] 启动时工作区健康检查失败:", error); + }); + }, []); + useEffect(() => { const mainElement = document.querySelector("main"); if (mainElement) { diff --git a/src/RootRouter.tsx b/src/RootRouter.tsx index 00ae1c0af..36a8c5334 100644 --- a/src/RootRouter.tsx +++ b/src/RootRouter.tsx @@ -7,6 +7,7 @@ import App from "./App"; import { SmartInputPage } from "./pages/smart-input"; import { UpdateNotificationPage } from "./pages/update-notification"; import { Toaster } from "./components/ui/sonner"; +import { AppCrashBoundary } from "./components/layout/AppCrashBoundary"; /** * 根据 URL 路径渲染对应的组件 @@ -20,19 +21,27 @@ export function RootRouter() { // 截图对话悬浮窗口路由(也用于语音输入) if (pathname === "/smart-input") { - return ; + return ( + + + + ); } // 更新提醒悬浮窗口路由 if (pathname === "/update-notification") { - return ; + return ( + + + + ); } // 默认渲染主应用 return ( - <> + - + ); } diff --git a/src/components/projects/tabs/novel-flow/NovelFlowWorkbench.tsx b/src/components/projects/tabs/novel-flow/NovelFlowWorkbench.tsx index 9327fd6a9..ea1ee3f91 100644 --- a/src/components/projects/tabs/novel-flow/NovelFlowWorkbench.tsx +++ b/src/components/projects/tabs/novel-flow/NovelFlowWorkbench.tsx @@ -21,6 +21,7 @@ import { polishNovelChapter, rewriteNovelChapter, updateNovelSettings, + type NovelCharacterRecord, type NovelGenerationRun, type NovelProjectSnapshot, } from "@/lib/api/novel"; @@ -43,10 +44,13 @@ import { CheckCircle2, ChevronDown, ChevronRight, + Copy, + Download, Loader2, RefreshCw, Sparkles, Users, + X, } from "lucide-react"; import { toast } from "sonner"; @@ -108,6 +112,14 @@ export interface NovelFlowWorkbenchProps { projectName?: string; } +interface RecentNovelActionError { + message: string; + context: string; + stage: NovelPipelineStage; + action?: FlowAction; + occurredAt: number; +} + function getSettingsModeStorageKey(projectId: string): string { return `novel_settings_mode_${projectId}`; } @@ -277,6 +289,160 @@ function getStageStatusLabel(status: NovelStageStatus): string { return "待开始"; } +function normalizeCharacterText(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const normalized = value.trim().replace(/^["']+|["',\s]+$/g, "").trim(); + if (!normalized) { + return null; + } + if (normalized.includes('":') || ["{", "}", "[", "]", ","].includes(normalized)) { + return null; + } + return normalized; +} + +function getCardField( + card: Record, + keys: string[], +): string | null { + for (const key of keys) { + const value = normalizeCharacterText(card[key]); + if (value) { + return value; + } + } + return null; +} + +function formatCharacterRole(roleType: string): string { + const normalized = roleType.trim().toLowerCase(); + if (normalized === "main") return "主角"; + if (normalized === "antagonist") return "反派"; + if (normalized === "support") return "配角"; + return roleType || "配角"; +} + +function buildCharacterDisplay( + character: NovelCharacterRecord, + index: number, +): { + name: string; + roleLabel: string; + details: Array<{ label: string; value: string }>; +} { + const rawCard = character.card_json; + const card = + rawCard && typeof rawCard === "object" && !Array.isArray(rawCard) + ? (rawCard as Record) + : {}; + + const name = + normalizeCharacterText(character.name) || + getCardField(card, ["name", "character_name", "characterName", "角色名"]) || + `角色${index + 1}`; + + const roleLabel = formatCharacterRole( + normalizeCharacterText(character.role_type) || + getCardField(card, ["role_type", "roleType", "type", "role"]) || + "support", + ); + + const details = [ + { + label: "关系", + value: + getCardField(card, ["relationship", "relation", "关系"]) || "未提供", + }, + { + label: "弧线", + value: getCardField(card, ["arc", "character_arc", "人物弧线"]) || "未提供", + }, + { + label: "能力", + value: getCardField(card, ["abilities", "ability", "能力"]) || "未提供", + }, + ]; + + return { name, roleLabel, details }; +} + +function copyTextWithExecCommand(text: string): boolean { + if (typeof document === "undefined") { + return false; + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", "true"); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + textarea.style.pointerEvents = "none"; + document.body.appendChild(textarea); + textarea.select(); + textarea.setSelectionRange(0, text.length); + const copied = document.execCommand("copy"); + document.body.removeChild(textarea); + return copied; +} + +async function copyTextToClipboard(text: string): Promise { + try { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + } catch { + // 忽略并走降级方案 + } + + if (copyTextWithExecCommand(text)) { + return; + } + + throw new Error("复制失败,请检查剪贴板权限后重试"); +} + +function buildNovelActionErrorReport(options: { + projectId: string; + projectName?: string; + preferredProvider?: string; + preferredModel?: string; + selectedChapterId?: string; + error: RecentNovelActionError; +}): string { + const payload = { + project_id: options.projectId, + project_name: options.projectName ?? "", + stage: options.error.stage, + action: options.error.action ?? "", + context: options.error.context, + message: options.error.message, + selected_chapter_id: options.selectedChapterId ?? "", + preferred_provider: options.preferredProvider ?? "", + preferred_model: options.preferredModel ?? "", + occurred_at: options.error.occurredAt, + occurred_at_text: formatDateTime(options.error.occurredAt), + }; + return JSON.stringify(payload, null, 2); +} + +function downloadTextFile(filename: string, content: string): void { + if (typeof window === "undefined" || typeof document === "undefined") { + throw new Error("当前环境不支持导出文件"); + } + const blob = new Blob([content], { type: "application/json;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); +} + export function NovelFlowWorkbench({ projectId, projectName, @@ -303,6 +469,7 @@ export function NovelFlowWorkbench({ const [settingsSaveError, setSettingsSaveError] = useState(null); const [lastSavedAt, setLastSavedAt] = useState(null); const [activeStage, setActiveStage] = useState("setup"); + const [lastActionError, setLastActionError] = useState(null); const autoSaveTimerRef = useRef(null); @@ -346,6 +513,21 @@ export function NovelFlowWorkbench({ } }, []); + const recordActionError = useCallback( + (context: string, error: unknown, action?: FlowAction): string => { + const message = getErrorMessage(error); + setLastActionError({ + message, + context, + action, + stage: activeStage, + occurredAt: Date.now(), + }); + return message; + }, + [activeStage], + ); + const runAction = useCallback( async (action: FlowAction, successText: string, fn: () => Promise) => { setRunningAction(action); @@ -353,12 +535,13 @@ export function NovelFlowWorkbench({ await fn(); toast.success(successText); } catch (error) { - toast.error(`${successText}失败: ${getErrorMessage(error)}`); + const message = recordActionError(`${successText}失败`, error, action); + toast.error(`${successText}失败: ${message}`); } finally { setRunningAction(null); } }, - [], + [recordActionError], ); const loadSnapshot = useCallback(async () => { @@ -393,12 +576,13 @@ export function NovelFlowWorkbench({ if (isNovelProjectNotFound(message)) { setRuns([]); } else { + recordActionError("加载运行记录失败", error, "refresh"); toast.error(`加载运行记录失败: ${message}`); } } finally { setLoadingRuns(false); } - }, [projectId]); + }, [projectId, recordActionError]); const refreshNovelData = useCallback(async () => { await Promise.all([loadSnapshot(), loadRuns()]); @@ -461,11 +645,12 @@ export function NovelFlowWorkbench({ toast.success("小说项目初始化完成"); await refreshNovelData(); } catch (error) { - toast.error(`初始化失败: ${getErrorMessage(error)}`); + const message = recordActionError("初始化小说项目失败", error); + toast.error(`初始化失败: ${message}`); } finally { setInitializing(false); } - }, [projectId, projectName, refreshNovelData]); + }, [projectId, projectName, recordActionError, refreshNovelData]); const handleSaveSettings = useCallback( async (silent = false): Promise => { @@ -496,7 +681,7 @@ export function NovelFlowWorkbench({ } return true; } catch (error) { - const message = getErrorMessage(error); + const message = recordActionError("保存创作设定失败", error, "save-settings"); setSettingsSaveError(message); if (!silent) { toast.error(`保存创作设定失败: ${message}`); @@ -506,7 +691,15 @@ export function NovelFlowWorkbench({ setSavingSettings(false); } }, - [clearAutoSaveTimer, projectId, savingSettings, settingsDirty, settingsDraft, snapshot], + [ + clearAutoSaveTimer, + projectId, + recordActionError, + savingSettings, + settingsDirty, + settingsDraft, + snapshot, + ], ); useEffect(() => { @@ -752,6 +945,70 @@ export function NovelFlowWorkbench({ pipelineState.primaryAction, ]); + const handleCopyLastActionError = useCallback(async () => { + if (!lastActionError) { + toast.info("暂无可复制错误"); + return; + } + const content = buildNovelActionErrorReport({ + projectId, + projectName, + preferredProvider, + preferredModel, + selectedChapterId, + error: lastActionError, + }); + try { + await copyTextToClipboard(content); + toast.success("错误信息已复制"); + } catch (error) { + toast.error(`复制失败: ${getErrorMessage(error)}`); + } + }, [ + lastActionError, + preferredModel, + preferredProvider, + projectId, + projectName, + selectedChapterId, + ]); + + const handleExportLastActionError = useCallback(() => { + if (!lastActionError) { + toast.info("暂无可导出错误"); + return; + } + const content = buildNovelActionErrorReport({ + projectId, + projectName, + preferredProvider, + preferredModel, + selectedChapterId, + error: lastActionError, + }); + const date = new Date(lastActionError.occurredAt); + const pad = (value: number) => value.toString().padStart(2, "0"); + const filename = `proxycast-novel-error-${date.getFullYear()}${pad( + date.getMonth() + 1, + )}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad( + date.getSeconds(), + )}.json`; + + try { + downloadTextFile(filename, content); + toast.success("错误 JSON 已导出到系统默认下载目录"); + } catch (error) { + toast.error(`导出失败: ${getErrorMessage(error)}`); + } + }, [ + lastActionError, + preferredModel, + preferredProvider, + projectId, + projectName, + selectedChapterId, + ]); + if (loadingSnapshot && !hasSnapshot) { return (
@@ -856,6 +1113,58 @@ export function NovelFlowWorkbench({ + {lastActionError && ( + + +
+
+ 最近错误 + + {lastActionError.context} · {formatDateTime(lastActionError.occurredAt)} + +
+
+ + + +
+
+
+ +
{lastActionError.message}
+
+ 阶段:{STAGE_METAS.find((item) => item.id === lastActionError.stage)?.title ?? "未知"} + {lastActionError.action ? ` · 动作:${lastActionError.action}` : ""} +
+
+
+ )} +
@@ -1035,14 +1344,25 @@ export function NovelFlowWorkbench({
暂无角色数据
) : (
- {snapshot.characters.map((character) => ( -
-
{character.name}
-
- {character.role_type} · 版本 {character.version} + {snapshot.characters.map((character, index) => { + const display = buildCharacterDisplay(character, index); + return ( +
+
{display.name}
+
+ {display.roleLabel} · 版本 {character.version} +
+
+ {display.details.map((item) => ( +
+ {item.label}: + {item.value} +
+ ))} +
-
- ))} + ); + })}
)} @@ -1296,6 +1616,11 @@ export function NovelFlowWorkbench({ {formatRunStatus(run.result_status)}
+ {run.error_message && ( +
+ {run.error_message} +
+ )}
)) )}