feat: v0.7.0 四阶段全量迭代 — 修复面收口 · 安全纵深 · 架构还债 · 能力演进
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m45s
CI / 全量测试 (Electron ABI) (push) Failing after 5m22s
CI / 产物编译验证 (push) Successful in 10m3s

P1 修复面收口: v0.6.3 截断自愈推全量(Anthropic/Ollama/非流式/引擎兜底); SSE 上游错误帧检测进重试通道;
clearMessages 摘要游标根治; truncateResult 内联图片白名单统一; 前端四 bug(确认弹窗锁死/MemoryViewer/
Virtuoso Footer/abort 尾部过滤) + reasoning 缓冲跨迭代污染; 托盘通知过滤与新建会话死链接线

P2 安全纵深: MCP 审批闭环(ConfirmationHook×PolicyEngine 联动+重名拒注册); SSRF 收敛 ssrf-guard 共享模块
(web_fetch 双通道校验+重定向终态复检); Electron 加固(preload CJS 化→sandbox:true/CSP/权限白名单/will-navigate);
run_command cmd.exe 白名单通道元字符守门; diff_viewer 10MB 预检; Anthropic thinking 预算下限; Agnes 思考显式关闭

P3 架构还债: OpenAICompatibleAdapter 中间基类收敛四家样板; 错误分类单轨化(删 mapError/getFetchSignal,
超时显式 ETIMEDOUT); PRAGMA user_version 迁移版本化; 死代码清理专项(cn.ts/SHORTCUTS/ContextMenu 分支/
getWindowState/modifiedArgs/sandbox 空壳); i18next 引入; a11y 第一轮; SearXNG 页批量草稿模型统一

P4 能力演进: Ollama pull 可取消/capabilities 探测/num_ctx 实测缓存; UpdateService feed 比对式自动更新
(app:updateCheck IPC + StatusBar 入口); MiMo providerOptions(web_search 服务端工具/strict JSON);
web_fetch extract_mode=markdown(turndown); network.proxyUrl 全局代理(Chromium sessions+undici dispatcher)

测试: 264 → 507 用例(Electron ABI 全绿零跳过), 覆盖引擎压缩管线/重试竞速/MEMORY.md 闸门/file_editor 五操作/
filesystem 七工具实体夹具/git 真实仓库/SSE 错误帧/全线截断自愈/Provider 请求形态矩阵/SSRF 表测/钩子分级矩阵/
OutputValidator 全量/SLO 指标/MCP 安全纯函数/task_manager 链路/渲染层纯域/i18n 桥契约
This commit is contained in:
2026-08-27 17:06:58 +08:00
parent b6e2a8bd25
commit 3940716dc2
78 changed files with 6369 additions and 1341 deletions
+120 -17
View File
@@ -23,6 +23,7 @@
*/
import { BaseAdapter } from './base-adapter';
import { truncatedArgumentsPayload } from './shared/sse-stream';
import log from 'electron-log';
import { nanoid } from 'nanoid';
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types';
@@ -44,6 +45,10 @@ export class OllamaAdapter extends BaseAdapter {
constructor(config: ConstructorParameters<typeof BaseAdapter>[0]) {
super(config);
this.baseURL = config.baseURL || 'http://localhost:11434';
// v0.6.4 P4-1: 每个适配器实例(= 每会话独立引擎)启动时做一次 /api/show 探测,
// 把 num_ctx 实测值填充进 getContextWindow 缓存。fire-and-forget:失败静默,
// 不阻塞/不影响首个请求;此后压缩预算基于实测窗口而非保守默认 4096。
this.refreshContextWindow();
}
// ===== POST /api/chat =====
@@ -136,7 +141,25 @@ export class OllamaAdapter extends BaseAdapter {
if (chunk.message?.tool_calls) {
for (const tc of chunk.message.tool_calls) {
const args = tc.function?.arguments;
const parsedArgs = typeof args === 'string' ? JSON.parse(args) : (args ?? {});
// v0.6.4 缺口修复: NDJSON 路径的截断自愈 —— 原实现 JSON.parse 抛错会
// 落入外层 catch:该 tool call 整体静默丢弃,且同一行剩余处理
// (含 done/USAGE 检查)一并被跳过,与 v0.6.3 已根治的 OpenAI 共享层
// 旧行为完全相同。现独立捕获并转为 _truncatedArguments 自愈载荷,
// 同时保证本 chunk 的后续分支照常执行。
let parsedArgs: Record<string, unknown>;
if (typeof args === 'string') {
try {
parsedArgs = JSON.parse(args);
} catch (parseErr) {
const sample = args.slice(-120);
log.warn(
`[Ollama] Tool call args truncated (unparseable JSON, ${(parseErr as Error).message}). Tail: ...${sample}`,
);
parsedArgs = truncatedArgumentsPayload((parseErr as Error).message, sample);
}
} else {
parsedArgs = (args as Record<string, unknown>) ?? {};
}
yield {
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
requestId: request.meta.requestId,
@@ -284,17 +307,25 @@ export class OllamaAdapter extends BaseAdapter {
}>;
};
if (data.models?.length) {
return data.models.map((m) => ({
id: m.name,
name: m.name,
// Ollama 模型上下文窗口由 options.num_ctx 决定,此处给保守值
contextWindow: OllamaAdapter.DEFAULT_CONTEXT_WINDOW,
supportsToolCalling: true, // Ollama 多数模型支持,具体能力需通过 /api/show 查询
supportsThinking: true,
description: m.details
? `${m.details.family ?? 'unknown'} / ${m.details.parameter_size ?? '?'} / ${m.details.quantization_level ?? '?'}`
: undefined,
}));
// v0.6.4 P4-1: 能力标志改为逐模型 /api/show 实测探测;单个探测失败
// 该模型回退保守 true(不可用时行为与旧实现一致,fail-open 保可用性)
const enriched = await Promise.all(
data.models.map(async (m) => {
const caps = await this.probeCapabilities(m.name);
return {
id: m.name,
name: m.name,
// Ollama 模型上下文窗口由 options.num_ctx 决定,此处给保守值
contextWindow: OllamaAdapter.DEFAULT_CONTEXT_WINDOW,
supportsToolCalling: caps ? caps.supportsTools : true,
supportsThinking: caps ? caps.supportsThinking : true,
description: m.details
? `${m.details.family ?? 'unknown'} / ${m.details.parameter_size ?? '?'} / ${m.details.quantization_level ?? '?'}`
: undefined,
};
}),
);
return enriched;
}
}
} catch {
@@ -312,7 +343,62 @@ export class OllamaAdapter extends BaseAdapter {
* 此处返回默认值,供 Engine 在未指定时参考。
*/
override getContextWindow(): number {
return OllamaAdapter.DEFAULT_CONTEXT_WINDOW;
return this.cachedContextWindow ?? OllamaAdapter.DEFAULT_CONTEXT_WINDOW;
}
/**
* v0.6.4 P4-1: 从 /api/show 的 parameters 区解析 num_ctx 真值。
*
* 契约约束:IMetonaProviderAdapter.getContextWindow 是同步接口(引擎压缩判定
* 依赖同步取值),无法在内部 await。因此采用"机会主义缓存"策略:
* send/sendStream 启动时 fire-and-forget 刷新缓存;首次请求前返回默认 4096,
* 之后永远返回实测值。压缩预算的准确性随使用逐渐收敛到真值。
*/
private cachedContextWindow: number | null = null;
private refreshingContextWindow = false;
private refreshContextWindow(): void {
if (this.refreshingContextWindow) return;
this.refreshingContextWindow = true;
void this.showModel(this.config.defaultModel)
.then((info) => {
if (!info?.parameters) return;
const match = /^num_ctx\s+(\d+)\s*$/m.exec(info.parameters);
if (match) {
const value = Number(match[1]);
if (Number.isFinite(value) && value > 0) {
this.cachedContextWindow = value;
log.info(`[Ollama] Context window (num_ctx) detected: ${value}`);
}
}
})
.catch(() => {
/* 模型探测失败不阻塞对话 */
})
.finally(() => {
this.refreshingContextWindow = false;
});
}
/**
* v0.6.4 P4-1: 通过 /api/show 的 capabilities[] 动态探测模型真实能力。
* 此前 listModels 对所有本地模型硬编码 supportsToolCalling/supportsThinking:true
* (注释自知不准)—— 语言模型不支持 tools 时引擎仍下发工具定义,
* 造成"模型口头说调工具实际不调"的回归温床。探测失败返回 null 由调用方回退保守值。
*/
async probeCapabilities(model: string): Promise<{
supportsTools: boolean;
supportsVision: boolean;
supportsThinking: boolean;
} | null> {
const info = await this.showModel(model);
if (!info || !Array.isArray(info.capabilities)) return null;
const caps = new Set(info.capabilities.map((c) => String(c)));
return {
supportsTools: caps.has('tools'),
supportsVision: caps.has('vision'),
supportsThinking: caps.has('thinking'),
};
}
// ===== POST /api/show =====
@@ -339,12 +425,22 @@ export class OllamaAdapter extends BaseAdapter {
// ===== POST /api/pull =====
async pullModel(model: string, onProgress?: (progress: { status: string; completed?: number; total?: number }) => void): Promise<void> {
/**
* v0.6.4 P4-1 重构:pull 支持外部取消信号 —— 原实现固定 600s 超时会掐死
* 大模型下载(进度不能续命、无取消通道),大仓/慢网络场景必然失败。
* 现契约:调用方通过 AbortSignal 控制生命周期(UI 取消按钮即可触发);
* 超时语义交给用户取消或服务端断流(读循环结束即完成),不再人为设上限。
*/
async pullModel(
model: string,
onProgress?: (progress: { status: string; completed?: number; total?: number }) => void,
signal?: AbortSignal,
): Promise<void> {
const response = await fetch(`${this.baseURL}/api/pull`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, stream: true }),
signal: AbortSignal.timeout(600_000), // 模型下载可能较慢,10 分钟超时
signal,
});
if (!response.ok || !response.body) throw new Error(`Ollama pull error: ${response.status}`);
@@ -557,8 +653,15 @@ export class OllamaAdapter extends BaseAdapter {
let args: Record<string, unknown> = {};
try {
args = typeof rawArgs === 'string' ? JSON.parse(rawArgs) : (rawArgs as Record<string, unknown>) ?? {};
} catch {
args = {};
} catch (parseErr) {
// v0.6.4: 非流式路径截断自愈对齐 —— 原 catch 静默降级 {},与流式修复后的
// 行为不一致。统一转为 _truncatedArguments 错误参数。
const sample =
typeof rawArgs === 'string' ? rawArgs.slice(-120) : String(rawArgs).slice(-120);
log.warn(
`[Ollama] Non-stream tool call args truncated (unparseable JSON, ${(parseErr as Error).message}). Tail: ...${sample}`,
);
args = truncatedArgumentsPayload((parseErr as Error).message, sample);
}
return {
// L-9 修复(审计补充): 非流式路径统一使用 nanoid,与流式路径(sendStream)保持一致