diff --git a/src-tauri/crates/core/src/workspace/manager.rs b/src-tauri/crates/core/src/workspace/manager.rs index 4379dc47a..3bd6eb58c 100644 --- a/src-tauri/crates/core/src/workspace/manager.rs +++ b/src-tauri/crates/core/src/workspace/manager.rs @@ -363,6 +363,12 @@ impl WorkspaceManager { params_vec.push(Box::new(tags_json)); } + if let Some(ref root_path) = updates.root_path { + let root_path_str = root_path.to_str().ok_or("无效的路径")?.to_string(); + set_clauses.push("root_path = ?"); + params_vec.push(Box::new(root_path_str)); + } + params_vec.push(Box::new(id.clone())); let sql = format!( diff --git a/src-tauri/crates/core/src/workspace/types.rs b/src-tauri/crates/core/src/workspace/types.rs index 71834a0b8..ca3fcd5a9 100644 --- a/src-tauri/crates/core/src/workspace/types.rs +++ b/src-tauri/crates/core/src/workspace/types.rs @@ -185,6 +185,9 @@ pub struct WorkspaceUpdate { /// 标签列表 #[serde(skip_serializing_if = "Option::is_none")] pub tags: Option>, + /// 新根目录路径 + #[serde(skip_serializing_if = "Option::is_none")] + pub root_path: Option, } #[cfg(test)] diff --git a/src-tauri/src/commands/workspace_cmd.rs b/src-tauri/src/commands/workspace_cmd.rs index 186406bc0..9746ade07 100644 --- a/src-tauri/src/commands/workspace_cmd.rs +++ b/src-tauri/src/commands/workspace_cmd.rs @@ -13,6 +13,7 @@ use crate::database::DbConnection; use crate::models::project_model::ProjectContext; +use crate::services::workspace_health_service::ensure_workspace_root_ready; use crate::workspace::{ Workspace, WorkspaceManager, WorkspaceSettings, WorkspaceType, WorkspaceUpdate, }; @@ -75,6 +76,17 @@ pub struct WorkspaceListItem { pub tags: Vec, } +/// Workspace 目录健康检查结果 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceEnsureResult { + pub workspace_id: String, + pub root_path: String, + pub existed: bool, + pub created: bool, + pub repaired: bool, +} + impl From for WorkspaceListItem { fn from(ws: Workspace) -> Self { Self { @@ -122,6 +134,8 @@ pub struct UpdateWorkspaceRequest { pub is_archived: Option, #[serde(default)] pub tags: Option>, + #[serde(default)] + pub root_path: Option, } // ==================== Tauri 命令 ==================== @@ -147,11 +161,11 @@ pub async fn workspace_create( .map(|t| WorkspaceType::parse(&t)) .unwrap_or_default(); - let workspace = manager.create_with_type( - request.name, - PathBuf::from(&request.root_path), - workspace_type, - )?; + let root_path = PathBuf::from(&request.root_path); + + ensure_workspace_root_ready(&root_path)?; + + let workspace = manager.create_with_type(request.name, root_path, workspace_type)?; Ok(workspace.into()) } @@ -184,6 +198,20 @@ pub async fn workspace_update( ) -> Result { let manager = WorkspaceManager::new(db.inner().clone()); + let new_root_path = if let Some(ref path_str) = request.root_path { + let path = PathBuf::from(path_str); + // 预校验新路径:路径必须存在且为目录 + if !path.exists() { + return Err(format!("指定的路径不存在: {path_str}")); + } + if !path.is_dir() { + return Err(format!("指定的路径不是目录: {path_str}")); + } + Some(path) + } else { + None + }; + let updates = WorkspaceUpdate { name: request.name, settings: request.settings, @@ -192,6 +220,7 @@ pub async fn workspace_update( is_favorite: request.is_favorite, is_archived: request.is_archived, tags: request.tags, + root_path: new_root_path, }; let workspace = manager.update(&id, updates)?; @@ -238,6 +267,63 @@ pub async fn workspace_get_default( Ok(workspace.map(|ws| ws.into())) } +/// 修复指定 workspace 的目录可用性 +#[tauri::command] +pub async fn workspace_ensure_ready( + db: State<'_, DbConnection>, + id: String, +) -> Result { + let manager = WorkspaceManager::new(db.inner().clone()); + let workspace = manager + .get(&id)? + .ok_or_else(|| format!("Workspace 不存在: {id}"))?; + let root_path = workspace.root_path.to_string_lossy().to_string(); + + let created = ensure_workspace_root_ready(&workspace.root_path)?; + if created { + tracing::warn!( + "[Workspace] 检测到目录缺失,已自动创建: id={}, root={}", + workspace.id, + root_path + ); + } + + Ok(WorkspaceEnsureResult { + workspace_id: workspace.id, + root_path, + existed: !created, + created, + repaired: created, + }) +} + +/// 修复默认 workspace 的目录可用性(若无默认 workspace 返回 null) +#[tauri::command] +pub async fn workspace_ensure_default_ready( + db: State<'_, DbConnection>, +) -> Result, String> { + let manager = WorkspaceManager::new(db.inner().clone()); + let Some(workspace) = manager.get_default()? else { + return Ok(None); + }; + let root_path = workspace.root_path.to_string_lossy().to_string(); + let created = ensure_workspace_root_ready(&workspace.root_path)?; + if created { + tracing::warn!( + "[Workspace] 启动检查发现默认 workspace 目录缺失,已自动创建: id={}, root={}", + workspace.id, + root_path + ); + } + Ok(Some(WorkspaceEnsureResult { + workspace_id: workspace.id, + root_path, + existed: !created, + created, + repaired: created, + })) +} + /// 通过路径获取 workspace #[tauri::command] pub async fn workspace_get_by_path( @@ -288,6 +374,11 @@ pub async fn get_or_create_default_project( // 不存在则创建默认项目 let default_project_path = get_workspace_projects_root_dir()?.join("default"); + + std::fs::create_dir_all(&default_project_path).map_err(|e| { + format!("创建默认项目目录失败: {e}") + })?; + let workspace = manager.create_with_type( "默认项目".to_string(), default_project_path, diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 5ffceaf41..408dc3197 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -18,3 +18,4 @@ pub mod sysinfo_service; pub mod update_check_service; pub mod update_window; pub mod web_search_prompt_service; +pub mod workspace_health_service; diff --git a/src-tauri/src/services/workspace_health_service.rs b/src-tauri/src/services/workspace_health_service.rs new file mode 100644 index 000000000..bf5b33e2a --- /dev/null +++ b/src-tauri/src/services/workspace_health_service.rs @@ -0,0 +1,43 @@ +use std::path::Path; + +/// 确保 workspace 根目录可用。 +/// +/// 返回值: +/// - `Ok(true)`: 目录原本不存在,已自动创建 +/// - `Ok(false)`: 目录已存在且可用 +/// - `Err(...)`: 路径非法/无权限等不可恢复错误 +pub fn ensure_workspace_root_ready(workspace_root: &Path) -> Result { + if workspace_root.exists() { + if workspace_root.is_dir() { + return Ok(false); + } + return Err(format!( + "Workspace 路径存在但不是目录: {}。请删除同名文件或重新选择目录。", + workspace_root.to_string_lossy() + )); + } + + std::fs::create_dir_all(workspace_root).map_err(|error| { + let path_str = workspace_root.to_string_lossy(); + let hint = workspace_root + .parent() + .map(|parent| { + if !parent.exists() { + format!("父目录 '{}' 不存在", parent.display()) + } else if std::fs::metadata(parent) + .map(|metadata| metadata.permissions().readonly()) + .unwrap_or(false) + { + format!("父目录 '{}' 无写入权限", parent.display()) + } else { + format!("错误: {error}") + } + }) + .unwrap_or_else(|| format!("错误: {error}")); + format!( + "Workspace 路径不存在,且自动创建失败: {path_str}。{hint}。请重新选择一个有效的本地目录。" + ) + })?; + + Ok(true) +} diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.ts b/src/components/agent/chat/hooks/useAsterAgentChat.ts index e27ef6356..21821f4aa 100644 --- a/src/components/agent/chat/hooks/useAsterAgentChat.ts +++ b/src/components/agent/chat/hooks/useAsterAgentChat.ts @@ -7,6 +7,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { toast } from "sonner"; +import { invoke } from "@tauri-apps/api/core"; import { safeListen } from "@/lib/dev-bridge"; import type { UnlistenFn } from "@tauri-apps/api/event"; import { @@ -74,6 +75,18 @@ const normalizeActionType = ( return null; }; +const WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE = "workspace_path_auto_created"; + +const isWorkspacePathErrorMessage = (message: string): boolean => { + return ( + message.includes("Workspace 路径不存在") || + message.includes("Workspace 路径不是目录") || + message.includes("Workspace 路径存在但不是目录") || + message.includes("工作区目录缺失") || + message.includes("workspace path") + ); +}; + const appendActionRequiredToParts = ( parts: ContentPart[], actionRequired: ActionRequired, @@ -972,6 +985,10 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { const [topics, setTopics] = useState([]); const [isSending, setIsSending] = useState(false); const [pendingActions, setPendingActions] = useState([]); + const [workspacePathMissing, setWorkspacePathMissing] = useState<{ + content: string; + images: MessageImage[]; + } | null>(null); const initialPreferencesRef = useRef( resolveWorkspaceAgentPreferences(workspaceId), @@ -1748,6 +1765,9 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { break; case "warning": { + if (data.code === WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE) { + break; + } const warningKey = `${activeSessionId}:${data.code || data.message}`; if (!warnedKeysRef.current.has(warningKey)) { warnedKeysRef.current.add(warningKey); @@ -1790,9 +1810,12 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { ); } catch (error) { console.error("[AsterChat] 发送失败:", error); - const errMsg = String(error); + const errMsg = + error instanceof Error ? error.message : String(error); if (errMsg.includes("429") || errMsg.toLowerCase().includes("rate limit")) { toast.warning("请求过于频繁,请稍后重试"); + } else if (isWorkspacePathErrorMessage(errMsg)) { + setWorkspacePathMissing({ content, images }); } else { toast.error(`发送失败: ${error}`); } @@ -1844,6 +1867,31 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { toast.info("已停止生成"); }, [sessionId]); + // 修复 workspace 路径并重试 + const fixWorkspacePathAndRetry = useCallback( + async (newPath: string) => { + if (!workspacePathMissing) return; + const { content: retryContent, images: retryImages } = workspacePathMissing; + setWorkspacePathMissing(null); + try { + await invoke("workspace_update", { + id: workspaceId, + request: { rootPath: newPath }, + }); + await sendMessage(retryContent, retryImages, false, false, true); + } catch (err) { + toast.error( + `修复路径失败: ${err instanceof Error ? err.message : String(err)}`, + ); + } + }, + [workspaceId, workspacePathMissing, sendMessage], + ); + + const dismissWorkspacePathError = useCallback(() => { + setWorkspacePathMissing(null); + }, []); + // 确认权限请求 const confirmAction = useCallback( async (response: ConfirmResponse) => { @@ -2460,5 +2508,10 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { // Aster 特有功能 pendingActions, confirmAction, + + // Workspace 路径修复 + workspacePathMissing, + fixWorkspacePathAndRetry, + dismissWorkspacePathError, }; } diff --git a/src/components/agent/chat/index.tsx b/src/components/agent/chat/index.tsx index cee751c73..c4e99c3b6 100644 --- a/src/components/agent/chat/index.tsx +++ b/src/components/agent/chat/index.tsx @@ -9,6 +9,8 @@ import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import { toast } from "sonner"; import styled from "styled-components"; +import { open as openDialog } from "@tauri-apps/plugin-dialog"; +import { invoke } from "@tauri-apps/api/core"; import { useAgentChatUnified } from "./hooks"; import { useSessionFiles } from "./hooks/useSessionFiles"; import { useContentSync } from "./hooks/useContentSync"; @@ -71,6 +73,7 @@ import { skillsApi, type Skill } from "@/lib/api/skills"; import { buildHomeAgentParams } from "@/lib/workspace/navigation"; import { LatestRunStatusBadge } from "@/components/execution/LatestRunStatusBadge"; import { setActiveContentTarget } from "@/lib/activeContentTarget"; +import { recordWorkspaceRepair } from "@/lib/workspaceHealthTelemetry"; import type { MessageImage } from "./types"; import type { @@ -338,6 +341,9 @@ export function AgentChatPage({ null, ); + // 主动 workspace 健康检查失败标记(区别于 workspacePathMissing 发送失败场景) + const [workspaceHealthError, setWorkspaceHealthError] = useState(false); + // 引用的角色列表(用于注入到消息中) const [mentionedCharacters, setMentionedCharacters] = useState( [], @@ -456,6 +462,36 @@ export function AgentChatPage({ loadData(); }, [projectId, contentId, lockTheme, initialTheme]); + // 当 projectId 变化时主动检查 workspace 目录健康状态 + // 静默修复(auto-created)或显示 banner 提示用户重新选择 + useEffect(() => { + setWorkspaceHealthError(false); + const normalizedId = normalizeProjectId(projectId); + if (!normalizedId) return; + + invoke<{ created: boolean; rootPath: string }>("workspace_ensure_ready", { + id: normalizedId, + }) + .then(({ created, rootPath }) => { + if (created) { + recordWorkspaceRepair({ + workspaceId: normalizedId, + rootPath, + source: "agent_chat_page", + }); + console.info( + "[AgentChatPage] workspace 目录已自动修复:", + rootPath, + ); + } + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + console.warn("[AgentChatPage] workspace 目录检查失败:", message); + setWorkspaceHealthError(true); + }); + }, [projectId]); + useEffect(() => { const normalizedProjectId = normalizeProjectId(projectId); if (!normalizedProjectId) { @@ -520,6 +556,9 @@ export function AgentChatPage({ switchTopic: originalSwitchTopic, deleteTopic, renameTopic, + workspacePathMissing, + fixWorkspacePathAndRetry, + dismissWorkspacePathError, } = useAgentChatUnified({ systemPrompt, onWriteFile: (content, fileName) => { @@ -1873,6 +1912,29 @@ export function AgentChatPage({ _onNavigate?.("resources"); }, [_onNavigate]); + const handleSelectWorkspaceDirectory = useCallback(async () => { + const newPath = await openDialog({ directory: true, multiple: false }); + if (!newPath) return; + if (workspacePathMissing) { + // 发送失败场景:更新路径并重试原来的消息 + await fixWorkspacePathAndRetry(newPath); + } else if (projectId) { + // 主动健康检查发现问题:只更新路径,不需要重试 + try { + await invoke("workspace_update", { + id: projectId, + request: { rootPath: newPath }, + }); + setWorkspaceHealthError(false); + toast.success("工作区目录已更新"); + } catch (err) { + toast.error( + `更新路径失败: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + }, [fixWorkspacePathAndRetry, projectId, workspacePathMissing]); + // 聊天区域内容 const chatContent = ( @@ -1942,6 +2004,29 @@ export function AgentChatPage({ {showChatLayout && ( <> + {(workspacePathMissing || workspaceHealthError) && ( +
+ 工作区目录不存在,请重新选择一个本地目录后继续 + + +
+ )} { + constructor(props: AppCrashBoundaryProps) { + super(props); + this.state = { + hasError: false, + error: null, + componentStack: "", + resetToken: 0, + }; + } + + static getDerivedStateFromError(error: Error): Partial { + return { + hasError: true, + error, + }; + } + + componentDidCatch(error: Error, info: React.ErrorInfo): void { + const componentStack = info.componentStack || ""; + this.setState({ componentStack }); + + void reportFrontendError(error, { + source: "app-crash-boundary", + workflow_step: "root_render", + component: "AppCrashBoundary", + component_stack: componentStack, + page_url: + typeof window !== "undefined" ? window.location.href : "unknown", + }); + } + + private handleRetry = () => { + this.setState((previous) => ({ + hasError: false, + error: null, + componentStack: "", + resetToken: previous.resetToken + 1, + })); + }; + + render() { + if (this.state.hasError) { + return ( + + ); + } + + return ( + + {this.props.children} + + ); + } +} diff --git a/src/components/layout/CrashRecoveryPanel.tsx b/src/components/layout/CrashRecoveryPanel.tsx new file mode 100644 index 000000000..a8686e746 --- /dev/null +++ b/src/components/layout/CrashRecoveryPanel.tsx @@ -0,0 +1,328 @@ +import { useCallback, useMemo, useState } from "react"; +import { AlertTriangle, Download, FolderOpen, RefreshCw } from "lucide-react"; +import { + getConfig, + getLogs, + getPersistedLogsTail, +} from "@/hooks/useTauri"; +import { + buildCrashDiagnosticPayload, + copyCrashDiagnosticJsonToClipboard, + copyCrashDiagnosticToClipboard, + exportCrashDiagnosticToJson, + isClipboardPermissionDeniedError, + normalizeCrashReportingConfig, + openCrashDiagnosticDownloadDirectory, +} from "@/lib/crashDiagnostic"; +import { cn } from "@/lib/utils"; +import { ClipboardPermissionGuideCard } from "@/components/settings-v2/system/shared/ClipboardPermissionGuideCard"; +import { invoke } from "@tauri-apps/api/core"; +import { open as openDialog } from "@tauri-apps/plugin-dialog"; + +interface CrashRecoveryPanelProps { + error: Error | null; + componentStack: string; + onRetry: () => void; +} + +export function CrashRecoveryPanel({ + error, + componentStack, + onRetry, +}: CrashRecoveryPanelProps) { + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState<{ + type: "success" | "error"; + text: string; + } | null>(null); + const [showClipboardGuide, setShowClipboardGuide] = useState(false); + const errorMessage = error?.message ?? ""; + + const sceneTag = + errorMessage.includes("Workspace 路径不存在") || + errorMessage.includes("Workspace 路径存在但不是目录") + ? "workspace-path-missing" + : "crash-recovery"; + + // 从错误消息中提取旧的 workspace 路径 + const oldWorkspacePath = useMemo(() => { + const match = errorMessage.match( + /Workspace 路径(?:不存在,且自动创建失败|存在但不是目录): (.+?)。/, + ); + return match?.[1] ?? null; + }, [errorMessage]); + + const stackPreview = useMemo(() => { + const raw = componentStack.trim(); + if (!raw) { + return ""; + } + return raw + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .slice(0, 8) + .join(" | "); + }, [componentStack]); + + const buildPayload = useCallback(async () => { + const notes: string[] = []; + if (error?.message) { + notes.push(`boundary_error: ${error.message}`); + } + if (error?.stack) { + notes.push(`boundary_error_stack: ${error.stack.split("\n").slice(0, 5).join(" | ")}`); + } + if (stackPreview) { + notes.push(`boundary_component_stack: ${stackPreview}`); + } + + const [config, logs, persistedLogs] = await Promise.all([ + getConfig().catch(() => { + notes.push("get_config_failed"); + return null; + }), + getLogs().catch(() => { + notes.push("get_logs_failed"); + return []; + }), + getPersistedLogsTail(250).catch(() => { + notes.push("get_persisted_logs_tail_failed"); + return []; + }), + ]); + + return buildCrashDiagnosticPayload({ + crashConfig: normalizeCrashReportingConfig(config?.crash_reporting), + logs, + persistedLogTail: persistedLogs, + appVersion: import.meta.env.VITE_APP_VERSION, + platform: navigator.platform, + userAgent: navigator.userAgent, + maxCrashLogs: 40, + maxPersistedLogs: 250, + collectionNotes: notes, + }); + }, [error, stackPreview]); + + const runAction = useCallback( + async ( + action: (payload: Awaited>) => Promise | void, + successText: string, + ) => { + setBusy(true); + setMessage(null); + setShowClipboardGuide(false); + try { + const payload = await buildPayload(); + await action(payload); + setMessage({ type: "success", text: successText }); + } catch (actionError) { + const denied = isClipboardPermissionDeniedError(actionError); + setShowClipboardGuide(denied); + setMessage({ + type: "error", + text: actionError instanceof Error ? actionError.message : "生成诊断信息失败", + }); + } finally { + setBusy(false); + } + }, + [buildPayload], + ); + + const handleCopyTemplate = useCallback(() => { + void runAction( + (payload) => copyCrashDiagnosticToClipboard(payload), + "诊断信息已复制,可直接发送给开发者", + ); + }, [runAction]); + + const handleCopyJson = useCallback(() => { + void runAction( + (payload) => copyCrashDiagnosticJsonToClipboard(payload), + "纯 JSON 诊断信息已复制", + ); + }, [runAction]); + + const handleExportJson = useCallback(() => { + void runAction(async (payload) => { + const result = exportCrashDiagnosticToJson(payload, { sceneTag }); + let openedPath: string | null = null; + try { + const opened = await openCrashDiagnosticDownloadDirectory(); + openedPath = opened.openedPath; + } catch { + openedPath = null; + } + setMessage({ + type: "success", + text: openedPath + ? `诊断文件已导出:${result.fileName},并已打开目录:${openedPath}` + : `诊断文件已导出:${result.fileName}(位置:${result.locationHint})`, + }); + }, "诊断文件已导出"); + }, [sceneTag, runAction]); + + const handleSelectNewDirectory = useCallback(async () => { + if (!oldWorkspacePath) { + setMessage({ type: "error", text: "无法从错误信息中提取 workspace 路径" }); + return; + } + setBusy(true); + setMessage(null); + try { + const newPath = await openDialog({ directory: true, multiple: false }); + if (!newPath) { + return; + } + const workspace = await invoke<{ id: string } | null>( + "workspace_get_by_path", + { rootPath: oldWorkspacePath }, + ); + if (!workspace) { + setMessage({ type: "error", text: `未找到路径为 ${oldWorkspacePath} 的 workspace` }); + return; + } + await invoke("workspace_update", { + id: workspace.id, + request: { rootPath: newPath }, + }); + setMessage({ type: "success", text: `Workspace 路径已更新为:${newPath}` }); + onRetry(); + } catch (err) { + setMessage({ + type: "error", + text: err instanceof Error ? err.message : "更新 workspace 路径失败", + }); + } finally { + setBusy(false); + } + }, [oldWorkspacePath, onRetry]); + + const handleOpenDownloadDirectory = useCallback(() => { + void runAction( + async () => { + const result = await openCrashDiagnosticDownloadDirectory(); + setMessage({ + type: "success", + text: `已打开下载目录:${result.openedPath}`, + }); + }, + "已打开下载目录", + ); + }, [runAction]); + + return ( +
+
+
+
+ +
+
+

应用发生错误,已进入恢复模式

+

+ 建议先复制或导出诊断信息,再点击“重试恢复”继续使用。 +

+
+
+ + {error?.message && ( +
+ 最近错误:{error.message} +
+ )} + + {message && ( +
+ {message.text} +
+ )} + + {showClipboardGuide && } + +
+ {sceneTag === "workspace-path-missing" ? ( + + ) : null} + + + + + +
+
+
+ ); +} diff --git a/src/hooks/useProjects.ts b/src/hooks/useProjects.ts index 383ae742c..7780666f2 100644 --- a/src/hooks/useProjects.ts +++ b/src/hooks/useProjects.ts @@ -13,6 +13,15 @@ import type { ProjectUpdate, ProjectFilter, } from "@/types/project"; +import { recordWorkspaceRepair } from "@/lib/workspaceHealthTelemetry"; + +interface WorkspaceEnsureResult { + workspaceId: string; + rootPath: string; + existed: boolean; + created: boolean; + repaired: boolean; +} // WorkspaceType 用于类型定义,暂未使用 // import type { WorkspaceType } from '@/types/workspace'; @@ -65,6 +74,24 @@ export function useProjects(): UseProjectsReturn { invoke("workspace_get_default"), ]); + if (defaultProj?.id) { + const ensureResult = await invoke( + "workspace_ensure_ready", + { id: defaultProj.id }, + ); + if (ensureResult.created) { + recordWorkspaceRepair({ + workspaceId: ensureResult.workspaceId, + rootPath: ensureResult.rootPath, + source: "projects_refresh", + }); + console.info( + "[Projects] 默认项目目录缺失,已自动修复:", + ensureResult.rootPath, + ); + } + } + setProjects(list); setDefaultProject(defaultProj); } catch (err) { diff --git a/src/hooks/useWorkspace.ts b/src/hooks/useWorkspace.ts index d38effff4..c4717c797 100644 --- a/src/hooks/useWorkspace.ts +++ b/src/hooks/useWorkspace.ts @@ -7,6 +7,7 @@ import { useState, useEffect, useCallback } from "react"; import { invoke } from "@tauri-apps/api/core"; import type { ProjectType } from "@/lib/api/project"; +import { recordWorkspaceRepair } from "@/lib/workspaceHealthTelemetry"; /** Workspace 列表项 */ export interface Workspace { @@ -37,6 +38,7 @@ export interface CreateWorkspaceRequest { export interface UpdateWorkspaceRequest { name?: string; settings?: WorkspaceSettings; + rootPath?: string; } /** Hook 返回类型 */ @@ -63,6 +65,14 @@ export interface UseWorkspaceReturn { getByPath: (rootPath: string) => Promise; } +interface WorkspaceEnsureResult { + workspaceId: string; + rootPath: string; + existed: boolean; + created: boolean; + repaired: boolean; +} + /** * Workspace 管理 Hook */ @@ -87,6 +97,24 @@ export function useWorkspace(): UseWorkspaceReturn { setWorkspaces(list); setCurrentWorkspace(defaultWs); + + if (defaultWs?.id) { + const ensureResult = await invoke( + "workspace_ensure_ready", + { id: defaultWs.id }, + ); + if (ensureResult.created) { + recordWorkspaceRepair({ + workspaceId: ensureResult.workspaceId, + rootPath: ensureResult.rootPath, + source: "workspace_refresh", + }); + console.info( + "[Workspace] 默认工作区目录缺失,已自动修复:", + ensureResult.rootPath, + ); + } + } } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { @@ -133,6 +161,21 @@ export function useWorkspace(): UseWorkspaceReturn { const setDefault = useCallback( async (id: string): Promise => { await invoke("workspace_set_default", { id }); + const ensureResult = await invoke( + "workspace_ensure_ready", + { id }, + ); + if (ensureResult.created) { + recordWorkspaceRepair({ + workspaceId: ensureResult.workspaceId, + rootPath: ensureResult.rootPath, + source: "workspace_set_default", + }); + console.info( + "[Workspace] 切换默认工作区时检测到目录缺失,已自动修复:", + ensureResult.rootPath, + ); + } await refresh(); }, [refresh], diff --git a/src/lib/workspaceHealthTelemetry.ts b/src/lib/workspaceHealthTelemetry.ts new file mode 100644 index 000000000..804864ae5 --- /dev/null +++ b/src/lib/workspaceHealthTelemetry.ts @@ -0,0 +1,82 @@ +export type WorkspaceRepairSource = + | "app_startup" + | "workspace_refresh" + | "workspace_set_default" + | "projects_refresh" + | "agent_chat_page"; + +export interface WorkspaceRepairRecord { + timestamp: string; + workspace_id: string; + root_path: string; + source: WorkspaceRepairSource; +} + +export interface RecordWorkspaceRepairInput { + workspaceId: string; + rootPath: string; + source: WorkspaceRepairSource; +} + +const WORKSPACE_REPAIR_HISTORY_KEY = "proxycast.workspace_repair_history.v1"; +const MAX_WORKSPACE_REPAIR_HISTORY = 50; + +export function recordWorkspaceRepair( + input: RecordWorkspaceRepairInput, +): void { + if (!input.workspaceId || !input.rootPath) return; + + const nextRecord: WorkspaceRepairRecord = { + timestamp: new Date().toISOString(), + workspace_id: input.workspaceId, + root_path: input.rootPath, + source: input.source, + }; + + const history = readWorkspaceRepairHistory(); + history.push(nextRecord); + const trimmed = history.slice(-MAX_WORKSPACE_REPAIR_HISTORY); + writeWorkspaceRepairHistory(trimmed); +} + +export function getWorkspaceRepairHistory(limit = 50): WorkspaceRepairRecord[] { + if (limit <= 0) return []; + const history = readWorkspaceRepairHistory(); + return history.slice(-limit).reverse(); +} + +export function clearWorkspaceRepairHistory(): void { + if (typeof localStorage === "undefined") return; + localStorage.removeItem(WORKSPACE_REPAIR_HISTORY_KEY); +} + +function readWorkspaceRepairHistory(): WorkspaceRepairRecord[] { + if (typeof localStorage === "undefined") return []; + + const raw = localStorage.getItem(WORKSPACE_REPAIR_HISTORY_KEY); + if (!raw) return []; + + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter(isWorkspaceRepairRecord); + } catch { + return []; + } +} + +function writeWorkspaceRepairHistory(records: WorkspaceRepairRecord[]): void { + if (typeof localStorage === "undefined") return; + localStorage.setItem(WORKSPACE_REPAIR_HISTORY_KEY, JSON.stringify(records)); +} + +function isWorkspaceRepairRecord(value: unknown): value is WorkspaceRepairRecord { + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + return ( + typeof record.timestamp === "string" && + typeof record.workspace_id === "string" && + typeof record.root_path === "string" && + typeof record.source === "string" + ); +}