diff --git a/README.md b/README.md
index a1d7cf9..1a42b87 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
@@ -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 ? (
-
+ : undefined}
+ onClick={handleNext}
+ size="small"
+ >
{step === STEPS.length - 1 ? '开始使用' : '下一步'}
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;
}
}