feat: v0.5.4 多模态增强 — 多轮图片记忆 + 多模态总开关 + DeepSeek vision 模型支持
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m47s
CI / 全量测试 (Electron ABI) (push) Failing after 5m25s
CI / 产物编译验证 (push) Successful in 10m4s

多轮图片记忆:
- 此前历史轮次的图片不回传 LLM(attachments 仅存压缩 preview,历史组装
  时被丢弃)— 跨轮对话中模型对图片内容"失忆"
- 修复:SessionSummaryService.buildHistoryMessages 从持久化的 attachments
  恢复 images(type=image 的 preview base64),历史图片随上下文回传
- Token 控制:最多注入最近 10 张(MAX_HISTORY_IMAGES,从最新消息向前
  收集)— 每张 1024px 压缩图约数百至千余 token,无上限会吃满上下文
- 摘要区间(summarizedUntilRowid 之前)的图片不恢复,符合滚动摘要语义

多模态总开关 llm.multimodalEnabled(默认关闭):
- 新配置项:CONFIG_DEFAULTS 种子 + 设置弹框 LLM 配置 Switch +
  首次引导向导 LLM 步骤 Switch(含说明文案)
- 上传入口双重判断:总开关 × 模型能力 — 未开启时即使模型支持多模态
  也不能上传图片(ChatInput 的选择/拖拽/粘贴统一拦截,Toast 区分
  "开关未开启"与"当前模型不支持"两种原因)
- 保存成功后同步 Agent Store 立即生效;App 启动时随 setProvider 加载

DeepSeek vision 模型支持:
- 新增 deepseek-v4-flash-vision-exp(OpenAI image_url content parts 格式,
  128K 上下文 / 8K 输出)
- adapter 按 isVisionModel() 判断:vision 模型将带 images 的消息转换为
  [{type:'text'},{type:'image_url'}] parts;非 vision 模型保持 images
  静默丢弃(防 API 400)

测试(236 → 243 用例):
- 多轮图片记忆 ×3(session-summary.test.ts):历史 attachments 恢复
  images / 上限 10 张从最新向前 / 摘要区间图片不恢复
- DeepSeek vision 请求格式 ×4(deepseek-vision.test.ts,契约级 mock
  fetch 断言请求体):image_url parts 转换 / 非 vision 模型丢弃 /
  max_tokens 钳制 8192 / 无图不转换
- 测试顺序修正:多轮图片用例置于 describe 末尾(插入新行消耗全局自增
  rowid,插在中间会破坏既有用例对 rowid 数值的断言)

文档: README 同步(DeepSeek 模型表 + vision 多模态列、llm.multimodalEnabled
配置项、多轮图片记忆特性行、243 用例数)

验证: lint 0 / typecheck 双工程 0 / test:electron 243 全过 / build 成功
This commit is contained in:
2026-08-21 23:00:20 +08:00
parent 3a30e8f5b4
commit 4ee5100661
12 changed files with 1363 additions and 353 deletions
+486 -167
View File
@@ -10,7 +10,16 @@
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { Box, Typography, IconButton, Tooltip, Stack, Button, Paper, InputBase } from '@mui/material';
import {
Box,
Typography,
IconButton,
Tooltip,
Stack,
Button,
Paper,
InputBase,
} from '@mui/material';
import { Send, Paperclip, Square, X, FileText, Image as ImageIcon } from 'lucide-react';
import { nanoid } from 'nanoid';
import { useAgentStore } from '@renderer/stores/agent-store';
@@ -26,14 +35,48 @@ const SLASH_COMMANDS = [
];
const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
const TEXT_EXTENSIONS = ['txt', 'md', 'json', 'csv', 'ts', 'tsx', 'js', 'jsx', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'css', 'html', 'xml', 'yaml', 'yml', 'toml', 'ini', 'sh', 'bash', 'zsh', 'fish', 'sql', 'env', 'gitignore', 'dockerfile', 'makefile', 'log'];
const TEXT_EXTENSIONS = [
'txt',
'md',
'json',
'csv',
'ts',
'tsx',
'js',
'jsx',
'py',
'rb',
'go',
'rs',
'java',
'c',
'cpp',
'h',
'css',
'html',
'xml',
'yaml',
'yml',
'toml',
'ini',
'sh',
'bash',
'zsh',
'fish',
'sql',
'env',
'gitignore',
'dockerfile',
'makefile',
'log',
];
interface Attachment {
id: string;
file: File;
type: 'image' | 'text' | 'other';
preview?: string; // 图片 base64 data URL
textContent?: string; // 文本文件内容
preview?: string; // 图片 base64 data URL
textContent?: string; // 文本文件内容
}
export function ChatInput(): React.JSX.Element {
@@ -51,13 +94,29 @@ export function ChatInput(): React.JSX.Element {
const toolsReady = useAgentStore((s) => s.toolsReady);
const currentSessionId = useSessionStore((s) => s.currentSessionId);
const provider = useAgentStore((s) => s.provider);
const model = useAgentStore((s) => s.model);
// v0.5.4: 多模态总开关(llm.multimodalEnabled,设置/引导向导中配置)
const multimodalEnabled = useAgentStore((s) => s.multimodalEnabled);
/** DeepSeek 不支持多模态图片 */
const supportsImages = provider !== 'deepseek';
/**
* v0.5.4: 图片上传双重判断 — 总开关 × 模型能力
* - 开关关闭:即使模型支持多模态也不能上传(显式控制)
* - 模型能力:DeepSeek 仅 vision 系列支持;其他五家 Provider 均支持
*/
const modelSupportsImages =
provider !== 'deepseek' || (model.length > 0 && model.includes('vision'));
const supportsImages = multimodalEnabled && modelSupportsImages;
// 草稿自动保存
useEffect(() => { if (currentSessionId) { const d = sessionStorage.getItem(`draft-${currentSessionId}`); setInput(d ?? ''); } }, [currentSessionId]);
useEffect(() => { if (currentSessionId && input) sessionStorage.setItem(`draft-${currentSessionId}`, input); }, [input, currentSessionId]);
useEffect(() => {
if (currentSessionId) {
const d = sessionStorage.getItem(`draft-${currentSessionId}`);
setInput(d ?? '');
}
}, [currentSessionId]);
useEffect(() => {
if (currentSessionId && input) sessionStorage.setItem(`draft-${currentSessionId}`, input);
}, [input, currentSessionId]);
// ===== 附件处理 =====
@@ -68,104 +127,136 @@ export function ChatInput(): React.JSX.Element {
return 'other';
}, []);
const processFile = useCallback(async (file: File): Promise<Attachment> => {
const type = classifyFile(file);
// L-10 修复: 统一使用 nanoid 生成附件 ID(与项目其他位置一致)
const attachment: Attachment = { id: `att_${nanoid(6)}`, file, type };
const processFile = useCallback(
async (file: File): Promise<Attachment> => {
const type = classifyFile(file);
// L-10 修复: 统一使用 nanoid 生成附件 ID(与项目其他位置一致)
const attachment: Attachment = { id: `att_${nanoid(6)}`, file, type };
if (type === 'image') {
// 图片转 base64 data URL → 压缩(限制 1024px, JPEG 0.7
const dataUri = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(new Error('图片读取失败'));
reader.readAsDataURL(file);
});
attachment.preview = await compressImage(dataUri, 1024, 0.7);
} else if (type === 'text') {
// 文本文件读取内容
attachment.textContent = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(new Error('文本文件读取失败'));
reader.readAsText(file);
});
}
if (type === 'image') {
// 图片转 base64 data URL → 压缩(限制 1024px, JPEG 0.7
const dataUri = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(new Error('图片读取失败'));
reader.readAsDataURL(file);
});
attachment.preview = await compressImage(dataUri, 1024, 0.7);
} else if (type === 'text') {
// 文本文件读取内容
attachment.textContent = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(new Error('文本文件读取失败'));
reader.readAsText(file);
});
}
return attachment;
}, [classifyFile]);
return attachment;
},
[classifyFile],
);
const addFiles = useCallback(async (files: FileList | File[]) => {
const fileArray = Array.from(files).slice(0, 5); // 最多 5 个附件
const addFiles = useCallback(
async (files: FileList | File[]) => {
const fileArray = Array.from(files).slice(0, 5); // 最多 5 个附件
// DeepSeek 不支持多模态,过滤图片
const filtered = supportsImages
? fileArray
: fileArray.filter((f) => !IMAGE_TYPES.includes(f.type));
// v0.5.4: 图片上传双重拦截(总开关 × 模型能力),非图片附件不受影响
const filtered = supportsImages
? fileArray
: fileArray.filter((f) => !IMAGE_TYPES.includes(f.type));
if (filtered.length < fileArray.length && !supportsImages) {
// v0.3.0: 用 Toast 提示用户 DeepSeek 不支持图片
const skipped = fileArray.length - filtered.length;
// v0.3.0 修复:记录 Toast 加载失败错误到控制台,而非静默吞掉
import('@metona-team/metona-toast').then((mod) => {
mod.default.warning(`DeepSeek 不支持图片,已自动跳过 ${skipped} 个图片文件`);
}).catch((err) => {
console.error('[ChatInput] Failed to load metona-toast for DeepSeek image warning:', err);
});
}
if (filtered.length < fileArray.length && !supportsImages) {
const skipped = fileArray.length - filtered.length;
// v0.5.4: 区分拒绝原因 — 开关未开启 vs 当前模型不支持
const reason = multimodalEnabled
? `当前模型不支持图片(${provider} 需多模态模型),已跳过 ${skipped} 个图片文件`
: `多模态未开启(设置 → LLM 配置),已跳过 ${skipped} 个图片文件`;
import('@metona-team/metona-toast')
.then((mod) => {
mod.default.warning(reason);
})
.catch((err) => {
console.error('[ChatInput] Failed to load metona-toast for image warning:', err);
});
}
if (filtered.length === 0) return;
if (filtered.length === 0) return;
// v0.3.0 修复: 使用 allSettled 处理部分文件读取失败,避免一个失败导致全部丢失
const results = await Promise.allSettled(filtered.map(processFile));
const successful = results.filter(
(r): r is PromiseFulfilledResult<Attachment> => r.status === 'fulfilled',
).map((r) => r.value);
if (successful.length === 0) {
import('@metona-team/metona-toast').then((mod) => {
mod.default.error('文件读取失败,请检查文件是否损坏或被锁定');
}).catch(() => {});
return;
}
if (successful.length < filtered.length) {
const failedCount = filtered.length - successful.length;
import('@metona-team/metona-toast').then((mod) => {
mod.default.warning(`${failedCount} 个文件读取失败,已跳过`);
}).catch(() => {});
}
setAttachments((prev) => [...prev, ...successful]);
}, [processFile, supportsImages]);
// v0.3.0 修复: 使用 allSettled 处理部分文件读取失败,避免一个失败导致全部丢失
const results = await Promise.allSettled(filtered.map(processFile));
const successful = results
.filter((r): r is PromiseFulfilledResult<Attachment> => r.status === 'fulfilled')
.map((r) => r.value);
if (successful.length === 0) {
import('@metona-team/metona-toast')
.then((mod) => {
mod.default.error('文件读取失败,请检查文件是否损坏或被锁定');
})
.catch(() => {});
return;
}
if (successful.length < filtered.length) {
const failedCount = filtered.length - successful.length;
import('@metona-team/metona-toast')
.then((mod) => {
mod.default.warning(`${failedCount} 个文件读取失败,已跳过`);
})
.catch(() => {});
}
setAttachments((prev) => [...prev, ...successful]);
},
[processFile, supportsImages],
);
const removeAttachment = useCallback((id: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== id));
}, []);
// 文件选择
const handleFileSelect = useCallback(() => { fileInputRef.current?.click(); }, []);
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) addFiles(e.target.files);
e.target.value = '';
}, [addFiles]);
const handleFileSelect = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) addFiles(e.target.files);
e.target.value = '';
},
[addFiles],
);
// 拖拽
const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); }, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.stopPropagation();
if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
}, [addFiles]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
},
[addFiles],
);
// 粘贴
const handlePaste = useCallback((e: React.ClipboardEvent) => {
const items = Array.from(e.clipboardData.items);
const files: File[] = [];
for (const item of items) {
if (item.kind === 'file') {
const f = item.getAsFile();
if (f) files.push(f);
const handlePaste = useCallback(
(e: React.ClipboardEvent) => {
const items = Array.from(e.clipboardData.items);
const files: File[] = [];
for (const item of items) {
if (item.kind === 'file') {
const f = item.getAsFile();
if (f) files.push(f);
}
}
}
if (files.length) { e.preventDefault(); addFiles(files); }
}, [addFiles]);
if (files.length) {
e.preventDefault();
addFiles(files);
}
},
[addFiles],
);
// ===== 发送消息 =====
@@ -179,20 +270,32 @@ export function ChatInput(): React.JSX.Element {
// 处理 / 命令
if (trimmed.startsWith('/')) {
const cmd = trimmed.split(' ')[0].toLowerCase();
if (cmd === '/clear') { useAgentStore.getState().clearMessages(); setInput(''); setAttachments([]); setShowSlashMenu(false); return; }
if (cmd === '/clear') {
useAgentStore.getState().clearMessages();
setInput('');
setAttachments([]);
setShowSlashMenu(false);
return;
}
if (cmd === '/export') {
// P2-11: /export 改为导出 Markdown(人类可读),JSON 导出走会话右键菜单
import('@renderer/lib/export-markdown').then(({ buildSessionMarkdown, downloadMarkdown }) => {
const messages = useAgentStore.getState().messages;
const md = buildSessionMarkdown('会话导出', messages);
downloadMarkdown(`session-${Date.now()}.md`, md);
}).catch(() => {});
setInput(''); setShowSlashMenu(false); return;
import('@renderer/lib/export-markdown')
.then(({ buildSessionMarkdown, downloadMarkdown }) => {
const messages = useAgentStore.getState().messages;
const md = buildSessionMarkdown('会话导出', messages);
downloadMarkdown(`session-${Date.now()}.md`, md);
})
.catch(() => {});
setInput('');
setShowSlashMenu(false);
return;
}
// v0.3.0: /tool — 打开设置面板的工具管理 Tab
if (cmd === '/tool') {
useUIStore.getState().openSettings();
setInput(''); setAttachments([]); setShowSlashMenu(false);
setInput('');
setAttachments([]);
setShowSlashMenu(false);
return;
}
// v0.3.0: /memory — 切换到详情面板的 Memory 标签
@@ -208,7 +311,9 @@ export function ChatInput(): React.JSX.Element {
if (!useUIStore.getState().detailVisible) {
uiStore.toggleDetail();
}
setInput(''); setAttachments([]); setShowSlashMenu(false);
setInput('');
setAttachments([]);
setShowSlashMenu(false);
return;
}
}
@@ -234,7 +339,11 @@ export function ChatInput(): React.JSX.Element {
}
}
sendMessage(messageContent, images.length > 0 ? images : undefined, attachmentInfos.length > 0 ? attachmentInfos : undefined);
sendMessage(
messageContent,
images.length > 0 ? images : undefined,
attachmentInfos.length > 0 ? attachmentInfos : undefined,
);
setInput('');
setAttachments([]);
setShowSlashMenu(false);
@@ -242,82 +351,166 @@ export function ChatInput(): React.JSX.Element {
if (textareaRef.current) textareaRef.current.style.height = 'auto';
}, [input, attachments, isStreaming, sendMessage, currentSessionId]);
const handleAbort = useCallback(() => { abort(); }, [abort]);
const handleAbort = useCallback(() => {
abort();
}, [abort]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
// H-9 修复: 快捷键对齐规范
// @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 快捷键规范
// 规范要求: Cmd/Ctrl+Enter = 发送消息, Cmd/Ctrl+Shift+Enter = 换行
// 之前代码是 Ctrl+Enter=换行, Enter=发送,与规范相反
// 修复后:
// Cmd/Ctrl+Enter = 发送消息(规范要求)
// Cmd/Ctrl+Shift+Enter = 换行(规范要求)
// Enter = 发送消息(保持聊天应用习惯
// Shift+Enter = 换行(textarea 默认行为,无需处理
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
// H-9 修复: 快捷键对齐规范
// @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 快捷键规范
// 规范要求: Cmd/Ctrl+Enter = 发送消息, Cmd/Ctrl+Shift+Enter = 换行
// 之前代码是 Ctrl+Enter=换行, Enter=发送,与规范相反
// 修复后:
// Cmd/Ctrl+Enter = 发送消息(规范要求)
// Cmd/Ctrl+Shift+Enter = 换行(规范要求
// Enter = 发送消息(保持聊天应用习惯
// Shift+Enter = 换行(textarea 默认行为,无需处理)
// Cmd/Ctrl+Shift+Enter — 换行(规范要求)
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && e.shiftKey) {
e.preventDefault();
const t = e.currentTarget as HTMLTextAreaElement;
const s = t.selectionStart;
const en = t.selectionEnd;
setInput((p) => p.slice(0, s) + '\n' + p.slice(en));
requestAnimationFrame(() => { t.selectionStart = t.selectionEnd = s + 1; });
return;
}
// Cmd/Ctrl+Enter — 发送消息(规范要求,优先于 Enter)
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSend();
return;
}
// Enter(不带修饰键)— 发送消息(保持聊天应用习惯)
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); return; }
if (e.key === 'Escape' && showSlashMenu) { setShowSlashMenu(false); return; }
}, [handleSend, showSlashMenu]);
// Cmd/Ctrl+Shift+Enter — 换行(规范要求)
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && e.shiftKey) {
e.preventDefault();
const t = e.currentTarget as HTMLTextAreaElement;
const s = t.selectionStart;
const en = t.selectionEnd;
setInput((p) => p.slice(0, s) + '\n' + p.slice(en));
requestAnimationFrame(() => {
t.selectionStart = t.selectionEnd = s + 1;
});
return;
}
// Cmd/Ctrl+Enter — 发送消息(规范要求,优先于 Enter)
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSend();
return;
}
// Enter(不带修饰键)— 发送消息(保持聊天应用习惯)
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
return;
}
if (e.key === 'Escape' && showSlashMenu) {
setShowSlashMenu(false);
return;
}
},
[handleSend, showSlashMenu],
);
// H-8 修复: 使用 InputBase 后,onChange 类型需兼容 HTMLInputElement | HTMLTextAreaElement
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
const v = e.target.value; setInput(v);
if (v === '/') { setShowSlashMenu(true); setSlashFilter(''); }
else if (v.startsWith('/') && !v.includes(' ')) { setShowSlashMenu(true); setSlashFilter(v.slice(1).toLowerCase()); }
else { setShowSlashMenu(false); }
}, []);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
const v = e.target.value;
setInput(v);
if (v === '/') {
setShowSlashMenu(true);
setSlashFilter('');
} else if (v.startsWith('/') && !v.includes(' ')) {
setShowSlashMenu(true);
setSlashFilter(v.slice(1).toLowerCase());
} else {
setShowSlashMenu(false);
}
},
[],
);
const filteredCommands = SLASH_COMMANDS.filter((c) => c.label.toLowerCase().includes(`/${slashFilter}`));
const filteredCommands = SLASH_COMMANDS.filter((c) =>
c.label.toLowerCase().includes(`/${slashFilter}`),
);
return (
<Box sx={{ flexShrink: 0, px: 2, pb: 1.5 }}>
<Paper sx={{ maxWidth: 768, mx: 'auto', borderRadius: 3, p: 1.5, bgcolor: 'background.paper', border: '1px solid', borderColor: 'divider', position: 'relative' }}
onDragOver={handleDragOver} onDrop={handleDrop}
<Paper
sx={{
maxWidth: 768,
mx: 'auto',
borderRadius: 3,
p: 1.5,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
position: 'relative',
}}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* 附件预览区 */}
{attachments.length > 0 && (
<Stack direction="row" spacing={1} sx={{ mb: 1, flexWrap: 'wrap', gap: 1 }}>
{attachments.map((att) => (
<AttachmentPreview key={att.id} attachment={att} onRemove={() => removeAttachment(att.id)} />
<AttachmentPreview
key={att.id}
attachment={att}
onRemove={() => removeAttachment(att.id)}
/>
))}
</Stack>
)}
{/* / 命令菜单 */}
{showSlashMenu && filteredCommands.length > 0 && (
<div style={{ position: 'absolute', bottom: '100%', left: 0, right: 0, marginBottom: 4, padding: '4px 0', background: 'var(--bg-secondary, #1a1d27)', border: '1px solid var(--border-color, #2a2d3a)', borderRadius: 8, boxShadow: '0 -4px 12px rgba(0,0,0,0.2)', zIndex: 10 }}>
<div
style={{
position: 'absolute',
bottom: '100%',
left: 0,
right: 0,
marginBottom: 4,
padding: '4px 0',
background: 'var(--bg-secondary, #1a1d27)',
border: '1px solid var(--border-color, #2a2d3a)',
borderRadius: 8,
boxShadow: '0 -4px 12px rgba(0,0,0,0.2)',
zIndex: 10,
}}
>
{filteredCommands.map((cmd) => (
<div key={cmd.id} onClick={() => { setInput(cmd.label + ' '); setShowSlashMenu(false); textareaRef.current?.focus(); }}
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 12px', fontSize: 12, cursor: 'pointer', color: 'var(--text-primary, #e1e4ed)' }}
<div
key={cmd.id}
onClick={() => {
setInput(cmd.label + ' ');
setShowSlashMenu(false);
textareaRef.current?.focus();
}}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '6px 12px',
fontSize: 12,
cursor: 'pointer',
color: 'var(--text-primary, #e1e4ed)',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.05)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<span style={{ color: '#818cf8', fontSize: 14, fontWeight: 700, flexShrink: 0 }}>/</span>
<span style={{ color: '#818cf8', fontSize: 14, fontWeight: 700, flexShrink: 0 }}>
/
</span>
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{cmd.label}</span>
<span style={{ color: 'var(--text-secondary, #64748b)', fontSize: 11 }}>{cmd.description}</span>
<span style={{ color: 'var(--text-secondary, #64748b)', fontSize: 11 }}>
{cmd.description}
</span>
</div>
))}
</div>
)}
<input ref={fileInputRef} type="file" multiple accept={supportsImages ? 'image/*,.txt,.md,.json,.csv,.ts,.tsx,.js,.jsx,.py,.rb,.go,.rs,.java,.c,.cpp,.h,.css,.html,.xml,.yaml,.yml,.toml,.ini,.sh,.sql,.log,.pdf' : '.txt,.md,.json,.csv,.ts,.tsx,.js,.jsx,.py,.rb,.go,.rs,.java,.c,.cpp,.h,.css,.html,.xml,.yaml,.yml,.toml,.ini,.sh,.sql,.log,.pdf'} style={{ display: 'none' }} onChange={handleFileChange} />
<input
ref={fileInputRef}
type="file"
multiple
accept={
supportsImages
? 'image/*,.txt,.md,.json,.csv,.ts,.tsx,.js,.jsx,.py,.rb,.go,.rs,.java,.c,.cpp,.h,.css,.html,.xml,.yaml,.yml,.toml,.ini,.sh,.sql,.log,.pdf'
: '.txt,.md,.json,.csv,.ts,.tsx,.js,.jsx,.py,.rb,.go,.rs,.java,.c,.cpp,.h,.css,.html,.xml,.yaml,.yml,.toml,.ini,.sh,.sql,.log,.pdf'
}
style={{ display: 'none' }}
onChange={handleFileChange}
/>
{/* H-8 修复: 使用 MUI InputBase 替代原生 textarea — 遵循 MUI 强制使用规范 */}
{/* @see standard/开发规范.md — MUI 强制使用、禁止自写 UI 组件 */}
@@ -328,7 +521,13 @@ export function ChatInput(): React.JSX.Element {
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={configLoaded ? (toolsReady ? '输入消息... (Cmd/Ctrl+Enter 发送, Cmd/Ctrl+Shift+Enter 换行, / 命令)' : '工具加载中...') : '正在加载配置...'}
placeholder={
configLoaded
? toolsReady
? '输入消息... (Cmd/Ctrl+Enter 发送, Cmd/Ctrl+Shift+Enter 换行, / 命令)'
: '工具加载中...'
: '正在加载配置...'
}
disabled={isStreaming || !configLoaded || !toolsReady}
multiline
rows={1}
@@ -350,9 +549,18 @@ export function ChatInput(): React.JSX.Element {
}}
/>
<Stack direction="row" sx={{ mt: 1, minHeight: 32, justifyContent: 'space-between', alignItems: 'center' }}>
<Stack
direction="row"
sx={{ mt: 1, minHeight: 32, justifyContent: 'space-between', alignItems: 'center' }}
>
{/* 左侧:附件按钮 */}
<Tooltip title={supportsImages ? '附加文件(图片/文本/代码)' : '附加文件(文本/代码)— DeepSeek 不支持图片'}>
<Tooltip
title={
supportsImages
? '附加文件(图片/文本/代码)'
: '附加文件(文本/代码)— DeepSeek 不支持图片'
}
>
<IconButton size="small" sx={{ color: 'text.secondary' }} onClick={handleFileSelect}>
<Paperclip size={14} />
</IconButton>
@@ -361,11 +569,32 @@ export function ChatInput(): React.JSX.Element {
{/* 右侧:发送按钮 */}
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
{isStreaming ? (
<Button variant="contained" color="error" size="small" onClick={handleAbort} sx={{ height: 28, fontSize: 12 }}>
<Button
variant="contained"
color="error"
size="small"
onClick={handleAbort}
sx={{ height: 28, fontSize: 12 }}
>
<Square size={12} style={{ marginRight: 6 }} />
</Button>
) : (
<Button variant="contained" size="small" onClick={handleSend} disabled={(!input.trim() && attachments.length === 0) || !configLoaded || !toolsReady} sx={{ height: 28, fontSize: 12, opacity: (input.trim() || attachments.length > 0) && configLoaded && toolsReady ? 1 : 0.5 }}>
<Button
variant="contained"
size="small"
onClick={handleSend}
disabled={
(!input.trim() && attachments.length === 0) || !configLoaded || !toolsReady
}
sx={{
height: 28,
fontSize: 12,
opacity:
(input.trim() || attachments.length > 0) && configLoaded && toolsReady
? 1
: 0.5,
}}
>
<Send size={12} style={{ marginRight: 6 }} />
</Button>
)}
@@ -378,17 +607,69 @@ export function ChatInput(): React.JSX.Element {
// ===== 附件预览组件 =====
function AttachmentPreview({ attachment, onRemove }: { attachment: Attachment; onRemove: () => void }) {
function AttachmentPreview({
attachment,
onRemove,
}: {
attachment: Attachment;
onRemove: () => void;
}) {
const { type, file, preview } = attachment;
if (type === 'image' && preview) {
return (
<Box sx={{ position: 'relative', width: 64, height: 64, borderRadius: 1.5, overflow: 'hidden', border: '1px solid', borderColor: 'divider', flexShrink: 0 }}>
<Box component="img" src={preview} alt={file.name} sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<IconButton size="small" onClick={onRemove} sx={{ position: 'absolute', top: 0, right: 0, width: 20, height: 20, bgcolor: 'rgba(0,0,0,0.6)', '&:hover': { bgcolor: 'rgba(0,0,0,0.8)' }, color: '#fff' }}>
<Box
sx={{
position: 'relative',
width: 64,
height: 64,
borderRadius: 1.5,
overflow: 'hidden',
border: '1px solid',
borderColor: 'divider',
flexShrink: 0,
}}
>
<Box
component="img"
src={preview}
alt={file.name}
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
<IconButton
size="small"
onClick={onRemove}
sx={{
position: 'absolute',
top: 0,
right: 0,
width: 20,
height: 20,
bgcolor: 'rgba(0,0,0,0.6)',
'&:hover': { bgcolor: 'rgba(0,0,0,0.8)' },
color: '#fff',
}}
>
<X size={12} />
</IconButton>
<Typography variant="caption" sx={{ position: 'absolute', bottom: 0, left: 0, right: 0, bgcolor: 'rgba(0,0,0,0.6)', color: '#fff', fontSize: 9, textAlign: 'center', py: 0.25, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', px: 0.5 }}>
<Typography
variant="caption"
sx={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
bgcolor: 'rgba(0,0,0,0.6)',
color: '#fff',
fontSize: 9,
textAlign: 'center',
py: 0.25,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
px: 0.5,
}}
>
{file.name}
</Typography>
</Box>
@@ -397,13 +678,49 @@ function AttachmentPreview({ attachment, onRemove }: { attachment: Attachment; o
// 文本文件 / 其他文件
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1, borderRadius: 1.5, bgcolor: 'secondary.main', border: '1px solid', borderColor: 'divider', maxWidth: 200, flexShrink: 0 }}>
{type === 'text' ? <FileText size={16} style={{ color: '#22d3ee', flexShrink: 0 }} /> : <ImageIcon size={16} style={{ color: '#8b8fa7', flexShrink: 0 }} />}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 1.5,
py: 1,
borderRadius: 1.5,
bgcolor: 'secondary.main',
border: '1px solid',
borderColor: 'divider',
maxWidth: 200,
flexShrink: 0,
}}
>
{type === 'text' ? (
<FileText size={16} style={{ color: '#22d3ee', flexShrink: 0 }} />
) : (
<ImageIcon size={16} style={{ color: '#8b8fa7', flexShrink: 0 }} />
)}
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="caption" sx={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 11, color: 'text.primary' }}>{file.name}</Typography>
<Typography variant="caption" sx={{ fontSize: 9, color: 'text.secondary' }}>{formatFileSize(file.size)}</Typography>
<Typography
variant="caption"
sx={{
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: 11,
color: 'text.primary',
}}
>
{file.name}
</Typography>
<Typography variant="caption" sx={{ fontSize: 9, color: 'text.secondary' }}>
{formatFileSize(file.size)}
</Typography>
</Box>
<IconButton size="small" onClick={onRemove} sx={{ width: 20, height: 20, flexShrink: 0, color: 'text.secondary' }}>
<IconButton
size="small"
onClick={onRemove}
sx={{ width: 20, height: 20, flexShrink: 0, color: 'text.secondary' }}
>
<X size={12} />
</IconButton>
</Box>
@@ -416,11 +733,7 @@ function AttachmentPreview({ attachment, onRemove }: { attachment: Attachment; o
*
* @see AgnesAIDesktop — 已验证 Agnes AI 可正常处理压缩后的图片
*/
function compressImage(
dataUri: string,
maxSize: number,
quality: number,
): Promise<string> {
function compressImage(dataUri: string, maxSize: number, quality: number): Promise<string> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
@@ -430,9 +743,15 @@ function compressImage(
return;
}
if (width > height) {
if (width > maxSize) { height = Math.round((height * maxSize) / width); width = maxSize; }
if (width > maxSize) {
height = Math.round((height * maxSize) / width);
width = maxSize;
}
} else {
if (height > maxSize) { width = Math.round((width * maxSize) / height); height = maxSize; }
if (height > maxSize) {
width = Math.round((width * maxSize) / height);
height = maxSize;
}
}
const canvas = document.createElement('canvas');
canvas.width = width;
+269 -58
View File
@@ -3,7 +3,26 @@
*/
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 {
Dialog,
DialogContent,
Button,
TextField,
Select,
MenuItem,
Stepper,
Step,
StepLabel,
Box,
Typography,
Stack,
FormControl,
InputLabel,
IconButton,
InputAdornment,
FormControlLabel,
Switch,
} from '@mui/material';
import { ArrowRight, ArrowLeft, CheckCircle, Eye, EyeOff } from 'lucide-react';
import { useUIStore } from '@renderer/stores/ui-store';
import { useAgentStore } from '@renderer/stores/agent-store';
@@ -18,6 +37,8 @@ export function OnboardingWizard(): React.JSX.Element | null {
const setOnboardingCompleted = useUIStore((s) => s.setOnboardingCompleted);
const [step, setStep] = useState(0);
const [provider, setProvider] = useState('');
// v0.5.4: 多模态总开关(保存到 llm.multimodalEnabled,控制图片上传入口)
const [multimodalEnabled, setMultimodalEnabled] = useState(false);
const [baseURL, setBaseURL] = useState('');
const [model, setModel] = useState('');
const [apiKey, setApiKey] = useState('');
@@ -33,8 +54,11 @@ export function OnboardingWizard(): React.JSX.Element | null {
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;
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);
@@ -58,9 +82,17 @@ export function OnboardingWizard(): React.JSX.Element | null {
// 注意: 不保存 apiKey(敏感信息不写入 localStorage
useEffect(() => {
try {
localStorage.setItem(ONBOARDING_PROGRESS_KEY, JSON.stringify({
step, provider, baseURL, model, workspacePath, contextWindow,
}));
localStorage.setItem(
ONBOARDING_PROGRESS_KEY,
JSON.stringify({
step,
provider,
baseURL,
model,
workspacePath,
contextWindow,
}),
);
} catch {
// 写入失败(如隐私模式)忽略
}
@@ -84,7 +116,10 @@ export function OnboardingWizard(): React.JSX.Element | null {
contextWindow != null && (!Number.isFinite(contextWindow) || contextWindow < ctxMin);
const handleNext = async () => {
if (step < STEPS.length - 1) { setStep(step + 1); return; }
if (step < STEPS.length - 1) {
setStep(step + 1);
return;
}
try {
if (window.metona?.config?.setBatch) {
// v0.3.9: 改用批量保存,避免并行 config.set 中间态触发 reloadAdapter 失败
@@ -98,7 +133,10 @@ export function OnboardingWizard(): React.JSX.Element | null {
if (baseURL.trim()) entries.push({ key: 'llm.baseURL', value: baseURL.trim() });
if (model.trim()) entries.push({ key: 'llm.model', value: model.trim() });
if (apiKey.trim()) entries.push({ key: 'llm.apiKey', value: apiKey.trim() });
if (workspacePath.trim()) entries.push({ key: 'workspace.path', value: workspacePath.trim() });
// v0.5.4: 多模态总开关
entries.push({ key: 'llm.multimodalEnabled', value: multimodalEnabled });
if (workspacePath.trim())
entries.push({ key: 'workspace.path', value: workspacePath.trim() });
// 上下文窗口:根据 Provider 落库到对应 key
// - ollama: ollama.numCtx(允许 null=由模型决定)
// - deepseek/agnes/mimo: {provider}.contextWindow(必须有值且 >= 4096
@@ -112,10 +150,14 @@ export function OnboardingWizard(): React.JSX.Element | null {
const r = await window.metona.config.setBatch(entries);
if (r && !r.success) {
console.error('[OnboardingWizard]', 'Batch config save failed:', r.error);
import('@metona-team/metona-toast').then((mod) => mod.default.error(r.error ?? '配置保存失败,请重试')).catch(() => {});
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(r.error ?? '配置保存失败,请重试'))
.catch(() => {});
return;
}
useAgentStore.getState().setProvider(provider.trim() || 'deepseek', model.trim() || '');
// v0.5.4: 多模态开关立即同步(setProvider 内部会从配置异步加载,此处确保即时生效)
useAgentStore.getState().setMultimodalEnabled(multimodalEnabled);
// 同步 contextWindow 到 Agent Store(与 SettingsModal 行为一致)
if (contextWindow != null && contextWindow >= ctxMin) {
useAgentStore.setState({ contextWindow });
@@ -125,42 +167,86 @@ export function OnboardingWizard(): React.JSX.Element | null {
}
setOnboardingCompleted(true);
// #48 修复: 引导完成后清理 localStorage 进度,下次启动不再恢复
try { localStorage.removeItem(ONBOARDING_PROGRESS_KEY); } catch { /* ignore */ }
try {
localStorage.removeItem(ONBOARDING_PROGRESS_KEY);
} catch {
/* ignore */
}
} catch (err) {
console.error('[OnboardingWizard]', 'Failed to save configuration:', err);
// 用户主动操作失败必须有反馈,否则向导不关闭、用户卡死
import('@metona-team/metona-toast').then((mod) => mod.default.error(`保存配置失败:${(err as Error).message}`)).catch(() => {});
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(`保存配置失败:${(err as Error).message}`))
.catch(() => {});
}
};
return (
<Dialog open maxWidth="sm" slotProps={{ paper: { sx: { borderRadius: 3, overflow: 'hidden' } } }}>
<Dialog
open
maxWidth="sm"
slotProps={{ paper: { sx: { borderRadius: 3, overflow: 'hidden' } } }}
>
<Box sx={{ px: 3, pt: 2, pb: 0 }}>
<Stepper activeStep={step} alternativeLabel sx={{ '& .MuiStepLabel-label': { fontSize: 11 } }}>
{STEPS.map((s) => <Step key={s}><StepLabel>{s}</StepLabel></Step>)}
<Stepper
activeStep={step}
alternativeLabel
sx={{ '& .MuiStepLabel-label': { fontSize: 11 } }}
>
{STEPS.map((s) => (
<Step key={s}>
<StepLabel>{s}</StepLabel>
</Step>
))}
</Stepper>
</Box>
<DialogContent sx={{ minHeight: 280, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
<DialogContent
sx={{
minHeight: 280,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
{step === 0 && (
<Box sx={{ textAlign: 'center' }}>
<Box component="img" src="./logo.png" alt="Metona" sx={{ width: 64, height: 64, mx: 'auto', mb: 2, borderRadius: 2 }} />
<Typography variant="h6" sx={{ mb: 1 }}>使 MetonaAI Desktop</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}> AI Agent MCP </Typography>
<Box
component="img"
src="./logo.png"
alt="Metona"
sx={{ width: 64, height: 64, mx: 'auto', mb: 2, borderRadius: 2 }}
/>
<Typography variant="h6" sx={{ mb: 1 }}>
使 MetonaAI Desktop
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
AI Agent MCP
</Typography>
<Typography variant="caption"> 1 </Typography>
</Box>
)}
{step === 1 && (
<Box sx={{ width: '100%' }}>
<Typography variant="h6" sx={{ mb: 2 }}> LLM Provider</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}> Provider API Base URL </Typography>
<Typography variant="h6" sx={{ mb: 2 }}>
LLM Provider
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
Provider API Base URL
</Typography>
<Stack spacing={2}>
<FormControl size="small"><InputLabel>Provider</InputLabel>
<Select value={provider} label="Provider" onChange={(e) => {
const v = e.target.value;
setProvider(v);
// 联动默认上下文窗口(与 SettingsModal 默认值一致)
setContextWindow(DEFAULT_CTX[v] ?? null);
}}>
<FormControl size="small">
<InputLabel>Provider</InputLabel>
<Select
value={provider}
label="Provider"
onChange={(e) => {
const v = e.target.value;
setProvider(v);
// 联动默认上下文窗口(与 SettingsModal 默认值一致)
setContextWindow(DEFAULT_CTX[v] ?? null);
}}
>
<MenuItem value="deepseek">DeepSeek</MenuItem>
<MenuItem value="agnes">Agnes AI</MenuItem>
<MenuItem value="mimo">MiMo ()</MenuItem>
@@ -169,71 +255,196 @@ export function OnboardingWizard(): React.JSX.Element | null {
<MenuItem value="anthropic">Anthropic</MenuItem>
</Select>
</FormControl>
<TextField size="small" label="API Base URL" value={baseURL} onChange={(e) => setBaseURL(e.target.value)} placeholder="如 https://api.deepseek.com" />
<TextField size="small" label="模型名称" value={model} onChange={(e) => setModel(e.target.value)} placeholder="如 deepseek-v4-pro、qwen3:latest" />
<TextField size="small" label="API Key" type={showKey ? 'text' : 'password'} value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-...(本地模型可留空)"
slotProps={{ input: { endAdornment: <InputAdornment position="end"><IconButton size="small" onClick={() => setShowKey(!showKey)}>{showKey ? <EyeOff size={14} /> : <Eye size={14} />}</IconButton></InputAdornment> } }}
<TextField
size="small"
label="API Base URL"
value={baseURL}
onChange={(e) => setBaseURL(e.target.value)}
placeholder="如 https://api.deepseek.com"
/>
<TextField
size="small"
label={provider === 'ollama' ? '上下文长度 (num_ctx)' : '上下文窗口 (contextWindow)'}
label="模型名称"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="如 deepseek-v4-pro、qwen3:latest"
/>
<TextField
size="small"
label="API Key"
type={showKey ? 'text' : 'password'}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-...(本地模型可留空)"
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton size="small" onClick={() => setShowKey(!showKey)}>
{showKey ? <EyeOff size={14} /> : <Eye size={14} />}
</IconButton>
</InputAdornment>
),
},
}}
/>
<TextField
size="small"
label={
provider === 'ollama' ? '上下文长度 (num_ctx)' : '上下文窗口 (contextWindow)'
}
type="number"
value={contextWindow ?? ''}
onChange={(e) => {
const v = e.target.value;
setContextWindow(v === '' ? null : Number(v));
}}
placeholder={provider === 'ollama' ? '默认由模型决定(如 2048、4096、128000' : '如 64000、128000、1000000'}
placeholder={
provider === 'ollama'
? '默认由模型决定(如 2048、4096、128000'
: '如 64000、128000、1000000'
}
slotProps={{ htmlInput: { min: ctxMin, step: ctxMin } }}
error={ctxError}
helperText={ctxError ? `最小值为 ${ctxMin}` : (provider === 'ollama' ? ' ' : '用于上下文压缩判断,不传给 API')}
helperText={
ctxError
? `最小值为 ${ctxMin}`
: provider === 'ollama'
? ' '
: '用于上下文压缩判断,不传给 API'
}
/>
{/* v0.5.4: 多模态总开关 — 未开启时即使模型支持也不能上传图片 */}
<FormControlLabel
control={
<Switch
size="small"
checked={multimodalEnabled}
onChange={(e) => setMultimodalEnabled(e.target.checked)}
/>
}
label={
<Stack>
<Typography variant="body2"></Typography>
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
DeepSeek vision
</Typography>
</Stack>
}
sx={{ alignItems: 'flex-start', m: 0 }}
/>
</Stack>
</Box>
)}
{step === 2 && (
<Box sx={{ width: '100%' }}>
<Typography variant="h6" sx={{ mb: 2 }}> Agent</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}> SOUL.md Agent </Typography>
<Box sx={{ px: 2, py: 1.5, borderRadius: 2, bgcolor: 'action.hover', border: '1px solid', borderColor: 'divider', fontSize: 12, color: 'text.secondary' }}>
<div><strong style={{ color: '#e1e4ed' }}>SOUL.md</strong> Agent </div>
<div style={{ marginTop: 8, fontSize: 11, opacity: 0.7 }}></div>
<Typography variant="h6" sx={{ mb: 2 }}>
Agent
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
SOUL.md Agent
</Typography>
<Box
sx={{
px: 2,
py: 1.5,
borderRadius: 2,
bgcolor: 'action.hover',
border: '1px solid',
borderColor: 'divider',
fontSize: 12,
color: 'text.secondary',
}}
>
<div>
<strong style={{ color: '#e1e4ed' }}>SOUL.md</strong> Agent
</div>
<div style={{ marginTop: 8, fontSize: 11, opacity: 0.7 }}>
</div>
</Box>
</Box>
)}
{step === 3 && (
<Box sx={{ width: '100%' }}>
<Typography variant="h6" sx={{ mb: 2 }}></Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>使</Typography>
<Typography variant="h6" sx={{ mb: 2 }}>
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
使
</Typography>
<Stack direction="row" spacing={1} sx={{ mb: 2, alignItems: 'center' }}>
<TextField size="small" value={workspacePath} onChange={(e) => setWorkspacePath(e.target.value)} placeholder="~/MetonaWorkspaces/default/" sx={{ flex: 1 }} />
<Button variant="outlined" size="small" onClick={async () => {
if (window.metona?.app?.selectFolder) {
try {
const r = await window.metona.app.selectFolder(workspacePath || undefined);
if (!r.canceled && r.path) setWorkspacePath(r.path);
} catch (err) {
console.error('[OnboardingWizard]', err);
import('@metona-team/metona-toast').then((mod) => mod.default.error('选择文件夹失败')).catch(() => {});
<TextField
size="small"
value={workspacePath}
onChange={(e) => setWorkspacePath(e.target.value)}
placeholder="~/MetonaWorkspaces/default/"
sx={{ flex: 1 }}
/>
<Button
variant="outlined"
size="small"
onClick={async () => {
if (window.metona?.app?.selectFolder) {
try {
const r = await window.metona.app.selectFolder(workspacePath || undefined);
if (!r.canceled && r.path) setWorkspacePath(r.path);
} catch (err) {
console.error('[OnboardingWizard]', err);
import('@metona-team/metona-toast')
.then((mod) => mod.default.error('选择文件夹失败'))
.catch(() => {});
}
}
}
}}></Button>
}}
>
</Button>
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}> SOUL.mdMEMORY.md </Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
SOUL.mdMEMORY.md
</Typography>
</Box>
)}
{step === 4 && (
<Box sx={{ textAlign: 'center' }}>
<CheckCircle size={48} style={{ color: '#34d399', margin: '0 auto 16px' }} />
<Typography variant="h6" sx={{ mb: 1 }}></Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1 }}>MetonaAI Desktop AI Agent </Typography>
<Typography variant="h6" sx={{ mb: 1 }}>
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1 }}>
MetonaAI Desktop AI Agent
</Typography>
<Typography variant="caption"> Ctrl+Enter / </Typography>
</Box>
)}
</DialogContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', px: 3, py: 1.5, borderTop: 1, borderColor: 'divider' }}>
<Button startIcon={<ArrowLeft size={12} />} onClick={() => setStep(step - 1)} disabled={step === 0} size="small" sx={{ color: 'text.secondary' }}></Button>
<Button variant="contained" endIcon={step < STEPS.length - 1 ? <ArrowRight size={12} /> : undefined} onClick={handleNext} size="small">
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
px: 3,
py: 1.5,
borderTop: 1,
borderColor: 'divider',
}}
>
<Button
startIcon={<ArrowLeft size={12} />}
onClick={() => setStep(step - 1)}
disabled={step === 0}
size="small"
sx={{ color: 'text.secondary' }}
>
</Button>
<Button
variant="contained"
endIcon={step < STEPS.length - 1 ? <ArrowRight size={12} /> : undefined}
onClick={handleNext}
size="small"
>
{step === STEPS.length - 1 ? '开始使用' : '下一步'}
</Button>
</Box>
+32 -1
View File
@@ -18,6 +18,8 @@ import {
FormControl,
IconButton,
CircularProgress,
FormControlLabel,
Switch,
} from '@mui/material';
import { Eye, EyeOff } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store';
@@ -50,6 +52,8 @@ export function LLMSettings() {
const [showFbKey, setShowFbKey] = useState(false);
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
// v0.5.4: 多模态总开关(未开启时禁止上传图片,即使模型支持)
const [multimodalEnabled, setMultimodalEnabled] = useState(false);
// v0.5.0: DeepSeek 余额显示(复用主进程 getBalance,原为适配器死代码)
const [balance, setBalance] = useState<{
currency: string;
@@ -116,9 +120,11 @@ export function LLMSettings() {
window.metona.config.get('llm.fallbackModel'),
window.metona.config.get('llm.fallbackApiKey'),
window.metona.config.get('llm.fallbackBaseURL'),
// v0.5.4: 多模态开关
window.metona.config.get('llm.multimodalEnabled'),
]);
if (cancelled) return;
const [p, m, k, u, nc, ds, ag, mi, oa, an, fbp, fbm, fbk, fbu] = results;
const [p, m, k, u, nc, ds, ag, mi, oa, an, fbp, fbm, fbk, fbu, mm] = results;
setProvider((p as string) ?? '');
setModel((m as string) ?? '');
setApiKey((k as string) ?? '');
@@ -133,6 +139,7 @@ export function LLMSettings() {
setFbModel((fbm as string) ?? '');
setFbApiKey((fbk as string) ?? '');
setFbBaseURL((fbu as string) ?? '');
setMultimodalEnabled(mm === true);
} catch (err) {
console.error('[LLMSettings]', err);
} finally {
@@ -248,6 +255,8 @@ export function LLMSettings() {
{ key: 'llm.model', value: model },
{ key: 'llm.apiKey', value: apiKey },
{ key: 'llm.baseURL', value: baseURL },
// v0.5.4: 多模态总开关
{ key: 'llm.multimodalEnabled', value: multimodalEnabled },
{ key: 'ollama.numCtx', value: numCtx },
{ key: 'deepseek.contextWindow', value: dsCtxWindow },
{ key: 'agnes.contextWindow', value: agnesCtxWindow },
@@ -266,6 +275,8 @@ export function LLMSettings() {
.then((mod) => mod.default.error(r.error ?? '配置保存失败'))
.catch(() => {});
} else {
// v0.5.4: 保存成功后同步多模态开关到 Agent Store(立即生效,控制上传入口)
useAgentStore.getState().setMultimodalEnabled(multimodalEnabled);
import('@metona-team/metona-toast')
.then((mod) => mod.default.success('配置已保存'))
.catch(() => {});
@@ -332,6 +343,26 @@ export function LLMSettings() {
error={modelHasSpace}
helperText={modelHasSpace ? '模型名称不能包含空格' : ' '}
/>
{/* v0.5.4: 多模态总开关 — 未开启时即使模型支持也不能上传图片 */}
<FormControlLabel
control={
<Switch
size="small"
checked={multimodalEnabled}
onChange={(e) => setMultimodalEnabled(e.target.checked)}
/>
}
label={
<Stack>
<Typography variant="body2"></Typography>
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
DeepSeek vision
10
</Typography>
</Stack>
}
sx={{ alignItems: 'flex-start', m: 0 }}
/>
{provider !== 'ollama' && (
<>
<TextField