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