feat: 升级至 v0.3.16 — 工单修复 51 项 + 审查修复 30 项(安全加固/适配器/工具系统/数据层/前端)
This commit is contained in:
@@ -62,6 +62,8 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
const [remainingMs, setRemainingMs] = useState<number>(0);
|
||||
// 记录首次接收时的剩余时间,作为进度条总量(固定不变)
|
||||
const [initialMs, setInitialMs] = useState<number>(0);
|
||||
// #40 修复: autoExecute 永久自动执行风险高,勾选时弹出二次确认避免误点击
|
||||
const [confirmAutoExecute, setConfirmAutoExecute] = useState(false);
|
||||
|
||||
// ===== 弹框打开时主动拉取已积压的 pending 请求 =====
|
||||
// 解决:并行工具触发的多个 IPC 事件可能在本组件 mount 前已到达,
|
||||
@@ -291,12 +293,16 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
: requests[0]?.reason ?? 'Agent 正在请求执行工具';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={requests.length > 0}
|
||||
onClose={(_, reason) => {
|
||||
// 超时时禁止通过外部点击/ESC 关闭(与"拒绝全部"按钮 disabled 一致)
|
||||
// 防止超时后用户误触关闭,导致后端 pending 状态不一致
|
||||
if (isExpired) return;
|
||||
// 审查修复: 内层二次确认 Dialog 打开时,外层禁用 ESC/backdrop 关闭
|
||||
// 防止 ESC 穿透到外层导致意外拒绝所有工具执行
|
||||
if (confirmAutoExecute) return;
|
||||
// 只允许"拒绝全部"语义的关闭方式(点击外部 / ESC)
|
||||
// reason: 'backdropClick' | 'escapeKeyDown' | 'closeButtonClick'
|
||||
if (reason === 'backdropClick' || reason === 'escapeKeyDown') {
|
||||
@@ -305,6 +311,7 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
}}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
disableEscapeKeyDown={confirmAutoExecute}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: { bgcolor: 'background.paper' },
|
||||
@@ -520,9 +527,12 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
<Checkbox
|
||||
checked={autoExecute}
|
||||
onChange={(e) => {
|
||||
setAutoExecute(e.target.checked);
|
||||
// 勾选永久自动执行时,自动勾选会话内记忆(保持一致)
|
||||
if (e.target.checked) setRemember(true);
|
||||
// #40 修复: autoExecute 永久自动执行风险高,勾选时弹出二次确认避免误点击
|
||||
if (e.target.checked) {
|
||||
setConfirmAutoExecute(true);
|
||||
} else {
|
||||
setAutoExecute(false);
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
@@ -576,5 +586,42 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* #40 修复: autoExecute 二次确认 Dialog — 避免误点击导致永久自动执行 */}
|
||||
{/* 审查修复: disableEscapeKeyDown + onKeyDown stopPropagation 防止 ESC 事件穿透到外层 Dialog */}
|
||||
<Dialog
|
||||
open={confirmAutoExecute}
|
||||
onClose={() => setConfirmAutoExecute(false)}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
disableEscapeKeyDown
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DialogTitle sx={{ fontSize: 14 }}>确认永久自动执行?</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
勾选后,选中的工具将永久自动执行(跨会话不再询问),包括未来的潜在危险操作。此设置可在「设置 → 工具管理」中关闭。
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1, color: 'warning.main', fontWeight: 600 }}>
|
||||
确定要启用吗?
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmAutoExecute(false)} color="inherit">取消</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setAutoExecute(true);
|
||||
// 勾选永久自动执行时,自动勾选会话内记忆(保持一致)
|
||||
setRemember(true);
|
||||
setConfirmAutoExecute(false);
|
||||
}}
|
||||
color="warning"
|
||||
variant="contained"
|
||||
>
|
||||
确认自动执行
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,13 +9,10 @@
|
||||
*/
|
||||
|
||||
import { memo } from 'react';
|
||||
import { Box, Typography, Stack } from '@mui/material';
|
||||
import { Terminal } from 'lucide-react';
|
||||
import type { ChatMessage } from '@renderer/stores/agent-store';
|
||||
import { UserMessage } from './UserMessage';
|
||||
import { AssistantMessage } from './AssistantMessage';
|
||||
import { SystemMessage } from './SystemMessage';
|
||||
import { formatTime } from '@renderer/lib/formatters';
|
||||
|
||||
interface MessageItemProps {
|
||||
message: ChatMessage;
|
||||
@@ -23,7 +20,7 @@ interface MessageItemProps {
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
function MessageItemImpl({ message, isLast, isStreaming }: MessageItemProps): React.JSX.Element {
|
||||
function MessageItemImpl({ message, isLast, isStreaming }: MessageItemProps): React.JSX.Element | null {
|
||||
switch (message.role) {
|
||||
case 'user':
|
||||
return <UserMessage message={message} />;
|
||||
@@ -37,8 +34,11 @@ function MessageItemImpl({ message, isLast, isStreaming }: MessageItemProps): Re
|
||||
);
|
||||
|
||||
case 'tool':
|
||||
// 工具消息渲染为独立的结果块
|
||||
return <ToolMessage message={message} />;
|
||||
// #38 修复: 与 AgentStore.setCurrentSession 的过滤逻辑保持一致,
|
||||
// 不在 UI 中渲染独立的 tool 消息卡片。
|
||||
// tool 结果已包含在 assistant 消息的 toolCalls 中(见 AssistantMessage),
|
||||
// 独立的 tool 消息仅用于 LLM API 上下文,不需要在前端显示为独立卡片。
|
||||
return null;
|
||||
|
||||
case 'system':
|
||||
default:
|
||||
@@ -53,51 +53,3 @@ function MessageItemImpl({ message, isLast, isStreaming }: MessageItemProps): Re
|
||||
* - isLast 是 boolean,仅在最后一条切换时变化
|
||||
*/
|
||||
export const MessageItem = memo(MessageItemImpl);
|
||||
|
||||
/** ToolMessage — 独立的工具结果消息(role=tool) */
|
||||
function ToolMessage({ message }: { message: ChatMessage }): React.JSX.Element {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 1.5,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderLeft: '3px solid',
|
||||
borderLeftColor: '#22d3ee',
|
||||
bgcolor: 'secondary.main',
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
mb: 2,
|
||||
animation: 'fadeInUp 300ms ease-out',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 0.5, alignItems: 'center' }}>
|
||||
<Terminal size={12} style={{ color: '#22d3ee' }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 500, color: 'text.primary' }}>
|
||||
工具输出
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled' }}>
|
||||
{formatTime(message.timestamp)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
fontSize: 11,
|
||||
borderRadius: 1,
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
overflowX: 'auto',
|
||||
bgcolor: 'background.default',
|
||||
color: 'text.secondary',
|
||||
fontFamily: "'SF Mono',monospace",
|
||||
maxHeight: 120,
|
||||
m: 0,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{message.content.length > 500 ? message.content.slice(0, 500) + '\n... [截断]' : message.content}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,19 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: { componentStack: string }): void {
|
||||
// #49 修复: 上报错误到主进程,便于记录到 electron-log 与审计日志,集中排查
|
||||
// 主进程可注册 ipcMain.on('error:report', ...) 接收;未注册时 send 为静默失败,不影响兜底
|
||||
try {
|
||||
window.metona?.app?.reportError({
|
||||
type: 'react_error_boundary',
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
componentStack: info.componentStack,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
// 上报失败不影响兜底逻辑
|
||||
}
|
||||
console.error('[ErrorBoundary]', error, info.componentStack);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* OnboardingWizard — 首次使用引导向导
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, Button, TextField, Select, MenuItem, Stepper, Step, StepLabel, Box, Typography, Stack, FormControl, InputLabel, IconButton, InputAdornment } from '@mui/material';
|
||||
import { ArrowRight, ArrowLeft, FolderOpen, Play, CheckCircle, Eye, EyeOff } from 'lucide-react';
|
||||
import { useUIStore } from '@renderer/stores/ui-store';
|
||||
@@ -10,6 +10,9 @@ import { useAgentStore } from '@renderer/stores/agent-store';
|
||||
|
||||
const STEPS = ['欢迎', '配置 LLM', '自定义 Agent', '工作空间', '开始使用'];
|
||||
|
||||
// #48 修复: Onboarding 进度持久化 key
|
||||
const ONBOARDING_PROGRESS_KEY = 'onboarding_progress';
|
||||
|
||||
export function OnboardingWizard(): React.JSX.Element | null {
|
||||
const onboardingCompleted = useUIStore((s) => s.onboardingCompleted);
|
||||
const setOnboardingCompleted = useUIStore((s) => s.setOnboardingCompleted);
|
||||
@@ -23,6 +26,46 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
||||
// 上下文窗口(联动 Provider:ollama 可空=由模型决定;其他 provider 默认值见 DEFAULT_CTX)
|
||||
const [contextWindow, setContextWindow] = useState<number | null>(null);
|
||||
|
||||
// #48 修复: 启动时从 localStorage 恢复未完成的引导进度,避免中途退出后需重新填写
|
||||
// 注意: apiKey 属于敏感信息,不持久化到 localStorage,恢复时需用户重新输入
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(ONBOARDING_PROGRESS_KEY);
|
||||
if (!saved) return;
|
||||
const p = JSON.parse(saved) as {
|
||||
step?: number; provider?: string; baseURL?: string;
|
||||
model?: string; workspacePath?: string;
|
||||
contextWindow?: number | null;
|
||||
};
|
||||
if (typeof p.step === 'number' && p.step >= 0 && p.step < STEPS.length) setStep(p.step);
|
||||
if (typeof p.provider === 'string') setProvider(p.provider);
|
||||
if (typeof p.baseURL === 'string') setBaseURL(p.baseURL);
|
||||
if (typeof p.model === 'string') setModel(p.model);
|
||||
if (typeof p.workspacePath === 'string') setWorkspacePath(p.workspacePath);
|
||||
if (p.contextWindow !== undefined) setContextWindow(p.contextWindow);
|
||||
// 审查修复: 恢复后如果 provider 需要 apiKey 但 apiKey 为空(未持久化),
|
||||
// 回退到步骤 1(配置 LLM)让用户重新输入 apiKey,避免配置不完整
|
||||
// ollama 不需要 apiKey
|
||||
if (p.provider && p.provider !== 'ollama' && p.step && p.step >= 2) {
|
||||
setStep(1);
|
||||
}
|
||||
} catch {
|
||||
// 解析失败忽略,使用默认值
|
||||
}
|
||||
}, []);
|
||||
|
||||
// #48 修复: 每步完成后保存进度到 localStorage,中途退出(关闭窗口/刷新)可恢复
|
||||
// 注意: 不保存 apiKey(敏感信息不写入 localStorage)
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(ONBOARDING_PROGRESS_KEY, JSON.stringify({
|
||||
step, provider, baseURL, model, workspacePath, contextWindow,
|
||||
}));
|
||||
} catch {
|
||||
// 写入失败(如隐私模式)忽略
|
||||
}
|
||||
}, [step, provider, baseURL, model, workspacePath, contextWindow]);
|
||||
|
||||
if (onboardingCompleted) return null;
|
||||
|
||||
// 各 Provider 上下文窗口默认值(与 SettingsModal 保持一致)
|
||||
@@ -79,6 +122,8 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
||||
useAgentStore.getState().setConfigLoaded(true);
|
||||
}
|
||||
setOnboardingCompleted(true);
|
||||
// #48 修复: 引导完成后清理 localStorage 进度,下次启动不再恢复
|
||||
try { localStorage.removeItem(ONBOARDING_PROGRESS_KEY); } catch { /* ignore */ }
|
||||
} catch (err) {
|
||||
console.error('[OnboardingWizard]', 'Failed to save configuration:', err);
|
||||
// 用户主动操作失败必须有反馈,否则向导不关闭、用户卡死
|
||||
|
||||
@@ -154,6 +154,28 @@ function WorkspaceSettings() {
|
||||
if (inheritSoul) filesToInherit.push('SOUL.md');
|
||||
if (inheritDatabase) filesToInherit.push('.metona/agent.db');
|
||||
|
||||
// #51 修复: 继承数据库前校验源数据库完整性,避免继承损坏的数据库导致新工作空间数据丢失
|
||||
if (inheritDatabase && currentPath) {
|
||||
try {
|
||||
const integrityResult = await window.metona?.workspace?.checkDatabaseIntegrity(currentPath);
|
||||
if (!integrityResult?.success) {
|
||||
import('metona-toast').then((mod) => mod.default.error(`源数据库校验失败:${integrityResult?.error ?? '未知错误'}`)).catch(() => {});
|
||||
setApplying(false);
|
||||
return;
|
||||
}
|
||||
if (!integrityResult.ok) {
|
||||
import('metona-toast').then((mod) => mod.default.error(`源数据库损坏(${integrityResult.detail}),无法继承`)).catch(() => {});
|
||||
setApplying(false);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[SettingsModal] Database integrity check failed:', err);
|
||||
import('metona-toast').then((mod) => mod.default.error(`源数据库校验异常:${(err as Error).message}`)).catch(() => {});
|
||||
setApplying(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (filesToInherit.length > 0 && currentPath) {
|
||||
try {
|
||||
await window.metona.workspace.inheritFiles({
|
||||
|
||||
@@ -36,6 +36,16 @@ function matchModifier(event: KeyboardEvent, ctrl?: boolean, shift?: boolean): b
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* #39 修复: 检测键盘事件目标是否为输入框(input/textarea/contentEditable)
|
||||
* 用于在输入框聚焦时屏蔽单字符快捷键,避免拦截用户正常输入
|
||||
*/
|
||||
function isInputTarget(e: KeyboardEvent): boolean {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) return false;
|
||||
return ['INPUT', 'TEXTAREA'].includes(target.tagName) || target.isContentEditable;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局快捷键 Hook
|
||||
*
|
||||
@@ -46,6 +56,12 @@ export function useKeyboardShortcuts(): void {
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
const key = e.key.toLowerCase();
|
||||
|
||||
// #39 修复: 输入框聚焦时,单字符无修饰键直接放行,避免拦截用户输入
|
||||
// Ctrl/Cmd 组合键不受影响(如 Ctrl+N/L/D 等仍可触发)
|
||||
if (isInputTarget(e) && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Esc — 关闭弹窗
|
||||
if (key === 'escape') {
|
||||
const { settingsOpen, closeSettings } = useUIStore.getState();
|
||||
|
||||
Vendored
+8
@@ -175,6 +175,7 @@ interface MetonaAppAPI {
|
||||
showItemInFolder: (path: string) => Promise<{ success: boolean; error?: string }>;
|
||||
selectFolder: (defaultPath?: string) => Promise<{ canceled: boolean; path: string }>;
|
||||
restart: () => Promise<{ success: boolean }>;
|
||||
reportError: (payload: unknown) => void;
|
||||
}
|
||||
|
||||
// ===== Workspace API =====
|
||||
@@ -222,6 +223,13 @@ interface MetonaWorkspaceAPI {
|
||||
inheritFiles: (params: { targetPath: string; sourcePath: string; files: string[] }) =>
|
||||
Promise<MetonaWorkspaceInheritResult>;
|
||||
getInfo: () => Promise<MetonaWorkspaceInfo>;
|
||||
// #51 修复: 校验源数据库完整性(PRAGMA integrity_check)
|
||||
checkDatabaseIntegrity: (sourcePath: string) => Promise<{
|
||||
success: boolean;
|
||||
ok?: boolean;
|
||||
detail?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ===== Toast API =====
|
||||
|
||||
Reference in New Issue
Block a user