diff --git a/README.md b/README.md index a1d7cf9..1a42b87 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version + Version License Electron React @@ -241,6 +241,7 @@ Metona 的核心是一个 **ReAct (Reasoning + Acting)** 状态机驱动引擎 | 💭 **Thinking 模式** | 支持 deepseek-v4-pro / agnes-2.0-flash / qwen3 等推理模型的思维链展示 | | 🔁 **死循环检测** | 连续 3 轮相同工具调用签名自动终止 | | 📦 **上下文压缩** | 80% 阈值触发 LLM 摘要压缩,按 token 预算动态保留近期消息(超长 tool_result 二次截断) | +| 🖼️ **多轮图片记忆** | 历史轮次的图片随上下文回传 LLM(最近 10 张,从最新向前收集);多模态总开关 `llm.multimodalEnabled` 控制上传入口 | | 🔄 **错误重试** | 指数退避 (1s/2s/4s) + ±20% jitter,上限 30s | | ⏱️ **可配置迭代** | 最大迭代次数 (默认 20)、总超时 (默认 600s)、工具执行超时 (默认 120s) | | 🧵 **子任务委派** | TaskOrchestrator 支持最大 3 层深度的 SubAgent 编排 | @@ -348,7 +349,7 @@ Metona 通过统一的 `IMetonaProviderAdapter` 接口抽象了所有 LLM Provid | 适配器 | Provider | 模型 | 上下文 | 流式格式 | Thinking | 多模态 | |:---|:---|:---|:---|:---|:---|:---| -| **DeepSeekAdapter** | `deepseek` | deepseek-v4-pro / deepseek-v4-flash | 1M tokens | SSE | `thinking.type` + `reasoning_effort` | 否 | +| **DeepSeekAdapter** | `deepseek` | deepseek-v4-pro / deepseek-v4-flash / deepseek-v4-flash-vision-exp | 1M (vision 128K) | SSE | `thinking.type` + `reasoning_effort` | 是(仅 vision 系列) | | **AgnesAdapter** | `agnes` | agnes-2.0-flash | 1M tokens | SSE | `chat_template_kwargs` / Anthropic 兼容 | 是 (URL + Base64) | | **MimoAdapter** | `mimo` | mimo-v2.5-pro / mimo-v2.5 | 1M tokens | SSE | `thinking.type: enabled` | 是 (URL + Base64) | | **OllamaAdapter** | `ollama` | qwen3 / gemma3 / deepseek-r1 等 | 可配 (num_ctx) | NDJSON | `think` 参数 | 是 (Base64) | @@ -642,6 +643,7 @@ OLLAMA_BASE_URL=http://localhost:11434 |:---|:---|:---| | `llm.provider` | (空) | LLM Provider ID(未配置时回退 .env) | | `llm.model` | (空) | 模型标识符 | +| `llm.multimodalEnabled` | `false` | 多模态总开关 — 未开启时即使模型支持也不能上传图片 | | `agent.maxIterations` | `20` | ReAct 最大迭代轮次 | | `agent.totalTimeoutMs` | `600000` | Agent 总超时 (ms) | | `agent.toolExecutionTimeoutMs` | `120000` | 单个工具执行超时 (ms) | @@ -865,7 +867,7 @@ npm run format # Prettier 格式化 # ─── 测试 ───────────────────────────────── npm test # 运行单元测试 (Vitest, 系统 Node — audit 套件因 better-sqlite3 ABI 自动跳过) -npm run test:electron # 运行全量单元测试 (Electron Node ABI, 236 用例全执行, 含 SQLite 审计链哈希 + 引擎工具链集成) +npm run test:electron # 运行全量单元测试 (Electron Node ABI, 243 用例全执行, 含 SQLite 审计链哈希 + 引擎工具链集成) npm run test:watch # 测试监听模式 # ─── 构建 ───────────────────────────────── diff --git a/electron/harness/adapters/__tests__/deepseek-vision.test.ts b/electron/harness/adapters/__tests__/deepseek-vision.test.ts new file mode 100644 index 0000000..88d565b --- /dev/null +++ b/electron/harness/adapters/__tests__/deepseek-vision.test.ts @@ -0,0 +1,125 @@ +/** + * DeepSeekAdapter 多模态(vision 模型)请求格式测试(v0.5.4) + * + * 背景:DeepSeek 新增 vision 实验模型 deepseek-v4-flash-vision-exp + * (OpenAI image_url content parts 格式)。适配器行为: + * - vision 模型:带 images 的消息 content 转换为 [{type:'text'},{type:'image_url'}] parts + * - 非 vision 模型:images 静默丢弃(共享层行为,防 API 400) + * + * 测试策略(契约级):mock fetch 记录真实请求体断言。 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('electron-log', () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +import { DeepSeekAdapter } from '../deepseek.adapter'; +import type { MetonaRequest } from '../../types'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +beforeEach(() => { + mockFetch.mockReset(); +}); +afterEach(() => { + mockFetch.mockReset(); +}); + +function makeAdapter(model: string): DeepSeekAdapter { + return new DeepSeekAdapter({ + provider: 'deepseek', + baseURL: 'https://api.deepseek.com', + apiKey: 'sk-test', + defaultModel: model, + }); +} + +function makeRequest(images?: Array<{ url: string }>): MetonaRequest { + return { + meta: { + sessionId: 's', + iteration: 1, + requestId: 'r', + timestamp: Date.now(), + agentVersion: '1', + }, + systemPrompt: { roleDefinition: 'sys', outputConstraints: '', safetyGuidelines: '' }, + messages: [{ role: 'user', content: '这张图片里有什么?', images, timestamp: Date.now() }], + params: { temperature: 0, stream: false, thinkingEnabled: false }, + }; +} + +function okResponse(): Response { + return { + ok: true, + status: 200, + json: async () => ({ choices: [{ message: { content: 'ok' } }], usage: {} }), + } as unknown as Response; +} + +function requestBody(): { model: string; messages: Array> } { + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + return JSON.parse(init.body as string); +} + +describe('DeepSeek vision 模型多模态请求格式(v0.5.4)', () => { + it('vision 模型:带图片的消息转换为 image_url content parts', async () => { + mockFetch.mockResolvedValue(okResponse()); + const adapter = makeAdapter('deepseek-v4-flash-vision-exp'); + + await adapter.send(makeRequest([{ url: 'data:image/jpeg;base64,TESTPIC' }])); + + const body = requestBody(); + expect(body.model).toBe('deepseek-v4-flash-vision-exp'); + // system 消息 + user 消息(含 content parts) + expect(body.messages).toHaveLength(2); + const userMsg = body.messages[1]; + expect(userMsg.role).toBe('user'); + expect(Array.isArray(userMsg.content)).toBe(true); + const parts = userMsg.content as Array>; + expect(parts[0]).toEqual({ type: 'text', text: '这张图片里有什么?' }); + expect(parts[1]).toEqual({ + type: 'image_url', + image_url: { url: 'data:image/jpeg;base64,TESTPIC' }, + }); + }); + + it('非 vision 模型:images 被静默丢弃(content 保持纯文本)', async () => { + mockFetch.mockResolvedValue(okResponse()); + const adapter = makeAdapter('deepseek-v4-pro'); + + await adapter.send(makeRequest([{ url: 'data:image/jpeg;base64,TESTPIC' }])); + + const body = requestBody(); + const userMsg = body.messages[1]; + // 非 vision 模型 content 保持字符串(不转 parts,不发图片 → 不会 400) + expect(userMsg.content).toBe('这张图片里有什么?'); + }); + + it('vision 模型 max_tokens 钳制到 8192(MODEL_INFO 上限)', async () => { + mockFetch.mockResolvedValue(okResponse()); + const adapter = makeAdapter('deepseek-v4-flash-vision-exp'); + + await adapter.send({ + ...makeRequest(), + params: { maxTokens: 63_488, temperature: 0, stream: false }, + } as MetonaRequest); + + const body = JSON.parse((mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string); + expect(body.max_tokens).toBe(8_192); + }); + + it('vision 模型无图片时不转换(content 保持纯文本)', async () => { + mockFetch.mockResolvedValue(okResponse()); + const adapter = makeAdapter('deepseek-v4-flash-vision-exp'); + + await adapter.send(makeRequest(undefined)); + + const body = requestBody(); + const userMsg = body.messages[1]; + expect(userMsg.content).toBe('这张图片里有什么?'); + }); +}); diff --git a/electron/harness/adapters/deepseek.adapter.ts b/electron/harness/adapters/deepseek.adapter.ts index dfaae77..d209a98 100644 --- a/electron/harness/adapters/deepseek.adapter.ts +++ b/electron/harness/adapters/deepseek.adapter.ts @@ -10,6 +10,7 @@ * @see apis/deepseek-api-docs-20260518.html */ +import log from 'electron-log'; import { BaseAdapter } from './base-adapter'; import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types'; import { MetonaFinishReason } from '../types'; @@ -20,7 +21,11 @@ import { parseSSEStream, parseOpenAICompatibleResponse } from './shared/sse-stre export class DeepSeekAdapter extends BaseAdapter { // H-2 修复: provider → providerId(规范要求) override readonly providerId: string = 'deepseek'; - readonly supportedModels = ['deepseek-v4-pro', 'deepseek-v4-flash']; + readonly supportedModels = [ + 'deepseek-v4-pro', + 'deepseek-v4-flash', + 'deepseek-v4-flash-vision-exp', + ]; readonly supportsToolCalling = true; readonly supportsThinking = true; @@ -44,8 +49,29 @@ export class DeepSeekAdapter extends BaseAdapter { supportsThinking: true, description: 'DeepSeek 快速版,1M 上下文,低延迟推理', }, + // v0.5.4: DeepSeek 多模态实验模型(OpenAI image_url content parts 格式) + 'deepseek-v4-flash-vision-exp': { + id: 'deepseek-v4-flash-vision-exp', + name: 'DeepSeek V4 Flash Vision (Exp)', + contextWindow: 128_000, + maxOutputTokens: 8_192, + supportsToolCalling: true, + supportsThinking: false, + description: 'DeepSeek 多模态实验模型,支持图片输入(image_url content parts)', + }, }; + /** + * v0.5.4: 当前模型是否支持多模态图片输入 + * + * DeepSeek 仅 vision 系列模型支持图片(命名含 'vision'); + * 非 vision 模型收到 images 时静默丢弃(避免 API 400)。 + * 前端上传入口由 llm.multimodalEnabled 配置总开关控制,此处是 adapter 侧的模型级防线。 + */ + private isVisionModel(): boolean { + return this.config.defaultModel.includes('vision'); + } + // ===== POST /chat/completions (非流式) ===== // H-2 修复: chat → send(规范要求) @@ -252,6 +278,34 @@ export class DeepSeekAdapter extends BaseAdapter { stream, }; + // v0.5.4: vision 模型的图片处理(OpenAI image_url content parts 格式) + // 非 vision 模型保持 images 静默丢弃(共享层行为,避免 API 400) + if (this.isVisionModel()) { + const nonSystemMsgs = request.messages.filter((m) => m.role !== 'system'); + let imageCount = 0; + // messages[0] 是 system,非 system 消息从 messages[1] 开始(与 nonSystemMsgs 对齐) + for (let i = 1; i < messages.length; i++) { + const origMsg = nonSystemMsgs[i - 1]; + if (!origMsg?.images?.length) continue; + + imageCount += origMsg.images.length; + const contentParts: Array> = []; + if (origMsg.content) { + contentParts.push({ type: 'text', text: origMsg.content }); + } + for (const img of origMsg.images) { + contentParts.push({ + type: 'image_url', + image_url: { url: img.url }, + }); + } + messages[i].content = contentParts; + } + if (imageCount > 0) { + log.info(`[DeepSeek] Vision model processing ${imageCount} image(s)`); + } + } + if (stream) { body.stream_options = { include_usage: true }; } diff --git a/electron/services/__tests__/session-summary.test.ts b/electron/services/__tests__/session-summary.test.ts index b785f2b..07fbd76 100644 --- a/electron/services/__tests__/session-summary.test.ts +++ b/electron/services/__tests__/session-summary.test.ts @@ -91,7 +91,11 @@ describe.skipIf(!dbAvailable)('SessionSummary 分层上下文 × 截断交互', }); afterAll(() => { - try { db?.close(); } catch { /* ignore */ } + try { + db?.close(); + } catch { + /* ignore */ + } rmSync(dir, { recursive: true, force: true }); }); @@ -118,6 +122,9 @@ describe.skipIf(!dbAvailable)('SessionSummary 分层上下文 × 截断交互', expect(history.length).toBe(3); }); + // ===== v0.5.4: 多轮图片记忆用例置于 describe 末尾(插入新行消耗全局自增 rowid, + // 若插在中间会破坏 inclusive=false 用例对 rowid 数值的断言) ===== + it('截断清理摘要(缺陷 #1 回归):游标落在删除范围内时摘要被删', () => { // 编辑重发第 2 条消息(rowid 2,inclusive)→ 删除 rowid>=2 的 4 条 // 摘要游标为 3,落在 [2, ∞) 内 → 摘要必须被清理 @@ -159,4 +166,92 @@ describe.skipIf(!dbAvailable)('SessionSummary 分层上下文 × 截断交互', expect(sessionService.truncateMessagesAfter('s_test', 'not_exist', true)).toBe(false); expect(sessionService.getMessages('s_test').length).toBe(before); }); + + /** 插入带 attachments 的用户消息(模拟 ChatInput 持久化形态) */ + const insertMessageWithAttachments = ( + sessionId: string, + id: string, + content: string, + attachments: Array<{ type: string; preview?: string; textContent?: string }>, + ): void => { + db.prepare( + `INSERT INTO messages (id, session_id, role, content, attachments, created_at) VALUES (?, ?, 'user', ?, ?, ?)`, + ).run(id, sessionId, content, JSON.stringify(attachments), Date.now()); + }; + + it('多轮图片记忆:历史消息的图片 attachments 恢复为 images 回传', () => { + db.prepare(`DELETE FROM messages WHERE session_id = 's_img'`).run(); + db.prepare( + `INSERT INTO sessions (id, created_at, updated_at, message_count) VALUES ('s_img', ?, ?, 0)`, + ).run(Date.now(), Date.now()); + + insertMessageWithAttachments('s_img', 'img_m1', '第一轮带图', [ + { type: 'image', preview: 'data:image/jpeg;base64,ROUND1PIC' }, + ]); + insertMessageWithAttachments('s_img', 'img_m2', '第二轮无图', []); + insertMessageWithAttachments('s_img', 'img_m3', '第三轮带图', [ + { type: 'image', preview: 'data:image/jpeg;base64,ROUND3PIC' }, + { type: 'text', textContent: '文本附件不恢复为图片' }, + ]); + + const history = summaryService.buildHistoryMessages('s_img'); + // 第一轮的图片仍被恢复(多轮记忆修复点:此前历史消息 images 恒为 undefined) + const round1 = history.find((m: any) => m.content === '第一轮带图'); + expect(round1?.images).toEqual([{ url: 'data:image/jpeg;base64,ROUND1PIC', detail: 'auto' }]); + // 第三轮两张附件只恢复 image 类型 + const round3 = history.find((m: any) => m.content === '第三轮带图'); + expect(round3?.images).toEqual([{ url: 'data:image/jpeg;base64,ROUND3PIC', detail: 'auto' }]); + // 无图消息不带 images 字段 + const round2 = history.find((m: any) => m.content === '第二轮无图'); + expect(round2?.images).toBeUndefined(); + }); + + it('多轮图片记忆:注入上限 10 张(从最新向前收集,更早的丢弃)', () => { + db.prepare(`DELETE FROM messages WHERE session_id = 's_cap'`).run(); + db.prepare( + `INSERT INTO sessions (id, created_at, updated_at, message_count) VALUES ('s_cap', ?, ?, 0)`, + ).run(Date.now(), Date.now()); + + // 12 条带图消息(每条 1 张)— 上限 10 张,最早的 2 条不注入 + for (let i = 1; i <= 12; i++) { + insertMessageWithAttachments('s_cap', `cap_m${i}`, `第 ${i} 轮`, [ + { type: 'image', preview: `data:image/jpeg;base64,PIC${i}` }, + ]); + } + + const history = summaryService.buildHistoryMessages('s_cap'); + const withImages = history.filter((m: any) => m.images?.length > 0); + expect(withImages).toHaveLength(10); + // 保留的是第 3~12 轮(最新的 10 张) + const contents = withImages.map((m: any) => m.content); + expect(contents).not.toContain('第 1 轮'); + expect(contents).not.toContain('第 2 轮'); + expect(contents).toContain('第 12 轮'); + }); + + it('多轮图片记忆:摘要区间内的图片不恢复(符合滚动摘要语义)', () => { + db.prepare(`DELETE FROM messages WHERE session_id = 's_sum_img'`).run(); + db.prepare( + `INSERT INTO sessions (id, created_at, updated_at, message_count) VALUES ('s_sum_img', ?, ?, 0)`, + ).run(Date.now(), Date.now()); + + for (let i = 1; i <= 3; i++) { + insertMessageWithAttachments('s_sum_img', `sm${i}`, `摘要区间第 ${i} 条`, [ + { type: 'image', preview: `data:image/jpeg;base64,OLDPIC${i}` }, + ]); + } + // 摘要覆盖前 3 条(含全部图片消息) + const cursor = ( + db.prepare(`SELECT MAX(rowid) AS r FROM messages WHERE session_id = 's_sum_img'`).get() as { + r: number; + } + ).r; + summaryService.saveSummary('s_sum_img', '早期含图对话的摘要', cursor); + insertMessageWithAttachments('s_sum_img', 'sm4', '摘要后的新消息(无图)', []); + + const history = summaryService.buildHistoryMessages('s_sum_img'); + // 摘要消息 + 1 条原文,均无图片(游标前的图片消息不在 tail 中) + expect(history).toHaveLength(2); + expect(history.every((m: any) => !m.images?.length)).toBe(true); + }); }); diff --git a/electron/services/database.service.ts b/electron/services/database.service.ts index 13bc8c5..59747eb 100644 --- a/electron/services/database.service.ts +++ b/electron/services/database.service.ts @@ -42,6 +42,9 @@ export const CONFIG_DEFAULTS: ConfigDefaultEntry[] = [ { key: 'llm.baseURL', value: '', category: 'llm' }, { key: 'llm.temperature', value: 0, category: 'llm' }, { key: 'llm.maxTokens', value: 63488, category: 'llm' }, + // v0.5.4: 多模态总开关 — 即使模型支持多模态,未开启也不能上传图片(默认关闭, + // 用户在设置/引导向导显式开启;上传入口 = 开关 × 模型能力双重判断) + { key: 'llm.multimodalEnabled', value: false, category: 'llm' }, // P1: Provider 故障转移配置 { key: 'llm.fallbackProvider', value: '', category: 'llm' }, { key: 'llm.fallbackModel', value: '', category: 'llm' }, diff --git a/electron/services/session-summary.service.ts b/electron/services/session-summary.service.ts index 2bd8c52..aa4f298 100644 --- a/electron/services/session-summary.service.ts +++ b/electron/services/session-summary.service.ts @@ -35,6 +35,8 @@ const SUMMARY_TIMEOUT_MS = 30_000; const PER_MESSAGE_TRUNCATE = 600; /** 传给 LLM 的总字符上限 */ const MAX_DIGEST_CHARS = 24_000; +/** v0.5.4: 多轮图片记忆 — 历史上下文注入的最大图片数(从最新向前收集,防 token 爆炸) */ +const MAX_HISTORY_IMAGES = 10; export class SessionSummaryService { constructor( @@ -62,10 +64,18 @@ export class SessionSummaryService { reasoningContent: m.reasoningContent, toolCalls: m.toolCalls as MetonaMessage['toolCalls'], toolResult: m.toolResult as MetonaMessage['toolResult'], + // v0.5.4: 保留 attachments 供 restoreHistoryImages 恢复图片(多轮图片记忆) + attachments: (m as { attachments?: unknown[] }).attachments, timestamp: m.timestamp, iteration: m.iteration, })); + // v0.5.4: 多轮图片记忆 — 从持久化的 attachments(压缩 base64 preview)恢复 images, + // 历史轮次的图片重新注入 LLM 上下文(此前仅发送当轮可见,跨轮即"失忆")。 + // 数量上限防 token 爆炸:只取最近 MAX_HISTORY_IMAGES 张(从最新消息向前收集)。 + // 摘要区间(summarizedUntilRowid 之前)的图片无法恢复 — 符合滚动摘要的语义。 + this.restoreHistoryImages(messages); + if (existing && messages.length > 0) { // 摘要以 assistant 角色注入(与 engine 运行时压缩的注入策略一致) const summaryMessage: MetonaMessage = { @@ -78,6 +88,38 @@ export class SessionSummaryService { return messages; } + /** + * v0.5.4: 恢复历史消息的 images(多轮图片记忆) + * + * attachments 中 type=image 的 preview(1024px JPEG 压缩 base64)在消息 + * 持久化时已保存(与编辑重发的恢复逻辑同源)。此处将其映射回 + * MetonaMessage.images,让历史轮次图片随上下文回传 LLM。 + * + * 上限策略:从最新消息向前收集,最多 MAX_HISTORY_IMAGES 张 — + * 每张 1024px 图约数百至千余 token,无上限的长会话会迅速吃满上下文。 + */ + private restoreHistoryImages(messages: MetonaMessage[]): void { + let remaining = MAX_HISTORY_IMAGES; + for (let i = messages.length - 1; i >= 0 && remaining > 0; i--) { + const raw = messages[i] as MetonaMessage & { + attachments?: Array<{ type?: string; preview?: string }>; + }; + const imageAttachments = (raw.attachments ?? []).filter( + (a) => a.type === 'image' && typeof a.preview === 'string' && a.preview.length > 0, + ); + if (imageAttachments.length === 0) continue; + + const take = Math.min(imageAttachments.length, remaining); + // 优先保留靠后的图片(时间更近) + const picked = imageAttachments.slice(-take); + messages[i].images = picked.map((a) => ({ + url: a.preview as string, + detail: 'auto' as const, + })); + remaining -= take; + } + } + /** * 会话结束后评估并生成滚动摘要(fire-and-forget 调用,失败仅记录日志) */ @@ -117,14 +159,16 @@ export class SessionSummaryService { saveSummary(sessionId: string, summary: string, untilRowid: number): void { const db = this.getDB(); - db.prepare(` + db.prepare( + ` INSERT INTO session_summaries (session_id, summary, summarized_until_rowid, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET summary = excluded.summary, summarized_until_rowid = excluded.summarized_until_rowid, updated_at = excluded.updated_at - `).run(sessionId, summary, untilRowid, Date.now()); + `, + ).run(sessionId, summary, untilRowid, Date.now()); } // ===== LLM 摘要 ===== @@ -167,7 +211,8 @@ export class SessionSummaryService { 'If a prior summary exists, merge it with the new content into one updated summary. ' + 'Preserve key facts, decisions, tool outcomes, file paths, and open questions needed for future reasoning. ' + 'Output in the same language as the conversation. Maximum 400 words. Output ONLY the summary text.', - safetyGuidelines: 'Do not include sensitive data like passwords or API keys in the summary.', + safetyGuidelines: + 'Do not include sensitive data like passwords or API keys in the summary.', }, messages: [ { diff --git a/package-lock.json b/package-lock.json index 41c4501..532b749 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ai-desktop", - "version": "0.5.2", + "version": "0.5.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ai-desktop", - "version": "0.5.2", + "version": "0.5.3", "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", diff --git a/package.json b/package.json index 42677a0..07a451a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ai-desktop", - "version": "0.5.3", + "version": "0.5.4", "description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用", "main": "dist-electron/main/main.js", "author": "Metona Team", diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx index 3a79bd3..e4d75dc 100644 --- a/src/components/chat/ChatInput.tsx +++ b/src/components/chat/ChatInput.tsx @@ -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 => { - const type = classifyFile(file); - // L-10 修复: 统一使用 nanoid 生成附件 ID(与项目其他位置一致) - const attachment: Attachment = { id: `att_${nanoid(6)}`, file, type }; + const processFile = useCallback( + async (file: File): Promise => { + 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((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((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((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((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 => 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 => 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) => { - if (e.target.files) addFiles(e.target.files); - e.target.value = ''; - }, [addFiles]); + const handleFileSelect = useCallback(() => { + fileInputRef.current?.click(); + }, []); + const handleFileChange = useCallback( + (e: React.ChangeEvent) => { + 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) => { - 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) => { + 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 ( - {/* 附件预览区 */} {attachments.length > 0 && ( {attachments.map((att) => ( - removeAttachment(att.id)} /> + removeAttachment(att.id)} + /> ))} )} {/* / 命令菜单 */} {showSlashMenu && filteredCommands.length > 0 && ( -

+
{filteredCommands.map((cmd) => ( -
{ 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)' }} +
{ + 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')} > - / + + / + {cmd.label} - {cmd.description} + + {cmd.description} +
))}
)} - + {/* 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 { }} /> - + {/* 左侧:附件按钮 */} - + @@ -361,11 +569,32 @@ export function ChatInput(): React.JSX.Element { {/* 右侧:发送按钮 */} {isStreaming ? ( - ) : ( - )} @@ -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 ( - - - + + + - + {file.name} @@ -397,13 +678,49 @@ function AttachmentPreview({ attachment, onRemove }: { attachment: Attachment; o // 文本文件 / 其他文件 return ( - - {type === 'text' ? : } + + {type === 'text' ? ( + + ) : ( + + )} - {file.name} - {formatFileSize(file.size)} + + {file.name} + + + {formatFileSize(file.size)} + - + @@ -416,11 +733,7 @@ function AttachmentPreview({ attachment, onRemove }: { attachment: Attachment; o * * @see AgnesAIDesktop — 已验证 Agnes AI 可正常处理压缩后的图片 */ -function compressImage( - dataUri: string, - maxSize: number, - quality: number, -): Promise { +function compressImage(dataUri: string, maxSize: number, quality: number): Promise { 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; diff --git a/src/components/onboarding/OnboardingWizard.tsx b/src/components/onboarding/OnboardingWizard.tsx index 9a3e490..30bacad 100644 --- a/src/components/onboarding/OnboardingWizard.tsx +++ b/src/components/onboarding/OnboardingWizard.tsx @@ -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 ( - + - - {STEPS.map((s) => {s})} + + {STEPS.map((s) => ( + + {s} + + ))} - + {step === 0 && ( - - 欢迎使用 MetonaAI Desktop - 生产级通用 AI Agent 智能体桌面应用,支持多轮对话、工具调用、记忆系统和 MCP 协议集成。 + + + 欢迎使用 MetonaAI Desktop + + + 生产级通用 AI Agent 智能体桌面应用,支持多轮对话、工具调用、记忆系统和 MCP 协议集成。 + 让我们花 1 分钟完成初始配置。 )} {step === 1 && ( - 配置 LLM Provider - 选择 Provider 并填写 API 信息。Base URL 和模型名称支持任意输入。 + + 配置 LLM Provider + + + 选择 Provider 并填写 API 信息。Base URL 和模型名称支持任意输入。 + - Provider - { + const v = e.target.value; + setProvider(v); + // 联动默认上下文窗口(与 SettingsModal 默认值一致) + setContextWindow(DEFAULT_CTX[v] ?? null); + }} + > DeepSeek Agnes AI MiMo (小米) @@ -169,71 +255,196 @@ export function OnboardingWizard(): React.JSX.Element | null { Anthropic - setBaseURL(e.target.value)} placeholder="如 https://api.deepseek.com" /> - setModel(e.target.value)} placeholder="如 deepseek-v4-pro、qwen3:latest" /> - setApiKey(e.target.value)} placeholder="sk-...(本地模型可留空)" - slotProps={{ input: { endAdornment: setShowKey(!showKey)}>{showKey ? : } } }} + setBaseURL(e.target.value)} + placeholder="如 https://api.deepseek.com" /> setModel(e.target.value)} + placeholder="如 deepseek-v4-pro、qwen3:latest" + /> + setApiKey(e.target.value)} + placeholder="sk-...(本地模型可留空)" + slotProps={{ + input: { + endAdornment: ( + + setShowKey(!showKey)}> + {showKey ? : } + + + ), + }, + }} + /> + { 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: 多模态总开关 — 未开启时即使模型支持也不能上传图片 */} + setMultimodalEnabled(e.target.checked)} + /> + } + label={ + + 启用多模态(图片输入) + + 开启后可在输入框上传图片;DeepSeek 需 vision 系列模型 + + + } + sx={{ alignItems: 'flex-start', m: 0 }} /> )} {step === 2 && ( - 自定义 Agent - 编辑工作空间中的 SOUL.md 文件来定义 Agent 的身份和性格。 - -
SOUL.md — 定义 Agent 的身份、性格、核心价值观
-
此步骤可稍后在工作空间目录中完成。
+ + 自定义 Agent + + + 编辑工作空间中的 SOUL.md 文件来定义 Agent 的身份和性格。 + + +
+ SOUL.md — 定义 Agent + 的身份、性格、核心价值观 +
+
+ 此步骤可稍后在工作空间目录中完成。 +
)} {step === 3 && ( - 工作空间 - 选择工作空间目录,或使用默认路径。 + + 工作空间 + + + 选择工作空间目录,或使用默认路径。 + - setWorkspacePath(e.target.value)} placeholder="~/MetonaWorkspaces/default/" sx={{ flex: 1 }} /> - + }} + > + 选择文件夹 + - 包含 SOUL.md、MEMORY.md 两个必需文件,首次打开时自动创建。 + + 包含 SOUL.md、MEMORY.md 两个必需文件,首次打开时自动创建。 + )} {step === 4 && ( - 配置完成! - MetonaAI Desktop 已准备就绪。开始与你的 AI Agent 对话吧! + + 配置完成! + + + MetonaAI Desktop 已准备就绪。开始与你的 AI Agent 对话吧! + 按 Ctrl+Enter 发送消息,输入 / 查看命令列表 )}
- - - + diff --git a/src/components/settings/LLMSettings.tsx b/src/components/settings/LLMSettings.tsx index 9b756e0..efa45fd 100644 --- a/src/components/settings/LLMSettings.tsx +++ b/src/components/settings/LLMSettings.tsx @@ -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: 多模态总开关 — 未开启时即使模型支持也不能上传图片 */} + setMultimodalEnabled(e.target.checked)} + /> + } + label={ + + 启用多模态(图片输入) + + 开启后可在输入框上传图片;DeepSeek 需 vision + 系列模型。历史会话图片会随上下文回传(最近 10 张) + + + } + sx={{ alignItems: 'flex-start', m: 0 }} + /> {provider !== 'ollama' && ( <> void; setMessages: (messages: ChatMessage[]) => void; addMessage: (message: ChatMessage) => void; updateMessage: (id: string, updates: Partial) => void; - sendMessage: (content: string, images?: Array<{ url: string; detail?: 'low' | 'high' | 'auto' }>, attachments?: AttachmentInfo[]) => void; + sendMessage: ( + content: string, + images?: Array<{ url: string; detail?: 'low' | 'high' | 'auto' }>, + attachments?: AttachmentInfo[], + ) => void; updateLastAssistantMessage: (delta: string) => void; setAgentStatus: (status: AgentStatus) => void; setStreaming: (streaming: boolean) => void; @@ -159,6 +165,8 @@ interface AgentState { */ applyCompression: (savedTokens: number) => void; setProvider: (provider: string, model: string) => void; + // v0.5.4: 多模态开关 setter(App 初始化与设置保存时同步) + setMultimodalEnabled: (enabled: boolean) => void; setMaxIterations: (max: number) => void; setCurrentIteration: (n: number) => void; saveTraceData: () => void; @@ -183,7 +191,13 @@ export const useAgentStore = create((set, get) => ({ agentStatus: 'idle', currentIteration: 0, maxIterations: 20, - tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 }, + tokenUsage: { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + lastInputTokens: 0, + lastCompressedSaved: 0, + }, traceSteps: [], isStreaming: false, configLoaded: false, @@ -192,104 +206,158 @@ export const useAgentStore = create((set, get) => ({ provider: '', model: '', contextWindow: 0, + // v0.5.4: 多模态总开关(默认关闭,setProvider 时从配置加载) + multimodalEnabled: false, // ===== Actions ===== setCurrentSession: (id) => { - set({ currentSessionId: id, messages: [], traceSteps: [], currentIteration: 0, tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 }, agentStatus: 'idle', isStreaming: false, currentRunId: null }); + set({ + currentSessionId: id, + messages: [], + traceSteps: [], + currentIteration: 0, + tokenUsage: { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + lastInputTokens: 0, + lastCompressedSaved: 0, + }, + agentStatus: 'idle', + isStreaming: false, + currentRunId: null, + }); // 从数据库加载该会话的消息 if (id && window.metona?.sessions?.getMessages) { - window.metona.sessions.getMessages(id).then((msgs) => { - // v0.3.0 修复: 竞态保护 — 快速切换会话时,旧请求返回后不再覆盖当前会话消息 - if (get().currentSessionId !== id) return; - const messages = (msgs as Array<{ - id: string; role: string; content: string; - reasoningContent?: string; toolCalls?: unknown[]; - toolResult?: unknown; - attachments?: Array<{ id: string; name: string; type: string; size: number; preview?: string; textContent?: string }>; - iteration?: number; - timestamp: number; - }>) - // v0.3.0 修复: 过滤掉 tool 消息 — tool 结果已包含在 assistant 消息的 toolCalls 中 - // 独立的 tool 消息只用于 LLM API 上下文,不需要在前端显示为独立卡片 - .filter((m) => m.role !== 'tool') - .map((m) => ({ - id: m.id, - role: m.role as ChatMessage['role'], - content: m.content, - reasoningContent: m.reasoningContent, - toolCalls: m.toolCalls as ToolCallInfo[] | undefined, - attachments: m.attachments as AttachmentInfo[] | undefined, - iteration: m.iteration, - timestamp: m.timestamp, - })); - set({ messages }); - }).catch((err) => { console.error('[AgentStore]', err); }); + window.metona.sessions + .getMessages(id) + .then((msgs) => { + // v0.3.0 修复: 竞态保护 — 快速切换会话时,旧请求返回后不再覆盖当前会话消息 + if (get().currentSessionId !== id) return; + const messages = ( + msgs as Array<{ + id: string; + role: string; + content: string; + reasoningContent?: string; + toolCalls?: unknown[]; + toolResult?: unknown; + attachments?: Array<{ + id: string; + name: string; + type: string; + size: number; + preview?: string; + textContent?: string; + }>; + iteration?: number; + timestamp: number; + }> + ) + // v0.3.0 修复: 过滤掉 tool 消息 — tool 结果已包含在 assistant 消息的 toolCalls 中 + // 独立的 tool 消息只用于 LLM API 上下文,不需要在前端显示为独立卡片 + .filter((m) => m.role !== 'tool') + .map((m) => ({ + id: m.id, + role: m.role as ChatMessage['role'], + content: m.content, + reasoningContent: m.reasoningContent, + toolCalls: m.toolCalls as ToolCallInfo[] | undefined, + attachments: m.attachments as AttachmentInfo[] | undefined, + iteration: m.iteration, + timestamp: m.timestamp, + })); + set({ messages }); + }) + .catch((err) => { + console.error('[AgentStore]', err); + }); } // 从数据库加载该会话的 trace 步骤和 token 用量 if (id && window.metona?.sessions?.getTrace) { - window.metona.sessions.getTrace(id).then((data) => { - // v0.3.0 修复: 竞态保护 — 快速切换会话时,旧请求返回后不再覆盖当前会话 trace - if (get().currentSessionId !== id) return; - if (data) { - if (data.traceSteps) { - // 兼容旧数据:为缺少 id/states 的 trace 步骤补全 - const steps = (data.traceSteps as TraceStep[]).map((t, i) => ({ - ...t, - id: t.id ?? `trace_legacy_${t.iteration}_${t.state}_${i}`, - states: t.states ?? [t.state], - })); - set({ traceSteps: steps }); + window.metona.sessions + .getTrace(id) + .then((data) => { + // v0.3.0 修复: 竞态保护 — 快速切换会话时,旧请求返回后不再覆盖当前会话 trace + if (get().currentSessionId !== id) return; + if (data) { + if (data.traceSteps) { + // 兼容旧数据:为缺少 id/states 的 trace 步骤补全 + const steps = (data.traceSteps as TraceStep[]).map((t, i) => ({ + ...t, + id: t.id ?? `trace_legacy_${t.iteration}_${t.state}_${i}`, + states: t.states ?? [t.state], + })); + set({ traceSteps: steps }); + } + // v0.3.18 修复: 兼容旧数据 — 旧 tokenUsage 没有 lastInputTokens/lastCompressedSaved 字段 + if (data.tokenUsage) { + const old = data.tokenUsage as Partial; + set({ + tokenUsage: { + inputTokens: old.inputTokens ?? 0, + outputTokens: old.outputTokens ?? 0, + totalTokens: old.totalTokens ?? 0, + lastInputTokens: old.lastInputTokens ?? 0, + lastCompressedSaved: old.lastCompressedSaved ?? 0, + }, + }); + } } - // v0.3.18 修复: 兼容旧数据 — 旧 tokenUsage 没有 lastInputTokens/lastCompressedSaved 字段 - if (data.tokenUsage) { - const old = data.tokenUsage as Partial; - set({ - tokenUsage: { - inputTokens: old.inputTokens ?? 0, - outputTokens: old.outputTokens ?? 0, - totalTokens: old.totalTokens ?? 0, - lastInputTokens: old.lastInputTokens ?? 0, - lastCompressedSaved: old.lastCompressedSaved ?? 0, - }, - }); - } - } - }).catch((err) => { console.error('[AgentStore]', err); }); + }) + .catch((err) => { + console.error('[AgentStore]', err); + }); } }, setMessages: (messages) => set({ messages }), - addMessage: (message) => - set((s) => ({ messages: [...s.messages, message] })), + addMessage: (message) => set((s) => ({ messages: [...s.messages, message] })), updateMessage: (id, updates) => set((s) => ({ - messages: s.messages.map((m) => - m.id === id ? { ...m, ...updates } : m, - ), + messages: s.messages.map((m) => (m.id === id ? { ...m, ...updates } : m)), })), - sendMessage: async (content: string, images?: Array<{ url: string; detail?: 'low' | 'high' | 'auto' }>, attachments?: AttachmentInfo[]) => { + sendMessage: async ( + content: string, + images?: Array<{ url: string; detail?: 'low' | 'high' | 'auto' }>, + attachments?: AttachmentInfo[], + ) => { let sessionId = get().currentSessionId; // v0.3.18 修复: 工具未就绪时阻止发送,避免 MCP 工具不可用 if (!get().toolsReady) { - import('@metona-team/metona-toast').then((mod) => mod.default.warning('工具正在加载中,请稍候...')).catch(() => {}); + import('@metona-team/metona-toast') + .then((mod) => mod.default.warning('工具正在加载中,请稍候...')) + .catch(() => {}); return; } // 没有当前会话时自动创建 if (!sessionId && window.metona?.sessions?.create) { try { - const session = await window.metona.sessions.create() as { id: string; title: string; createdAt: number; updatedAt: number; messageCount: number; pinned: boolean; archived: boolean }; + const session = (await window.metona.sessions.create()) as { + id: string; + title: string; + createdAt: number; + updatedAt: number; + messageCount: number; + pinned: boolean; + archived: boolean; + }; useSessionStore.getState().addSession({ - id: session.id, title: session.title, createdAt: session.createdAt, - updatedAt: session.updatedAt, messageCount: session.messageCount, - pinned: session.pinned, archived: session.archived, + id: session.id, + title: session.title, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + messageCount: session.messageCount, + pinned: session.pinned, + archived: session.archived, }); sessionId = session.id; set({ currentSessionId: sessionId }); @@ -305,7 +373,9 @@ export const useAgentStore = create((set, get) => ({ timestamp: Date.now(), }); // 额外弹 toast 作为通知,确保用户感知(system message 仅在聊天流内可见) - 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; } } @@ -313,7 +383,7 @@ export const useAgentStore = create((set, get) => ({ const userMessage: ChatMessage = { id: genMsgId('user'), role: 'user', - content, // 用户可见内容(纯文本) + content, // 用户可见内容(纯文本) timestamp: Date.now(), attachments: attachments && attachments.length > 0 ? attachments : undefined, }; @@ -327,19 +397,30 @@ export const useAgentStore = create((set, get) => ({ // 方案 A: 不清空 traceSteps,避免前一条消息的 trace 被永久覆盖 // TraceViewer 按 runId 过滤显示,只展示当前 run 的 steps // 历史 trace 仍在 DB 中,切换会话回来可恢复 - tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 }, + tokenUsage: { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + lastInputTokens: 0, + lastCompressedSaved: 0, + }, })); // 自动更新会话标题为用户第一条消息 if (sessionId && window.metona?.sessions?.getMessages) { - window.metona.sessions.getMessages(sessionId).then((msgs) => { - if (msgs.length <= 1) { - // 这是第一条消息,更新标题 - const title = content.length > 30 ? content.slice(0, 30) + '...' : content; - window.metona?.sessions?.rename(sessionId, title); - useSessionStore.getState().updateSession(sessionId, { title }); - } - }).catch((err) => { console.error('[AgentStore]', err); }); + window.metona.sessions + .getMessages(sessionId) + .then((msgs) => { + if (msgs.length <= 1) { + // 这是第一条消息,更新标题 + const title = content.length > 30 ? content.slice(0, 30) + '...' : content; + window.metona?.sessions?.rename(sessionId, title); + useSessionStore.getState().updateSession(sessionId, { title }); + } + }) + .catch((err) => { + console.error('[AgentStore]', err); + }); } if (sessionId && window.metona?.agent?.sendMessage) { @@ -360,15 +441,16 @@ export const useAgentStore = create((set, get) => ({ // 非图片文件:JSON 结构化,原始文本内容(不编码) const ext = att.name.split('.').pop() ?? 'unknown'; const rawContent = att.textContent ?? ''; - parts.push(JSON.stringify({ - file_name: att.name, - file_type: ext, - content: rawContent, - })); + parts.push( + JSON.stringify({ + file_name: att.name, + file_type: ext, + content: rawContent, + }), + ); } - llmContent = parts.length > 0 - ? (content ? content + '\n\n' : '') + parts.join('\n') - : content; + llmContent = + parts.length > 0 ? (content ? content + '\n\n' : '') + parts.join('\n') : content; } const messageWithImages = { @@ -386,7 +468,9 @@ export const useAgentStore = create((set, get) => ({ timestamp: Date.now(), }); // 额外弹 toast 作为通知,确保用户感知(system message 仅在聊天流内可见) - 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(() => {}); }); } }, @@ -422,22 +506,17 @@ export const useAgentStore = create((set, get) => ({ setCurrentRunId: (runId) => set({ currentRunId: runId }), - addTraceStep: (step) => - set((s) => ({ traceSteps: [...s.traceSteps, step] })), + addTraceStep: (step) => set((s) => ({ traceSteps: [...s.traceSteps, step] })), updateTraceStep: (iteration, updates) => set((s) => ({ - traceSteps: s.traceSteps.map((t) => - t.iteration === iteration ? { ...t, ...updates } : t, - ), + traceSteps: s.traceSteps.map((t) => (t.iteration === iteration ? { ...t, ...updates } : t)), })), // L-2: 按 ID 精确匹配 traceStep(避免 iteration 碰撞) updateTraceStepById: (id, updates) => set((s) => ({ - traceSteps: s.traceSteps.map((t) => - t.id === id ? { ...t, ...updates } : t, - ), + traceSteps: s.traceSteps.map((t) => (t.id === id ? { ...t, ...updates } : t)), })), updateLastTraceStep: (updates) => @@ -448,8 +527,7 @@ export const useAgentStore = create((set, get) => ({ return { traceSteps: steps }; }), - updateTokenUsage: (usage) => - set((s) => ({ tokenUsage: { ...s.tokenUsage, ...usage } })), + updateTokenUsage: (usage) => set((s) => ({ tokenUsage: { ...s.tokenUsage, ...usage } })), // v0.3.18 修复: 应用上下文压缩事件,记录节省的 token 数 // 压缩后下一轮 LLM 调用的 inputTokens 会大幅下降,lastInputTokens 会自动反映 @@ -459,23 +537,43 @@ export const useAgentStore = create((set, get) => ({ setProvider: (provider, model) => { // L-16 修复: 使用命名常量替代魔法数字 // v0.3.1: DeepSeek/Agnes 不再固定 1M,从配置读取 - const initialCtx = provider === 'ollama' ? DEFAULT_OLLAMA_CONTEXT_WINDOW : DEFAULT_CLOUD_CONTEXT_WINDOW; + const initialCtx = + provider === 'ollama' ? DEFAULT_OLLAMA_CONTEXT_WINDOW : DEFAULT_CLOUD_CONTEXT_WINDOW; set({ provider, model, contextWindow: initialCtx }); + // v0.5.4: 多模态开关随 provider/model 状态一并加载(App 初始化/设置保存共用此路径) + if (window.metona?.config?.get) { + window.metona.config + .get('llm.multimodalEnabled') + .then((v) => { + set({ multimodalEnabled: v === true }); + }) + .catch(() => { + /* 读取失败保持默认 false */ + }); + } // v0.3.1: 异步读取实际配置(所有 Provider) if (window.metona?.config?.get) { // Ollama 用 ollama.numCtx,DeepSeek 用 deepseek.contextWindow,Agnes 用 agnes.contextWindow const configKey = provider === 'ollama' ? 'ollama.numCtx' : `${provider}.contextWindow`; // P2-12 修复: 竞态保护 — 快速切换 provider 时,旧 Promise resolve 不覆盖新值 const expectedProvider = provider; - window.metona.config.get(configKey).then((v) => { - if (get().provider !== expectedProvider) return; // provider 已切换,丢弃旧结果 - if (v != null && typeof v === 'number' && v > 0) { - set({ contextWindow: v }); - } - }).catch((err) => { console.error('[AgentStore]', err); }); + window.metona.config + .get(configKey) + .then((v) => { + if (get().provider !== expectedProvider) return; // provider 已切换,丢弃旧结果 + if (v != null && typeof v === 'number' && v > 0) { + set({ contextWindow: v }); + } + }) + .catch((err) => { + console.error('[AgentStore]', err); + }); } }, + // v0.5.4: 多模态开关 setter — 设置保存/开关切换时同步(立即生效,ChatInput 消费) + setMultimodalEnabled: (enabled) => set({ multimodalEnabled: enabled }), + setMaxIterations: (max) => set({ maxIterations: max }), setCurrentIteration: (currentIteration) => set({ currentIteration }), @@ -483,7 +581,11 @@ export const useAgentStore = create((set, get) => ({ saveTraceData: () => { const { currentSessionId, traceSteps, tokenUsage } = get(); if (currentSessionId && window.metona?.sessions?.saveTrace) { - window.metona.sessions.saveTrace(currentSessionId, { traceSteps, tokenUsage }).catch((err) => { console.error('[AgentStore]', err); }); + window.metona.sessions + .saveTrace(currentSessionId, { traceSteps, tokenUsage }) + .catch((err) => { + console.error('[AgentStore]', err); + }); } }, @@ -492,7 +594,13 @@ export const useAgentStore = create((set, get) => ({ messages: [], agentStatus: 'idle', currentIteration: 0, - tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 }, + tokenUsage: { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + lastInputTokens: 0, + lastCompressedSaved: 0, + }, traceSteps: [], isStreaming: false, currentRunId: null, @@ -504,7 +612,9 @@ export const useAgentStore = create((set, get) => ({ // 旧 run 的 DONE 到达时会匹配 currentRunId,处理后清除;新 run 的 INIT 会覆盖 set({ agentStatus: 'idle', isStreaming: false }); if (sessionId && window.metona?.agent?.abortSession) { - window.metona.agent.abortSession(sessionId).catch((err) => { console.error('[AgentStore]', err); }); + window.metona.agent.abortSession(sessionId).catch((err) => { + console.error('[AgentStore]', err); + }); } }, @@ -512,7 +622,9 @@ export const useAgentStore = create((set, get) => ({ editAndResend: async (messageId, newContent) => { const { messages, currentSessionId, isStreaming } = get(); if (isStreaming) { - import('@metona-team/metona-toast').then((mod) => mod.default.warning('Agent 正在回复中,请先中断再编辑重发')).catch(() => {}); + import('@metona-team/metona-toast') + .then((mod) => mod.default.warning('Agent 正在回复中,请先中断再编辑重发')) + .catch(() => {}); return; } const idx = messages.findIndex((m) => m.id === messageId); @@ -526,7 +638,9 @@ export const useAgentStore = create((set, get) => ({ await window.metona.sessions.truncateAfter(currentSessionId, messageId, true); } catch (err) { console.error('[AgentStore] truncateAfter failed:', err); - import('@metona-team/metona-toast').then((mod) => mod.default.error('消息截断失败,无法重发')).catch(() => {}); + import('@metona-team/metona-toast') + .then((mod) => mod.default.error('消息截断失败,无法重发')) + .catch(() => {}); return; } } @@ -556,13 +670,22 @@ export const useAgentStore = create((set, get) => ({ regenerate: async () => { const { messages, currentSessionId, isStreaming } = get(); if (isStreaming) { - import('@metona-team/metona-toast').then((mod) => mod.default.warning('Agent 正在回复中,无法重新生成')).catch(() => {}); + import('@metona-team/metona-toast') + .then((mod) => mod.default.warning('Agent 正在回复中,无法重新生成')) + .catch(() => {}); return; } // 找最后一条用户消息 - const lastUserIdx = messages.length - 1 - [...messages].reverse().findIndex((m) => m.role === 'user'); - if (lastUserIdx < 0 || lastUserIdx >= messages.length || messages[lastUserIdx].role !== 'user') { - import('@metona-team/metona-toast').then((mod) => mod.default.info('没有可重新生成的用户消息')).catch(() => {}); + const lastUserIdx = + messages.length - 1 - [...messages].reverse().findIndex((m) => m.role === 'user'); + if ( + lastUserIdx < 0 || + lastUserIdx >= messages.length || + messages[lastUserIdx].role !== 'user' + ) { + import('@metona-team/metona-toast') + .then((mod) => mod.default.info('没有可重新生成的用户消息')) + .catch(() => {}); return; } const lastUser = messages[lastUserIdx]; @@ -573,7 +696,9 @@ export const useAgentStore = create((set, get) => ({ await window.metona.sessions.truncateAfter(currentSessionId, lastUser.id, true); } catch (err) { console.error('[AgentStore] truncateAfter failed:', err); - import('@metona-team/metona-toast').then((mod) => mod.default.error('消息截断失败,无法重新生成')).catch(() => {}); + import('@metona-team/metona-toast') + .then((mod) => mod.default.error('消息截断失败,无法重新生成')) + .catch(() => {}); return; } }