硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(llm.contextWindow)
与「最大输出上限」(llm.maxTokens),跨 Provider/模型原样透传。
P0 正确性收口:
- 迁移 11/12(SCHEMA_VERSION 5):记忆表 embedding 列 + 分 Provider 窗口键清理
- 记忆生命周期接线:会话终态清理 working memory / episodic 90 天 TTL / access_count 回写
- 回放缓冲模块化 + 会话终态清理(杜绝 4MB/会话内存滞留)
- i18n 收口:主进程 main-locale(zh/en,ui.locale 热切换)+ 渲染层 17 处出层
P1 能力演进:
- 本地向量混合检索:0.6×向量余弦 + 0.4×TF-IDF,Ollama embeddings 首次投产,
存量记忆惰性回填,嵌入不可用自动回退 TF-IDF
- MEMORY.md 维护闭环:固化去重消除截断盲区;两阶段维护(AI 建议 → 用户确认 →
原子改写 + 语义记忆双轨同步 + 审计);>50KB 告警
- 可观测闭环:cacheTokens 引擎→前端透传(Token 面板命中率/成本行)+ 输入框
上下文占用指示条
- MCP Prompts/Resources 对话可用:/mcp:{server}:{prompt} 与 @mcp:{server}:{uri}
P2 体验补全:
- 工具自定义策略(正则白/黑名单 + 频率 + 强制确认,热生效)
- 连续 ≥3 同类工具确认聚合为单弹框
- 会话消息游标分页(首屏 200 条向上翻页)
- 开机自启;Playwright + Electron E2E 冒烟(本地 mock LLM 零外联)
Review 回归修复:MCP 大小写失配 / 分页状态复位 / 清空=未配置语义(Number(null)=0
隐患)/ MEMORY.md 告警位置 / working_memories FK(迁移 13)/ 全局配置层废键清理;
附带根治权限加固启动时序、代理回环放行、safeStorage 降级、悬空 symlink 逃逸。
验证:typecheck/lint 0 问题;test:electron 2478/2478(0 跳过);E2E 2/2;
docs/v0.8.1-迭代实施清单.md 全项留档。
405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
/**
|
||
* 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, MetonaImageContent } 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?: MetonaImageContent[]): 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;
|
||
}
|
||
|
||
// toNativeRequest 返回 Record<string, unknown>;这里收敛为「已知字段 + 任意扩展字段」
|
||
// 的交叉类型,测试可直接断言 max_tokens/thinking/temperature/stop/stream 等协议字段。
|
||
function requestBody(): { model: string; messages: Array<Record<string, unknown>> } & 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 原样透传(v0.8.1:8192 钳制已废除)', 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(63_488);
|
||
});
|
||
|
||
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('这张图片里有什么?');
|
||
});
|
||
|
||
it('vision 模型:多张图片全部转为 image_url parts(保持顺序)', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||
|
||
await adapter.send(
|
||
makeRequest([
|
||
{ url: 'data:image/png;base64,AAA' },
|
||
{ url: 'data:image/png;base64,BBB' },
|
||
{ url: 'data:image/jpeg;base64,CCC' },
|
||
]),
|
||
);
|
||
|
||
const body = requestBody();
|
||
const parts = body.messages[1].content as Array<Record<string, unknown>>;
|
||
expect(parts).toHaveLength(4); // 1 text + 3 image
|
||
expect(parts[1]).toMatchObject({
|
||
type: 'image_url',
|
||
image_url: { url: 'data:image/png;base64,AAA' },
|
||
});
|
||
expect(parts[2]).toMatchObject({
|
||
type: 'image_url',
|
||
image_url: { url: 'data:image/png;base64,BBB' },
|
||
});
|
||
expect(parts[3]).toMatchObject({
|
||
type: 'image_url',
|
||
image_url: { url: 'data:image/jpeg;base64,CCC' },
|
||
});
|
||
});
|
||
|
||
it('vision 模型:无文本消息时仍生成 image parts(不丢弃图片)', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||
|
||
await adapter.send({
|
||
...makeRequest([{ url: 'data:image/png;base64,AAA' }]),
|
||
messages: [
|
||
{
|
||
role: 'user',
|
||
content: null,
|
||
images: [{ url: 'data:image/png;base64,AAA' }],
|
||
timestamp: Date.now(),
|
||
},
|
||
],
|
||
} as MetonaRequest);
|
||
|
||
const body = requestBody();
|
||
const parts = body.messages[1].content as Array<Record<string, unknown>>;
|
||
// 无文本 → 只有 image_url part(text part 不生成)
|
||
expect(parts).toHaveLength(1);
|
||
expect(parts[0].type).toBe('image_url');
|
||
});
|
||
|
||
it('非 vision 模型:即使 content 为 null 也不转换图片(纯 null content)', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-pro');
|
||
|
||
await adapter.send({
|
||
...makeRequest([{ url: 'data:image/png;base64,AAA' }]),
|
||
messages: [
|
||
{
|
||
role: 'user',
|
||
content: null,
|
||
images: [{ url: 'data:image/png;base64,AAA' }],
|
||
timestamp: Date.now(),
|
||
},
|
||
],
|
||
} as MetonaRequest);
|
||
|
||
const body = requestBody();
|
||
const userMsg = body.messages[1];
|
||
expect(userMsg.content).toBeNull();
|
||
});
|
||
|
||
it('vision 模型 detail 字段被丢弃(image_url 仅保留 url)', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||
|
||
await adapter.send(makeRequest([{ url: 'data:image/png;base64,AAA', detail: 'high' }]));
|
||
|
||
const body = requestBody();
|
||
const parts = body.messages[1].content as Array<Record<string, unknown>>;
|
||
expect(parts[1]).toEqual({
|
||
type: 'image_url',
|
||
image_url: { url: 'data:image/png;base64,AAA' },
|
||
});
|
||
});
|
||
|
||
it('vision 模型 max_tokens 未配置 → 不下发该字段(v0.8.1:无写死兜底)', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||
|
||
await adapter.send({
|
||
...makeRequest(),
|
||
params: { temperature: 0, stream: false },
|
||
} as MetonaRequest);
|
||
|
||
const body = requestBody();
|
||
expect(body.max_tokens).toBeUndefined();
|
||
});
|
||
|
||
it('vision 模型 thinking 参数显式映射(thinkingEnabled 兼容)', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||
|
||
await adapter.send({
|
||
...makeRequest([{ url: 'data:image/png;base64,AAA' }]),
|
||
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true },
|
||
} as MetonaRequest);
|
||
|
||
const body = requestBody();
|
||
// v0.8.0 修订(用户意图优先): 元信息 supportsThinking=false 不再拦截 ——
|
||
// 用户开启思考则照发 enabled + reasoning_effort(事故复盘中该模型实际
|
||
// 产生了推理内容,元信息不可靠;预算耗尽由引擎降级重试兜底)
|
||
expect(body.thinking).toEqual({ type: 'enabled' });
|
||
expect(body.reasoning_effort).toBe('high');
|
||
});
|
||
|
||
it('vision 模型 messages 数组首位始终为 system 消息', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||
|
||
await adapter.send(makeRequest([{ url: 'data:image/png;base64,AAA' }]));
|
||
const body = requestBody();
|
||
expect(body.messages[0].role).toBe('system');
|
||
expect(body.messages[0].content as string).toContain('sys');
|
||
});
|
||
|
||
it('tool 结果消息不被 images 转换影响(role=tool 保留 tool_call_id)', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||
|
||
await adapter.send({
|
||
...makeRequest([{ url: 'data:image/png;base64,AAA' }]),
|
||
messages: [
|
||
{
|
||
role: 'user',
|
||
content: '请读图',
|
||
images: [{ url: 'data:image/png;base64,AAA' }],
|
||
timestamp: Date.now(),
|
||
},
|
||
{
|
||
role: 'assistant',
|
||
content: null,
|
||
toolCalls: [
|
||
{
|
||
id: 'tc1',
|
||
name: 'view_image',
|
||
args: { path: 'a.png' },
|
||
iteration: 1,
|
||
timestamp: Date.now(),
|
||
},
|
||
],
|
||
timestamp: Date.now(),
|
||
},
|
||
{
|
||
role: 'tool',
|
||
content: null,
|
||
toolResult: {
|
||
toolCallId: 'tc1',
|
||
toolName: 'view_image',
|
||
result: { path: 'a.png' },
|
||
success: true,
|
||
durationMs: 1,
|
||
timestamp: Date.now(),
|
||
},
|
||
timestamp: Date.now(),
|
||
},
|
||
],
|
||
} as MetonaRequest);
|
||
|
||
const body = requestBody();
|
||
const toolMsg = body.messages[3];
|
||
expect(toolMsg.role).toBe('tool');
|
||
expect(toolMsg.tool_call_id).toBe('tc1');
|
||
expect(toolMsg.content).toBe('{"path":"a.png"}');
|
||
});
|
||
|
||
it('孤立 tool 消息被过滤(无前置 tool_calls)', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||
|
||
await adapter.send({
|
||
...makeRequest(),
|
||
messages: [
|
||
{ role: 'user', content: 'hi', timestamp: Date.now() },
|
||
{
|
||
role: 'tool',
|
||
content: null,
|
||
toolResult: {
|
||
toolCallId: 'tc_orphan',
|
||
toolName: 'x',
|
||
result: 'r',
|
||
success: true,
|
||
durationMs: 1,
|
||
timestamp: Date.now(),
|
||
},
|
||
timestamp: Date.now(),
|
||
},
|
||
],
|
||
} as MetonaRequest);
|
||
|
||
const body = requestBody();
|
||
// system + user,孤立 tool 被剔除
|
||
expect(body.messages).toHaveLength(2);
|
||
});
|
||
|
||
it('temperature 与 stop 序列透传', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||
|
||
await adapter.send({
|
||
...makeRequest(),
|
||
params: { maxTokens: 4096, temperature: 0.4, stream: false, stopSequences: ['<END>'] },
|
||
} as MetonaRequest);
|
||
|
||
const body = requestBody();
|
||
expect(body.temperature).toBe(0.4);
|
||
expect(body.stop).toEqual(['<END>']);
|
||
});
|
||
|
||
it('send(非流式路径)强制 stream=false 且不附加 stream_options', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||
|
||
await adapter.send({
|
||
...makeRequest([{ url: 'data:image/png;base64,AAA' }]),
|
||
params: { maxTokens: 4096, temperature: 0, stream: true },
|
||
} as MetonaRequest);
|
||
|
||
const body = requestBody();
|
||
// send() 契约:无论请求参数如何,非流式路径强制 stream=false,stream_options 仅流式路径注入
|
||
expect(body.stream).toBe(false);
|
||
expect(body.stream_options).toBeUndefined();
|
||
});
|
||
|
||
it('非 vision 模型带图片不产生 image_url(content 保持字符串)', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash');
|
||
|
||
await adapter.send(makeRequest([{ url: 'data:image/png;base64,AAA' }]));
|
||
const body = requestBody();
|
||
const userMsg = body.messages[1];
|
||
expect(typeof userMsg.content).toBe('string');
|
||
expect(userMsg.content).not.toContain('image_url');
|
||
});
|
||
|
||
it('vision 模型 base64 data URI 原样保留在 image_url 中', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||
|
||
const dataUri = 'data:image/png;base64,' + 'Z'.repeat(50);
|
||
await adapter.send(makeRequest([{ url: dataUri }]));
|
||
const body = requestBody();
|
||
const parts = body.messages[1].content as Array<Record<string, unknown>>;
|
||
expect(parts[1]).toMatchObject({ image_url: { url: dataUri } });
|
||
});
|
||
|
||
it('vision 模型 reasoning_content 在 assistant 历史中保留', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||
|
||
await adapter.send({
|
||
...makeRequest(),
|
||
messages: [
|
||
{ role: 'user', content: 'hi', timestamp: Date.now() },
|
||
{ role: 'assistant', content: 'answer', reasoningContent: 'trace', timestamp: Date.now() },
|
||
],
|
||
} as MetonaRequest);
|
||
|
||
const body = requestBody();
|
||
const assistantMsg = body.messages[2] as Record<string, unknown>;
|
||
expect(assistantMsg.reasoning_content).toBe('trace');
|
||
});
|
||
});
|