feat: 升级至 v0.3.8 — Trace Viewer 叠加修复 + LLM 配置校验改造 + toast 反馈统一
- Trace Viewer 叠加渲染修复 - sendMessage 补 traceSteps/tokenUsage 重置(止血) - TraceStep 加 runId 字段,useAgentStream 按 runId 判定同迭代(稳健改造) - LLM 配置校验中等改造(SettingsModal LLMSettings) - 取消 onChange 实时落库,改为本地 state + Save 按钮统一提交 - 字段级 inline error:Base URL http(s):// 正则、Model 空格校验、contextWindow ≥ 4096 - Provider 切换清空 Model(之前只清 apiKey) - 保存结果改用 metona-toast(不再用 Alert) - toast 反馈机制全面统一(15 处问题修复) - ContextMenu: 原生 confirm/prompt 改 MUI Dialog(新增 useDialogStore + ContextMenuDialogHost) - ContextMenu: 抽 copyWithToast helper 替代散落 7 处静默失败 - OnboardingWizard: 配置保存失败静默改 toast.error - useKeyboardShortcuts: Ctrl+N / Ctrl+Shift+C 失败补 toast - SettingsModal: inheritFiles 失败 toast.warning、handleApply 失败 toast.error - LogsSettings: resultAlert Alert 改 toast(保留 Dialog 确认) - MemoryViewer: 删除失败改 toast(加载/搜索保留 Alert) - TaskList: create/update/delete/无会话改 toast - agent-store: sendMessage/session 失败补 toast(保留 system message) - 删除当前会话同步清空 ChatPanel/DetailPanel - Sidebar confirmDelete + ContextMenu delete 均补 agent-store 重置 - 修复 useSessionStore.removeSession 不触发 agent-store 同步的遗漏
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 生产级通用 AI Agent 智能体桌面应用
|
> 生产级通用 AI Agent 智能体桌面应用
|
||||||
|
|
||||||
[](./package.json)
|
[](./package.json)
|
||||||
[](./LICENSE)
|
[](./LICENSE)
|
||||||
[](https://www.electronjs.org/)
|
[](https://www.electronjs.org/)
|
||||||
[](https://react.dev/)
|
[](https://react.dev/)
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.3.7",
|
"version": "0.3.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.3.7",
|
"version": "0.3.8",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.3.7",
|
"version": "0.3.8",
|
||||||
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
|
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
|
||||||
"main": "dist-electron/main/main.js",
|
"main": "dist-electron/main/main.js",
|
||||||
"author": "Metona Team",
|
"author": "Metona Team",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { OnboardingWizard } from '@renderer/components/onboarding/OnboardingWiza
|
|||||||
import { CommandPalette } from '@renderer/components/CommandPalette';
|
import { CommandPalette } from '@renderer/components/CommandPalette';
|
||||||
import { ToastContainer } from '@renderer/components/ToastContainer';
|
import { ToastContainer } from '@renderer/components/ToastContainer';
|
||||||
import { ConfirmationDialog } from '@renderer/components/ConfirmationDialog';
|
import { ConfirmationDialog } from '@renderer/components/ConfirmationDialog';
|
||||||
|
import { ContextMenuDialogHost } from '@renderer/components/ContextMenu';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useKeyboardShortcuts } from '@renderer/hooks/useKeyboardShortcuts';
|
import { useKeyboardShortcuts } from '@renderer/hooks/useKeyboardShortcuts';
|
||||||
import { useAgentStream } from '@renderer/hooks/useAgentStream';
|
import { useAgentStream } from '@renderer/hooks/useAgentStream';
|
||||||
@@ -81,6 +82,7 @@ export default function App(): React.JSX.Element {
|
|||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
<ConfirmationDialog />
|
<ConfirmationDialog />
|
||||||
|
<ContextMenuDialogHost />
|
||||||
</div>
|
</div>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
+152
-13
@@ -6,8 +6,13 @@
|
|||||||
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 右键菜单设计
|
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 右键菜单设计
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Menu, MenuItem, ListItemIcon, ListItemText } from '@mui/material';
|
import {
|
||||||
|
Menu, MenuItem, ListItemIcon, ListItemText,
|
||||||
|
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||||
|
Button, TextField, Typography,
|
||||||
|
} from '@mui/material';
|
||||||
import { Copy, Quote, Edit, Trash2, RotateCcw, Eye, Code, ExternalLink, Pin, Archive, FileDown } from 'lucide-react';
|
import { Copy, Quote, Edit, Trash2, RotateCcw, Eye, Code, ExternalLink, Pin, Archive, FileDown } from 'lucide-react';
|
||||||
|
import { create } from 'zustand';
|
||||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||||
import { useSessionStore } from '@renderer/stores/session-store';
|
import { useSessionStore } from '@renderer/stores/session-store';
|
||||||
|
|
||||||
@@ -44,6 +49,126 @@ export function ContextMenu({ x, y, onClose, items }: ContextMenuProps): React.J
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用 Dialog Store — 模块级状态,供 createContextMenuItems 等工厂函数调用
|
||||||
|
*
|
||||||
|
* 替代原生 confirm()/prompt()(Electron 下不可靠,且与 MUI 风格不一致)。
|
||||||
|
* 由 ContextMenuDialogHost 组件渲染实际的 MUI Dialog。
|
||||||
|
*/
|
||||||
|
interface DialogState {
|
||||||
|
// confirm dialog
|
||||||
|
confirmOpen: boolean;
|
||||||
|
confirmTitle: string;
|
||||||
|
confirmMessage: string;
|
||||||
|
confirmResolve: ((ok: boolean) => void) | null;
|
||||||
|
// prompt dialog
|
||||||
|
promptOpen: boolean;
|
||||||
|
promptTitle: string;
|
||||||
|
promptLabel: string;
|
||||||
|
promptValue: string;
|
||||||
|
promptResolve: ((value: string | null) => void) | null;
|
||||||
|
// actions
|
||||||
|
confirm: (message: string, title?: string) => Promise<boolean>;
|
||||||
|
prompt: (label: string, defaultValue?: string, title?: string) => Promise<string | null>;
|
||||||
|
setPromptValue: (v: string) => void;
|
||||||
|
closeConfirm: (ok: boolean) => void;
|
||||||
|
closePrompt: (value: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useDialogStore = create<DialogState>((set, get) => ({
|
||||||
|
confirmOpen: false,
|
||||||
|
confirmTitle: '',
|
||||||
|
confirmMessage: '',
|
||||||
|
confirmResolve: null,
|
||||||
|
promptOpen: false,
|
||||||
|
promptTitle: '',
|
||||||
|
promptLabel: '',
|
||||||
|
promptValue: '',
|
||||||
|
promptResolve: null,
|
||||||
|
confirm: (message, title = '确认') => new Promise<boolean>((resolve) => {
|
||||||
|
set({ confirmOpen: true, confirmTitle: title, confirmMessage: message, confirmResolve: resolve });
|
||||||
|
}),
|
||||||
|
prompt: (label, defaultValue = '', title = '输入') => new Promise<string | null>((resolve) => {
|
||||||
|
set({ promptOpen: true, promptTitle: title, promptLabel: label, promptValue: defaultValue, promptResolve: resolve });
|
||||||
|
}),
|
||||||
|
setPromptValue: (v) => set({ promptValue: v }),
|
||||||
|
closeConfirm: (ok) => {
|
||||||
|
const { confirmResolve } = get();
|
||||||
|
confirmResolve?.(ok);
|
||||||
|
set({ confirmOpen: false, confirmResolve: null, confirmTitle: '', confirmMessage: '' });
|
||||||
|
},
|
||||||
|
closePrompt: (value) => {
|
||||||
|
const { promptResolve } = get();
|
||||||
|
promptResolve?.(value);
|
||||||
|
set({ promptOpen: false, promptResolve: null, promptTitle: '', promptLabel: '', promptValue: '' });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ContextMenuDialogHost — 渲染通用 confirm/prompt Dialog
|
||||||
|
*
|
||||||
|
* 在 App 根组件挂载一次即可。由 useDialogStore 驱动显示。
|
||||||
|
*/
|
||||||
|
export function ContextMenuDialogHost(): React.JSX.Element {
|
||||||
|
const confirmOpen = useDialogStore((s) => s.confirmOpen);
|
||||||
|
const confirmTitle = useDialogStore((s) => s.confirmTitle);
|
||||||
|
const confirmMessage = useDialogStore((s) => s.confirmMessage);
|
||||||
|
const closeConfirm = useDialogStore((s) => s.closeConfirm);
|
||||||
|
|
||||||
|
const promptOpen = useDialogStore((s) => s.promptOpen);
|
||||||
|
const promptTitle = useDialogStore((s) => s.promptTitle);
|
||||||
|
const promptLabel = useDialogStore((s) => s.promptLabel);
|
||||||
|
const promptValue = useDialogStore((s) => s.promptValue);
|
||||||
|
const setPromptValue = useDialogStore((s) => s.setPromptValue);
|
||||||
|
const closePrompt = useDialogStore((s) => s.closePrompt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={confirmOpen} onClose={() => closeConfirm(false)} maxWidth="xs" fullWidth>
|
||||||
|
<DialogTitle sx={{ fontSize: 14 }}>{confirmTitle}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography variant="body2">{confirmMessage}</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => closeConfirm(false)} color="inherit" size="small">取消</Button>
|
||||||
|
<Button onClick={() => closeConfirm(true)} variant="contained" color="error" size="small" autoFocus>确定</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
<Dialog open={promptOpen} onClose={() => closePrompt(null)} maxWidth="xs" fullWidth>
|
||||||
|
<DialogTitle sx={{ fontSize: 14 }}>{promptTitle}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
label={promptLabel}
|
||||||
|
value={promptValue}
|
||||||
|
onChange={(e) => setPromptValue(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') closePrompt(promptValue); }}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => closePrompt(null)} color="inherit" size="small">取消</Button>
|
||||||
|
<Button onClick={() => closePrompt(promptValue)} variant="contained" size="small">确定</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* copyWithToast — 复制到剪贴板,失败时弹 toast
|
||||||
|
*
|
||||||
|
* 替代 navigator.clipboard.writeText(...).catch(err => console.error(...)) 模式。
|
||||||
|
* 复制是用户主动操作,失败时必须有反馈。
|
||||||
|
*/
|
||||||
|
function copyWithToast(text: string): void {
|
||||||
|
navigator.clipboard.writeText(text).catch((err) => {
|
||||||
|
console.error('[Clipboard]', err);
|
||||||
|
import('metona-toast').then((mod) => mod.default.error('复制失败')).catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建右键菜单项的工厂函数
|
* 创建右键菜单项的工厂函数
|
||||||
*/
|
*/
|
||||||
@@ -52,7 +177,7 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
|
|||||||
case 'message': {
|
case 'message': {
|
||||||
const content = (data as { content?: string })?.content ?? '';
|
const content = (data as { content?: string })?.content ?? '';
|
||||||
return [
|
return [
|
||||||
{ id: 'copy', icon: Copy, label: '复制', action: () => navigator.clipboard.writeText(content).catch((err) => console.error('[Clipboard]', err)) },
|
{ id: 'copy', icon: Copy, label: '复制', action: () => copyWithToast(content) },
|
||||||
{ id: 'quote', icon: Quote, label: '引用回复', action: () => {
|
{ id: 'quote', icon: Quote, label: '引用回复', action: () => {
|
||||||
const input = document.querySelector<HTMLTextAreaElement>('[data-chat-input]');
|
const input = document.querySelector<HTMLTextAreaElement>('[data-chat-input]');
|
||||||
if (input) { input.value = content.split('\n').map((l: string) => `> ${l}`).join('\n') + '\n\n'; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); }
|
if (input) { input.value = content.split('\n').map((l: string) => `> ${l}`).join('\n') + '\n\n'; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); }
|
||||||
@@ -63,9 +188,9 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
|
|||||||
case 'tool-call': {
|
case 'tool-call': {
|
||||||
const tc = data as { args?: Record<string, unknown>; result?: unknown } | undefined;
|
const tc = data as { args?: Record<string, unknown>; result?: unknown } | undefined;
|
||||||
return [
|
return [
|
||||||
{ id: 'view-params', icon: Eye, label: '查看参数', action: () => { if (tc?.args) navigator.clipboard.writeText(JSON.stringify(tc.args, null, 2)).catch((err) => console.error('[Clipboard]', err)); }},
|
{ id: 'view-params', icon: Eye, label: '查看参数', action: () => { if (tc?.args) copyWithToast(JSON.stringify(tc.args, null, 2)); }},
|
||||||
{ id: 'view-result', icon: Eye, label: '查看完整结果', action: () => { if (tc?.result) navigator.clipboard.writeText(JSON.stringify(tc.result, null, 2)).catch((err) => console.error('[Clipboard]', err)); }},
|
{ id: 'view-result', icon: Eye, label: '查看完整结果', action: () => { if (tc?.result) copyWithToast(JSON.stringify(tc.result, null, 2)); }},
|
||||||
{ id: 'copy-result', icon: Copy, label: '复制结果', action: () => { if (tc?.result) navigator.clipboard.writeText(typeof tc.result === 'string' ? tc.result : JSON.stringify(tc.result)).catch((err) => console.error('[Clipboard]', err)); }},
|
{ id: 'copy-result', icon: Copy, label: '复制结果', action: () => { if (tc?.result) copyWithToast(typeof tc.result === 'string' ? tc.result : JSON.stringify(tc.result)); }},
|
||||||
{ id: 're-execute', icon: RotateCcw, label: '重新执行', action: () => {
|
{ id: 're-execute', icon: RotateCcw, label: '重新执行', action: () => {
|
||||||
const m = [...useAgentStore.getState().messages].reverse().find((m) => m.role === 'user');
|
const m = [...useAgentStore.getState().messages].reverse().find((m) => m.role === 'user');
|
||||||
if (m) useAgentStore.getState().sendMessage(m.content);
|
if (m) useAgentStore.getState().sendMessage(m.content);
|
||||||
@@ -83,9 +208,9 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
|
|||||||
return [
|
return [
|
||||||
{ id: 'rename', icon: Edit, label: '重命名', action: async () => {
|
{ id: 'rename', icon: Edit, label: '重命名', action: async () => {
|
||||||
if (!sid) return;
|
if (!sid) return;
|
||||||
// window.prompt may be unreliable in Electron; fall back to sidebar double-click rename
|
// 用 MUI Dialog 替代原生 prompt()(Electron 下不可靠,且与 MUI 风格不一致)
|
||||||
let t: string | null = null;
|
const currentTitle = useSessionStore.getState().sessions.find((x) => x.id === sid)?.title ?? '';
|
||||||
try { t = prompt('新会话名称:'); } catch { /* prompt unavailable, user can rename via sidebar double-click */ return; }
|
const t = await useDialogStore.getState().prompt('新会话名称', currentTitle, '重命名会话');
|
||||||
if (!t?.trim()) return;
|
if (!t?.trim()) return;
|
||||||
try {
|
try {
|
||||||
const r = await window.metona?.sessions?.rename(sid, t.trim());
|
const r = await window.metona?.sessions?.rename(sid, t.trim());
|
||||||
@@ -144,12 +269,26 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
|
|||||||
}},
|
}},
|
||||||
{ id: 'delete', icon: Trash2, label: '删除', action: async () => {
|
{ id: 'delete', icon: Trash2, label: '删除', action: async () => {
|
||||||
if (!sid) return;
|
if (!sid) return;
|
||||||
// window.confirm may be unreliable in Electron
|
// 用 MUI Dialog 替代原生 confirm()(Electron 下不可靠)
|
||||||
try { if (!confirm('确定删除此会话?')) return; } catch { /* confirm unavailable */ return; }
|
// 与 Sidebar.tsx 的 showDeleteDialog 行为一致
|
||||||
|
const ok = await useDialogStore.getState().confirm('确定删除此会话?此操作不可撤销。', '删除会话');
|
||||||
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
const r = await window.metona?.sessions?.delete(sid);
|
const r = await window.metona?.sessions?.delete(sid);
|
||||||
if (r?.success) {
|
if (r?.success) {
|
||||||
useSessionStore.getState().removeSession(sid);
|
useSessionStore.getState().removeSession(sid);
|
||||||
|
// 修复: 删除当前会话时同步清空 agent-store,否则 ChatPanel 和 DetailPanel 仍显示已删除会话的内容
|
||||||
|
// 与 Sidebar.tsx 的 confirmDelete 行为一致
|
||||||
|
if (useAgentStore.getState().currentSessionId === sid) {
|
||||||
|
useAgentStore.setState({
|
||||||
|
currentSessionId: null,
|
||||||
|
messages: [],
|
||||||
|
traceSteps: [],
|
||||||
|
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
||||||
|
currentRunId: null,
|
||||||
|
currentIteration: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
showError(r?.error ?? '删除失败');
|
showError(r?.error ?? '删除失败');
|
||||||
}
|
}
|
||||||
@@ -164,7 +303,7 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
|
|||||||
case 'code-block': {
|
case 'code-block': {
|
||||||
const code = (data as { code?: string })?.code;
|
const code = (data as { code?: string })?.code;
|
||||||
return [
|
return [
|
||||||
{ id: 'copy-code', icon: Copy, label: '复制代码', action: () => { if (code) navigator.clipboard.writeText(code).catch((err) => console.error('[Clipboard]', err)); }},
|
{ id: 'copy-code', icon: Copy, label: '复制代码', action: () => { if (code) copyWithToast(code); }},
|
||||||
{ id: 'open-editor', icon: Code, label: '在编辑器中打开', action: () => { if (code) window.open(URL.createObjectURL(new Blob([code], { type: 'text/plain' })), '_blank'); }},
|
{ id: 'open-editor', icon: Code, label: '在编辑器中打开', action: () => { if (code) window.open(URL.createObjectURL(new Blob([code], { type: 'text/plain' })), '_blank'); }},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -172,9 +311,9 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
|
|||||||
case 'trace-step': {
|
case 'trace-step': {
|
||||||
const step = data as { thought?: string; toolCalls?: Array<{ name: string; args: Record<string, unknown> }> } | undefined;
|
const step = data as { thought?: string; toolCalls?: Array<{ name: string; args: Record<string, unknown> }> } | undefined;
|
||||||
return [
|
return [
|
||||||
{ id: 'copy-thought', icon: Copy, label: '复制 Thought', action: () => { if (step?.thought) navigator.clipboard.writeText(step.thought).catch((err) => console.error('[Clipboard]', err)); }},
|
{ id: 'copy-thought', icon: Copy, label: '复制 Thought', action: () => { if (step?.thought) copyWithToast(step.thought); }},
|
||||||
{ id: 'copy-params', icon: Copy, label: '复制工具参数', action: () => {
|
{ id: 'copy-params', icon: Copy, label: '复制工具参数', action: () => {
|
||||||
if (step?.toolCalls) navigator.clipboard.writeText(step.toolCalls.map((tc) => `${tc.name}: ${JSON.stringify(tc.args, null, 2)}`).join('\n')).catch((err) => console.error('[Clipboard]', err));
|
if (step?.toolCalls) copyWithToast(step.toolCalls.map((tc) => `${tc.name}: ${JSON.stringify(tc.args, null, 2)}`).join('\n'));
|
||||||
}},
|
}},
|
||||||
{ id: 'export', icon: ExternalLink, label: '导出步骤详情', action: () => {
|
{ id: 'export', icon: ExternalLink, label: '导出步骤详情', action: () => {
|
||||||
if (step) { const b = new Blob([JSON.stringify(step, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `trace-step-${Date.now()}.json`; a.click(); }
|
if (step) { const b = new Blob([JSON.stringify(step, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `trace-step-${Date.now()}.json`; a.click(); }
|
||||||
|
|||||||
@@ -128,6 +128,18 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
useSessionStore.getState().removeSession(session.id);
|
useSessionStore.getState().removeSession(session.id);
|
||||||
|
// 修复: 删除当前会话时同步清空 agent-store,否则 ChatPanel 和 DetailPanel 仍显示已删除会话的内容
|
||||||
|
// 与 SettingsModal LogsSettings 的 clearSessions 分支一致
|
||||||
|
if (useAgentStore.getState().currentSessionId === session.id) {
|
||||||
|
useAgentStore.setState({
|
||||||
|
currentSessionId: null,
|
||||||
|
messages: [],
|
||||||
|
traceSteps: [],
|
||||||
|
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
||||||
|
currentRunId: null,
|
||||||
|
currentIteration: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
setShowDeleteDialog(false);
|
setShowDeleteDialog(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -172,15 +172,15 @@ export function MemoryViewer(): React.JSX.Element {
|
|||||||
const handleDelete = async (type: MemoryType, id: string) => {
|
const handleDelete = async (type: MemoryType, id: string) => {
|
||||||
if (!window.metona?.memory?.delete) return;
|
if (!window.metona?.memory?.delete) return;
|
||||||
try {
|
try {
|
||||||
setError(null);
|
|
||||||
const res = await window.metona.memory.delete(type, id);
|
const res = await window.metona.memory.delete(type, id);
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
setError(res.error ?? '删除失败');
|
// 用户主动操作失败应用 toast(与项目惯例一致),不污染加载/搜索的 Alert
|
||||||
|
import('metona-toast').then((mod) => mod.default.error(res.error ?? '删除失败')).catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await loadMemories();
|
await loadMemories();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message ?? '删除失败');
|
import('metona-toast').then((mod) => mod.default.error((err as Error).message ?? '删除失败')).catch(() => {});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
|||||||
const failedCount = results.filter((r) => r.status === 'rejected').length;
|
const failedCount = results.filter((r) => r.status === 'rejected').length;
|
||||||
if (failedCount > 0) {
|
if (failedCount > 0) {
|
||||||
console.error('[OnboardingWizard]', `${failedCount} config(s) failed to save`);
|
console.error('[OnboardingWizard]', `${failedCount} config(s) failed to save`);
|
||||||
|
// 用户主动操作失败必须有反馈,否则按钮看起来无响应
|
||||||
|
import('metona-toast').then((mod) => mod.default.error(`部分配置保存失败(${failedCount} 项),请重试`)).catch(() => {});
|
||||||
// 不调用 setOnboardingCompleted(true),让用户重试
|
// 不调用 setOnboardingCompleted(true),让用户重试
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -50,6 +52,8 @@ export function OnboardingWizard(): React.JSX.Element | null {
|
|||||||
setOnboardingCompleted(true);
|
setOnboardingCompleted(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[OnboardingWizard]', 'Failed to save configuration:', err);
|
console.error('[OnboardingWizard]', 'Failed to save configuration:', err);
|
||||||
|
// 用户主动操作失败必须有反馈,否则向导不关闭、用户卡死
|
||||||
|
import('metona-toast').then((mod) => mod.default.error(`保存配置失败:${(err as Error).message}`)).catch(() => {});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,9 @@ function WorkspaceSettings() {
|
|||||||
files: filesToInherit,
|
files: filesToInherit,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// 用户勾选了继承文件,失败时必须告知,否则切到新空间才发现文件是空的,潜在数据丢失风险
|
||||||
console.error('[SettingsModal] Inherit failed:', err);
|
console.error('[SettingsModal] Inherit failed:', err);
|
||||||
|
import('metona-toast').then((mod) => mod.default.warning(`部分文件继承失败:${(err as Error).message},请手动检查`)).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +176,8 @@ function WorkspaceSettings() {
|
|||||||
setCheckResult(null);
|
setCheckResult(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[SettingsModal]', err);
|
console.error('[SettingsModal]', err);
|
||||||
|
// 用户主动操作(切换工作空间)失败必须有反馈
|
||||||
|
import('metona-toast').then((mod) => mod.default.error(`切换工作空间失败:${(err as Error).message}`)).catch(() => {});
|
||||||
} finally {
|
} finally {
|
||||||
setApplying(false);
|
setApplying(false);
|
||||||
}
|
}
|
||||||
@@ -317,18 +321,63 @@ function WorkspaceSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function LLMSettings() {
|
function LLMSettings() {
|
||||||
const [provider, setProvider] = useConfig('llm.provider', '');
|
// 改为本地 state + Save 按钮统一提交,避免 onChange 实时落库导致:
|
||||||
const [model, setModel] = useConfig('llm.model', '');
|
// 1. 改 Base URL 时被回滚卡死(useConfig seqRef 机制与连续输入冲突)
|
||||||
const [apiKey, setApiKey] = useConfig('llm.apiKey', '');
|
// 2. 每按一个字符就触发一次 IPC + DB + reloadAdapter,浪费且会打断输入
|
||||||
const [baseURL, setBaseURL] = useConfig('llm.baseURL', '');
|
// 3. 错误提示笼统(不指向具体字段)
|
||||||
const [numCtx, setNumCtx] = useConfig('ollama.numCtx', null as number | null);
|
const [provider, setProvider] = useState<string>('');
|
||||||
|
const [model, setModel] = useState<string>('');
|
||||||
|
const [apiKey, setApiKey] = useState<string>('');
|
||||||
|
const [baseURL, setBaseURL] = useState<string>('');
|
||||||
|
const [numCtx, setNumCtx] = useState<number | null>(null);
|
||||||
// v0.3.1: DeepSeek/Agnes/MiMo contextWindow 可配置(不再写死)
|
// v0.3.1: DeepSeek/Agnes/MiMo contextWindow 可配置(不再写死)
|
||||||
const [dsCtxWindow, setDsCtxWindow] = useConfig('deepseek.contextWindow', 1000000);
|
const [dsCtxWindow, setDsCtxWindow] = useState<number>(1000000);
|
||||||
const [agnesCtxWindow, setAgnesCtxWindow] = useConfig('agnes.contextWindow', 1000000);
|
const [agnesCtxWindow, setAgnesCtxWindow] = useState<number>(1000000);
|
||||||
const [mimoCtxWindow, setMimoCtxWindow] = useConfig('mimo.contextWindow', 131072);
|
const [mimoCtxWindow, setMimoCtxWindow] = useState<number>(131072);
|
||||||
const [showKey, setShowKey] = useState(false);
|
const [showKey, setShowKey] = useState(false);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// 初始化:一次性加载所有 LLM 配置字段
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const load = async () => {
|
||||||
|
if (!window.metona?.config?.get) {
|
||||||
|
setLoaded(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [p, m, k, u, nc, ds, ag, mi] = await Promise.all([
|
||||||
|
window.metona.config.get('llm.provider'),
|
||||||
|
window.metona.config.get('llm.model'),
|
||||||
|
window.metona.config.get('llm.apiKey'),
|
||||||
|
window.metona.config.get('llm.baseURL'),
|
||||||
|
window.metona.config.get('ollama.numCtx'),
|
||||||
|
window.metona.config.get('deepseek.contextWindow'),
|
||||||
|
window.metona.config.get('agnes.contextWindow'),
|
||||||
|
window.metona.config.get('mimo.contextWindow'),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
setProvider((p as string) ?? '');
|
||||||
|
setModel((m as string) ?? '');
|
||||||
|
setApiKey((k as string) ?? '');
|
||||||
|
setBaseURL((u as string) ?? '');
|
||||||
|
setNumCtx((nc as number | null) ?? null);
|
||||||
|
if (typeof ds === 'number' && ds > 0) setDsCtxWindow(ds);
|
||||||
|
if (typeof ag === 'number' && ag > 0) setAgnesCtxWindow(ag);
|
||||||
|
if (typeof mi === 'number' && mi > 0) setMimoCtxWindow(mi);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SettingsModal]', err);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoaded(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 同步 Provider/Model 到 Agent Store(含 contextWindow)
|
// 同步 Provider/Model 到 Agent Store(含 contextWindow)
|
||||||
|
// 注意:仅同步运行时状态,不落库
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
useAgentStore.getState().setProvider(provider, model);
|
useAgentStore.getState().setProvider(provider, model);
|
||||||
}, [provider, model]);
|
}, [provider, model]);
|
||||||
@@ -346,16 +395,37 @@ function LLMSettings() {
|
|||||||
}
|
}
|
||||||
}, [provider, numCtx, dsCtxWindow, agnesCtxWindow, mimoCtxWindow]);
|
}, [provider, numCtx, dsCtxWindow, agnesCtxWindow, mimoCtxWindow]);
|
||||||
|
|
||||||
// 切换 Provider 时自动填充默认 URL + 清空 apiKey(不同 Provider 的 key 不通用)
|
// ===== 字段级 inline 校验 =====
|
||||||
|
// Base URL:非空时必须以 http:// 或 https:// 开头(避免漏写协议头导致发消息时报 Invalid URL)
|
||||||
|
const urlError = !!baseURL && !/^https?:\/\/.+/.test(baseURL);
|
||||||
|
// Model:非空时不允许包含空格(OpenAI API 会把空格后的部分当作额外参数)
|
||||||
|
const modelHasSpace = !!model && /\s/.test(model);
|
||||||
|
// contextWindow / numCtx:必须为有限正数且不低于最小值
|
||||||
|
const numCtxError = numCtx != null && (!Number.isFinite(numCtx) || numCtx < 512);
|
||||||
|
const dsCtxError = !Number.isFinite(dsCtxWindow) || dsCtxWindow < 4096;
|
||||||
|
const agnesCtxError = !Number.isFinite(agnesCtxWindow) || agnesCtxWindow < 4096;
|
||||||
|
const mimoCtxError = !Number.isFinite(mimoCtxWindow) || mimoCtxWindow < 4096;
|
||||||
|
|
||||||
|
// 是否存在阻断保存的错误(API Key 为空只警告,不阻断 — 允许先填其他字段再回来填 key)
|
||||||
|
const hasBlockingError = urlError || modelHasSpace || numCtxError ||
|
||||||
|
(provider === 'deepseek' && dsCtxError) ||
|
||||||
|
(provider === 'agnes' && agnesCtxError) ||
|
||||||
|
(provider === 'mimo' && mimoCtxError);
|
||||||
|
|
||||||
|
// 切换 Provider 时:清空 apiKey + 清空 model + 自动填充默认 URL
|
||||||
|
// 不同 Provider 的 key/model 互不通用,避免用旧值调用新 API 导致 401 / model not found
|
||||||
const handleProviderChange = (newProvider: string) => {
|
const handleProviderChange = (newProvider: string) => {
|
||||||
const oldProvider = provider;
|
const oldProvider = provider;
|
||||||
setProvider(newProvider);
|
setProvider(newProvider);
|
||||||
// 切换 Provider 时清空 apiKey(不同 Provider 的 key 格式不同,避免用旧 key 调用新 API 导致 401)
|
// 切换 Provider 时清空 apiKey(不同 Provider 的 key 格式不同)
|
||||||
// Ollama 不需要 apiKey,也一并清空
|
|
||||||
if (oldProvider !== newProvider && apiKey) {
|
if (oldProvider !== newProvider && apiKey) {
|
||||||
setApiKey('');
|
setApiKey('');
|
||||||
}
|
}
|
||||||
// 自动填充默认 URL(仅在 URL 为空或与旧 provider 默认 URL 匹配时)
|
// 切换 Provider 时清空 model(不同 Provider 支持的模型名不同,如 deepseek-v4-pro 不适用于 ollama)
|
||||||
|
if (oldProvider !== newProvider && model) {
|
||||||
|
setModel('');
|
||||||
|
}
|
||||||
|
// 自动填充默认 URL(仅在 URL 为空或与旧 provider 默认 URL 匹配时覆盖)
|
||||||
const currentUrl = baseURL.trim();
|
const currentUrl = baseURL.trim();
|
||||||
const isDefaultUrl = Object.values(PROVIDER_URLS).includes(currentUrl);
|
const isDefaultUrl = Object.values(PROVIDER_URLS).includes(currentUrl);
|
||||||
if (isDefaultUrl || !currentUrl) {
|
if (isDefaultUrl || !currentUrl) {
|
||||||
@@ -363,6 +433,63 @@ function LLMSettings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (hasBlockingError) {
|
||||||
|
import('metona-toast').then((mod) => mod.default.error('请修正表单中的错误后再保存')).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const setConfig = window.metona?.config?.set;
|
||||||
|
if (!setConfig) {
|
||||||
|
import('metona-toast').then((mod) => mod.default.error('配置 API 不可用')).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 串行保存:避免并发 IPC 调用(reloadAdapter 内部 lastConfigSig 比较会跳过中间态的重载)
|
||||||
|
const fields: Array<[string, unknown]> = [
|
||||||
|
['llm.provider', provider],
|
||||||
|
['llm.model', model],
|
||||||
|
['llm.apiKey', apiKey],
|
||||||
|
['llm.baseURL', baseURL],
|
||||||
|
['ollama.numCtx', numCtx],
|
||||||
|
['deepseek.contextWindow', dsCtxWindow],
|
||||||
|
['agnes.contextWindow', agnesCtxWindow],
|
||||||
|
['mimo.contextWindow', mimoCtxWindow],
|
||||||
|
];
|
||||||
|
let firstError: string | null = null;
|
||||||
|
for (const [key, value] of fields) {
|
||||||
|
try {
|
||||||
|
const r = await setConfig(key, value);
|
||||||
|
if (r && !r.success && !firstError) {
|
||||||
|
firstError = r.error ?? '配置保存失败';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!firstError) firstError = (err as Error).message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (firstError) {
|
||||||
|
import('metona-toast').then((mod) => mod.default.error(firstError!)).catch(() => {});
|
||||||
|
} else {
|
||||||
|
import('metona-toast').then((mod) => mod.default.success('配置已保存')).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
import('metona-toast').then((mod) => mod.default.error(`保存失败:${(err as Error).message}`)).catch(() => {});
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!loaded) {
|
||||||
|
return (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>LLM 配置</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>加载中...</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKeyEmpty = provider !== 'ollama' && !apiKey.trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>LLM 配置</Typography>
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>LLM 配置</Typography>
|
||||||
@@ -374,18 +501,37 @@ function LLMSettings() {
|
|||||||
<MenuItem value="ollama">Ollama (本地)</MenuItem>
|
<MenuItem value="ollama">Ollama (本地)</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<TextField size="small" label="API Base URL" value={baseURL} onChange={(e) => setBaseURL(e.target.value)} placeholder="如 https://api.deepseek.com" />
|
<TextField
|
||||||
<TextField size="small" label="模型名称" value={model} onChange={(e) => setModel(e.target.value)} placeholder="如 deepseek-v4-pro、qwen3:latest" />
|
size="small"
|
||||||
|
label="API Base URL"
|
||||||
|
value={baseURL}
|
||||||
|
onChange={(e) => setBaseURL(e.target.value)}
|
||||||
|
placeholder="如 https://api.deepseek.com"
|
||||||
|
error={urlError}
|
||||||
|
helperText={urlError ? '需以 http:// 或 https:// 开头' : ' '}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="模型名称"
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => setModel(e.target.value)}
|
||||||
|
placeholder="如 deepseek-v4-pro、qwen3:latest"
|
||||||
|
error={modelHasSpace}
|
||||||
|
helperText={modelHasSpace ? '模型名称不能包含空格' : ' '}
|
||||||
|
/>
|
||||||
{provider !== 'ollama' && (
|
{provider !== 'ollama' && (
|
||||||
<>
|
<>
|
||||||
<TextField size="small" label="API Key" type={showKey ? 'text' : 'password'} value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-..."
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="API Key"
|
||||||
|
type={showKey ? 'text' : 'password'}
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
placeholder="sk-..."
|
||||||
|
error={apiKeyEmpty}
|
||||||
|
helperText={apiKeyEmpty ? `必填,未填 ${PROVIDER_LABELS[provider] ?? provider} 的 API Key 会 401` : ' '}
|
||||||
slotProps={{ input: { endAdornment: <IconButton size="small" onClick={() => setShowKey(!showKey)}>{showKey ? <EyeOff size={14} /> : <Eye size={14} />}</IconButton> } }}
|
slotProps={{ input: { endAdornment: <IconButton size="small" onClick={() => setShowKey(!showKey)}>{showKey ? <EyeOff size={14} /> : <Eye size={14} />}</IconButton> } }}
|
||||||
/>
|
/>
|
||||||
{!apiKey && (
|
|
||||||
<Typography variant="caption" sx={{ color: 'warning.main', fontSize: 11 }}>
|
|
||||||
⚠ 请输入 {PROVIDER_LABELS[provider] ?? provider} 的 API Key,否则发送消息会报错
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{provider === 'ollama' && (
|
{provider === 'ollama' && (
|
||||||
@@ -400,6 +546,8 @@ function LLMSettings() {
|
|||||||
}}
|
}}
|
||||||
placeholder="默认由模型决定(如 2048、4096、128000)"
|
placeholder="默认由模型决定(如 2048、4096、128000)"
|
||||||
slotProps={{ htmlInput: { min: 512, step: 512 } }}
|
slotProps={{ htmlInput: { min: 512, step: 512 } }}
|
||||||
|
error={numCtxError}
|
||||||
|
helperText={numCtxError ? '最小值为 512' : ' '}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* v0.3.1: DeepSeek/Agnes 上下文窗口配置(用于 Engine 压缩判断和 UI 显示,不传给 API) */}
|
{/* v0.3.1: DeepSeek/Agnes 上下文窗口配置(用于 Engine 压缩判断和 UI 显示,不传给 API) */}
|
||||||
@@ -408,11 +556,12 @@ function LLMSettings() {
|
|||||||
size="small"
|
size="small"
|
||||||
label="上下文窗口 (contextWindow)"
|
label="上下文窗口 (contextWindow)"
|
||||||
type="number"
|
type="number"
|
||||||
value={dsCtxWindow ?? 1000000}
|
value={dsCtxWindow}
|
||||||
onChange={(e) => setDsCtxWindow(Number(e.target.value) || 1000000)}
|
onChange={(e) => setDsCtxWindow(Number(e.target.value) || 1000000)}
|
||||||
placeholder="如 64000、128000、1000000"
|
placeholder="如 64000、128000、1000000"
|
||||||
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
||||||
helperText="用于上下文压缩判断,不传给 API"
|
error={dsCtxError}
|
||||||
|
helperText={dsCtxError ? '最小值为 4096' : '用于上下文压缩判断,不传给 API'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{provider === 'agnes' && (
|
{provider === 'agnes' && (
|
||||||
@@ -420,11 +569,12 @@ function LLMSettings() {
|
|||||||
size="small"
|
size="small"
|
||||||
label="上下文窗口 (contextWindow)"
|
label="上下文窗口 (contextWindow)"
|
||||||
type="number"
|
type="number"
|
||||||
value={agnesCtxWindow ?? 1000000}
|
value={agnesCtxWindow}
|
||||||
onChange={(e) => setAgnesCtxWindow(Number(e.target.value) || 1000000)}
|
onChange={(e) => setAgnesCtxWindow(Number(e.target.value) || 1000000)}
|
||||||
placeholder="如 64000、128000、1000000"
|
placeholder="如 64000、128000、1000000"
|
||||||
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
||||||
helperText="用于上下文压缩判断,不传给 API"
|
error={agnesCtxError}
|
||||||
|
helperText={agnesCtxError ? '最小值为 4096' : '用于上下文压缩判断,不传给 API'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{provider === 'mimo' && (
|
{provider === 'mimo' && (
|
||||||
@@ -432,13 +582,32 @@ function LLMSettings() {
|
|||||||
size="small"
|
size="small"
|
||||||
label="上下文窗口 (contextWindow)"
|
label="上下文窗口 (contextWindow)"
|
||||||
type="number"
|
type="number"
|
||||||
value={mimoCtxWindow ?? 131072}
|
value={mimoCtxWindow}
|
||||||
onChange={(e) => setMimoCtxWindow(Number(e.target.value) || 131072)}
|
onChange={(e) => setMimoCtxWindow(Number(e.target.value) || 131072)}
|
||||||
placeholder="如 32768、65536、131072"
|
placeholder="如 32768、65536、131072"
|
||||||
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
slotProps={{ htmlInput: { min: 4096, step: 4096 } }}
|
||||||
helperText="官方未公布具体值,默认 131072,用于压缩判断"
|
error={mimoCtxError}
|
||||||
|
helperText={mimoCtxError ? '最小值为 4096' : '官方未公布具体值,默认 131072,用于压缩判断'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Save 按钮:批量提交,取消 onChange 实时落库 */}
|
||||||
|
<Stack direction="row" spacing={1} sx={{ mt: 1, alignItems: 'center' }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || hasBlockingError}
|
||||||
|
startIcon={saving ? <CircularProgress size={12} /> : undefined}
|
||||||
|
>
|
||||||
|
{saving ? '保存中...' : '保存配置'}
|
||||||
|
</Button>
|
||||||
|
{hasBlockingError && (
|
||||||
|
<Typography variant="caption" sx={{ color: 'error.main', fontSize: 11 }}>
|
||||||
|
请修正表单错误后再保存
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1013,9 +1182,8 @@ function AppearanceSettings({ theme, setTheme }: { theme: ThemeMode; setTheme: (
|
|||||||
function LogsSettings() {
|
function LogsSettings() {
|
||||||
const [logLevel, setLogLevel] = useConfig('logging.level', 'info');
|
const [logLevel, setLogLevel] = useConfig('logging.level', 'info');
|
||||||
const [clearing, setClearing] = useState<string | null>(null);
|
const [clearing, setClearing] = useState<string | null>(null);
|
||||||
// L-11 修复(审计补充): 用 MUI Dialog 替换原生 confirm() 和 alert(),保持 UI 一致性
|
// L-11 修复(审计补充): 用 MUI Dialog 替换原生 confirm(),保持 UI 一致性
|
||||||
const [confirmClear, setConfirmClear] = useState<'sessions' | 'memories' | 'auditLogs' | null>(null);
|
const [confirmClear, setConfirmClear] = useState<'sessions' | 'memories' | 'auditLogs' | null>(null);
|
||||||
const [resultAlert, setResultAlert] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
|
||||||
// P3-13: 显示日志文件路径,并提供"打开日志文件夹"按钮
|
// P3-13: 显示日志文件路径,并提供"打开日志文件夹"按钮
|
||||||
// electron-log 默认写入路径为 ${userData}/logs/main.log
|
// electron-log 默认写入路径为 ${userData}/logs/main.log
|
||||||
const [logFilePath, setLogFilePath] = useState<string>('');
|
const [logFilePath, setLogFilePath] = useState<string>('');
|
||||||
@@ -1051,10 +1219,10 @@ function LogsSettings() {
|
|||||||
try {
|
try {
|
||||||
const r = await window.metona?.app?.showItemInFolder?.(logFilePath);
|
const r = await window.metona?.app?.showItemInFolder?.(logFilePath);
|
||||||
if (r && !r.success) {
|
if (r && !r.success) {
|
||||||
setResultAlert({ type: 'error', message: `打开失败: ${r.error ?? '未知错误'}` });
|
import('metona-toast').then((mod) => mod.default.error(`打开失败: ${r.error ?? '未知错误'}`)).catch(() => {});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setResultAlert({ type: 'error', message: `打开失败: ${(e as Error).message}` });
|
import('metona-toast').then((mod) => mod.default.error(`打开失败: ${(e as Error).message}`)).catch(() => {});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1067,7 +1235,7 @@ function LogsSettings() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
setCopyState('error');
|
setCopyState('error');
|
||||||
setTimeout(() => setCopyState('idle'), 1500);
|
setTimeout(() => setCopyState('idle'), 1500);
|
||||||
setResultAlert({ type: 'error', message: `复制失败: ${(e as Error).message}` });
|
import('metona-toast').then((mod) => mod.default.error(`复制失败: ${(e as Error).message}`)).catch(() => {});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1079,16 +1247,16 @@ function LogsSettings() {
|
|||||||
const b = new Blob([JSON.stringify(r.data, null, 2)], { type: 'application/json' });
|
const b = new Blob([JSON.stringify(r.data, null, 2)], { type: 'application/json' });
|
||||||
const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `metona-export-${Date.now()}.json`; a.click();
|
const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `metona-export-${Date.now()}.json`; a.click();
|
||||||
} else {
|
} else {
|
||||||
setResultAlert({ type: 'error', message: `导出失败: ${r.error ?? '未知错误'}` });
|
import('metona-toast').then((mod) => mod.default.error(`导出失败: ${r.error ?? '未知错误'}`)).catch(() => {});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[LogsSettings]', err);
|
console.error('[LogsSettings]', err);
|
||||||
setResultAlert({ type: 'error', message: `导出失败: ${(err as Error).message}` });
|
import('metona-toast').then((mod) => mod.default.error(`导出失败: ${(err as Error).message}`)).catch(() => {});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const labels: Record<string, string> = { sessions: '所有会话', memories: '所有记忆', auditLogs: '审计日志' };
|
const labels: Record<string, string> = { sessions: '所有会话', memories: '所有记忆', auditLogs: '审计日志' };
|
||||||
|
|
||||||
// L-11 修复(审计补充): 清理数据改用 Dialog 确认 + Alert 反馈,不再用原生 confirm/alert
|
// L-11 修复(审计补充): 清理数据改用 Dialog 确认,结果用 toast 反馈
|
||||||
const handleClearConfirm = async () => {
|
const handleClearConfirm = async () => {
|
||||||
if (!confirmClear) return;
|
if (!confirmClear) return;
|
||||||
const type = confirmClear;
|
const type = confirmClear;
|
||||||
@@ -1100,7 +1268,7 @@ function LogsSettings() {
|
|||||||
else if (type === 'memories') r = await window.metona?.data?.clearMemories();
|
else if (type === 'memories') r = await window.metona?.data?.clearMemories();
|
||||||
else r = await window.metona?.data?.clearAuditLogs();
|
else r = await window.metona?.data?.clearAuditLogs();
|
||||||
if (r?.success) {
|
if (r?.success) {
|
||||||
setResultAlert({ type: 'success', message: `${labels[type]}已清理` });
|
import('metona-toast').then((mod) => mod.default.success(`${labels[type]}已清理`)).catch(() => {});
|
||||||
// 修复: 清理会话后同步清空前端状态,无需重启应用
|
// 修复: 清理会话后同步清空前端状态,无需重启应用
|
||||||
if (type === 'sessions') {
|
if (type === 'sessions') {
|
||||||
useSessionStore.getState().setSessions([]);
|
useSessionStore.getState().setSessions([]);
|
||||||
@@ -1113,10 +1281,10 @@ function LogsSettings() {
|
|||||||
useUIStore.getState().bumpMemoryVersion();
|
useUIStore.getState().bumpMemoryVersion();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setResultAlert({ type: 'error', message: `失败: ${r?.error ?? '未知错误'}` });
|
import('metona-toast').then((mod) => mod.default.error(`失败: ${r?.error ?? '未知错误'}`)).catch(() => {});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setResultAlert({ type: 'error', message: `失败: ${(e as Error).message}` });
|
import('metona-toast').then((mod) => mod.default.error(`失败: ${(e as Error).message}`)).catch(() => {});
|
||||||
}
|
}
|
||||||
setClearing(null);
|
setClearing(null);
|
||||||
};
|
};
|
||||||
@@ -1170,9 +1338,9 @@ function LogsSettings() {
|
|||||||
<Divider />
|
<Divider />
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>数据管理</Typography>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>数据管理</Typography>
|
||||||
<Button variant="outlined" fullWidth size="small" onClick={handleExport}>📦 导出全部数据(JSON)</Button>
|
<Button variant="outlined" fullWidth size="small" onClick={handleExport}>📦 导出全部数据(JSON)</Button>
|
||||||
<Button variant="outlined" fullWidth size="small" color="error" onClick={() => { setResultAlert(null); setConfirmClear('sessions'); }} disabled={clearing !== null}>{clearing === 'sessions' ? '清理中...' : '🗑️ 清理所有会话'}</Button>
|
<Button variant="outlined" fullWidth size="small" color="error" onClick={() => setConfirmClear('sessions')} disabled={clearing !== null}>{clearing === 'sessions' ? '清理中...' : '🗑️ 清理所有会话'}</Button>
|
||||||
<Button variant="outlined" fullWidth size="small" color="error" onClick={() => { setResultAlert(null); setConfirmClear('memories'); }} disabled={clearing !== null}>{clearing === 'memories' ? '清理中...' : '🗑️ 清理所有记忆'}</Button>
|
<Button variant="outlined" fullWidth size="small" color="error" onClick={() => setConfirmClear('memories')} disabled={clearing !== null}>{clearing === 'memories' ? '清理中...' : '🗑️ 清理所有记忆'}</Button>
|
||||||
<Button variant="outlined" fullWidth size="small" color="error" onClick={() => { setResultAlert(null); setConfirmClear('auditLogs'); }} disabled={clearing !== null}>{clearing === 'auditLogs' ? '清理中...' : '🗑️ 清理审计日志'}</Button>
|
<Button variant="outlined" fullWidth size="small" color="error" onClick={() => setConfirmClear('auditLogs')} disabled={clearing !== null}>{clearing === 'auditLogs' ? '清理中...' : '🗑️ 清理审计日志'}</Button>
|
||||||
|
|
||||||
{/* L-11 修复(审计补充): 清理数据确认 Dialog(替代原生 confirm()) */}
|
{/* L-11 修复(审计补充): 清理数据确认 Dialog(替代原生 confirm()) */}
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -1192,17 +1360,6 @@ function LogsSettings() {
|
|||||||
<Button onClick={handleClearConfirm} color="error" variant="contained">清理</Button>
|
<Button onClick={handleClearConfirm} color="error" variant="contained">清理</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* L-11 修复(审计补充): 清理结果反馈 Alert(替代原生 alert()),3 秒后自动消失 */}
|
|
||||||
{resultAlert && (
|
|
||||||
<Alert
|
|
||||||
severity={resultAlert.type}
|
|
||||||
onClose={() => setResultAlert(null)}
|
|
||||||
sx={{ fontSize: 12 }}
|
|
||||||
>
|
|
||||||
{resultAlert.message}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,10 +97,12 @@ export function TaskList(): React.JSX.Element {
|
|||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
setError('请先选择或创建会话');
|
// 用户主动操作失败应用 toast(与项目惯例一致)
|
||||||
|
import('metona-toast').then((mod) => mod.default.error('请先选择或创建会话')).catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!newTitle.trim()) {
|
if (!newTitle.trim()) {
|
||||||
|
// 表单校验保留 inline error(让用户看到具体哪个字段有问题)
|
||||||
setError('任务标题不能为空');
|
setError('任务标题不能为空');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -114,7 +116,7 @@ export function TaskList(): React.JSX.Element {
|
|||||||
priority: newPriority,
|
priority: newPriority,
|
||||||
});
|
});
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
setError(res.error ?? '创建任务失败');
|
import('metona-toast').then((mod) => mod.default.error(res.error ?? '创建任务失败')).catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setNewTitle('');
|
setNewTitle('');
|
||||||
@@ -122,7 +124,7 @@ export function TaskList(): React.JSX.Element {
|
|||||||
setShowAddForm(false);
|
setShowAddForm(false);
|
||||||
await loadTasks(sessionId);
|
await loadTasks(sessionId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message ?? '创建任务失败');
|
import('metona-toast').then((mod) => mod.default.error((err as Error).message ?? '创建任务失败')).catch(() => {});
|
||||||
} finally {
|
} finally {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
}
|
}
|
||||||
@@ -131,32 +133,30 @@ export function TaskList(): React.JSX.Element {
|
|||||||
const handleToggleComplete = async (task: MetonaTask) => {
|
const handleToggleComplete = async (task: MetonaTask) => {
|
||||||
if (!window.metona?.tasks?.update) return;
|
if (!window.metona?.tasks?.update) return;
|
||||||
const nextStatus: TaskStatus = task.status === 'completed' ? 'pending' : 'completed';
|
const nextStatus: TaskStatus = task.status === 'completed' ? 'pending' : 'completed';
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
const res = await window.metona.tasks.update(task.id, { status: nextStatus });
|
const res = await window.metona.tasks.update(task.id, { status: nextStatus });
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
setError(res.error ?? '更新任务失败');
|
import('metona-toast').then((mod) => mod.default.error(res.error ?? '更新任务失败')).catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await loadTasks(sessionId ?? undefined);
|
await loadTasks(sessionId ?? undefined);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message ?? '更新任务失败');
|
import('metona-toast').then((mod) => mod.default.error((err as Error).message ?? '更新任务失败')).catch(() => {});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!window.metona?.tasks?.delete) return;
|
if (!window.metona?.tasks?.delete) return;
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
const res = await window.metona.tasks.delete(id);
|
const res = await window.metona.tasks.delete(id);
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
setError(res.error ?? '删除任务失败');
|
import('metona-toast').then((mod) => mod.default.error(res.error ?? '删除任务失败')).catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (expandedId === id) setExpandedId(null);
|
if (expandedId === id) setExpandedId(null);
|
||||||
await loadTasks(sessionId ?? undefined);
|
await loadTasks(sessionId ?? undefined);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message ?? '删除任务失败');
|
import('metona-toast').then((mod) => mod.default.error((err as Error).message ?? '删除任务失败')).catch(() => {});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -417,9 +417,10 @@ export function useAgentStream(): void {
|
|||||||
const traceSteps = store.traceSteps;
|
const traceSteps = store.traceSteps;
|
||||||
const lastStep = traceSteps[traceSteps.length - 1];
|
const lastStep = traceSteps[traceSteps.length - 1];
|
||||||
|
|
||||||
// 判断是否需要创建新步骤:同一迭代的首次状态创建新步骤
|
// 判断是否需要创建新步骤:同一 runId + 同一迭代的首次状态创建新步骤
|
||||||
// 后续状态转换(EXECUTING/OBSERVING等)追加到 states 数组
|
// 后续状态转换(EXECUTING/OBSERVING等)追加到 states 数组
|
||||||
const isSameIteration = lastStep && lastStep.iteration === data.iteration;
|
// runId 判定:防止跨 run 的事件被误判为同一迭代(如 traceSteps 未清空时残留的旧 step)
|
||||||
|
const isSameIteration = lastStep && lastStep.iteration === data.iteration && lastStep.runId === data.runId;
|
||||||
|
|
||||||
if (isSameIteration) {
|
if (isSameIteration) {
|
||||||
// 同一迭代内的状态转换 → 追加状态到 states 数组,更新当前 state
|
// 同一迭代内的状态转换 → 追加状态到 states 数组,更新当前 state
|
||||||
@@ -437,7 +438,8 @@ export function useAgentStream(): void {
|
|||||||
// MT-2 修复: 当 state === 'TERMINATED' 且 iteration 不匹配时,
|
// MT-2 修复: 当 state === 'TERMINATED' 且 iteration 不匹配时,
|
||||||
// 说明 abort 发生在循环条件检查时,currentIteration 已自增但没有对应 step。
|
// 说明 abort 发生在循环条件检查时,currentIteration 已自增但没有对应 step。
|
||||||
// 此时不应创建孤立的只含 TERMINATED 的步骤,而是更新最后一个步骤的 states 数组
|
// 此时不应创建孤立的只含 TERMINATED 的步骤,而是更新最后一个步骤的 states 数组
|
||||||
if (data.state === 'TERMINATED' && lastStep) {
|
// runId 一致性检查:仅当 lastStep 属于当前 run 时才 append,避免污染上一条消息的 step
|
||||||
|
if (data.state === 'TERMINATED' && lastStep && lastStep.runId === data.runId) {
|
||||||
const currentStates = lastStep.states ?? [lastStep.state];
|
const currentStates = lastStep.states ?? [lastStep.state];
|
||||||
if (currentStates[currentStates.length - 1] !== 'TERMINATED') {
|
if (currentStates[currentStates.length - 1] !== 'TERMINATED') {
|
||||||
store.updateLastTraceStep({
|
store.updateLastTraceStep({
|
||||||
@@ -446,9 +448,12 @@ export function useAgentStream(): void {
|
|||||||
completedAt: Date.now(),
|
completedAt: Date.now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (data.state === 'TERMINATED' && (!lastStep || lastStep.runId !== data.runId)) {
|
||||||
|
// 跨 run 的孤立 TERMINATED 事件:上一条消息已结束/已 abort,没有当前 run 的 step 可更新
|
||||||
|
// 不创建孤立的只含 TERMINATED 的 step(无意义),仅更新 Agent 状态
|
||||||
} else {
|
} else {
|
||||||
// 新迭代 → 标记上一步完成,创建新步骤
|
// 新迭代 → 标记上一步完成(仅当 lastStep 属于当前 run),创建新步骤
|
||||||
if (lastStep && !lastStep.completedAt) {
|
if (lastStep && !lastStep.completedAt && lastStep.runId === data.runId) {
|
||||||
store.updateLastTraceStep({ completedAt: Date.now() });
|
store.updateLastTraceStep({ completedAt: Date.now() });
|
||||||
}
|
}
|
||||||
store.addTraceStep({
|
store.addTraceStep({
|
||||||
@@ -457,6 +462,7 @@ export function useAgentStream(): void {
|
|||||||
state: data.state,
|
state: data.state,
|
||||||
states: [data.state],
|
states: [data.state],
|
||||||
startedAt: Date.now(),
|
startedAt: Date.now(),
|
||||||
|
runId: data.runId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,11 @@ export function useKeyboardShortcuts(): void {
|
|||||||
useSessionStore.getState().setCurrentSession(result.id);
|
useSessionStore.getState().setCurrentSession(result.id);
|
||||||
// v0.3.0 修复: 同步 agent-store,确保聊天面板清空并加载新会话
|
// v0.3.0 修复: 同步 agent-store,确保聊天面板清空并加载新会话
|
||||||
useAgentStore.getState().setCurrentSession(result.id);
|
useAgentStore.getState().setCurrentSession(result.id);
|
||||||
}).catch((err) => { console.error('[KeyboardShortcuts]', err); });
|
}).catch((err) => {
|
||||||
|
console.error('[KeyboardShortcuts]', err);
|
||||||
|
// 与 Sidebar/CommandPalette 入口对齐:失败时弹 toast 提示用户
|
||||||
|
import('metona-toast').then((mod) => mod.default.error('创建会话失败')).catch(() => {});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return;
|
return;
|
||||||
@@ -148,7 +152,11 @@ export function useKeyboardShortcuts(): void {
|
|||||||
const { messages } = useAgentStore.getState();
|
const { messages } = useAgentStore.getState();
|
||||||
const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant');
|
const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant');
|
||||||
if (lastAssistant?.content) {
|
if (lastAssistant?.content) {
|
||||||
navigator.clipboard.writeText(lastAssistant.content).catch((err) => console.error('[KeyboardShortcuts]', err));
|
navigator.clipboard.writeText(lastAssistant.content).catch((err) => {
|
||||||
|
console.error('[KeyboardShortcuts]', err);
|
||||||
|
// 复制是用户主动操作,失败时必须有反馈
|
||||||
|
import('metona-toast').then((mod) => mod.default.error('复制失败')).catch(() => {});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ export interface TraceStep {
|
|||||||
thought?: string;
|
thought?: string;
|
||||||
toolCalls?: ToolCallInfo[];
|
toolCalls?: ToolCallInfo[];
|
||||||
tokenUsage?: { promptTokens: number; completionTokens: number; totalTokens: number };
|
tokenUsage?: { promptTokens: number; completionTokens: number; totalTokens: number };
|
||||||
|
/**
|
||||||
|
* 创建该 step 的 runId,用于跨 run 隔离
|
||||||
|
* 防御场景:abort 重发、并发 run、traceSteps 未清空时仍能正确识别新旧 step 边界
|
||||||
|
*/
|
||||||
|
runId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Store 状态 =====
|
// ===== Store 状态 =====
|
||||||
@@ -242,6 +247,8 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
|||||||
content: `错误: 无法创建会话 — ${(err as Error).message}`,
|
content: `错误: 无法创建会话 — ${(err as Error).message}`,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
|
// 额外弹 toast 作为通知,确保用户感知(system message 仅在聊天流内可见)
|
||||||
|
import('metona-toast').then((mod) => mod.default.error(`无法创建会话:${(err as Error).message}`)).catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,6 +267,10 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
|||||||
isStreaming: true,
|
isStreaming: true,
|
||||||
currentRunId: null, // 将在首个 streamEvent 中由 runId 设置
|
currentRunId: null, // 将在首个 streamEvent 中由 runId 设置
|
||||||
currentIteration: 0,
|
currentIteration: 0,
|
||||||
|
// 重置 trace 状态:防止新消息在上一条消息的 trace 上面叠加渲染
|
||||||
|
// 前一条消息的 trace 已在 done 事件中通过 saveTraceData() 持久化到 DB
|
||||||
|
traceSteps: [],
|
||||||
|
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 自动更新会话标题为用户第一条消息
|
// 自动更新会话标题为用户第一条消息
|
||||||
@@ -317,6 +328,8 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
|||||||
content: `错误: 消息发送失败 — ${(err as Error).message}`,
|
content: `错误: 消息发送失败 — ${(err as Error).message}`,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
|
// 额外弹 toast 作为通知,确保用户感知(system message 仅在聊天流内可见)
|
||||||
|
import('metona-toast').then((mod) => mod.default.error(`消息发送失败:${(err as Error).message}`)).catch(() => {});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user