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