feat: v0.8.1 记忆深化 · 观测闭环 · 体验收口 — 窗口/输出上限全局单一配置 · 2478 用例全量回归 + E2E 冒烟
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m8s
CI / 全量测试 (Electron ABI) (push) Failing after 6m0s
CI / 产物编译验证 (push) Successful in 10m58s

硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(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 全项留档。
This commit is contained in:
2026-09-08 09:35:58 +08:00
parent 839860083f
commit 9b45c445bf
85 changed files with 5286 additions and 1158 deletions
+51 -63
View File
@@ -37,18 +37,48 @@ export class OllamaAdapter extends BaseAdapter {
readonly supportsToolCalling = true;
readonly supportsThinking = true;
// H-2 修复: Ollama 本地模型默认上下文窗口(可由 options.num_ctx 覆盖)
private static readonly DEFAULT_CONTEXT_WINDOW = 4096;
private baseURL: string;
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();
// v0.8.1: 上下文窗口不再从 /api/show 探测或写死默认值获取 —— 唯一合法来源是
// 设置面板「上下文长度」(llm.contextWindow → AdapterConfig.contextWindow
// 引擎侧经 contextLength=num_ctx 下发)。构造时仅探测能力(thinking/tools/vision
// 属协议正确性门控),不再缓存窗口数值。
this.probeCapabilitiesOnce();
}
/** /api/show 能力探测只发一次(构造函数发起),失败不重试(fail-open) */
private probeAttempted = false;
private probeInProgress = false;
/**
* /api/show capabilities 探测缓存 —— 模型是否支持思考(协议正确性门控,
* 非窗口/输出上限语义)。null = 未探测/探测失败(fail-open 放行,与
* listModels 能力回退策略一致);false = 服务端明确不支持 → 不发 think 参数。
*/
private cachedThinkingSupport: boolean | null = null;
/**
* v0.8.1: 构造时 fire-and-forget 探测一次默认模型能力(仅 thinking 门控消费)。
* 旧实现同时缓存 num_ctx 窗口数值 —— 已按"窗口唯一来源是设置面板"契约删除。
*/
private probeCapabilitiesOnce(): void {
if (this.probeAttempted) return;
this.probeAttempted = true;
this.probeInProgress = true;
void this.showModel(this.config.defaultModel)
.then((info) => {
if (Array.isArray(info?.capabilities)) {
this.cachedThinkingSupport = info!.capabilities.map(String).includes('thinking');
}
})
.catch(() => {
/* 模型探测失败不阻塞对话(fail-open) */
})
.finally(() => {
this.probeInProgress = false;
});
}
// ===== POST /api/chat =====
@@ -356,14 +386,13 @@ export class OllamaAdapter extends BaseAdapter {
// 该模型回退保守 true(不可用时行为与旧实现一致,fail-open 保可用性)
// v0.7.3 P1-4: supportsVision 随探测结果透出(undefined = 未知 → 前端保守放行),
// 供上传入口拒绝不支持图片的本地语言模型
// v0.8.1: 不再填充 contextWindow —— 窗口唯一来源是设置面板「上下文长度」
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,
supportsVision: caps ? caps.supportsVision : undefined,
@@ -386,59 +415,15 @@ export class OllamaAdapter extends BaseAdapter {
/**
* H-2 修复: 获取上下文窗口大小(规范要求)
*
* Ollama 上下文窗口由 options.num_ctx 决定(默认 4096),
* Engine 应通过 MetonaRequest.params.contextLength 显式设置
* 此处返回默认值,供 Engine 在未指定时参考
* v0.8.1 硬性契约: 唯一来源是设置面板「上下文长度」(llm.contextWindow),
* 未配置返回 0 —— 删除了旧的 4096 写死默认值与 /api/show num_ctx 探测缓存
* Ollama 的 num_ctx 由引擎经 params.contextLength(同源配置)下发给服务端
*/
override getContextWindow(): number {
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;
/** v0.8.0 P0-3: /api/show 探测只发一次(构造函数发起),失败不重试(fail-open) */
private probeAttempted = false;
/**
* v0.8.0 P0-3: /api/show capabilities 探测缓存 —— 模型是否支持思考。
* null = 未探测/探测失败(fail-open 放行,与 listModels 能力回退策略一致);
* false = 服务端明确不支持 → toNativeRequest 不发 think 参数。
*/
private cachedThinkingSupport: boolean | null = null;
private refreshContextWindow(): void {
if (this.refreshingContextWindow || this.probeAttempted) return;
this.probeAttempted = true;
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}`);
}
}
// v0.8.0 P0-3: 同一次探测顺带缓存思考能力(供 toNativeRequest 同步门控)
if (Array.isArray(info.capabilities)) {
this.cachedThinkingSupport = info.capabilities.map(String).includes('thinking');
}
})
.catch(() => {
/* 模型探测失败不阻塞对话 */
})
.finally(() => {
this.refreshingContextWindow = false;
});
if (typeof this.config.contextWindow === 'number' && this.config.contextWindow > 0) {
return this.config.contextWindow;
}
return 0;
}
/**
@@ -466,7 +451,7 @@ export class OllamaAdapter extends BaseAdapter {
async showModel(
model: string,
): Promise<{ parameters: string; template: string; capabilities: string[] } | null> {
): Promise<{ parameters: string; template: string; capabilities?: string[] } | null> {
try {
const response = await fetch(`${this.baseURL}/api/show`, {
method: 'POST',
@@ -483,7 +468,10 @@ export class OllamaAdapter extends BaseAdapter {
return {
parameters: data.parameters ?? '',
template: data.template ?? '',
capabilities: data.capabilities ?? [],
// v0.8.1: 保留 undefined 语义 —— 响应未携带 capabilities 字段 = 未知
//(fail-open),显式数组(含空数组 = 服务端权威"无任何能力")才参与门控。
// 旧实现 `?? []` 把"字段缺失"与"权威空"混同,探测竞态下会误判不支持思考。
capabilities: data.capabilities,
};
} catch {
return null;
@@ -707,7 +695,7 @@ export class OllamaAdapter extends BaseAdapter {
// think 会导致每次请求 400 "does not support thinking",而非静默忽略),
// 故此处门控属协议正确性而非用户意图覆盖;探测为服务端实时真值(非静态
// 元信息)。未探测/探测失败(nullfail-open 放行,与 listModels 能力回退
// 策略一致。探测在适配器实例创建时 fire-and-forget 发起(refreshContextWindow)。
// 策略一致。探测在适配器实例创建时 fire-and-forget 发起(probeCapabilitiesOnce)。
if (request.params.thinkingEnabled) {
const modelThinkingSupported = this.cachedThinkingSupport !== false;
if (modelThinkingSupported) {