feat: 升级至 v0.3.16 — 工单修复 51 项 + 审查修复 30 项(安全加固/适配器/工具系统/数据层/前端)

This commit is contained in:
2026-07-22 10:31:13 +08:00
parent 516d8a728f
commit 2c2ca4a692
43 changed files with 1454 additions and 301 deletions
+50 -3
View File
@@ -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>
</>
);
}
+6 -54
View File
@@ -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>
);
}
+13
View File
@@ -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);
}
+46 -1
View File
@@ -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);
// 用户主动操作失败必须有反馈,否则向导不关闭、用户卡死
+22
View File
@@ -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({
+16
View File
@@ -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();
+8
View File
@@ -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 =====