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

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

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

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

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

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

验证: lint 0 / typecheck 双工程 0 / test:electron 243 全过 / build 成功
This commit is contained in:
2026-08-21 23:00:20 +08:00
parent 3a30e8f5b4
commit 4ee5100661
12 changed files with 1363 additions and 353 deletions
+5 -3
View File
@@ -10,7 +10,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/version-0.5.3-blue?style=flat-square" alt="Version" /> <img src="https://img.shields.io/badge/version-0.5.4-blue?style=flat-square" alt="Version" />
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License" /> <img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License" />
<img src="https://img.shields.io/badge/Electron-35-47848F?style=flat-square&logo=electron" alt="Electron" /> <img src="https://img.shields.io/badge/Electron-35-47848F?style=flat-square&logo=electron" alt="Electron" />
<img src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react" alt="React" /> <img src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react" alt="React" />
@@ -241,6 +241,7 @@ Metona 的核心是一个 **ReAct (Reasoning + Acting)** 状态机驱动引擎
| 💭 **Thinking 模式** | 支持 deepseek-v4-pro / agnes-2.0-flash / qwen3 等推理模型的思维链展示 | | 💭 **Thinking 模式** | 支持 deepseek-v4-pro / agnes-2.0-flash / qwen3 等推理模型的思维链展示 |
| 🔁 **死循环检测** | 连续 3 轮相同工具调用签名自动终止 | | 🔁 **死循环检测** | 连续 3 轮相同工具调用签名自动终止 |
| 📦 **上下文压缩** | 80% 阈值触发 LLM 摘要压缩,按 token 预算动态保留近期消息(超长 tool_result 二次截断) | | 📦 **上下文压缩** | 80% 阈值触发 LLM 摘要压缩,按 token 预算动态保留近期消息(超长 tool_result 二次截断) |
| 🖼️ **多轮图片记忆** | 历史轮次的图片随上下文回传 LLM(最近 10 张,从最新向前收集);多模态总开关 `llm.multimodalEnabled` 控制上传入口 |
| 🔄 **错误重试** | 指数退避 (1s/2s/4s) + ±20% jitter,上限 30s | | 🔄 **错误重试** | 指数退避 (1s/2s/4s) + ±20% jitter,上限 30s |
| ⏱️ **可配置迭代** | 最大迭代次数 (默认 20)、总超时 (默认 600s)、工具执行超时 (默认 120s) | | ⏱️ **可配置迭代** | 最大迭代次数 (默认 20)、总超时 (默认 600s)、工具执行超时 (默认 120s) |
| 🧵 **子任务委派** | TaskOrchestrator 支持最大 3 层深度的 SubAgent 编排 | | 🧵 **子任务委派** | TaskOrchestrator 支持最大 3 层深度的 SubAgent 编排 |
@@ -348,7 +349,7 @@ Metona 通过统一的 `IMetonaProviderAdapter` 接口抽象了所有 LLM Provid
| 适配器 | Provider | 模型 | 上下文 | 流式格式 | Thinking | 多模态 | | 适配器 | 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) | | **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) | | **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) | | **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.provider` | (空) | LLM Provider ID(未配置时回退 .env |
| `llm.model` | (空) | 模型标识符 | | `llm.model` | (空) | 模型标识符 |
| `llm.multimodalEnabled` | `false` | 多模态总开关 — 未开启时即使模型支持也不能上传图片 |
| `agent.maxIterations` | `20` | ReAct 最大迭代轮次 | | `agent.maxIterations` | `20` | ReAct 最大迭代轮次 |
| `agent.totalTimeoutMs` | `600000` | Agent 总超时 (ms) | | `agent.totalTimeoutMs` | `600000` | Agent 总超时 (ms) |
| `agent.toolExecutionTimeoutMs` | `120000` | 单个工具执行超时 (ms) | | `agent.toolExecutionTimeoutMs` | `120000` | 单个工具执行超时 (ms) |
@@ -865,7 +867,7 @@ npm run format # Prettier 格式化
# ─── 测试 ───────────────────────────────── # ─── 测试 ─────────────────────────────────
npm test # 运行单元测试 (Vitest, 系统 Node — audit 套件因 better-sqlite3 ABI 自动跳过) 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 # 测试监听模式 npm run test:watch # 测试监听模式
# ─── 构建 ───────────────────────────────── # ─── 构建 ─────────────────────────────────
@@ -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<Record<string, unknown>> } {
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<Record<string, unknown>>;
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 钳制到 8192MODEL_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('这张图片里有什么?');
});
});
+55 -1
View File
@@ -10,6 +10,7 @@
* @see apis/deepseek-api-docs-20260518.html * @see apis/deepseek-api-docs-20260518.html
*/ */
import log from 'electron-log';
import { BaseAdapter } from './base-adapter'; import { BaseAdapter } from './base-adapter';
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types'; import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types';
import { MetonaFinishReason } from '../types'; import { MetonaFinishReason } from '../types';
@@ -20,7 +21,11 @@ import { parseSSEStream, parseOpenAICompatibleResponse } from './shared/sse-stre
export class DeepSeekAdapter extends BaseAdapter { export class DeepSeekAdapter extends BaseAdapter {
// H-2 修复: provider → providerId(规范要求) // H-2 修复: provider → providerId(规范要求)
override readonly providerId: string = 'deepseek'; 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 supportsToolCalling = true;
readonly supportsThinking = true; readonly supportsThinking = true;
@@ -44,8 +49,29 @@ export class DeepSeekAdapter extends BaseAdapter {
supportsThinking: true, supportsThinking: true,
description: 'DeepSeek 快速版,1M 上下文,低延迟推理', 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 (非流式) ===== // ===== POST /chat/completions (非流式) =====
// H-2 修复: chat → send(规范要求) // H-2 修复: chat → send(规范要求)
@@ -252,6 +278,34 @@ export class DeepSeekAdapter extends BaseAdapter {
stream, 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<Record<string, unknown>> = [];
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) { if (stream) {
body.stream_options = { include_usage: true }; body.stream_options = { include_usage: true };
} }
@@ -91,7 +91,11 @@ describe.skipIf(!dbAvailable)('SessionSummary 分层上下文 × 截断交互',
}); });
afterAll(() => { afterAll(() => {
try { db?.close(); } catch { /* ignore */ } try {
db?.close();
} catch {
/* ignore */
}
rmSync(dir, { recursive: true, force: true }); rmSync(dir, { recursive: true, force: true });
}); });
@@ -118,6 +122,9 @@ describe.skipIf(!dbAvailable)('SessionSummary 分层上下文 × 截断交互',
expect(history.length).toBe(3); expect(history.length).toBe(3);
}); });
// ===== v0.5.4: 多轮图片记忆用例置于 describe 末尾(插入新行消耗全局自增 rowid,
// 若插在中间会破坏 inclusive=false 用例对 rowid 数值的断言) =====
it('截断清理摘要(缺陷 #1 回归):游标落在删除范围内时摘要被删', () => { it('截断清理摘要(缺陷 #1 回归):游标落在删除范围内时摘要被删', () => {
// 编辑重发第 2 条消息(rowid 2inclusive)→ 删除 rowid>=2 的 4 条 // 编辑重发第 2 条消息(rowid 2inclusive)→ 删除 rowid>=2 的 4 条
// 摘要游标为 3,落在 [2, ∞) 内 → 摘要必须被清理 // 摘要游标为 3,落在 [2, ∞) 内 → 摘要必须被清理
@@ -159,4 +166,92 @@ describe.skipIf(!dbAvailable)('SessionSummary 分层上下文 × 截断交互',
expect(sessionService.truncateMessagesAfter('s_test', 'not_exist', true)).toBe(false); expect(sessionService.truncateMessagesAfter('s_test', 'not_exist', true)).toBe(false);
expect(sessionService.getMessages('s_test').length).toBe(before); 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);
});
}); });
+3
View File
@@ -42,6 +42,9 @@ export const CONFIG_DEFAULTS: ConfigDefaultEntry[] = [
{ key: 'llm.baseURL', value: '', category: 'llm' }, { key: 'llm.baseURL', value: '', category: 'llm' },
{ key: 'llm.temperature', value: 0, category: 'llm' }, { key: 'llm.temperature', value: 0, category: 'llm' },
{ key: 'llm.maxTokens', value: 63488, category: 'llm' }, { key: 'llm.maxTokens', value: 63488, category: 'llm' },
// v0.5.4: 多模态总开关 — 即使模型支持多模态,未开启也不能上传图片(默认关闭,
// 用户在设置/引导向导显式开启;上传入口 = 开关 × 模型能力双重判断)
{ key: 'llm.multimodalEnabled', value: false, category: 'llm' },
// P1: Provider 故障转移配置 // P1: Provider 故障转移配置
{ key: 'llm.fallbackProvider', value: '', category: 'llm' }, { key: 'llm.fallbackProvider', value: '', category: 'llm' },
{ key: 'llm.fallbackModel', value: '', category: 'llm' }, { key: 'llm.fallbackModel', value: '', category: 'llm' },
+48 -3
View File
@@ -35,6 +35,8 @@ const SUMMARY_TIMEOUT_MS = 30_000;
const PER_MESSAGE_TRUNCATE = 600; const PER_MESSAGE_TRUNCATE = 600;
/** 传给 LLM 的总字符上限 */ /** 传给 LLM 的总字符上限 */
const MAX_DIGEST_CHARS = 24_000; const MAX_DIGEST_CHARS = 24_000;
/** v0.5.4: 多轮图片记忆 — 历史上下文注入的最大图片数(从最新向前收集,防 token 爆炸) */
const MAX_HISTORY_IMAGES = 10;
export class SessionSummaryService { export class SessionSummaryService {
constructor( constructor(
@@ -62,10 +64,18 @@ export class SessionSummaryService {
reasoningContent: m.reasoningContent, reasoningContent: m.reasoningContent,
toolCalls: m.toolCalls as MetonaMessage['toolCalls'], toolCalls: m.toolCalls as MetonaMessage['toolCalls'],
toolResult: m.toolResult as MetonaMessage['toolResult'], toolResult: m.toolResult as MetonaMessage['toolResult'],
// v0.5.4: 保留 attachments 供 restoreHistoryImages 恢复图片(多轮图片记忆)
attachments: (m as { attachments?: unknown[] }).attachments,
timestamp: m.timestamp, timestamp: m.timestamp,
iteration: m.iteration, iteration: m.iteration,
})); }));
// v0.5.4: 多轮图片记忆 — 从持久化的 attachments(压缩 base64 preview)恢复 images
// 历史轮次的图片重新注入 LLM 上下文(此前仅发送当轮可见,跨轮即"失忆")。
// 数量上限防 token 爆炸:只取最近 MAX_HISTORY_IMAGES 张(从最新消息向前收集)。
// 摘要区间(summarizedUntilRowid 之前)的图片无法恢复 — 符合滚动摘要的语义。
this.restoreHistoryImages(messages);
if (existing && messages.length > 0) { if (existing && messages.length > 0) {
// 摘要以 assistant 角色注入(与 engine 运行时压缩的注入策略一致) // 摘要以 assistant 角色注入(与 engine 运行时压缩的注入策略一致)
const summaryMessage: MetonaMessage = { const summaryMessage: MetonaMessage = {
@@ -78,6 +88,38 @@ export class SessionSummaryService {
return messages; return messages;
} }
/**
* v0.5.4: 恢复历史消息的 images(多轮图片记忆)
*
* attachments 中 type=image 的 preview1024px 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 调用,失败仅记录日志) * 会话结束后评估并生成滚动摘要(fire-and-forget 调用,失败仅记录日志)
*/ */
@@ -117,14 +159,16 @@ export class SessionSummaryService {
saveSummary(sessionId: string, summary: string, untilRowid: number): void { saveSummary(sessionId: string, summary: string, untilRowid: number): void {
const db = this.getDB(); const db = this.getDB();
db.prepare(` db.prepare(
`
INSERT INTO session_summaries (session_id, summary, summarized_until_rowid, updated_at) INSERT INTO session_summaries (session_id, summary, summarized_until_rowid, updated_at)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET ON CONFLICT(session_id) DO UPDATE SET
summary = excluded.summary, summary = excluded.summary,
summarized_until_rowid = excluded.summarized_until_rowid, summarized_until_rowid = excluded.summarized_until_rowid,
updated_at = excluded.updated_at updated_at = excluded.updated_at
`).run(sessionId, summary, untilRowid, Date.now()); `,
).run(sessionId, summary, untilRowid, Date.now());
} }
// ===== LLM 摘要 ===== // ===== LLM 摘要 =====
@@ -167,7 +211,8 @@ export class SessionSummaryService {
'If a prior summary exists, merge it with the new content into one updated summary. ' + '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. ' + '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.', '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: [ messages: [
{ {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.5.2", "version": "0.5.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.5.2", "version": "0.5.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.5.3", "version": "0.5.4",
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用", "description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
"main": "dist-electron/main/main.js", "main": "dist-electron/main/main.js",
"author": "Metona Team", "author": "Metona Team",
+486 -167
View File
@@ -10,7 +10,16 @@
*/ */
import { useState, useCallback, useRef, useEffect } from 'react'; 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 { Send, Paperclip, Square, X, FileText, Image as ImageIcon } from 'lucide-react';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { useAgentStore } from '@renderer/stores/agent-store'; 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 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 { interface Attachment {
id: string; id: string;
file: File; file: File;
type: 'image' | 'text' | 'other'; type: 'image' | 'text' | 'other';
preview?: string; // 图片 base64 data URL preview?: string; // 图片 base64 data URL
textContent?: string; // 文本文件内容 textContent?: string; // 文本文件内容
} }
export function ChatInput(): React.JSX.Element { export function ChatInput(): React.JSX.Element {
@@ -51,13 +94,29 @@ export function ChatInput(): React.JSX.Element {
const toolsReady = useAgentStore((s) => s.toolsReady); const toolsReady = useAgentStore((s) => s.toolsReady);
const currentSessionId = useSessionStore((s) => s.currentSessionId); const currentSessionId = useSessionStore((s) => s.currentSessionId);
const provider = useAgentStore((s) => s.provider); 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(() => {
useEffect(() => { if (currentSessionId && input) sessionStorage.setItem(`draft-${currentSessionId}`, input); }, [input, currentSessionId]); 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'; return 'other';
}, []); }, []);
const processFile = useCallback(async (file: File): Promise<Attachment> => { const processFile = useCallback(
const type = classifyFile(file); async (file: File): Promise<Attachment> => {
// L-10 修复: 统一使用 nanoid 生成附件 ID(与项目其他位置一致) const type = classifyFile(file);
const attachment: Attachment = { id: `att_${nanoid(6)}`, file, type }; // L-10 修复: 统一使用 nanoid 生成附件 ID(与项目其他位置一致)
const attachment: Attachment = { id: `att_${nanoid(6)}`, file, type };
if (type === 'image') { if (type === 'image') {
// 图片转 base64 data URL → 压缩(限制 1024px, JPEG 0.7 // 图片转 base64 data URL → 压缩(限制 1024px, JPEG 0.7
const dataUri = await new Promise<string>((resolve, reject) => { const dataUri = await new Promise<string>((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => resolve(reader.result as string); reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(new Error('图片读取失败')); reader.onerror = () => reject(new Error('图片读取失败'));
reader.readAsDataURL(file); reader.readAsDataURL(file);
}); });
attachment.preview = await compressImage(dataUri, 1024, 0.7); attachment.preview = await compressImage(dataUri, 1024, 0.7);
} else if (type === 'text') { } else if (type === 'text') {
// 文本文件读取内容 // 文本文件读取内容
attachment.textContent = await new Promise<string>((resolve, reject) => { attachment.textContent = await new Promise<string>((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => resolve(reader.result as string); reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(new Error('文本文件读取失败')); reader.onerror = () => reject(new Error('文本文件读取失败'));
reader.readAsText(file); reader.readAsText(file);
}); });
} }
return attachment; return attachment;
}, [classifyFile]); },
[classifyFile],
);
const addFiles = useCallback(async (files: FileList | File[]) => { const addFiles = useCallback(
const fileArray = Array.from(files).slice(0, 5); // 最多 5 个附件 async (files: FileList | File[]) => {
const fileArray = Array.from(files).slice(0, 5); // 最多 5 个附件
// DeepSeek 不支持多模态,过滤图片 // v0.5.4: 图片上传双重拦截(总开关 × 模型能力),非图片附件不受影响
const filtered = supportsImages const filtered = supportsImages
? fileArray ? fileArray
: fileArray.filter((f) => !IMAGE_TYPES.includes(f.type)); : fileArray.filter((f) => !IMAGE_TYPES.includes(f.type));
if (filtered.length < fileArray.length && !supportsImages) { if (filtered.length < fileArray.length && !supportsImages) {
// v0.3.0: 用 Toast 提示用户 DeepSeek 不支持图片 const skipped = fileArray.length - filtered.length;
const skipped = fileArray.length - filtered.length; // v0.5.4: 区分拒绝原因 — 开关未开启 vs 当前模型不支持
// v0.3.0 修复:记录 Toast 加载失败错误到控制台,而非静默吞掉 const reason = multimodalEnabled
import('@metona-team/metona-toast').then((mod) => { ? `当前模型不支持图片(${provider} 需多模态模型),已跳过 ${skipped} 个图片文件`
mod.default.warning(`DeepSeek 不支持图片,已自动跳过 ${skipped} 个图片文件`); : `多模态未开启(设置 → LLM 配置),已跳过 ${skipped} 个图片文件`;
}).catch((err) => { import('@metona-team/metona-toast')
console.error('[ChatInput] Failed to load metona-toast for DeepSeek image warning:', err); .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 处理部分文件读取失败,避免一个失败导致全部丢失 // v0.3.0 修复: 使用 allSettled 处理部分文件读取失败,避免一个失败导致全部丢失
const results = await Promise.allSettled(filtered.map(processFile)); const results = await Promise.allSettled(filtered.map(processFile));
const successful = results.filter( const successful = results
(r): r is PromiseFulfilledResult<Attachment> => r.status === 'fulfilled', .filter((r): r is PromiseFulfilledResult<Attachment> => r.status === 'fulfilled')
).map((r) => r.value); .map((r) => r.value);
if (successful.length === 0) { if (successful.length === 0) {
import('@metona-team/metona-toast').then((mod) => { import('@metona-team/metona-toast')
mod.default.error('文件读取失败,请检查文件是否损坏或被锁定'); .then((mod) => {
}).catch(() => {}); mod.default.error('文件读取失败,请检查文件是否损坏或被锁定');
return; })
} .catch(() => {});
if (successful.length < filtered.length) { return;
const failedCount = filtered.length - successful.length; }
import('@metona-team/metona-toast').then((mod) => { if (successful.length < filtered.length) {
mod.default.warning(`${failedCount} 个文件读取失败,已跳过`); const failedCount = filtered.length - successful.length;
}).catch(() => {}); import('@metona-team/metona-toast')
} .then((mod) => {
setAttachments((prev) => [...prev, ...successful]); mod.default.warning(`${failedCount} 个文件读取失败,已跳过`);
}, [processFile, supportsImages]); })
.catch(() => {});
}
setAttachments((prev) => [...prev, ...successful]);
},
[processFile, supportsImages],
);
const removeAttachment = useCallback((id: string) => { const removeAttachment = useCallback((id: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== id)); setAttachments((prev) => prev.filter((a) => a.id !== id));
}, []); }, []);
// 文件选择 // 文件选择
const handleFileSelect = useCallback(() => { fileInputRef.current?.click(); }, []); const handleFileSelect = useCallback(() => {
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { fileInputRef.current?.click();
if (e.target.files) addFiles(e.target.files); }, []);
e.target.value = ''; const handleFileChange = useCallback(
}, [addFiles]); (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) addFiles(e.target.files);
e.target.value = '';
},
[addFiles],
);
// 拖拽 // 拖拽
const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); }, []); const handleDragOver = useCallback((e: React.DragEvent) => {
const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault();
e.preventDefault(); e.stopPropagation(); e.stopPropagation();
if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files); }, []);
}, [addFiles]); 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 handlePaste = useCallback(
const items = Array.from(e.clipboardData.items); (e: React.ClipboardEvent) => {
const files: File[] = []; const items = Array.from(e.clipboardData.items);
for (const item of items) { const files: File[] = [];
if (item.kind === 'file') { for (const item of items) {
const f = item.getAsFile(); if (item.kind === 'file') {
if (f) files.push(f); const f = item.getAsFile();
if (f) files.push(f);
}
} }
} if (files.length) {
if (files.length) { e.preventDefault(); addFiles(files); } e.preventDefault();
}, [addFiles]); addFiles(files);
}
},
[addFiles],
);
// ===== 发送消息 ===== // ===== 发送消息 =====
@@ -179,20 +270,32 @@ export function ChatInput(): React.JSX.Element {
// 处理 / 命令 // 处理 / 命令
if (trimmed.startsWith('/')) { if (trimmed.startsWith('/')) {
const cmd = trimmed.split(' ')[0].toLowerCase(); 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') { if (cmd === '/export') {
// P2-11: /export 改为导出 Markdown(人类可读),JSON 导出走会话右键菜单 // P2-11: /export 改为导出 Markdown(人类可读),JSON 导出走会话右键菜单
import('@renderer/lib/export-markdown').then(({ buildSessionMarkdown, downloadMarkdown }) => { import('@renderer/lib/export-markdown')
const messages = useAgentStore.getState().messages; .then(({ buildSessionMarkdown, downloadMarkdown }) => {
const md = buildSessionMarkdown('会话导出', messages); const messages = useAgentStore.getState().messages;
downloadMarkdown(`session-${Date.now()}.md`, md); const md = buildSessionMarkdown('会话导出', messages);
}).catch(() => {}); downloadMarkdown(`session-${Date.now()}.md`, md);
setInput(''); setShowSlashMenu(false); return; })
.catch(() => {});
setInput('');
setShowSlashMenu(false);
return;
} }
// v0.3.0: /tool — 打开设置面板的工具管理 Tab // v0.3.0: /tool — 打开设置面板的工具管理 Tab
if (cmd === '/tool') { if (cmd === '/tool') {
useUIStore.getState().openSettings(); useUIStore.getState().openSettings();
setInput(''); setAttachments([]); setShowSlashMenu(false); setInput('');
setAttachments([]);
setShowSlashMenu(false);
return; return;
} }
// v0.3.0: /memory — 切换到详情面板的 Memory 标签 // v0.3.0: /memory — 切换到详情面板的 Memory 标签
@@ -208,7 +311,9 @@ export function ChatInput(): React.JSX.Element {
if (!useUIStore.getState().detailVisible) { if (!useUIStore.getState().detailVisible) {
uiStore.toggleDetail(); uiStore.toggleDetail();
} }
setInput(''); setAttachments([]); setShowSlashMenu(false); setInput('');
setAttachments([]);
setShowSlashMenu(false);
return; 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(''); setInput('');
setAttachments([]); setAttachments([]);
setShowSlashMenu(false); setShowSlashMenu(false);
@@ -242,82 +351,166 @@ export function ChatInput(): React.JSX.Element {
if (textareaRef.current) textareaRef.current.style.height = 'auto'; if (textareaRef.current) textareaRef.current.style.height = 'auto';
}, [input, attachments, isStreaming, sendMessage, currentSessionId]); }, [input, attachments, isStreaming, sendMessage, currentSessionId]);
const handleAbort = useCallback(() => { abort(); }, [abort]); const handleAbort = useCallback(() => {
abort();
}, [abort]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const handleKeyDown = useCallback(
// H-9 修复: 快捷键对齐规范 (e: React.KeyboardEvent) => {
// @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 快捷键规范 // H-9 修复: 快捷键对齐规范
// 规范要求: Cmd/Ctrl+Enter = 发送消息, Cmd/Ctrl+Shift+Enter = 换行 // @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 快捷键规范
// 之前代码是 Ctrl+Enter=换行, Enter=发送,与规范相反 // 规范要求: Cmd/Ctrl+Enter = 发送消息, Cmd/Ctrl+Shift+Enter = 换行
// 修复后: // 之前代码是 Ctrl+Enter=换行, Enter=发送,与规范相反
// Cmd/Ctrl+Enter = 发送消息(规范要求) // 修复后:
// Cmd/Ctrl+Shift+Enter = 换行(规范要求) // Cmd/Ctrl+Enter = 发送消息(规范要求)
// Enter = 发送消息(保持聊天应用习惯 // Cmd/Ctrl+Shift+Enter = 换行(规范要求
// Shift+Enter = 换行(textarea 默认行为,无需处理 // Enter = 发送消息(保持聊天应用习惯
// Shift+Enter = 换行(textarea 默认行为,无需处理)
// Cmd/Ctrl+Shift+Enter — 换行(规范要求) // Cmd/Ctrl+Shift+Enter — 换行(规范要求)
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && e.shiftKey) { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && e.shiftKey) {
e.preventDefault(); e.preventDefault();
const t = e.currentTarget as HTMLTextAreaElement; const t = e.currentTarget as HTMLTextAreaElement;
const s = t.selectionStart; const s = t.selectionStart;
const en = t.selectionEnd; const en = t.selectionEnd;
setInput((p) => p.slice(0, s) + '\n' + p.slice(en)); setInput((p) => p.slice(0, s) + '\n' + p.slice(en));
requestAnimationFrame(() => { t.selectionStart = t.selectionEnd = s + 1; }); requestAnimationFrame(() => {
return; t.selectionStart = t.selectionEnd = s + 1;
} });
// Cmd/Ctrl+Enter — 发送消息(规范要求,优先于 Enter) return;
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { }
e.preventDefault(); // Cmd/Ctrl+Enter — 发送消息(规范要求,优先于 Enter)
handleSend(); if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
return; e.preventDefault();
} handleSend();
// Enter(不带修饰键)— 发送消息(保持聊天应用习惯) return;
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); return; } }
if (e.key === 'Escape' && showSlashMenu) { setShowSlashMenu(false); return; } // Enter(不带修饰键)— 发送消息(保持聊天应用习惯)
}, [handleSend, showSlashMenu]); 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 // H-8 修复: 使用 InputBase 后,onChange 类型需兼容 HTMLInputElement | HTMLTextAreaElement
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => { const handleChange = useCallback(
const v = e.target.value; setInput(v); (e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
if (v === '/') { setShowSlashMenu(true); setSlashFilter(''); } const v = e.target.value;
else if (v.startsWith('/') && !v.includes(' ')) { setShowSlashMenu(true); setSlashFilter(v.slice(1).toLowerCase()); } setInput(v);
else { setShowSlashMenu(false); } 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 ( return (
<Box sx={{ flexShrink: 0, px: 2, pb: 1.5 }}> <Box sx={{ flexShrink: 0, px: 2, pb: 1.5 }}>
<Paper sx={{ maxWidth: 768, mx: 'auto', borderRadius: 3, p: 1.5, bgcolor: 'background.paper', border: '1px solid', borderColor: 'divider', position: 'relative' }} <Paper
onDragOver={handleDragOver} onDrop={handleDrop} sx={{
maxWidth: 768,
mx: 'auto',
borderRadius: 3,
p: 1.5,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
position: 'relative',
}}
onDragOver={handleDragOver}
onDrop={handleDrop}
> >
{/* 附件预览区 */} {/* 附件预览区 */}
{attachments.length > 0 && ( {attachments.length > 0 && (
<Stack direction="row" spacing={1} sx={{ mb: 1, flexWrap: 'wrap', gap: 1 }}> <Stack direction="row" spacing={1} sx={{ mb: 1, flexWrap: 'wrap', gap: 1 }}>
{attachments.map((att) => ( {attachments.map((att) => (
<AttachmentPreview key={att.id} attachment={att} onRemove={() => removeAttachment(att.id)} /> <AttachmentPreview
key={att.id}
attachment={att}
onRemove={() => removeAttachment(att.id)}
/>
))} ))}
</Stack> </Stack>
)} )}
{/* / 命令菜单 */} {/* / 命令菜单 */}
{showSlashMenu && filteredCommands.length > 0 && ( {showSlashMenu && filteredCommands.length > 0 && (
<div style={{ position: 'absolute', bottom: '100%', left: 0, right: 0, marginBottom: 4, padding: '4px 0', background: 'var(--bg-secondary, #1a1d27)', border: '1px solid var(--border-color, #2a2d3a)', borderRadius: 8, boxShadow: '0 -4px 12px rgba(0,0,0,0.2)', zIndex: 10 }}> <div
style={{
position: 'absolute',
bottom: '100%',
left: 0,
right: 0,
marginBottom: 4,
padding: '4px 0',
background: 'var(--bg-secondary, #1a1d27)',
border: '1px solid var(--border-color, #2a2d3a)',
borderRadius: 8,
boxShadow: '0 -4px 12px rgba(0,0,0,0.2)',
zIndex: 10,
}}
>
{filteredCommands.map((cmd) => ( {filteredCommands.map((cmd) => (
<div key={cmd.id} onClick={() => { setInput(cmd.label + ' '); setShowSlashMenu(false); textareaRef.current?.focus(); }} <div
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 12px', fontSize: 12, cursor: 'pointer', color: 'var(--text-primary, #e1e4ed)' }} key={cmd.id}
onClick={() => {
setInput(cmd.label + ' ');
setShowSlashMenu(false);
textareaRef.current?.focus();
}}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '6px 12px',
fontSize: 12,
cursor: 'pointer',
color: 'var(--text-primary, #e1e4ed)',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.05)')} onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.05)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
> >
<span style={{ color: '#818cf8', fontSize: 14, fontWeight: 700, flexShrink: 0 }}>/</span> <span style={{ color: '#818cf8', fontSize: 14, fontWeight: 700, flexShrink: 0 }}>
/
</span>
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{cmd.label}</span> <span style={{ fontFamily: 'monospace', fontSize: 12 }}>{cmd.label}</span>
<span style={{ color: 'var(--text-secondary, #64748b)', fontSize: 11 }}>{cmd.description}</span> <span style={{ color: 'var(--text-secondary, #64748b)', fontSize: 11 }}>
{cmd.description}
</span>
</div> </div>
))} ))}
</div> </div>
)} )}
<input ref={fileInputRef} type="file" multiple accept={supportsImages ? 'image/*,.txt,.md,.json,.csv,.ts,.tsx,.js,.jsx,.py,.rb,.go,.rs,.java,.c,.cpp,.h,.css,.html,.xml,.yaml,.yml,.toml,.ini,.sh,.sql,.log,.pdf' : '.txt,.md,.json,.csv,.ts,.tsx,.js,.jsx,.py,.rb,.go,.rs,.java,.c,.cpp,.h,.css,.html,.xml,.yaml,.yml,.toml,.ini,.sh,.sql,.log,.pdf'} style={{ display: 'none' }} onChange={handleFileChange} /> <input
ref={fileInputRef}
type="file"
multiple
accept={
supportsImages
? 'image/*,.txt,.md,.json,.csv,.ts,.tsx,.js,.jsx,.py,.rb,.go,.rs,.java,.c,.cpp,.h,.css,.html,.xml,.yaml,.yml,.toml,.ini,.sh,.sql,.log,.pdf'
: '.txt,.md,.json,.csv,.ts,.tsx,.js,.jsx,.py,.rb,.go,.rs,.java,.c,.cpp,.h,.css,.html,.xml,.yaml,.yml,.toml,.ini,.sh,.sql,.log,.pdf'
}
style={{ display: 'none' }}
onChange={handleFileChange}
/>
{/* H-8 修复: 使用 MUI InputBase 替代原生 textarea — 遵循 MUI 强制使用规范 */} {/* H-8 修复: 使用 MUI InputBase 替代原生 textarea — 遵循 MUI 强制使用规范 */}
{/* @see standard/开发规范.md — MUI 强制使用、禁止自写 UI 组件 */} {/* @see standard/开发规范.md — MUI 强制使用、禁止自写 UI 组件 */}
@@ -328,7 +521,13 @@ export function ChatInput(): React.JSX.Element {
onChange={handleChange} onChange={handleChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={handlePaste} 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} disabled={isStreaming || !configLoaded || !toolsReady}
multiline multiline
rows={1} rows={1}
@@ -350,9 +549,18 @@ export function ChatInput(): React.JSX.Element {
}} }}
/> />
<Stack direction="row" sx={{ mt: 1, minHeight: 32, justifyContent: 'space-between', alignItems: 'center' }}> <Stack
direction="row"
sx={{ mt: 1, minHeight: 32, justifyContent: 'space-between', alignItems: 'center' }}
>
{/* 左侧:附件按钮 */} {/* 左侧:附件按钮 */}
<Tooltip title={supportsImages ? '附加文件(图片/文本/代码)' : '附加文件(文本/代码)— DeepSeek 不支持图片'}> <Tooltip
title={
supportsImages
? '附加文件(图片/文本/代码)'
: '附加文件(文本/代码)— DeepSeek 不支持图片'
}
>
<IconButton size="small" sx={{ color: 'text.secondary' }} onClick={handleFileSelect}> <IconButton size="small" sx={{ color: 'text.secondary' }} onClick={handleFileSelect}>
<Paperclip size={14} /> <Paperclip size={14} />
</IconButton> </IconButton>
@@ -361,11 +569,32 @@ export function ChatInput(): React.JSX.Element {
{/* 右侧:发送按钮 */} {/* 右侧:发送按钮 */}
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}> <Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
{isStreaming ? ( {isStreaming ? (
<Button variant="contained" color="error" size="small" onClick={handleAbort} sx={{ height: 28, fontSize: 12 }}> <Button
variant="contained"
color="error"
size="small"
onClick={handleAbort}
sx={{ height: 28, fontSize: 12 }}
>
<Square size={12} style={{ marginRight: 6 }} /> <Square size={12} style={{ marginRight: 6 }} />
</Button> </Button>
) : ( ) : (
<Button variant="contained" size="small" onClick={handleSend} disabled={(!input.trim() && attachments.length === 0) || !configLoaded || !toolsReady} sx={{ height: 28, fontSize: 12, opacity: (input.trim() || attachments.length > 0) && configLoaded && toolsReady ? 1 : 0.5 }}> <Button
variant="contained"
size="small"
onClick={handleSend}
disabled={
(!input.trim() && attachments.length === 0) || !configLoaded || !toolsReady
}
sx={{
height: 28,
fontSize: 12,
opacity:
(input.trim() || attachments.length > 0) && configLoaded && toolsReady
? 1
: 0.5,
}}
>
<Send size={12} style={{ marginRight: 6 }} /> <Send size={12} style={{ marginRight: 6 }} />
</Button> </Button>
)} )}
@@ -378,17 +607,69 @@ export function ChatInput(): React.JSX.Element {
// ===== 附件预览组件 ===== // ===== 附件预览组件 =====
function AttachmentPreview({ attachment, onRemove }: { attachment: Attachment; onRemove: () => void }) { function AttachmentPreview({
attachment,
onRemove,
}: {
attachment: Attachment;
onRemove: () => void;
}) {
const { type, file, preview } = attachment; const { type, file, preview } = attachment;
if (type === 'image' && preview) { if (type === 'image' && preview) {
return ( return (
<Box sx={{ position: 'relative', width: 64, height: 64, borderRadius: 1.5, overflow: 'hidden', border: '1px solid', borderColor: 'divider', flexShrink: 0 }}> <Box
<Box component="img" src={preview} alt={file.name} sx={{ width: '100%', height: '100%', objectFit: 'cover' }} /> sx={{
<IconButton size="small" onClick={onRemove} sx={{ position: 'absolute', top: 0, right: 0, width: 20, height: 20, bgcolor: 'rgba(0,0,0,0.6)', '&:hover': { bgcolor: 'rgba(0,0,0,0.8)' }, color: '#fff' }}> position: 'relative',
width: 64,
height: 64,
borderRadius: 1.5,
overflow: 'hidden',
border: '1px solid',
borderColor: 'divider',
flexShrink: 0,
}}
>
<Box
component="img"
src={preview}
alt={file.name}
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
<IconButton
size="small"
onClick={onRemove}
sx={{
position: 'absolute',
top: 0,
right: 0,
width: 20,
height: 20,
bgcolor: 'rgba(0,0,0,0.6)',
'&:hover': { bgcolor: 'rgba(0,0,0,0.8)' },
color: '#fff',
}}
>
<X size={12} /> <X size={12} />
</IconButton> </IconButton>
<Typography variant="caption" sx={{ position: 'absolute', bottom: 0, left: 0, right: 0, bgcolor: 'rgba(0,0,0,0.6)', color: '#fff', fontSize: 9, textAlign: 'center', py: 0.25, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', px: 0.5 }}> <Typography
variant="caption"
sx={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
bgcolor: 'rgba(0,0,0,0.6)',
color: '#fff',
fontSize: 9,
textAlign: 'center',
py: 0.25,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
px: 0.5,
}}
>
{file.name} {file.name}
</Typography> </Typography>
</Box> </Box>
@@ -397,13 +678,49 @@ function AttachmentPreview({ attachment, onRemove }: { attachment: Attachment; o
// 文本文件 / 其他文件 // 文本文件 / 其他文件
return ( return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1, borderRadius: 1.5, bgcolor: 'secondary.main', border: '1px solid', borderColor: 'divider', maxWidth: 200, flexShrink: 0 }}> <Box
{type === 'text' ? <FileText size={16} style={{ color: '#22d3ee', flexShrink: 0 }} /> : <ImageIcon size={16} style={{ color: '#8b8fa7', flexShrink: 0 }} />} sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 1.5,
py: 1,
borderRadius: 1.5,
bgcolor: 'secondary.main',
border: '1px solid',
borderColor: 'divider',
maxWidth: 200,
flexShrink: 0,
}}
>
{type === 'text' ? (
<FileText size={16} style={{ color: '#22d3ee', flexShrink: 0 }} />
) : (
<ImageIcon size={16} style={{ color: '#8b8fa7', flexShrink: 0 }} />
)}
<Box sx={{ minWidth: 0, flex: 1 }}> <Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="caption" sx={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 11, color: 'text.primary' }}>{file.name}</Typography> <Typography
<Typography variant="caption" sx={{ fontSize: 9, color: 'text.secondary' }}>{formatFileSize(file.size)}</Typography> variant="caption"
sx={{
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: 11,
color: 'text.primary',
}}
>
{file.name}
</Typography>
<Typography variant="caption" sx={{ fontSize: 9, color: 'text.secondary' }}>
{formatFileSize(file.size)}
</Typography>
</Box> </Box>
<IconButton size="small" onClick={onRemove} sx={{ width: 20, height: 20, flexShrink: 0, color: 'text.secondary' }}> <IconButton
size="small"
onClick={onRemove}
sx={{ width: 20, height: 20, flexShrink: 0, color: 'text.secondary' }}
>
<X size={12} /> <X size={12} />
</IconButton> </IconButton>
</Box> </Box>
@@ -416,11 +733,7 @@ function AttachmentPreview({ attachment, onRemove }: { attachment: Attachment; o
* *
* @see AgnesAIDesktop — 已验证 Agnes AI 可正常处理压缩后的图片 * @see AgnesAIDesktop — 已验证 Agnes AI 可正常处理压缩后的图片
*/ */
function compressImage( function compressImage(dataUri: string, maxSize: number, quality: number): Promise<string> {
dataUri: string,
maxSize: number,
quality: number,
): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
img.onload = () => { img.onload = () => {
@@ -430,9 +743,15 @@ function compressImage(
return; return;
} }
if (width > height) { 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 { } 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'); const canvas = document.createElement('canvas');
canvas.width = width; canvas.width = width;
+269 -58
View File
@@ -3,7 +3,26 @@
*/ */
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Dialog, DialogContent, Button, TextField, Select, MenuItem, Stepper, Step, StepLabel, Box, Typography, Stack, FormControl, InputLabel, IconButton, InputAdornment } from '@mui/material'; import {
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 { ArrowRight, ArrowLeft, CheckCircle, Eye, EyeOff } from 'lucide-react';
import { useUIStore } from '@renderer/stores/ui-store'; import { useUIStore } from '@renderer/stores/ui-store';
import { useAgentStore } from '@renderer/stores/agent-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 setOnboardingCompleted = useUIStore((s) => s.setOnboardingCompleted);
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [provider, setProvider] = useState(''); const [provider, setProvider] = useState('');
// v0.5.4: 多模态总开关(保存到 llm.multimodalEnabled,控制图片上传入口)
const [multimodalEnabled, setMultimodalEnabled] = useState(false);
const [baseURL, setBaseURL] = useState(''); const [baseURL, setBaseURL] = useState('');
const [model, setModel] = useState(''); const [model, setModel] = useState('');
const [apiKey, setApiKey] = useState(''); const [apiKey, setApiKey] = useState('');
@@ -33,8 +54,11 @@ export function OnboardingWizard(): React.JSX.Element | null {
const saved = localStorage.getItem(ONBOARDING_PROGRESS_KEY); const saved = localStorage.getItem(ONBOARDING_PROGRESS_KEY);
if (!saved) return; if (!saved) return;
const p = JSON.parse(saved) as { const p = JSON.parse(saved) as {
step?: number; provider?: string; baseURL?: string; step?: number;
model?: string; workspacePath?: string; provider?: string;
baseURL?: string;
model?: string;
workspacePath?: string;
contextWindow?: number | null; contextWindow?: number | null;
}; };
if (typeof p.step === 'number' && p.step >= 0 && p.step < STEPS.length) setStep(p.step); 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 // 注意: 不保存 apiKey(敏感信息不写入 localStorage
useEffect(() => { useEffect(() => {
try { try {
localStorage.setItem(ONBOARDING_PROGRESS_KEY, JSON.stringify({ localStorage.setItem(
step, provider, baseURL, model, workspacePath, contextWindow, ONBOARDING_PROGRESS_KEY,
})); JSON.stringify({
step,
provider,
baseURL,
model,
workspacePath,
contextWindow,
}),
);
} catch { } catch {
// 写入失败(如隐私模式)忽略 // 写入失败(如隐私模式)忽略
} }
@@ -84,7 +116,10 @@ export function OnboardingWizard(): React.JSX.Element | null {
contextWindow != null && (!Number.isFinite(contextWindow) || contextWindow < ctxMin); contextWindow != null && (!Number.isFinite(contextWindow) || contextWindow < ctxMin);
const handleNext = async () => { const handleNext = async () => {
if (step < STEPS.length - 1) { setStep(step + 1); return; } if (step < STEPS.length - 1) {
setStep(step + 1);
return;
}
try { try {
if (window.metona?.config?.setBatch) { if (window.metona?.config?.setBatch) {
// v0.3.9: 改用批量保存,避免并行 config.set 中间态触发 reloadAdapter 失败 // 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 (baseURL.trim()) entries.push({ key: 'llm.baseURL', value: baseURL.trim() });
if (model.trim()) entries.push({ key: 'llm.model', value: model.trim() }); if (model.trim()) entries.push({ key: 'llm.model', value: model.trim() });
if (apiKey.trim()) entries.push({ key: 'llm.apiKey', value: apiKey.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 // 上下文窗口:根据 Provider 落库到对应 key
// - ollama: ollama.numCtx(允许 null=由模型决定) // - ollama: ollama.numCtx(允许 null=由模型决定)
// - deepseek/agnes/mimo: {provider}.contextWindow(必须有值且 >= 4096 // - 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); const r = await window.metona.config.setBatch(entries);
if (r && !r.success) { if (r && !r.success) {
console.error('[OnboardingWizard]', 'Batch config save failed:', r.error); 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; return;
} }
useAgentStore.getState().setProvider(provider.trim() || 'deepseek', model.trim() || ''); useAgentStore.getState().setProvider(provider.trim() || 'deepseek', model.trim() || '');
// v0.5.4: 多模态开关立即同步(setProvider 内部会从配置异步加载,此处确保即时生效)
useAgentStore.getState().setMultimodalEnabled(multimodalEnabled);
// 同步 contextWindow 到 Agent Store(与 SettingsModal 行为一致) // 同步 contextWindow 到 Agent Store(与 SettingsModal 行为一致)
if (contextWindow != null && contextWindow >= ctxMin) { if (contextWindow != null && contextWindow >= ctxMin) {
useAgentStore.setState({ contextWindow }); useAgentStore.setState({ contextWindow });
@@ -125,42 +167,86 @@ export function OnboardingWizard(): React.JSX.Element | null {
} }
setOnboardingCompleted(true); setOnboardingCompleted(true);
// #48 修复: 引导完成后清理 localStorage 进度,下次启动不再恢复 // #48 修复: 引导完成后清理 localStorage 进度,下次启动不再恢复
try { localStorage.removeItem(ONBOARDING_PROGRESS_KEY); } catch { /* ignore */ } try {
localStorage.removeItem(ONBOARDING_PROGRESS_KEY);
} catch {
/* ignore */
}
} catch (err) { } catch (err) {
console.error('[OnboardingWizard]', 'Failed to save configuration:', 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 ( return (
<Dialog open maxWidth="sm" slotProps={{ paper: { sx: { borderRadius: 3, overflow: 'hidden' } } }}> <Dialog
open
maxWidth="sm"
slotProps={{ paper: { sx: { borderRadius: 3, overflow: 'hidden' } } }}
>
<Box sx={{ px: 3, pt: 2, pb: 0 }}> <Box sx={{ px: 3, pt: 2, pb: 0 }}>
<Stepper activeStep={step} alternativeLabel sx={{ '& .MuiStepLabel-label': { fontSize: 11 } }}> <Stepper
{STEPS.map((s) => <Step key={s}><StepLabel>{s}</StepLabel></Step>)} activeStep={step}
alternativeLabel
sx={{ '& .MuiStepLabel-label': { fontSize: 11 } }}
>
{STEPS.map((s) => (
<Step key={s}>
<StepLabel>{s}</StepLabel>
</Step>
))}
</Stepper> </Stepper>
</Box> </Box>
<DialogContent sx={{ minHeight: 280, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}> <DialogContent
sx={{
minHeight: 280,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
{step === 0 && ( {step === 0 && (
<Box sx={{ textAlign: 'center' }}> <Box sx={{ textAlign: 'center' }}>
<Box component="img" src="./logo.png" alt="Metona" sx={{ width: 64, height: 64, mx: 'auto', mb: 2, borderRadius: 2 }} /> <Box
<Typography variant="h6" sx={{ mb: 1 }}>使 MetonaAI Desktop</Typography> component="img"
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}> AI Agent MCP </Typography> src="./logo.png"
alt="Metona"
sx={{ width: 64, height: 64, mx: 'auto', mb: 2, borderRadius: 2 }}
/>
<Typography variant="h6" sx={{ mb: 1 }}>
使 MetonaAI Desktop
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
AI Agent MCP
</Typography>
<Typography variant="caption"> 1 </Typography> <Typography variant="caption"> 1 </Typography>
</Box> </Box>
)} )}
{step === 1 && ( {step === 1 && (
<Box sx={{ width: '100%' }}> <Box sx={{ width: '100%' }}>
<Typography variant="h6" sx={{ mb: 2 }}> LLM Provider</Typography> <Typography variant="h6" sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}> Provider API Base URL </Typography> LLM Provider
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
Provider API Base URL
</Typography>
<Stack spacing={2}> <Stack spacing={2}>
<FormControl size="small"><InputLabel>Provider</InputLabel> <FormControl size="small">
<Select value={provider} label="Provider" onChange={(e) => { <InputLabel>Provider</InputLabel>
const v = e.target.value; <Select
setProvider(v); value={provider}
// 联动默认上下文窗口(与 SettingsModal 默认值一致) label="Provider"
setContextWindow(DEFAULT_CTX[v] ?? null); onChange={(e) => {
}}> const v = e.target.value;
setProvider(v);
// 联动默认上下文窗口(与 SettingsModal 默认值一致)
setContextWindow(DEFAULT_CTX[v] ?? null);
}}
>
<MenuItem value="deepseek">DeepSeek</MenuItem> <MenuItem value="deepseek">DeepSeek</MenuItem>
<MenuItem value="agnes">Agnes AI</MenuItem> <MenuItem value="agnes">Agnes AI</MenuItem>
<MenuItem value="mimo">MiMo ()</MenuItem> <MenuItem value="mimo">MiMo ()</MenuItem>
@@ -169,71 +255,196 @@ export function OnboardingWizard(): React.JSX.Element | null {
<MenuItem value="anthropic">Anthropic</MenuItem> <MenuItem value="anthropic">Anthropic</MenuItem>
</Select> </Select>
</FormControl> </FormControl>
<TextField size="small" label="API Base URL" value={baseURL} onChange={(e) => setBaseURL(e.target.value)} placeholder="如 https://api.deepseek.com" /> <TextField
<TextField size="small" label="模型名称" value={model} onChange={(e) => setModel(e.target.value)} placeholder="如 deepseek-v4-pro、qwen3:latest" /> size="small"
<TextField size="small" label="API Key" type={showKey ? 'text' : 'password'} value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-...(本地模型可留空)" label="API Base URL"
slotProps={{ input: { endAdornment: <InputAdornment position="end"><IconButton size="small" onClick={() => setShowKey(!showKey)}>{showKey ? <EyeOff size={14} /> : <Eye size={14} />}</IconButton></InputAdornment> } }} value={baseURL}
onChange={(e) => setBaseURL(e.target.value)}
placeholder="如 https://api.deepseek.com"
/> />
<TextField <TextField
size="small" size="small"
label={provider === 'ollama' ? '上下文长度 (num_ctx)' : '上下文窗口 (contextWindow)'} label="模型名称"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="如 deepseek-v4-pro、qwen3:latest"
/>
<TextField
size="small"
label="API Key"
type={showKey ? 'text' : 'password'}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-...(本地模型可留空)"
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton size="small" onClick={() => setShowKey(!showKey)}>
{showKey ? <EyeOff size={14} /> : <Eye size={14} />}
</IconButton>
</InputAdornment>
),
},
}}
/>
<TextField
size="small"
label={
provider === 'ollama' ? '上下文长度 (num_ctx)' : '上下文窗口 (contextWindow)'
}
type="number" type="number"
value={contextWindow ?? ''} value={contextWindow ?? ''}
onChange={(e) => { onChange={(e) => {
const v = e.target.value; const v = e.target.value;
setContextWindow(v === '' ? null : Number(v)); 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 } }} slotProps={{ htmlInput: { min: ctxMin, step: ctxMin } }}
error={ctxError} error={ctxError}
helperText={ctxError ? `最小值为 ${ctxMin}` : (provider === 'ollama' ? ' ' : '用于上下文压缩判断,不传给 API')} helperText={
ctxError
? `最小值为 ${ctxMin}`
: provider === 'ollama'
? ' '
: '用于上下文压缩判断,不传给 API'
}
/>
{/* v0.5.4: 多模态总开关 — 未开启时即使模型支持也不能上传图片 */}
<FormControlLabel
control={
<Switch
size="small"
checked={multimodalEnabled}
onChange={(e) => setMultimodalEnabled(e.target.checked)}
/>
}
label={
<Stack>
<Typography variant="body2"></Typography>
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
DeepSeek vision
</Typography>
</Stack>
}
sx={{ alignItems: 'flex-start', m: 0 }}
/> />
</Stack> </Stack>
</Box> </Box>
)} )}
{step === 2 && ( {step === 2 && (
<Box sx={{ width: '100%' }}> <Box sx={{ width: '100%' }}>
<Typography variant="h6" sx={{ mb: 2 }}> Agent</Typography> <Typography variant="h6" sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}> SOUL.md Agent </Typography> Agent
<Box sx={{ px: 2, py: 1.5, borderRadius: 2, bgcolor: 'action.hover', border: '1px solid', borderColor: 'divider', fontSize: 12, color: 'text.secondary' }}> </Typography>
<div><strong style={{ color: '#e1e4ed' }}>SOUL.md</strong> Agent </div> <Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
<div style={{ marginTop: 8, fontSize: 11, opacity: 0.7 }}></div> SOUL.md Agent
</Typography>
<Box
sx={{
px: 2,
py: 1.5,
borderRadius: 2,
bgcolor: 'action.hover',
border: '1px solid',
borderColor: 'divider',
fontSize: 12,
color: 'text.secondary',
}}
>
<div>
<strong style={{ color: '#e1e4ed' }}>SOUL.md</strong> Agent
</div>
<div style={{ marginTop: 8, fontSize: 11, opacity: 0.7 }}>
</div>
</Box> </Box>
</Box> </Box>
)} )}
{step === 3 && ( {step === 3 && (
<Box sx={{ width: '100%' }}> <Box sx={{ width: '100%' }}>
<Typography variant="h6" sx={{ mb: 2 }}></Typography> <Typography variant="h6" sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>使</Typography>
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
使
</Typography>
<Stack direction="row" spacing={1} sx={{ mb: 2, alignItems: 'center' }}> <Stack direction="row" spacing={1} sx={{ mb: 2, alignItems: 'center' }}>
<TextField size="small" value={workspacePath} onChange={(e) => setWorkspacePath(e.target.value)} placeholder="~/MetonaWorkspaces/default/" sx={{ flex: 1 }} /> <TextField
<Button variant="outlined" size="small" onClick={async () => { size="small"
if (window.metona?.app?.selectFolder) { value={workspacePath}
try { onChange={(e) => setWorkspacePath(e.target.value)}
const r = await window.metona.app.selectFolder(workspacePath || undefined); placeholder="~/MetonaWorkspaces/default/"
if (!r.canceled && r.path) setWorkspacePath(r.path); sx={{ flex: 1 }}
} catch (err) { />
console.error('[OnboardingWizard]', err); <Button
import('@metona-team/metona-toast').then((mod) => mod.default.error('选择文件夹失败')).catch(() => {}); variant="outlined"
size="small"
onClick={async () => {
if (window.metona?.app?.selectFolder) {
try {
const r = await window.metona.app.selectFolder(workspacePath || undefined);
if (!r.canceled && r.path) setWorkspacePath(r.path);
} catch (err) {
console.error('[OnboardingWizard]', err);
import('@metona-team/metona-toast')
.then((mod) => mod.default.error('选择文件夹失败'))
.catch(() => {});
}
} }
} }}
}}></Button> >
</Button>
</Stack> </Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}> SOUL.mdMEMORY.md </Typography> <Typography variant="caption" sx={{ color: 'text.secondary' }}>
SOUL.mdMEMORY.md
</Typography>
</Box> </Box>
)} )}
{step === 4 && ( {step === 4 && (
<Box sx={{ textAlign: 'center' }}> <Box sx={{ textAlign: 'center' }}>
<CheckCircle size={48} style={{ color: '#34d399', margin: '0 auto 16px' }} /> <CheckCircle size={48} style={{ color: '#34d399', margin: '0 auto 16px' }} />
<Typography variant="h6" sx={{ mb: 1 }}></Typography> <Typography variant="h6" sx={{ mb: 1 }}>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1 }}>MetonaAI Desktop AI Agent </Typography>
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1 }}>
MetonaAI Desktop AI Agent
</Typography>
<Typography variant="caption"> Ctrl+Enter / </Typography> <Typography variant="caption"> Ctrl+Enter / </Typography>
</Box> </Box>
)} )}
</DialogContent> </DialogContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', px: 3, py: 1.5, borderTop: 1, borderColor: 'divider' }}> <Box
<Button startIcon={<ArrowLeft size={12} />} onClick={() => setStep(step - 1)} disabled={step === 0} size="small" sx={{ color: 'text.secondary' }}></Button> sx={{
<Button variant="contained" endIcon={step < STEPS.length - 1 ? <ArrowRight size={12} /> : undefined} onClick={handleNext} size="small"> display: 'flex',
justifyContent: 'space-between',
px: 3,
py: 1.5,
borderTop: 1,
borderColor: 'divider',
}}
>
<Button
startIcon={<ArrowLeft size={12} />}
onClick={() => setStep(step - 1)}
disabled={step === 0}
size="small"
sx={{ color: 'text.secondary' }}
>
</Button>
<Button
variant="contained"
endIcon={step < STEPS.length - 1 ? <ArrowRight size={12} /> : undefined}
onClick={handleNext}
size="small"
>
{step === STEPS.length - 1 ? '开始使用' : '下一步'} {step === STEPS.length - 1 ? '开始使用' : '下一步'}
</Button> </Button>
</Box> </Box>
+32 -1
View File
@@ -18,6 +18,8 @@ import {
FormControl, FormControl,
IconButton, IconButton,
CircularProgress, CircularProgress,
FormControlLabel,
Switch,
} from '@mui/material'; } from '@mui/material';
import { Eye, EyeOff } from 'lucide-react'; import { Eye, EyeOff } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store'; import { useAgentStore } from '@renderer/stores/agent-store';
@@ -50,6 +52,8 @@ export function LLMSettings() {
const [showFbKey, setShowFbKey] = useState(false); const [showFbKey, setShowFbKey] = useState(false);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
// v0.5.4: 多模态总开关(未开启时禁止上传图片,即使模型支持)
const [multimodalEnabled, setMultimodalEnabled] = useState(false);
// v0.5.0: DeepSeek 余额显示(复用主进程 getBalance,原为适配器死代码) // v0.5.0: DeepSeek 余额显示(复用主进程 getBalance,原为适配器死代码)
const [balance, setBalance] = useState<{ const [balance, setBalance] = useState<{
currency: string; currency: string;
@@ -116,9 +120,11 @@ export function LLMSettings() {
window.metona.config.get('llm.fallbackModel'), window.metona.config.get('llm.fallbackModel'),
window.metona.config.get('llm.fallbackApiKey'), window.metona.config.get('llm.fallbackApiKey'),
window.metona.config.get('llm.fallbackBaseURL'), window.metona.config.get('llm.fallbackBaseURL'),
// v0.5.4: 多模态开关
window.metona.config.get('llm.multimodalEnabled'),
]); ]);
if (cancelled) return; 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) ?? ''); setProvider((p as string) ?? '');
setModel((m as string) ?? ''); setModel((m as string) ?? '');
setApiKey((k as string) ?? ''); setApiKey((k as string) ?? '');
@@ -133,6 +139,7 @@ export function LLMSettings() {
setFbModel((fbm as string) ?? ''); setFbModel((fbm as string) ?? '');
setFbApiKey((fbk as string) ?? ''); setFbApiKey((fbk as string) ?? '');
setFbBaseURL((fbu as string) ?? ''); setFbBaseURL((fbu as string) ?? '');
setMultimodalEnabled(mm === true);
} catch (err) { } catch (err) {
console.error('[LLMSettings]', err); console.error('[LLMSettings]', err);
} finally { } finally {
@@ -248,6 +255,8 @@ export function LLMSettings() {
{ key: 'llm.model', value: model }, { key: 'llm.model', value: model },
{ key: 'llm.apiKey', value: apiKey }, { key: 'llm.apiKey', value: apiKey },
{ key: 'llm.baseURL', value: baseURL }, { key: 'llm.baseURL', value: baseURL },
// v0.5.4: 多模态总开关
{ key: 'llm.multimodalEnabled', value: multimodalEnabled },
{ key: 'ollama.numCtx', value: numCtx }, { key: 'ollama.numCtx', value: numCtx },
{ key: 'deepseek.contextWindow', value: dsCtxWindow }, { key: 'deepseek.contextWindow', value: dsCtxWindow },
{ key: 'agnes.contextWindow', value: agnesCtxWindow }, { key: 'agnes.contextWindow', value: agnesCtxWindow },
@@ -266,6 +275,8 @@ export function LLMSettings() {
.then((mod) => mod.default.error(r.error ?? '配置保存失败')) .then((mod) => mod.default.error(r.error ?? '配置保存失败'))
.catch(() => {}); .catch(() => {});
} else { } else {
// v0.5.4: 保存成功后同步多模态开关到 Agent Store(立即生效,控制上传入口)
useAgentStore.getState().setMultimodalEnabled(multimodalEnabled);
import('@metona-team/metona-toast') import('@metona-team/metona-toast')
.then((mod) => mod.default.success('配置已保存')) .then((mod) => mod.default.success('配置已保存'))
.catch(() => {}); .catch(() => {});
@@ -332,6 +343,26 @@ export function LLMSettings() {
error={modelHasSpace} error={modelHasSpace}
helperText={modelHasSpace ? '模型名称不能包含空格' : ' '} helperText={modelHasSpace ? '模型名称不能包含空格' : ' '}
/> />
{/* v0.5.4: 多模态总开关 — 未开启时即使模型支持也不能上传图片 */}
<FormControlLabel
control={
<Switch
size="small"
checked={multimodalEnabled}
onChange={(e) => setMultimodalEnabled(e.target.checked)}
/>
}
label={
<Stack>
<Typography variant="body2"></Typography>
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
DeepSeek vision
10
</Typography>
</Stack>
}
sx={{ alignItems: 'flex-start', m: 0 }}
/>
{provider !== 'ollama' && ( {provider !== 'ollama' && (
<> <>
<TextField <TextField
+241 -116
View File
@@ -39,8 +39,8 @@ export interface AttachmentInfo {
name: string; name: string;
type: 'image' | 'text' | 'other'; type: 'image' | 'text' | 'other';
size: number; size: number;
preview?: string; // 图片 base64 data URL preview?: string; // 图片 base64 data URL
textContent?: string; // 文本文件内容 textContent?: string; // 文本文件内容
} }
export interface ChatMessage { export interface ChatMessage {
@@ -133,13 +133,19 @@ interface AgentState {
provider: string; provider: string;
model: string; model: string;
contextWindow: number; contextWindow: number;
// v0.5.4: 多模态总开关(llm.multimodalEnabled)— 未开启时禁止上传图片
multimodalEnabled: boolean;
// Actions // Actions
setCurrentSession: (id: string | null) => void; setCurrentSession: (id: string | null) => void;
setMessages: (messages: ChatMessage[]) => void; setMessages: (messages: ChatMessage[]) => void;
addMessage: (message: ChatMessage) => void; addMessage: (message: ChatMessage) => void;
updateMessage: (id: string, updates: Partial<ChatMessage>) => void; updateMessage: (id: string, updates: Partial<ChatMessage>) => 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; updateLastAssistantMessage: (delta: string) => void;
setAgentStatus: (status: AgentStatus) => void; setAgentStatus: (status: AgentStatus) => void;
setStreaming: (streaming: boolean) => void; setStreaming: (streaming: boolean) => void;
@@ -159,6 +165,8 @@ interface AgentState {
*/ */
applyCompression: (savedTokens: number) => void; applyCompression: (savedTokens: number) => void;
setProvider: (provider: string, model: string) => void; setProvider: (provider: string, model: string) => void;
// v0.5.4: 多模态开关 setter(App 初始化与设置保存时同步)
setMultimodalEnabled: (enabled: boolean) => void;
setMaxIterations: (max: number) => void; setMaxIterations: (max: number) => void;
setCurrentIteration: (n: number) => void; setCurrentIteration: (n: number) => void;
saveTraceData: () => void; saveTraceData: () => void;
@@ -183,7 +191,13 @@ export const useAgentStore = create<AgentState>((set, get) => ({
agentStatus: 'idle', agentStatus: 'idle',
currentIteration: 0, currentIteration: 0,
maxIterations: 20, 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: [], traceSteps: [],
isStreaming: false, isStreaming: false,
configLoaded: false, configLoaded: false,
@@ -192,104 +206,158 @@ export const useAgentStore = create<AgentState>((set, get) => ({
provider: '', provider: '',
model: '', model: '',
contextWindow: 0, contextWindow: 0,
// v0.5.4: 多模态总开关(默认关闭,setProvider 时从配置加载)
multimodalEnabled: false,
// ===== Actions ===== // ===== Actions =====
setCurrentSession: (id) => { 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) { if (id && window.metona?.sessions?.getMessages) {
window.metona.sessions.getMessages(id).then((msgs) => { window.metona.sessions
// v0.3.0 修复: 竞态保护 — 快速切换会话时,旧请求返回后不再覆盖当前会话消息 .getMessages(id)
if (get().currentSessionId !== id) return; .then((msgs) => {
const messages = (msgs as Array<{ // v0.3.0 修复: 竞态保护 — 快速切换会话时,旧请求返回后不再覆盖当前会话消息
id: string; role: string; content: string; if (get().currentSessionId !== id) return;
reasoningContent?: string; toolCalls?: unknown[]; const messages = (
toolResult?: unknown; msgs as Array<{
attachments?: Array<{ id: string; name: string; type: string; size: number; preview?: string; textContent?: string }>; id: string;
iteration?: number; role: string;
timestamp: number; content: string;
}>) reasoningContent?: string;
// v0.3.0 修复: 过滤掉 tool 消息 — tool 结果已包含在 assistant 消息的 toolCalls 中 toolCalls?: unknown[];
// 独立的 tool 消息只用于 LLM API 上下文,不需要在前端显示为独立卡片 toolResult?: unknown;
.filter((m) => m.role !== 'tool') attachments?: Array<{
.map((m) => ({ id: string;
id: m.id, name: string;
role: m.role as ChatMessage['role'], type: string;
content: m.content, size: number;
reasoningContent: m.reasoningContent, preview?: string;
toolCalls: m.toolCalls as ToolCallInfo[] | undefined, textContent?: string;
attachments: m.attachments as AttachmentInfo[] | undefined, }>;
iteration: m.iteration, iteration?: number;
timestamp: m.timestamp, timestamp: number;
})); }>
set({ messages }); )
}).catch((err) => { console.error('[AgentStore]', err); }); // 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 用量 // 从数据库加载该会话的 trace 步骤和 token 用量
if (id && window.metona?.sessions?.getTrace) { if (id && window.metona?.sessions?.getTrace) {
window.metona.sessions.getTrace(id).then((data) => { window.metona.sessions
// v0.3.0 修复: 竞态保护 — 快速切换会话时,旧请求返回后不再覆盖当前会话 trace .getTrace(id)
if (get().currentSessionId !== id) return; .then((data) => {
if (data) { // v0.3.0 修复: 竞态保护 — 快速切换会话时,旧请求返回后不再覆盖当前会话 trace
if (data.traceSteps) { if (get().currentSessionId !== id) return;
// 兼容旧数据:为缺少 id/states 的 trace 步骤补全 if (data) {
const steps = (data.traceSteps as TraceStep[]).map((t, i) => ({ if (data.traceSteps) {
...t, // 兼容旧数据:为缺少 id/states 的 trace 步骤补全
id: t.id ?? `trace_legacy_${t.iteration}_${t.state}_${i}`, const steps = (data.traceSteps as TraceStep[]).map((t, i) => ({
states: t.states ?? [t.state], ...t,
})); id: t.id ?? `trace_legacy_${t.iteration}_${t.state}_${i}`,
set({ traceSteps: steps }); states: t.states ?? [t.state],
}));
set({ traceSteps: steps });
}
// v0.3.18 修复: 兼容旧数据 — 旧 tokenUsage 没有 lastInputTokens/lastCompressedSaved 字段
if (data.tokenUsage) {
const old = data.tokenUsage as Partial<TokenUsage>;
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) { .catch((err) => {
const old = data.tokenUsage as Partial<TokenUsage>; console.error('[AgentStore]', err);
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); });
} }
}, },
setMessages: (messages) => set({ messages }), setMessages: (messages) => set({ messages }),
addMessage: (message) => addMessage: (message) => set((s) => ({ messages: [...s.messages, message] })),
set((s) => ({ messages: [...s.messages, message] })),
updateMessage: (id, updates) => updateMessage: (id, updates) =>
set((s) => ({ set((s) => ({
messages: s.messages.map((m) => messages: s.messages.map((m) => (m.id === id ? { ...m, ...updates } : 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; let sessionId = get().currentSessionId;
// v0.3.18 修复: 工具未就绪时阻止发送,避免 MCP 工具不可用 // v0.3.18 修复: 工具未就绪时阻止发送,避免 MCP 工具不可用
if (!get().toolsReady) { 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; return;
} }
// 没有当前会话时自动创建 // 没有当前会话时自动创建
if (!sessionId && window.metona?.sessions?.create) { if (!sessionId && window.metona?.sessions?.create) {
try { 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({ useSessionStore.getState().addSession({
id: session.id, title: session.title, createdAt: session.createdAt, id: session.id,
updatedAt: session.updatedAt, messageCount: session.messageCount, title: session.title,
pinned: session.pinned, archived: session.archived, createdAt: session.createdAt,
updatedAt: session.updatedAt,
messageCount: session.messageCount,
pinned: session.pinned,
archived: session.archived,
}); });
sessionId = session.id; sessionId = session.id;
set({ currentSessionId: sessionId }); set({ currentSessionId: sessionId });
@@ -305,7 +373,9 @@ export const useAgentStore = create<AgentState>((set, get) => ({
timestamp: Date.now(), timestamp: Date.now(),
}); });
// 额外弹 toast 作为通知,确保用户感知(system message 仅在聊天流内可见) // 额外弹 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; return;
} }
} }
@@ -313,7 +383,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
const userMessage: ChatMessage = { const userMessage: ChatMessage = {
id: genMsgId('user'), id: genMsgId('user'),
role: 'user', role: 'user',
content, // 用户可见内容(纯文本) content, // 用户可见内容(纯文本)
timestamp: Date.now(), timestamp: Date.now(),
attachments: attachments && attachments.length > 0 ? attachments : undefined, attachments: attachments && attachments.length > 0 ? attachments : undefined,
}; };
@@ -327,19 +397,30 @@ export const useAgentStore = create<AgentState>((set, get) => ({
// 方案 A: 不清空 traceSteps,避免前一条消息的 trace 被永久覆盖 // 方案 A: 不清空 traceSteps,避免前一条消息的 trace 被永久覆盖
// TraceViewer 按 runId 过滤显示,只展示当前 run 的 steps // TraceViewer 按 runId 过滤显示,只展示当前 run 的 steps
// 历史 trace 仍在 DB 中,切换会话回来可恢复 // 历史 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) { if (sessionId && window.metona?.sessions?.getMessages) {
window.metona.sessions.getMessages(sessionId).then((msgs) => { window.metona.sessions
if (msgs.length <= 1) { .getMessages(sessionId)
// 这是第一条消息,更新标题 .then((msgs) => {
const title = content.length > 30 ? content.slice(0, 30) + '...' : content; if (msgs.length <= 1) {
window.metona?.sessions?.rename(sessionId, title); // 这是第一条消息,更新标题
useSessionStore.getState().updateSession(sessionId, { title }); const title = content.length > 30 ? content.slice(0, 30) + '...' : content;
} window.metona?.sessions?.rename(sessionId, title);
}).catch((err) => { console.error('[AgentStore]', err); }); useSessionStore.getState().updateSession(sessionId, { title });
}
})
.catch((err) => {
console.error('[AgentStore]', err);
});
} }
if (sessionId && window.metona?.agent?.sendMessage) { if (sessionId && window.metona?.agent?.sendMessage) {
@@ -360,15 +441,16 @@ export const useAgentStore = create<AgentState>((set, get) => ({
// 非图片文件:JSON 结构化,原始文本内容(不编码) // 非图片文件:JSON 结构化,原始文本内容(不编码)
const ext = att.name.split('.').pop() ?? 'unknown'; const ext = att.name.split('.').pop() ?? 'unknown';
const rawContent = att.textContent ?? ''; const rawContent = att.textContent ?? '';
parts.push(JSON.stringify({ parts.push(
file_name: att.name, JSON.stringify({
file_type: ext, file_name: att.name,
content: rawContent, file_type: ext,
})); content: rawContent,
}),
);
} }
llmContent = parts.length > 0 llmContent =
? (content ? content + '\n\n' : '') + parts.join('\n') parts.length > 0 ? (content ? content + '\n\n' : '') + parts.join('\n') : content;
: content;
} }
const messageWithImages = { const messageWithImages = {
@@ -386,7 +468,9 @@ export const useAgentStore = create<AgentState>((set, get) => ({
timestamp: Date.now(), timestamp: Date.now(),
}); });
// 额外弹 toast 作为通知,确保用户感知(system message 仅在聊天流内可见) // 额外弹 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<AgentState>((set, get) => ({
setCurrentRunId: (runId) => set({ currentRunId: runId }), setCurrentRunId: (runId) => set({ currentRunId: runId }),
addTraceStep: (step) => addTraceStep: (step) => set((s) => ({ traceSteps: [...s.traceSteps, step] })),
set((s) => ({ traceSteps: [...s.traceSteps, step] })),
updateTraceStep: (iteration, updates) => updateTraceStep: (iteration, updates) =>
set((s) => ({ set((s) => ({
traceSteps: s.traceSteps.map((t) => traceSteps: s.traceSteps.map((t) => (t.iteration === iteration ? { ...t, ...updates } : t)),
t.iteration === iteration ? { ...t, ...updates } : t,
),
})), })),
// L-2: 按 ID 精确匹配 traceStep(避免 iteration 碰撞) // L-2: 按 ID 精确匹配 traceStep(避免 iteration 碰撞)
updateTraceStepById: (id, updates) => updateTraceStepById: (id, updates) =>
set((s) => ({ set((s) => ({
traceSteps: s.traceSteps.map((t) => traceSteps: s.traceSteps.map((t) => (t.id === id ? { ...t, ...updates } : t)),
t.id === id ? { ...t, ...updates } : t,
),
})), })),
updateLastTraceStep: (updates) => updateLastTraceStep: (updates) =>
@@ -448,8 +527,7 @@ export const useAgentStore = create<AgentState>((set, get) => ({
return { traceSteps: steps }; return { traceSteps: steps };
}), }),
updateTokenUsage: (usage) => updateTokenUsage: (usage) => set((s) => ({ tokenUsage: { ...s.tokenUsage, ...usage } })),
set((s) => ({ tokenUsage: { ...s.tokenUsage, ...usage } })),
// v0.3.18 修复: 应用上下文压缩事件,记录节省的 token 数 // v0.3.18 修复: 应用上下文压缩事件,记录节省的 token 数
// 压缩后下一轮 LLM 调用的 inputTokens 会大幅下降,lastInputTokens 会自动反映 // 压缩后下一轮 LLM 调用的 inputTokens 会大幅下降,lastInputTokens 会自动反映
@@ -459,23 +537,43 @@ export const useAgentStore = create<AgentState>((set, get) => ({
setProvider: (provider, model) => { setProvider: (provider, model) => {
// L-16 修复: 使用命名常量替代魔法数字 // L-16 修复: 使用命名常量替代魔法数字
// v0.3.1: DeepSeek/Agnes 不再固定 1M,从配置读取 // 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 }); 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) // v0.3.1: 异步读取实际配置(所有 Provider)
if (window.metona?.config?.get) { if (window.metona?.config?.get) {
// Ollama 用 ollama.numCtxDeepSeek 用 deepseek.contextWindowAgnes 用 agnes.contextWindow // Ollama 用 ollama.numCtxDeepSeek 用 deepseek.contextWindowAgnes 用 agnes.contextWindow
const configKey = provider === 'ollama' ? 'ollama.numCtx' : `${provider}.contextWindow`; const configKey = provider === 'ollama' ? 'ollama.numCtx' : `${provider}.contextWindow`;
// P2-12 修复: 竞态保护 — 快速切换 provider 时,旧 Promise resolve 不覆盖新值 // P2-12 修复: 竞态保护 — 快速切换 provider 时,旧 Promise resolve 不覆盖新值
const expectedProvider = provider; const expectedProvider = provider;
window.metona.config.get(configKey).then((v) => { window.metona.config
if (get().provider !== expectedProvider) return; // provider 已切换,丢弃旧结果 .get(configKey)
if (v != null && typeof v === 'number' && v > 0) { .then((v) => {
set({ contextWindow: v }); if (get().provider !== expectedProvider) return; // provider 已切换,丢弃旧结果
} if (v != null && typeof v === 'number' && v > 0) {
}).catch((err) => { console.error('[AgentStore]', err); }); set({ contextWindow: v });
}
})
.catch((err) => {
console.error('[AgentStore]', err);
});
} }
}, },
// v0.5.4: 多模态开关 setter — 设置保存/开关切换时同步(立即生效,ChatInput 消费)
setMultimodalEnabled: (enabled) => set({ multimodalEnabled: enabled }),
setMaxIterations: (max) => set({ maxIterations: max }), setMaxIterations: (max) => set({ maxIterations: max }),
setCurrentIteration: (currentIteration) => set({ currentIteration }), setCurrentIteration: (currentIteration) => set({ currentIteration }),
@@ -483,7 +581,11 @@ export const useAgentStore = create<AgentState>((set, get) => ({
saveTraceData: () => { saveTraceData: () => {
const { currentSessionId, traceSteps, tokenUsage } = get(); const { currentSessionId, traceSteps, tokenUsage } = get();
if (currentSessionId && window.metona?.sessions?.saveTrace) { 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<AgentState>((set, get) => ({
messages: [], messages: [],
agentStatus: 'idle', agentStatus: 'idle',
currentIteration: 0, 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: [], traceSteps: [],
isStreaming: false, isStreaming: false,
currentRunId: null, currentRunId: null,
@@ -504,7 +612,9 @@ export const useAgentStore = create<AgentState>((set, get) => ({
// 旧 run 的 DONE 到达时会匹配 currentRunId,处理后清除;新 run 的 INIT 会覆盖 // 旧 run 的 DONE 到达时会匹配 currentRunId,处理后清除;新 run 的 INIT 会覆盖
set({ agentStatus: 'idle', isStreaming: false }); set({ agentStatus: 'idle', isStreaming: false });
if (sessionId && window.metona?.agent?.abortSession) { 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<AgentState>((set, get) => ({
editAndResend: async (messageId, newContent) => { editAndResend: async (messageId, newContent) => {
const { messages, currentSessionId, isStreaming } = get(); const { messages, currentSessionId, isStreaming } = get();
if (isStreaming) { 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; return;
} }
const idx = messages.findIndex((m) => m.id === messageId); const idx = messages.findIndex((m) => m.id === messageId);
@@ -526,7 +638,9 @@ export const useAgentStore = create<AgentState>((set, get) => ({
await window.metona.sessions.truncateAfter(currentSessionId, messageId, true); await window.metona.sessions.truncateAfter(currentSessionId, messageId, true);
} catch (err) { } catch (err) {
console.error('[AgentStore] truncateAfter failed:', 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; return;
} }
} }
@@ -556,13 +670,22 @@ export const useAgentStore = create<AgentState>((set, get) => ({
regenerate: async () => { regenerate: async () => {
const { messages, currentSessionId, isStreaming } = get(); const { messages, currentSessionId, isStreaming } = get();
if (isStreaming) { 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; return;
} }
// 找最后一条用户消息 // 找最后一条用户消息
const lastUserIdx = messages.length - 1 - [...messages].reverse().findIndex((m) => m.role === 'user'); const lastUserIdx =
if (lastUserIdx < 0 || lastUserIdx >= messages.length || messages[lastUserIdx].role !== 'user') { messages.length - 1 - [...messages].reverse().findIndex((m) => m.role === 'user');
import('@metona-team/metona-toast').then((mod) => mod.default.info('没有可重新生成的用户消息')).catch(() => {}); if (
lastUserIdx < 0 ||
lastUserIdx >= messages.length ||
messages[lastUserIdx].role !== 'user'
) {
import('@metona-team/metona-toast')
.then((mod) => mod.default.info('没有可重新生成的用户消息'))
.catch(() => {});
return; return;
} }
const lastUser = messages[lastUserIdx]; const lastUser = messages[lastUserIdx];
@@ -573,7 +696,9 @@ export const useAgentStore = create<AgentState>((set, get) => ({
await window.metona.sessions.truncateAfter(currentSessionId, lastUser.id, true); await window.metona.sessions.truncateAfter(currentSessionId, lastUser.id, true);
} catch (err) { } catch (err) {
console.error('[AgentStore] truncateAfter failed:', 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; return;
} }
} }