feat: 工作空间切换校验 + 初始化检测 + 继承基础设置
新增功能: - 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 响应先返回
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ipcMain, BrowserWindow, shell, app, dialog } from 'electron';
|
import { ipcMain, BrowserWindow, shell, app, dialog } from 'electron';
|
||||||
|
import { join } from 'path';
|
||||||
import type { SessionService } from '../services/session.service';
|
import type { SessionService } from '../services/session.service';
|
||||||
import type { ConfigService } from '../services/config.service';
|
import type { ConfigService } from '../services/config.service';
|
||||||
import type { WorkspaceService } from '../services/workspace.service';
|
import type { WorkspaceService } from '../services/workspace.service';
|
||||||
@@ -551,6 +552,117 @@ export function registerAllIPCHandlers(
|
|||||||
return { canceled: false, path: result.filePaths[0] };
|
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 () => {
|
ipcMain.handle('tools:list', async () => {
|
||||||
|
|||||||
@@ -114,6 +114,14 @@ const metonaAPI = {
|
|||||||
openExternal: (url: string) => ipcRenderer.invoke('app:openExternal', url),
|
openExternal: (url: string) => ipcRenderer.invoke('app:openExternal', url),
|
||||||
showItemInFolder: (path: string) => ipcRenderer.invoke('app:showItemInFolder', path),
|
showItemInFolder: (path: string) => ipcRenderer.invoke('app:showItemInFolder', path),
|
||||||
selectFolder: (defaultPath?: string) => ipcRenderer.invoke('app:selectFolder', defaultPath),
|
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),
|
||||||
},
|
},
|
||||||
|
|
||||||
// ===== 工具管理 =====
|
// ===== 工具管理 =====
|
||||||
|
|||||||
@@ -72,11 +72,77 @@ const PROVIDER_URLS: Record<string, string> = { deepseek: 'https://api.deepseek.
|
|||||||
|
|
||||||
function WorkspaceSettings() {
|
function WorkspaceSettings() {
|
||||||
const [workspacePath, setWorkspacePath] = useConfig('workspace.path', '');
|
const [workspacePath, setWorkspacePath] = useConfig('workspace.path', '');
|
||||||
|
// 切换工作空间的中间状态
|
||||||
|
const [pendingPath, setPendingPath] = useState<string | null>(null);
|
||||||
|
const [checkResult, setCheckResult] = useState<MetonaWorkspaceCheckResult | null>(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 () => {
|
const handleSelect = async () => {
|
||||||
if (!window.metona?.app?.selectFolder) return;
|
if (!window.metona?.app?.selectFolder) return;
|
||||||
const r = await window.metona.app.selectFolder(workspacePath || undefined);
|
const r = await window.metona.app.selectFolder(currentPath || undefined);
|
||||||
if (!r.canceled && r.path) setWorkspacePath(r.path);
|
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); };
|
const handleOpen = () => { if (workspacePath) window.metona?.app?.showItemInFolder(workspacePath); };
|
||||||
@@ -94,7 +160,108 @@ function WorkspaceSettings() {
|
|||||||
📂 在文件管理器中打开
|
📂 在文件管理器中打开
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 校验中状态 */}
|
||||||
|
{checking && (
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', py: 1 }}>
|
||||||
|
<CircularProgress size={14} />
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>正在校验工作空间...</Typography>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 校验失败 */}
|
||||||
|
{pendingPath && checkResult && !checkResult.valid && (
|
||||||
|
<Alert severity="error" sx={{ py: 0.5 }}>
|
||||||
|
<Typography variant="caption">路径无效:{checkResult.reason}</Typography>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||||
|
<Button size="small" onClick={handleCancel}>取消</Button>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 校验成功 — 显示工作空间状态 + 继承选项 */}
|
||||||
|
{pendingPath && checkResult?.valid && (
|
||||||
|
<Stack spacing={1.5} sx={{ p: 1.5, borderRadius: 1.5, bgcolor: 'background.default', border: '1px solid', borderColor: 'divider' }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.primary' }}>
|
||||||
|
目标工作空间:{checkResult.path}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{checkResult.isNewWorkspace ? (
|
||||||
|
<Alert severity="info" sx={{ py: 0.5, '& .MuiAlert-message': { fontSize: 12 } }}>
|
||||||
|
新工作空间 — 切换后将自动创建 4 个必需文件(SOUL.md、AGENTS.md、MEMORY.md、USERS.md)
|
||||||
|
</Alert>
|
||||||
|
) : checkResult.missingFiles && checkResult.missingFiles.length > 0 ? (
|
||||||
|
<Alert severity="warning" sx={{ py: 0.5, '& .MuiAlert-message': { fontSize: 12 } }}>
|
||||||
|
已有目录但缺少 {checkResult.missingFiles.length} 个文件:{checkResult.missingFiles.join(', ')}。缺失文件将自动创建。
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Alert severity="success" sx={{ py: 0.5, '& .MuiAlert-message': { fontSize: 12 } }}>
|
||||||
|
已有工作空间 — 4 个必需文件均已就绪,将直接加载现有配置。
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 继承选项(仅当当前有工作空间且新工作空间需要创建文件时显示) */}
|
||||||
|
{currentPath && currentPath !== pendingPath && (checkResult.isNewWorkspace || (checkResult.missingFiles && checkResult.missingFiles.length > 0)) && (
|
||||||
|
<Stack spacing={0.5} sx={{ mt: 0.5 }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||||
|
从当前工作空间继承基础设置:
|
||||||
|
</Typography>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox size="small" checked={inheritSoul} onChange={(e) => setInheritSoul(e.target.checked)} />}
|
||||||
|
label={<Typography variant="caption">SOUL.md(身份与角色定义)</Typography>}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox size="small" checked={inheritAgents} onChange={(e) => setInheritAgents(e.target.checked)} />}
|
||||||
|
label={<Typography variant="caption">AGENTS.md(行为规则)</Typography>}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox size="small" checked={inheritUsers} onChange={(e) => setInheritUsers(e.target.checked)} />}
|
||||||
|
label={<Typography variant="caption">USERS.md(用户画像)</Typography>}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10, pl: 3 }}>
|
||||||
|
MEMORY.md 不继承(记忆与工作空间项目上下文绑定)
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={applying}
|
||||||
|
startIcon={applying ? <CircularProgress size={12} /> : undefined}
|
||||||
|
>
|
||||||
|
{applying ? '应用中...' : '确认切换'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" size="small" onClick={handleCancel} disabled={applying}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
<Typography variant="caption" sx={{ color: 'text.disabled' }}>修改工作空间路径后需重启应用生效。</Typography>
|
<Typography variant="caption" sx={{ color: 'text.disabled' }}>修改工作空间路径后需重启应用生效。</Typography>
|
||||||
|
|
||||||
|
{/* 重启确认对话框 */}
|
||||||
|
<Dialog open={showRestartDialog} onClose={() => setShowRestartDialog(false)} maxWidth="xs" fullWidth>
|
||||||
|
<DialogTitle sx={{ fontSize: 14 }}>工作空间已切换</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
工作空间已更新为:
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12, mt: 0.5, p: 1, borderRadius: 1, bgcolor: 'background.default' }}>
|
||||||
|
{workspacePath}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mt: 1.5, color: 'text.secondary' }}>
|
||||||
|
需要重启应用以加载新工作空间的配置和文件。是否立即重启?
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button size="small" onClick={() => setShowRestartDialog(false)}>稍后手动重启</Button>
|
||||||
|
<Button size="small" variant="contained" color="primary" onClick={() => window.metona?.app?.restart()}>立即重启</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+26
@@ -172,6 +172,31 @@ interface MetonaAppAPI {
|
|||||||
openExternal: (url: string) => Promise<void>;
|
openExternal: (url: string) => Promise<void>;
|
||||||
showItemInFolder: (path: string) => Promise<void>;
|
showItemInFolder: (path: string) => Promise<void>;
|
||||||
selectFolder: (defaultPath?: string) => Promise<{ canceled: boolean; path: string }>;
|
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<MetonaWorkspaceCheckResult>;
|
||||||
|
inheritFiles: (params: { targetPath: string; sourcePath: string; files: string[] }) =>
|
||||||
|
Promise<MetonaWorkspaceInheritResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Toast API =====
|
// ===== Toast API =====
|
||||||
@@ -312,6 +337,7 @@ interface MetonaBridge {
|
|||||||
memory: MetonaMemoryAPI;
|
memory: MetonaMemoryAPI;
|
||||||
config: MetonaConfigAPI;
|
config: MetonaConfigAPI;
|
||||||
app: MetonaAppAPI;
|
app: MetonaAppAPI;
|
||||||
|
workspace: MetonaWorkspaceAPI;
|
||||||
toast: MetonaToastAPI;
|
toast: MetonaToastAPI;
|
||||||
tools: MetonaToolsAPI;
|
tools: MetonaToolsAPI;
|
||||||
data: MetonaDataAPI;
|
data: MetonaDataAPI;
|
||||||
|
|||||||
Reference in New Issue
Block a user