From 03631762155c5712d8e6e833914b64948ffcf75d Mon Sep 17 00:00:00 2001 From: thzxx Date: Sun, 12 Jul 2026 13:59:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B7=A5=E4=BD=9C=E7=A9=BA=E9=97=B4?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E6=A0=A1=E9=AA=8C=20+=20=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E5=8C=96=E6=A3=80=E6=B5=8B=20+=20=E7=BB=A7=E6=89=BF=E5=9F=BA?= =?UTF-8?q?=E7=A1=80=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增功能: - workspace:check IPC — 校验路径有效性 + 检测 4 个必需文件状态 - workspace:inheritFiles IPC — 从旧工作空间继承 SOUL/AGENTS/USERS 到新工作空间 - app:restart IPC — 渲染进程触发应用重启(app.relaunch + app.exit) 前端 WorkspaceSettings 改造: - 选择文件夹后立即校验路径(是否存在、是否目录、4 个必需文件状态) - 显示工作空间状态: · 新工作空间 → 提示将自动创建 4 个文件 · 部分文件缺失 → 提示缺失文件将自动创建 · 已有工作空间 → 提示将直接加载现有配置 - 继承选项:当切换到新工作空间时,可勾选继承 SOUL/AGENTS/USERS.md · MEMORY.md 不继承(记忆与工作空间项目上下文绑定) · 继承白名单:仅允许 SOUL.md、AGENTS.md、USERS.md(防路径遍历) - 确认切换按钮 + 取消按钮 - 切换完成后弹出重启确认对话框(立即重启 / 稍后手动重启) 安全考虑: - workspace:inheritFiles 使用文件名白名单,防止路径遍历攻击 - workspace:check 返回 resolvedPath,前端只展示不直接用于文件操作 - app:restart 延迟 200ms 让 IPC 响应先返回 --- electron/ipc/handlers.ts | 112 ++++++++++++++ electron/preload.ts | 8 + src/components/settings/SettingsModal.tsx | 171 +++++++++++++++++++++- src/types/global.d.ts | 26 ++++ 4 files changed, 315 insertions(+), 2 deletions(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index d6b1b79..148e6ea 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -10,6 +10,7 @@ */ import { ipcMain, BrowserWindow, shell, app, dialog } from 'electron'; +import { join } from 'path'; import type { SessionService } from '../services/session.service'; import type { ConfigService } from '../services/config.service'; import type { WorkspaceService } from '../services/workspace.service'; @@ -551,6 +552,117 @@ export function registerAllIPCHandlers( return { canceled: false, path: result.filePaths[0] }; }); + // 重启应用(工作空间切换后调用) + ipcMain.handle('app:restart', async () => { + log.info('[APP] Restart requested'); + // 延迟 200ms 让 IPC 响应先返回 + setTimeout(() => { + app.relaunch(); + app.exit(0); + }, 200); + return { success: true }; + }); + + // ===== 工作空间校验与继承 ===== + + // 校验工作空间路径:检测路径有效性 + 4 个必需文件状态 + ipcMain.handle('workspace:check', async (_event, targetPath: string) => { + if (!targetPath || typeof targetPath !== 'string') { + return { valid: false, reason: '路径不能为空' }; + } + + const { existsSync, statSync } = await import('fs'); + const { resolve } = await import('path'); + + const resolvedPath = resolve(targetPath); + + // 校验 1: 路径是否存在 + if (!existsSync(resolvedPath)) { + return { + valid: true, + path: resolvedPath, + exists: false, + missingFiles: ['SOUL.md', 'AGENTS.md', 'MEMORY.md', 'USERS.md'], + isNewWorkspace: true, + reason: '目录不存在,将在切换后自动创建', + }; + } + + // 校验 2: 是否为目录 + try { + const stat = statSync(resolvedPath); + if (!stat.isDirectory()) { + return { valid: false, reason: '路径不是目录' }; + } + } catch { + return { valid: false, reason: '无法访问路径' }; + } + + // 校验 3: 检测 4 个必需文件状态 + const requiredFiles = ['SOUL.md', 'AGENTS.md', 'MEMORY.md', 'USERS.md']; + const missingFiles: string[] = []; + for (const f of requiredFiles) { + if (!existsSync(join(resolvedPath, f))) missingFiles.push(f); + } + + return { + valid: true, + path: resolvedPath, + exists: true, + missingFiles, + isNewWorkspace: missingFiles.length === requiredFiles.length, + }; + }); + + // 从旧工作空间继承文件到新工作空间 + ipcMain.handle('workspace:inheritFiles', async (_event, params: { + targetPath: string; + sourcePath: string; + files: string[]; + }) => { + const { targetPath, sourcePath, files } = params; + if (!targetPath || !sourcePath || !Array.isArray(files)) { + return { success: false, error: '参数无效' }; + } + + const { existsSync, mkdirSync, copyFileSync } = await import('fs'); + const { resolve } = await import('path'); + + const resolvedTarget = resolve(targetPath); + const resolvedSource = resolve(sourcePath); + + // 确保目标目录存在 + if (!existsSync(resolvedTarget)) { + mkdirSync(resolvedTarget, { recursive: true }); + } + + const inherited: string[] = []; + const failed: Array<{ file: string; error: string }> = []; + + for (const fileName of files) { + // 仅允许继承白名单文件(防止路径遍历) + if (!['SOUL.md', 'AGENTS.md', 'USERS.md'].includes(fileName)) { + failed.push({ file: fileName, error: '不在继承白名单中' }); + continue; + } + const srcFile = join(resolvedSource, fileName); + const dstFile = join(resolvedTarget, fileName); + try { + if (existsSync(srcFile)) { + copyFileSync(srcFile, dstFile); + inherited.push(fileName); + log.info(`[WORKSPACE] Inherited ${fileName}: ${resolvedSource} → ${resolvedTarget}`); + } else { + failed.push({ file: fileName, error: '源文件不存在' }); + } + } catch (err) { + failed.push({ file: fileName, error: (err as Error).message }); + } + } + + return { success: true, inherited, failed }; + }); + // ===== 工具管理 ===== ipcMain.handle('tools:list', async () => { diff --git a/electron/preload.ts b/electron/preload.ts index 531c954..75e87bb 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -114,6 +114,14 @@ const metonaAPI = { openExternal: (url: string) => ipcRenderer.invoke('app:openExternal', url), showItemInFolder: (path: string) => ipcRenderer.invoke('app:showItemInFolder', path), selectFolder: (defaultPath?: string) => ipcRenderer.invoke('app:selectFolder', defaultPath), + restart: () => ipcRenderer.invoke('app:restart'), + }, + + // ===== 工作空间管理 ===== + workspace: { + check: (targetPath: string) => ipcRenderer.invoke('workspace:check', targetPath), + inheritFiles: (params: { targetPath: string; sourcePath: string; files: string[] }) => + ipcRenderer.invoke('workspace:inheritFiles', params), }, // ===== 工具管理 ===== diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index b05b6e0..5525b3c 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -72,11 +72,77 @@ const PROVIDER_URLS: Record = { deepseek: 'https://api.deepseek. function WorkspaceSettings() { const [workspacePath, setWorkspacePath] = useConfig('workspace.path', ''); + // 切换工作空间的中间状态 + const [pendingPath, setPendingPath] = useState(null); + const [checkResult, setCheckResult] = useState(null); + const [checking, setChecking] = useState(false); + const [inheritSoul, setInheritSoul] = useState(true); + const [inheritAgents, setInheritAgents] = useState(true); + const [inheritUsers, setInheritUsers] = useState(true); + const [applying, setApplying] = useState(false); + const [showRestartDialog, setShowRestartDialog] = useState(false); + + const currentPath = workspacePath; const handleSelect = async () => { if (!window.metona?.app?.selectFolder) return; - const r = await window.metona.app.selectFolder(workspacePath || undefined); - if (!r.canceled && r.path) setWorkspacePath(r.path); + const r = await window.metona.app.selectFolder(currentPath || undefined); + if (!r.canceled && r.path) { + // 立即校验新路径 + setPendingPath(r.path); + setChecking(true); + setCheckResult(null); + try { + const result = await window.metona.workspace.check(r.path); + setCheckResult(result); + } catch (err) { + console.error('[SettingsModal]', err); + setCheckResult({ valid: false, reason: (err as Error).message }); + } finally { + setChecking(false); + } + } + }; + + const handleApply = async () => { + if (!pendingPath || !checkResult?.valid) return; + setApplying(true); + try { + // 如果勾选了继承文件,从旧工作空间复制到新工作空间 + const filesToInherit: string[] = []; + if (inheritSoul) filesToInherit.push('SOUL.md'); + if (inheritAgents) filesToInherit.push('AGENTS.md'); + if (inheritUsers) filesToInherit.push('USERS.md'); + + if (filesToInherit.length > 0 && currentPath) { + try { + await window.metona.workspace.inheritFiles({ + targetPath: pendingPath, + sourcePath: currentPath, + files: filesToInherit, + }); + } catch (err) { + console.error('[SettingsModal] Inherit failed:', err); + } + } + + // 保存新路径到配置 + setWorkspacePath(pendingPath); + // 弹出重启确认对话框 + setShowRestartDialog(true); + // 清理中间状态 + setPendingPath(null); + setCheckResult(null); + } catch (err) { + console.error('[SettingsModal]', err); + } finally { + setApplying(false); + } + }; + + const handleCancel = () => { + setPendingPath(null); + setCheckResult(null); }; const handleOpen = () => { if (workspacePath) window.metona?.app?.showItemInFolder(workspacePath); }; @@ -94,7 +160,108 @@ function WorkspaceSettings() { 📂 在文件管理器中打开 )} + + {/* 校验中状态 */} + {checking && ( + + + 正在校验工作空间... + + )} + + {/* 校验失败 */} + {pendingPath && checkResult && !checkResult.valid && ( + + 路径无效:{checkResult.reason} + + + + + )} + + {/* 校验成功 — 显示工作空间状态 + 继承选项 */} + {pendingPath && checkResult?.valid && ( + + + 目标工作空间:{checkResult.path} + + + {checkResult.isNewWorkspace ? ( + + 新工作空间 — 切换后将自动创建 4 个必需文件(SOUL.md、AGENTS.md、MEMORY.md、USERS.md) + + ) : checkResult.missingFiles && checkResult.missingFiles.length > 0 ? ( + + 已有目录但缺少 {checkResult.missingFiles.length} 个文件:{checkResult.missingFiles.join(', ')}。缺失文件将自动创建。 + + ) : ( + + 已有工作空间 — 4 个必需文件均已就绪,将直接加载现有配置。 + + )} + + {/* 继承选项(仅当当前有工作空间且新工作空间需要创建文件时显示) */} + {currentPath && currentPath !== pendingPath && (checkResult.isNewWorkspace || (checkResult.missingFiles && checkResult.missingFiles.length > 0)) && ( + + + 从当前工作空间继承基础设置: + + setInheritSoul(e.target.checked)} />} + label={SOUL.md(身份与角色定义)} + /> + setInheritAgents(e.target.checked)} />} + label={AGENTS.md(行为规则)} + /> + setInheritUsers(e.target.checked)} />} + label={USERS.md(用户画像)} + /> + + MEMORY.md 不继承(记忆与工作空间项目上下文绑定) + + + )} + + + + + + + )} + 修改工作空间路径后需重启应用生效。 + + {/* 重启确认对话框 */} + setShowRestartDialog(false)} maxWidth="xs" fullWidth> + 工作空间已切换 + + + 工作空间已更新为: + + + {workspacePath} + + + 需要重启应用以加载新工作空间的配置和文件。是否立即重启? + + + + + + + ); } diff --git a/src/types/global.d.ts b/src/types/global.d.ts index d86d4d7..f14afb4 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -172,6 +172,31 @@ interface MetonaAppAPI { openExternal: (url: string) => Promise; showItemInFolder: (path: string) => Promise; selectFolder: (defaultPath?: string) => Promise<{ canceled: boolean; path: string }>; + restart: () => Promise<{ success: boolean }>; +} + +// ===== Workspace API ===== + +interface MetonaWorkspaceCheckResult { + valid: boolean; + path?: string; + exists?: boolean; + missingFiles?: string[]; + isNewWorkspace?: boolean; + reason?: string; +} + +interface MetonaWorkspaceInheritResult { + success: boolean; + inherited?: string[]; + failed?: Array<{ file: string; error: string }>; + error?: string; +} + +interface MetonaWorkspaceAPI { + check: (targetPath: string) => Promise; + inheritFiles: (params: { targetPath: string; sourcePath: string; files: string[] }) => + Promise; } // ===== Toast API ===== @@ -312,6 +337,7 @@ interface MetonaBridge { memory: MetonaMemoryAPI; config: MetonaConfigAPI; app: MetonaAppAPI; + workspace: MetonaWorkspaceAPI; toast: MetonaToastAPI; tools: MetonaToolsAPI; data: MetonaDataAPI;