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