fix: 构建 workspace 目录 4 层健康防护体系,彻底解决路径缺失问题

## 根本原因
workspace_create 只写 DB 不建目录,导致任何主题(小说/文章/海报等)
切换到目录已被删除/外置磁盘未挂载的 workspace 后,发送消息必然报错,
且旧提示语引导用户去不存在的"工作区设置"页面。

## 4 层防护

**L1 创建时(workspace_create)**
- 建 workspace 前调 ensure_workspace_root_ready,目录不存在则自动创建

**L2 启动/切换时(useWorkspace + useProjects)**
- refresh/setDefault/projects_refresh 时调 workspace_ensure_ready
- 静默自动修复已知 workspace 的缺失目录

**L3 发消息前(aster_agent_cmd)**
- aster_session_create + aster_agent_chat_stream 各自调 ensure_workspace_root_ready
- 能自动创建则创建并继续,无法创建则返回语义化错误

**L4 主动检查 + 兜底 UI(AgentChatPage + CrashRecoveryPanel)**
- 打开任意项目时主动调 workspace_ensure_ready,失败时显示 amber banner
- 发送失败也显示 amber banner,"重新选择目录"按钮处理两种场景
- CrashRecoveryPanel 新增 workspace-path-missing 场景识别与蓝色修复按钮

## 新增功能
- WorkspaceUpdate.root_path:支持通过 API 修改 workspace 根目录
- workspace_ensure_ready / workspace_ensure_default_ready:Tauri 健康检查命令
- workspace_health_service.rs:独立服务模块,含详细诊断信息
- AppCrashBoundary / CrashRecoveryPanel:通用 React 错误边界与恢复面板
- workspaceHealthTelemetry.ts:localStorage 修复历史记录(最多 50 条)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
coso
2026-03-02 17:43:25 +08:00
co-authored by Claude Sonnet 4.6
parent 16b48bad36
commit 2d00911c28
12 changed files with 845 additions and 6 deletions
@@ -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!(
@@ -185,6 +185,9 @@ pub struct WorkspaceUpdate {
/// 标签列表
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
/// 新根目录路径
#[serde(skip_serializing_if = "Option::is_none")]
pub root_path: Option<PathBuf>,
}
#[cfg(test)]
+96 -5
View File
@@ -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<String>,
}
/// 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<Workspace> for WorkspaceListItem {
fn from(ws: Workspace) -> Self {
Self {
@@ -122,6 +134,8 @@ pub struct UpdateWorkspaceRequest {
pub is_archived: Option<bool>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default)]
pub root_path: Option<String>,
}
// ==================== 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<WorkspaceListItem, String> {
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<WorkspaceEnsureResult, String> {
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<Option<WorkspaceEnsureResult>, 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,
+1
View File
@@ -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;
@@ -0,0 +1,43 @@
use std::path::Path;
/// 确保 workspace 根目录可用。
///
/// 返回值:
/// - `Ok(true)`: 目录原本不存在,已自动创建
/// - `Ok(false)`: 目录已存在且可用
/// - `Err(...)`: 路径非法/无权限等不可恢复错误
pub fn ensure_workspace_root_ready(workspace_root: &Path) -> Result<bool, String> {
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)
}
@@ -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<Topic[]>([]);
const [isSending, setIsSending] = useState(false);
const [pendingActions, setPendingActions] = useState<ActionRequired[]>([]);
const [workspacePathMissing, setWorkspacePathMissing] = useState<{
content: string;
images: MessageImage[];
} | null>(null);
const initialPreferencesRef = useRef<AgentPreferences>(
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,
};
}
+85
View File
@@ -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<Character[]>(
[],
@@ -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 = (
<ChatContainer>
@@ -1942,6 +2004,29 @@ export function AgentChatPage({
{showChatLayout && (
<>
{(workspacePathMissing || workspaceHealthError) && (
<div className="mx-4 mb-2 flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-300">
<span className="flex-1">工作区目录不存在,请重新选择一个本地目录后继续</span>
<button
type="button"
onClick={() => void handleSelectWorkspaceDirectory()}
className="shrink-0 rounded-md bg-amber-200 px-2.5 py-1 text-xs font-medium text-amber-900 hover:bg-amber-300 dark:bg-amber-800 dark:text-amber-100 dark:hover:bg-amber-700"
>
重新选择目录
</button>
<button
type="button"
onClick={() => {
setWorkspaceHealthError(false);
dismissWorkspacePathError();
}}
className="shrink-0 text-amber-600 hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-200"
aria-label="关闭"
>
✕
</button>
</div>
)}
<Inputbar
input={input}
setInput={setInput}
@@ -0,0 +1,77 @@
import React from "react";
import { reportFrontendError } from "@/lib/crashReporting";
import { CrashRecoveryPanel } from "./CrashRecoveryPanel";
interface AppCrashBoundaryProps {
children: React.ReactNode;
}
interface AppCrashBoundaryState {
hasError: boolean;
error: Error | null;
componentStack: string;
resetToken: number;
}
export class AppCrashBoundary extends React.Component<
AppCrashBoundaryProps,
AppCrashBoundaryState
> {
constructor(props: AppCrashBoundaryProps) {
super(props);
this.state = {
hasError: false,
error: null,
componentStack: "",
resetToken: 0,
};
}
static getDerivedStateFromError(error: Error): Partial<AppCrashBoundaryState> {
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 (
<CrashRecoveryPanel
error={this.state.error}
componentStack={this.state.componentStack}
onRetry={this.handleRetry}
/>
);
}
return (
<React.Fragment key={this.state.resetToken}>
{this.props.children}
</React.Fragment>
);
}
}
@@ -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<ReturnType<typeof buildPayload>>) => Promise<void> | 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 (
<div className="flex h-screen w-screen items-center justify-center bg-background px-6">
<div className="w-full max-w-2xl rounded-xl border bg-card p-6 shadow-sm">
<div className="mb-4 flex items-start gap-3">
<div className="rounded-lg bg-rose-500/10 p-2">
<AlertTriangle className="h-5 w-5 text-rose-500" />
</div>
<div className="min-w-0">
<h2 className="text-lg font-semibold">应用发生错误,已进入恢复模式</h2>
<p className="mt-1 text-sm text-muted-foreground">
建议先复制或导出诊断信息,再点击“重试恢复”继续使用。
</p>
</div>
</div>
{error?.message && (
<div className="mb-4 rounded-md border border-rose-200 bg-rose-50/70 px-3 py-2 text-xs text-rose-700 dark:border-rose-900/40 dark:bg-rose-950/20 dark:text-rose-300">
最近错误:{error.message}
</div>
)}
{message && (
<div
className={cn(
"mb-4 rounded-md px-3 py-2 text-sm",
message.type === "success"
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
: "bg-destructive/10 text-destructive",
)}
>
{message.text}
</div>
)}
{showClipboardGuide && <ClipboardPermissionGuideCard className="mb-4" />}
<div className="flex flex-wrap items-center gap-2">
{sceneTag === "workspace-path-missing" ? (
<button
type="button"
onClick={() => void handleSelectNewDirectory()}
disabled={busy}
className={cn(
"inline-flex items-center gap-1 rounded-md border border-blue-400 bg-blue-50 px-3 py-1.5 text-xs text-blue-700 transition-colors dark:border-blue-700 dark:bg-blue-950/30 dark:text-blue-300",
busy && "cursor-not-allowed opacity-50",
)}
>
<FolderOpen className="h-3.5 w-3.5" />
重新选择目录
</button>
) : null}
<button
type="button"
onClick={handleCopyTemplate}
disabled={busy}
className={cn(
"rounded-md border px-3 py-1.5 text-xs transition-colors",
busy && "cursor-not-allowed opacity-50",
)}
>
复制诊断信息
</button>
<button
type="button"
onClick={handleCopyJson}
disabled={busy}
className={cn(
"rounded-md border px-3 py-1.5 text-xs transition-colors",
busy && "cursor-not-allowed opacity-50",
)}
>
复制纯 JSON
</button>
<button
type="button"
onClick={handleExportJson}
disabled={busy}
className={cn(
"inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-xs transition-colors",
busy && "cursor-not-allowed opacity-50",
)}
>
<Download className="h-3.5 w-3.5" />
导出诊断 JSON
</button>
<button
type="button"
onClick={handleOpenDownloadDirectory}
disabled={busy}
className={cn(
"rounded-md border px-3 py-1.5 text-xs transition-colors",
busy && "cursor-not-allowed opacity-50",
)}
>
打开下载目录
</button>
<button
type="button"
onClick={onRetry}
disabled={busy}
className={cn(
"inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-xs transition-colors",
busy && "cursor-not-allowed opacity-50",
)}
>
<RefreshCw className="h-3.5 w-3.5" />
重试恢复
</button>
</div>
</div>
</div>
);
}
+27
View File
@@ -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<Project | null>("workspace_get_default"),
]);
if (defaultProj?.id) {
const ensureResult = await invoke<WorkspaceEnsureResult>(
"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) {
+43
View File
@@ -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<Workspace | null>;
}
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<WorkspaceEnsureResult>(
"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<void> => {
await invoke("workspace_set_default", { id });
const ensureResult = await invoke<WorkspaceEnsureResult>(
"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],
+82
View File
@@ -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<string, unknown>;
return (
typeof record.timestamp === "string" &&
typeof record.workspace_id === "string" &&
typeof record.root_path === "string" &&
typeof record.source === "string"
);
}