feat: v0.7.0 四阶段全量迭代 — 修复面收口 · 安全纵深 · 架构还债 · 能力演进
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:
@@ -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)保持一致
|
||||
|
||||
Reference in New Issue
Block a user