fix: v0.5.5 全量复检修复 — 图片 token 估算缺失(压缩预算失真)+ 过时文案清理
背景:v0.5.4 多模态增强(多轮图片记忆 + 总开关 + DeepSeek vision)发布后的 全量全方位复检,重点核查新增功能的跨模块边界。 复检通过项(无回归确认): - 多轮图片记忆 × 五家 adapter:Anthropic(base64 块)/ OpenAI(image_url parts)/ Ollama(纯 base64 数组)/ Agnes / MiMo 均正确处理恢复的历史 images - 多轮图片记忆 × 引擎运行时压缩:摘要请求仅含文本(图片不进摘要调用); toKeep 消息的 images 原样保留;孤立 tool 消息配对逻辑不受影响 - 多轮图片记忆 × 摘要服务:maybeSummarize 用截断文本 transcript,无图片干扰 - history 组装 slice(0,-1) × 图片恢复无冲突(当前消息 images 走独立通道) - 编辑重发/重新生成路径与图片恢复同源(attachments.preview),行为一致 修复项: - P1 token 估算器完全忽略 images(estimateMessagesTokens): 带 10 张图的消息被按纯文本估算。影响:压缩 keepBudget 严重低估 → 压缩后实际 token 仍超 80% 阈值 → 反复触发压缩循环(每轮多一次 LLM 摘要调用);上下文占用显示严重失真。修复:每张图按 1000 tokens 计入 (1024px 压缩图在主流 Provider 约 700~1500 视觉 token,取保守上界) - 文案清理:ChatInput 附件按钮 Tooltip 硬编码"DeepSeek 不支持图片"改为 按拒绝原因区分(开关未开启 vs 当前模型不支持);openai-format.ts 共享层 注释更新(DeepSeek vision 已支持,非 vision 模型才丢弃) 测试(243 → 245 用例): - 新增 token 估算图片用例 ×2:带 images 消息按每张 1000 tokens 计入 / 无 images 字段消息行为不变(向后兼容) 验证: lint 0 / typecheck 双工程 0 / test:electron 245 全过 / 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.4-blue?style=flat-square" alt="Version" />
|
<img src="https://img.shields.io/badge/version-0.5.5-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" />
|
||||||
@@ -867,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, 243 用例全执行, 含 SQLite 审计链哈希 + 引擎工具链集成)
|
npm run test:electron # 运行全量单元测试 (Electron Node ABI, 245 用例全执行, 含 SQLite 审计链哈希 + 引擎工具链集成)
|
||||||
npm run test:watch # 测试监听模式
|
npm run test:watch # 测试监听模式
|
||||||
|
|
||||||
# ─── 构建 ─────────────────────────────────
|
# ─── 构建 ─────────────────────────────────
|
||||||
|
|||||||
@@ -47,9 +47,10 @@ export function buildOpenAICompatibleMessages(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 注意:图片(多模态)处理不在此共享函数中。
|
// 注意:图片(多模态)处理不在此共享函数中。
|
||||||
// DeepSeek 不支持多模态,images 被静默丢弃是正确行为。
|
// DeepSeek 非 vision 模型不支持多模态,images 被静默丢弃是正确行为
|
||||||
// Agnes/MiMo 各自的 toNativeRequest 中有独立的 images 处理。
|
// (vision 模型在 DeepSeekAdapter.toNativeRequest 中独立处理)。
|
||||||
// 审查修复: #27 曾在此添加 images 处理,但 DeepSeek 不支持多模态会导致 API 400,已撤销。
|
// Agnes/MiMo/OpenAI 各自的 toNativeRequest 中有独立的 images 处理。
|
||||||
|
// 审查修复: #27 曾在此添加 images 处理,但 DeepSeek 非 vision 模型会导致 API 400,已撤销。
|
||||||
|
|
||||||
// === Assistant 消息 ===
|
// === Assistant 消息 ===
|
||||||
if (m.role === 'assistant') {
|
if (m.role === 'assistant') {
|
||||||
@@ -76,9 +77,9 @@ export function buildOpenAICompatibleMessages(
|
|||||||
// 否则 LLM 看到 "null" 不知道失败原因,可能重复调用导致死循环
|
// 否则 LLM 看到 "null" 不知道失败原因,可能重复调用导致死循环
|
||||||
msg.content = m.toolResult.error
|
msg.content = m.toolResult.error
|
||||||
? m.toolResult.error
|
? m.toolResult.error
|
||||||
: (typeof m.toolResult.result === 'string'
|
: typeof m.toolResult.result === 'string'
|
||||||
? m.toolResult.result
|
? m.toolResult.result
|
||||||
: JSON.stringify(m.toolResult.result));
|
: JSON.stringify(m.toolResult.result);
|
||||||
// #26 修复: 确保 tool 消息 content 不为 undefined
|
// #26 修复: 确保 tool 消息 content 不为 undefined
|
||||||
// JSON.stringify(undefined) 返回 undefined(非字符串),会导致 content 字段在序列化后消失
|
// JSON.stringify(undefined) 返回 undefined(非字符串),会导致 content 字段在序列化后消失
|
||||||
// OpenAI/DeepSeek/Agnes API 严格要求 tool 消息必须有 content 字段,缺失会返回 400
|
// OpenAI/DeepSeek/Agnes API 严格要求 tool 消息必须有 content 字段,缺失会返回 400
|
||||||
|
|||||||
@@ -37,6 +37,27 @@ describe('estimateStringTokens', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('estimateMessagesTokens', () => {
|
describe('estimateMessagesTokens', () => {
|
||||||
|
// ===== v0.5.5: 图片 token 估算(多轮图片记忆场景) =====
|
||||||
|
|
||||||
|
it('带 images 的消息按每张 1000 tokens 计入(v0.5.5 — 此前完全忽略)', () => {
|
||||||
|
const textOnly = estimateMessagesTokens([{ content: '看这张图', role: 'user' } as never]);
|
||||||
|
const withImage = estimateMessagesTokens([
|
||||||
|
{ content: '看这张图', role: 'user', images: [{ url: 'data:image/jpeg;base64,x' }] } as never,
|
||||||
|
]);
|
||||||
|
// 差值 = 1 张图的估算(1000)
|
||||||
|
expect(withImage - textOnly).toBe(1000);
|
||||||
|
|
||||||
|
const withThree = estimateMessagesTokens([
|
||||||
|
{ content: '', role: 'user', images: [{ url: 'a' }, { url: 'b' }, { url: 'c' }] } as never,
|
||||||
|
]);
|
||||||
|
// 3 张图 + 消息开销(content 为空 = 0)
|
||||||
|
expect(withThree).toBe(3 * 1000 + 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无 images 字段的消息行为不变(向后兼容)', () => {
|
||||||
|
expect(estimateMessagesTokens([{ content: 'abc', role: 'user' } as never])).toBe(4 + 1);
|
||||||
|
});
|
||||||
|
|
||||||
it('每条消息计入结构性开销(4 tokens)', () => {
|
it('每条消息计入结构性开销(4 tokens)', () => {
|
||||||
const msgs = [{ content: '' }, { content: '' }];
|
const msgs = [{ content: '' }, { content: '' }];
|
||||||
expect(estimateMessagesTokens(msgs)).toBe(8); // 2 * 4 overhead
|
expect(estimateMessagesTokens(msgs)).toBe(8); // 2 * 4 overhead
|
||||||
|
|||||||
@@ -20,17 +20,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// 中日韩统一表意文字 + 全角标点 + 日文假名 + 韩文谚文
|
// 中日韩统一表意文字 + 全角标点 + 日文假名 + 韩文谚文
|
||||||
const CJK_REGEX = /[\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/;
|
const CJK_REGEX =
|
||||||
|
/[\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* L-17 修复: 提取魔法系数为命名常量,便于统一调整
|
* L-17 修复: 提取魔法系数为命名常量,便于统一调整
|
||||||
* v0.3.18 修复: CJK_TOKEN_RATIO 从 1.5 调整为 1.0,更贴近 BPE 实际值
|
* v0.3.18 修复: CJK_TOKEN_RATIO 从 1.5 调整为 1.0,更贴近 BPE 实际值
|
||||||
* @see project_memory.md — Token estimation coefficients
|
* @see project_memory.md — Token estimation coefficients
|
||||||
*/
|
*/
|
||||||
const CJK_TOKEN_RATIO = 1.0; // 中文字符(含全角标点、日韩文):1 字符 ≈ 1.0 token(保守,实测 0.6-0.8)
|
const CJK_TOKEN_RATIO = 1.0; // 中文字符(含全角标点、日韩文):1 字符 ≈ 1.0 token(保守,实测 0.6-0.8)
|
||||||
const ASCII_TOKEN_RATIO = 0.25; // ASCII 字符(英文、数字、半角符号):4 字符 ≈ 1 token
|
const ASCII_TOKEN_RATIO = 0.25; // ASCII 字符(英文、数字、半角符号):4 字符 ≈ 1 token
|
||||||
const OTHER_TOKEN_RATIO = 1; // 其他 Unicode(emoji 等):1 字符 ≈ 1 token
|
const OTHER_TOKEN_RATIO = 1; // 其他 Unicode(emoji 等):1 字符 ≈ 1 token
|
||||||
const MSG_OVERHEAD_TOKENS = 4; // 每条消息的结构性开销(role、分隔符,参考 OpenAI 规范)
|
const MSG_OVERHEAD_TOKENS = 4; // 每条消息的结构性开销(role、分隔符,参考 OpenAI 规范)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 估算字符串的 token 数
|
* 估算字符串的 token 数
|
||||||
@@ -55,7 +56,9 @@ export function estimateStringTokens(text: string | null | undefined): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// L-17 修复: 使用命名常量替代魔法数字
|
// L-17 修复: 使用命名常量替代魔法数字
|
||||||
return Math.ceil(cjkCount * CJK_TOKEN_RATIO + asciiCount * ASCII_TOKEN_RATIO + otherCount * OTHER_TOKEN_RATIO);
|
return Math.ceil(
|
||||||
|
cjkCount * CJK_TOKEN_RATIO + asciiCount * ASCII_TOKEN_RATIO + otherCount * OTHER_TOKEN_RATIO,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,6 +66,17 @@ export function estimateStringTokens(text: string | null | undefined): number {
|
|||||||
*/
|
*/
|
||||||
const TOOL_CALL_OVERHEAD_TOKENS = 8;
|
const TOOL_CALL_OVERHEAD_TOKENS = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.5.5: 单张图片的 token 估算
|
||||||
|
*
|
||||||
|
* 多模态图片(vision 类模型)按视觉 token 计费:1024px 压缩图在主流
|
||||||
|
* Provider(OpenAI/Anthropic/DeepSeek vision)约 700~1500 tokens,取保守
|
||||||
|
* 上界 1000。此前估算器完全忽略 images——带 10 张图的消息被按纯文本
|
||||||
|
* 估算,压缩 keepBudget 严重低估,导致压缩后实际 token 仍超限、反复
|
||||||
|
* 触发压缩循环;上下文占用显示也严重失真。
|
||||||
|
*/
|
||||||
|
const IMAGE_TOKEN_ESTIMATE = 1000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 估算多条消息的总 token 数
|
* 估算多条消息的总 token 数
|
||||||
*
|
*
|
||||||
@@ -71,16 +85,23 @@ const TOOL_CALL_OVERHEAD_TOKENS = 8;
|
|||||||
* @param messages 消息列表(content 可为 null,对应仅有 tool_calls 的 assistant 消息)
|
* @param messages 消息列表(content 可为 null,对应仅有 tool_calls 的 assistant 消息)
|
||||||
* @returns 估算的 token 数
|
* @returns 估算的 token 数
|
||||||
*/
|
*/
|
||||||
export function estimateMessagesTokens(messages: Array<{
|
export function estimateMessagesTokens(
|
||||||
content: string | null;
|
messages: Array<{
|
||||||
reasoningContent?: string;
|
content: string | null;
|
||||||
toolCalls?: Array<{ id?: string; name?: string; args: Record<string, unknown> }>;
|
reasoningContent?: string;
|
||||||
toolCallId?: string;
|
toolCalls?: Array<{ id?: string; name?: string; args: Record<string, unknown> }>;
|
||||||
}>): number {
|
toolCallId?: string;
|
||||||
|
images?: Array<{ url: string }>;
|
||||||
|
}>,
|
||||||
|
): number {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
total += estimateStringTokens(msg.content);
|
total += estimateStringTokens(msg.content);
|
||||||
if (msg.reasoningContent) total += estimateStringTokens(msg.reasoningContent);
|
if (msg.reasoningContent) total += estimateStringTokens(msg.reasoningContent);
|
||||||
|
// v0.5.5: 图片按视觉 token 估算(多轮图片记忆场景,防压缩预算低估)
|
||||||
|
if (msg.images) {
|
||||||
|
total += msg.images.length * IMAGE_TOKEN_ESTIMATE;
|
||||||
|
}
|
||||||
if (msg.toolCalls) {
|
if (msg.toolCalls) {
|
||||||
for (const tc of msg.toolCalls) {
|
for (const tc of msg.toolCalls) {
|
||||||
// #50 修复: OpenAI tokenizer 会将 tool_call 的完整结构(id、name、args)都计入 token
|
// #50 修复: OpenAI tokenizer 会将 tool_call 的完整结构(id、name、args)都计入 token
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.5.4",
|
"version": "0.5.5",
|
||||||
"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",
|
||||||
|
|||||||
@@ -558,7 +558,9 @@ export function ChatInput(): React.JSX.Element {
|
|||||||
title={
|
title={
|
||||||
supportsImages
|
supportsImages
|
||||||
? '附加文件(图片/文本/代码)'
|
? '附加文件(图片/文本/代码)'
|
||||||
: '附加文件(文本/代码)— DeepSeek 不支持图片'
|
: multimodalEnabled
|
||||||
|
? '附加文件(文本/代码)— 当前模型不支持图片'
|
||||||
|
: '附加文件(文本/代码)— 多模态未开启(设置 → LLM 配置)'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<IconButton size="small" sx={{ color: 'text.secondary' }} onClick={handleFileSelect}>
|
<IconButton size="small" sx={{ color: 'text.secondary' }} onClick={handleFileSelect}>
|
||||||
|
|||||||
Reference in New Issue
Block a user