# better-webui 设计讨论与决策记录(v0.8 现行) > 记录三个未完成功能的**机制调查 → 逐条讨论 → 决策结果**。 > 本文件保留讨论过程作为依据;现行范围见 §1.1。 > 2026-08-27 起:本文按 docs 约定自 docs/ 根迁入 `docs/discussion/`(讨论与裁决记录 > 目录),章节号与内容未改,外部引用一律用 `docs/discussion/design.md`。 > 配套背景:[docs/manual/dev-notes.md](../manual/dev-notes.md)(部署/热加载/插件契约/坑与教训)。 > > **v0.27 工程清理**:本文中"已留 `similarityFn` 可插拔 seam""持久化/热调""REST/API-key > 升级路径"等表述所描述的功能已在本轮删除(见 dev-notes.md 顶部 v0.27 说明),仅保留 > 为历史裁决记录,不再反映现行行为。 --- ## 1. 范围与现状 ### 1.1 v0.8 现行范围(用户裁决,dsh 0.1.0-rc.7) v0.8 把归档入口从侧栏图标迁进设置:**设置面板左侧导航里的「归档会话」页** (紧跟「Agent 预设」页下方)。页面直接列出全部归档会话,不再需要弹层/计数: - **设置侧栏独立页**(`settings.section`,order 30,label「归档会话」):**查看** 归档会话、**恢复**(回侧栏)、**彻底删除**(两步确认;目录/归档集/记账槽 三处全清)、死行置灰 + 「清除失效记录」,宿主启动自动清扫死引用。 - **不显示数量计数**(与其他设置页一致);简介为一句平实文案,风格对齐「模型」页 intro(如「管理被原生界面归档隐藏的会话,可恢复到侧栏或彻底删除。」)。 - **自定义模型推理等级**:宿主幂等为 `llm-pi-ai` 下未声明推理能力的自定义 模型补 `reasoningEfforts: { off: null, low, medium, high }`,使原生 composer「推理等级」菜单对自定义模型生效(机制与裁决见 §9)。 **位置动因(用户裁决)**:侧栏 `sidebar.footer.action` 与 ui-cordis 的动态插件 面板(CordisPanel)同槽,运行 probe 等动态插件时该面板会占据同位置、把归档图标 挤掉。改为设置页后从根上避开该冲突;原侧栏图标删除。 **明确不做**(用户裁决):工具输出在对话视图的呈现差异保持原生现状——数据 在日志里完整、原生轨迹视图(Inspect)可看;自定义模型下个别 bash 行不可 展开是原生 BashRow 对「无 terminal result view 行」的设计行为(见 §9.2), 插件加面板 / 覆盖原生行均被用户否决。 v0.4 的标题栏垃圾桶、回收站弹层、撤回重写(依赖 harness 槽位补丁)全部移除; v0.5-v0.7 的侧栏归档图标在本版迁入设置页;fork 边界数学保留在 §5.1 供未来官方 插槽就绪时复活。 ### 1.2 历史基线 v0.3 基线:会话标题栏垃圾桶(两步确认 → 回收站 → 撤销)+ 侧栏回收站/归档查看器。 v0.4 曾实施(现大部分退役):撤回重写(fork 桥接)、trash 归档联动。 用户实测发现被删会话同时出现在回收站与归档区、两边都可删——遂将删除收敛进归档视图。 --- ## 2. 已核实的 harness 机制事实 以下**是后续设计的硬边界**(源码 + 运行中 3080 服务实测): | 事实 | 说明 | |---|---| | `session.list` 基线 = 内存 attached + 持久层冷 | `listVisibleSessionSummaries`:`ctx.sessions.list()`(内存)⊕ `persistence.list()`(冷,需 `cwd`)。**归档不会让它消失**。 | | `session.history` 对归档会话可读(实测 ✓) | 我 curl 验证:归档会话在 list 中、history 正常返回事件。**宿主侧打开完全正常**。 | | 归档 = 原生隐藏机制 | `workspace.archiveSession` 把 id 加入归档集 → 会话**从一切分组面消失**,保留 workspace 记账 + 持久层。**无 unarchive**(源码明示 future)。v0.4 补充:注册表的 `enqueueOperation`+`setState` 是普通运行时方法,插件可安全地增删归档集,持久化与 `host/archived-sessions-changed` 全端推送由 apiproxy 存储监视器自动完成 | | 归档会话的 `sessions.open` 会被清掉 | `WorkspaceRuntime.project()`(client/runtime workspaces/service.ts:342):当前 selection 落入归档集即 `sessions.clear()` 清回 New Session——"隐藏行不得留在列表背后打开"是原生规则。**这就是 O3"点开变空白会话"的根因**,`sessions.open` 路线架构上不通 | | 插件无法让宿主丢弃活会话 | `AgentHandle.dispose()` 是 capability(归 apiproxy 工厂);`SessionStore` 无 detach-by-id。→ **删除后附着的会话会一直留到服务重启** | | 日志 append-only,无截断 RPC | `session.*` 方法集 = list/create/history/models/selectModel/rename/**fork**/prompt/attachment/updateQueue/cancel。无“删消息/截断到 seq” | | 重写历史官方通道 = **`fork(atSeq)`** | 原生 branch 用 `sessions.fork({atSeq}) → open`。“atSeq 之后第一个 `turn/end`”为界裁出子会话。 | | 会话内替换上下文 = compaction 替换面 | `compaction/summary` + 紧随的 replacement `user/message` 可把一段 surface 节点 **shadow** 成摘要;**日志保留可回看**,模型上下文被替代。 | | 侧栏/工作区浏览器是**单入口无行级插槽** | `sidebar.workspaces` = single(整个浏览区),无 per-row 装饰槽;给侧栏行“置灰”须整组覆盖(v1 教训,禁用)。 | | 用户消息**无** extraActions 插槽 | 用户行只有 copy+clock;**仅 assistant 回合尾部**有 `conversation.chat.assistant-actions`(原生插件扩展位,夹在 copy/branch 之间)。 | | session-scope 插槽自动拿标准 kit | `useSession`/`sessionId`/`useSessions`/`useWorkspaces`,及 ui-conversation 注入的 `useInput`+`inputActions`(含 `setDraft`)。 | --- ## 3. 五问五答(用户决策记录) ### Q1 删除语义 → **归档联动(推荐项)** 采纳:**删除 = 移入回收站 + 原生归档(即时隐藏)**。 - trash RPC 内:quiesce(cancel+flush)→ `workspaceRegistry.archiveSession(id)`(原生隐藏) → 搬目录到 trash → 写 `trash.json`(补 `archived` 标记)。 - 会话**立即从侧栏消失**(原生机制,零覆盖),不再“删除后仍可见”。 - **代价(须接受)**:无 unarchive → 恢复的会话保持归档,只从归档菜单可回看/继续** 对话,**不回侧栏**。恢复流程 = 搬回 + 客户端 refresh + 自动 open + toast 明示 “已恢复到归档,可从归档菜单查看”。 - 未选:B 改 harness(加 delete/unarchive,需从源码跑 dsh);C 维持现状。 ### Q2 撤回入口位置 → **用户自定义:跟“复制当前提示词”的按钮同排** - **与现状冲突,即 [O1]**(§5)。复制按钮在“用户消息”行;但 harness 目前**只有** assistant 回合尾的 extraActions 位。要在用户消息行加按钮且不覆盖原生件,可选的合规做法受限。 - 候选:(a) 把撤回放到**每条 assistant 回合尾部**(同一操作行,且能天然满足 Q4 的 “任意位置”);(b) 严格在用户消息行 → 需给 harness 打小补丁(新增用户 actions 槽) 或做被否决的整组覆盖。 ### Q3 撤回后源会话 → **用户自定义:否了 fork** “撤回不删除,不 fork,保留在会话里,不发送给模型。可以重新查看,但不能沿着已经被重写的提示词继续。” - 这是**最难落地的约束 [O2]**。字面 = 同一会话、日志可回看、但模型上下文不能沿重写前的 提示继续。而 harness append-only,要“把已发后缀从上下文剔除”只有两条路: `fork(atSeq)`(新建子会话)或 compaction 替换面(把尾部 shadow 成摘要,需 LLM 后端且 语义是“摘要”而非“空白”)。 - 综合判断:用户否的可能是 **fork 带来的“可见会话散落”副作用**,未必反对 fork 机制本身 —— 需 §5 摆方案让用户二选一。 ### Q4 撤回范围 → **支持任意位置** 宜:不只“最后一条”,且支持从任意一条撤回 → 正好耦合 Q2 的“回合尾部操作位” (每个已完成回合给一个“撤回到此之前”)。 ### Q5 归档点不开的症状 → **用户实测,推翻我原假设** - 用户实测:点归档行**开出新的空白会话,无内容**;console 无插件报错,只有 `connection lost, retry` 网络重连噪音(`ERR_NETWORK_IO_SUSPENDED`)。 - 这**不是**我最初猜的“悬空 id → `sessions.open` 静默抛 `unknown session`”(那是直接失败, 不会开新空白会话),更像是落到“空白新会话/workspace 落空”分支或历史窗口拉取落空。 - **列为 [O3]**(§5):需在真实 client runtime 复现,或请用户给一次 devtools 调用详情后再修。 顺带发现的两个 bug: - 归档/回收站行标题全显示“无标题”—— 插件读 `summary.title`(durable title 全空), 应改用 `summary.displayTitle`。 - 归档集里已有 **多个 id 的会话目录被删**(疑似旧回收站“彻底删除”遗留),这些行会产生 “点不开/无内容”的错感,新设计要显式标灰/标记。 --- ## 4. 目标架构(SOLID + 模式) 按“不越 harness 原则”的可落地结构给出;**具体实现受 §5 未决项 <[ ]> 约束**。 ### 宿主半(src/host.js) | 模块 | 模式 | 职责 | |---|---|---| | `TrashStore` | Repository | trash.json 原子读写(tmp+rename),补 `archived` 标记;内存缓存当前索引 | | `SessionRemover` | Command 编排 | 一次 trash:quiesce → 原生归档 → 搬目录 → 写索引(顺序保证 cold 会话仍 `sessionKnown`) | | 方法表 | Command 派发 | 现有 `listTrash/trash/restore/destroy` 扩展;`restore` 返回值交客户端接管导航 | | inject | — | host 例新增 `'workspaceRegistry'`(精确名),trash 内调 `archiveSession` | ### 客户端(src/client.bundle.js) | 模块 | 模式 | 职责 | |---|---|---| | `api` 闭包 | Facade + ISP | 窄动词面:`trash/restore/destroy/listTrash/retract…`;统一 unwrap RpcResult | | `ArchiveViewer` 行模型 | Adapter(防腐读模型) | 合并 **archivedSessionIds × sessions.byId × trashItems** → 行状态 `alive / dead`(trashed 归回收站菜单) | | 行组件 | Strategy | 每状态自行动作集;dead 置灰不可点;alive → 回看 + 移入回收站 | | 回看出口 | try/catch | open 失败 → 显式 error 通知,不再静默 | | `RetractAction` | — | 挂头栏或回合尾(**取决于 O1**);`useSession` 纯派生(目标 turn 边界 seq/运行态)→ 两步确认 → `api.retract` | | toast 总线 | Observer | 扩展 `撤回失败/已撤回/已恢复(仍归档)` 通知 | ### 撤回(功能 1)候选实现(O1/O2 裁决后换算) - **路线 F(fork 桥接)**:cancel(运行中)→ `sessions.fork({atSeq, no 标题自增})` → `open(子)` → `input.for(actx).setDraft(原文本)`。**桥接回原会话视图**(视觉像就地改、 实际子会话),源自动归档。最接近“内容剔除 + 可继续”,副作用小而受控。 - **路线 N(同会近似)**:cancel + 同会后续 turn —— 不剔除旧上下文(不满足“不能沿重写继续”,仅近似)。 - **路线 C(compaction shadow 尾部)**:把被撤回尾部 shadow 成标记摘要,日志可看、上下文被替代; 依赖 LLM 摘要后端,语义是“摘要”非“空白”,更重。最终由用户裁决(O2)。 --- ## 5. 开放问题(已全部裁决) ### 5.1 裁决结果与落地(2026-08-18) | ID | 裁决 | 落地 | |---|---|---| | **O1** 撤回入口 | 用户坚持"复制按钮旁";用户消息行无官方槽 → **给 harness 打补丁新增 `conversation.chat.user-actions` 槽**(源码 + 部署镜像双落地,见 dev-notes §12),撤回按钮经该槽插在 copy 与时间之间 | `src/client.bundle.js` RetractPromptAction | | **O2** 撤回语义 | **fork 桥接**:cancel(运行中)→ `fork(atSeq=上一回合 turn/end seq)` → open 子会话 → `inputActions.setDraft(原文)` → **源会话自动归档**(可回看/可反归档) | 同上 + host `archive` | | **O3** 归档回看 | **放弃回看**:根因是原生投影规则(`WorkspaceRuntime.project()` 在当前会话落入归档集时清回 New Session),`sessions.open` 架构上不通。归档行改为信息行 + 恢复回侧栏 / 移入回收站 | SidebarToolsAction 归档弹层 | | **O4** 死归档行 | 用户要求**能直接清除记录**:死行置灰标注"会话已删" + 弹层底部「清除失效记录」(两步确认)→ host `purgeArchived` 经注册表操作队列从归档集移除死 id | host `purgeArchived` | ### 5.2 原始选项存档 | ID | 问题 | 选项 | 影响 | |---|---|---|---| | **O1** | 撤回入口“在复制用户提示词的按钮旁,”但 harness 该位无用户消息插件槽(只有 assistant 回合尾的 extra-actions) | (a) 放回合尾部(官方位、满足任意位置);(b) 严格在用户消息行(需补 harness 小补丁或做禁用覆盖) | 撤回 UI | | **O2** | “同会话 + 不留新会话 + 上下文不沿重写继续”难以照原样实现(append-only 无截断) | (1) fork 桥接回原视图(实际子会话 + 源归档);(2) 同会近似(上下文不剔除);(3) compaction shadow 尾部 | 撤回语义/实现 | | **O3** | 归档点开会弹“新空白会话”,真实原因需复现 | 用户给 devtools 一次详情;或用 harness runtime fixture 写 jsdom 集成测试驱动真实 renderer + `sessions.open` | 归档打开 / 测试 | | **O4** | 死(悬空)归档 id 的呈现 | 置灰 + 提示“会话已删” vs 仍支持“在回收站→彻底删”(但无法从归档集移除该 id,一行永远置灰) | 归档查看器 | --- ## 6. 热加载安全约束(不违背 harness 原则) - 只做 **additive 注册**:`conversation.session.header.actions`、`sidebar.footer.action`、 (待定)`conversation.chat.assistant-actions`。**不覆盖** `conversation.chat.node/user`、 `sidebar.workspaces`、`tool.call.toolview/bash`。 - 新增注入服务名精确核对:client 例 `'conversation'`/`'sessions'`/`'workspaces'`; host 例 `'workspaceRegistry'`。名错则插件静默不 apply。 - 客户端半改动 → 刷新热加载(webserver stat-poll)。宿主半(host.js / inject 变动)→ **由用户在 shell 重启 `dsh web`**,代理不碰长驻进程。 - 模块级共享态保持最简、可重连自愈:列表在弹层打开时重拉(现有 `listTrash`)。 - 不覆盖原生图标/复制/分叉;沿用现有 `--dsw-*` token + 28×28/36×36 几何。 --- ## 7. 验证计划(草案) - 单元:`tests/smoke.mjs` 扩展 —— jsdom 驱动构建产物,断言: trash 后 host `trash.json` 含 `archived:true`;`ArchiveViewer` alive/dead 判别 + dead 置灰; `sessions.open` 失败 → 通知(不静默);撤回(选定路线)两端:cancel + fork/同会 + `inputActions.setDraft` 收到原文。 - 宿主 RPC:curl 在 `/better-webui/trash` 后验证 `workspace.archiveSession` 生效 (`workspace.archivedSessionIds` 含 id、`session.list` 不再含该会话、trash 目录已搬)。 - 真机流程:host.js 改 → 用户重启 `dsh web`;client-only 改 → 刷新即可。 --- ## 8. 变更影响 / 增量(已实施) - O1–O4 裁决落定 → v0.4 全部实施(见 §5.1)。 - harness 源码补丁(user-actions 槽):`packages/client/ui-conversation` 三处 + 部署镜像补丁脚本 `scripts/patch-ui-conversation.mjs`(升级 dsh 后重跑)。 - host:`src/host.js` — trash 归档联动、restore/destroy 反归档、cancel、 restoreArchived、archive、purgeArchived RPC → 重启 `dsh web`。 - client:`src/client.bundle.js` — 撤回按钮、归档行改造、displayTitle 修正 → 刷新即可。 - v0.6 host:`src/host.js` — 自定义模型推理等级补齐(见 §9)→ 重启 `dsh web`。 --- ## 9. v0.6 决策记录:自定义模型推理等级 + 工具数据可见性(用户裁决) ### 9.1 功能 1:自定义模型推理等级 → **实施(宿主自动补齐,零 UI)** 机制事实(rc.7 源码 + 运行实例核实): - composer「推理等级」菜单只有在模型带推理元数据(`sessions.models` → llm resolveModelInfo → 适配器 `reasoningInfo`)时才出现;手写自定义模型 无 `reasoningEfforts` 声明 → pi-ai 端 `model.reasoning=false` → 菜单缺失, 会话里无法切换思考等级。 - 原生「模型设置」页刻意不提供推理等级控件(ui-settings-models 注释明示); 配置 schema(llm-pi-ai provider profile)却完整支持 model 级 `reasoningEfforts`(等级→wire 值)与 provider 级 `reasoning`/`thinkingBudgets`。 - 适配器按请求实时读配置,不改 settings 不进任何 UI,改完立即生效。 - pi-ai 各条 openai-completions 发 `reasoning_effort`(对未知 baseURL `supportsReasoningEffort` 默认 true);`off` 档位在请求层被转换为"不发参数"。 裁决:**插件宿主幂等补齐**。启动时(含启动后延迟二次补齐,防 pi-ai 命名空间 晚注册)与 `settings/document-updated`(根上下文监听,`ctx.root.on` 已验证 可收兄弟服务事件)后,对 `llm-pi-ai` 用户层所有未写 `reasoningEfforts` 的 `models[]`(整数组替换写出——settings 路径操作不认数组下标,已用真实 settings 服务验证)/ `modelOverrides` 条目写入 `{ off: null, low: 'low', medium: 'medium', high: 'high' }`(经公开 settings 服务 → settings.yaml 热加载)。已声明(dict 或 `false`)的不动; 不支持推理的模型可由用户在 yaml 置 `reasoningEfforts: false` 退出。 测试:`tests/reasoning.mjs` 17 项。 ### 9.2 功能 2:自定义模型工具输出在对话视图不可见 → **不做(保持现状)** - 数据侧:核对了 scnet/GLM、openrouter-0731 等"看不到输出"的会话日志, 全部工具调用的参数、结果原文均完整(90/38 条,0 缺失)——数据从未丢失, 原生轨迹视图(Inspect → Payload/Result)一直可看,与模型无关。 - 渲染侧:对话里 bash 行是否可展开、有没有截断输出,取决于发射 `tool/result` 时宿主是否附上 terminal result view(`BashRow.expandable = terminal !== null || genericError`);view 由工具 presenter 在发射时推导、不持久化。无 view 且 state=ok 的行**按设计不可展开**(bash 工具对后台启动/执行错误给 generic view)。dsh 代码中不存在按模型身份分叉的渲染路径。 - 用户否决了两种插件路径:新面板("别动 UI")与覆盖 `tool.call.toolview` 的 bash 键(零覆盖原则)。结论:该差异是原生设计行为,插件不干预;数据出口 沿用原生轨迹(Inspect)视图。 --- ## 10. v0.7 决策记录:活会话「彻底删除」后回到未分组(用户报障 → 修复) ### 10.1 症状与根因 用户实测:未分组(不归属任何工作区)的会话,原生「归档会话」→ 在本弹层 「彻底删除」后,会话**没有消失**,反而回到「未分组」列表。 根因(源码核实):`destroy` 删除磁盘日志 + 记账槽 + **归档 id**,但 dsh 没有 公开 API 让插件丢弃**宿主内存里的活会话**(`AgentHandle.dispose()` 是创建时 一次性消费掉的 capability;`SessionStore`/`AgentRegistry` 无 detach-by-id)。 `session.list` 从内存直接返回所有 live 会话(`listVisibleSessionSummaries` = `ctx.sessions.list()` ⊕ 持久层冷会话),于是: 1. 归档 id 被移除 → 客户端 `host/archived-sessions-changed` 把会话“反归档”; 2. 会话不在任何工作区(未分组)→ 出现在「未分组」; 3. 宿主内存仍提供它 → 任何刷新/重连都不会让列表真正消失。 这其实在 §2 硬边界第 49 行已记录(“删除后附着的会话会一直留到服务重启”), 只是此前没暴露成可见症状。 ### 10.2 方案裁决(用户选择 A:插件内修复,不改 dsh) | 方案 | 裁决 | |---|---| | **A 插件内修复(采纳)**:destroy 活会话时**保留归档 id**(删磁盘+记账),使它不回到未分组;新增 host `listArchive` 上报每个归档 id 的 `dead`/`live`,客户端据此把“数据已删但仍在内存”的行置灰为“会话已删 · 重启后清除”,启动清扫(`purge`)在会话不再存活后自动清掉该 id | 采纳 | | B 改 dsh 本体:给 `ctx.agents`/`ctx.sessions` 加公开 `dispose(id)`,destroy 真正丢弃活会话并触发 `host/session-removed` | 未选(违背“不改 dsh 本体”,需重建全局安装的 dsh) | | C 纯客户端移除:destroy 后客户端伪造 `host/session-removed` 从列表删 | 未选(宿主内存仍会重新提供,任何刷新/重连都会复活) | ### 10.3 行为变化 - **冷会话**(本进程未打开)彻底删除:与之前一致——磁盘 + 归档 id + 记账全清。 - **活会话**彻底删除:磁盘 + 记账全清;归档 id **保留**(隐藏), 弹层内该行置灰显示「会话已删 · 重启后清除」,无恢复/删除按钮;重启后启动 清扫自动把该 id 从归档集清掉。toast 提示“已彻底删除(记录将在重启后清除)”。 - 新 host 方法 `listArchive`:返回归档集每个 id 的 `dead`(无持久数据)与 `live`(宿主内存驻留)。 - wire 版本 2 → 3:旧宿主 + 新客户端会按设计走“请重启 dsh web”的 stale 提示。 ### 10.4 测试 - `tests/host.mjs`:新增活会话 destroy 场景(目录/记账清、归档 id 保留、 `listArchive` dead+live、重启模拟后 purge 清掉该 id)+ 终态/重启断言调整。 - `tests/smoke.mjs`:新增销毁活会话死行断言(置灰、无按钮、标注重启后清除)。 ### 10.5 v0.7 追加裁决:移除回收站/垃圾桶遗留(用户澄清) 用户补充:当初设想的“回收站”与“归档”功能实际重叠——“第一次删除进回收站, 回收站里可二次删除或恢复”,这个流程与“进归档后可恢复/彻底删除”完全一样, 故回收站是冗余的;若有遗留记录一并删除。 落地:宿主彻底移除退役 v0.4 的回收站实现: - 删除 `trash.json` 索引(`loadRecords`/`saveRecords`)与 `listTrash` RPC; - `restore` 只移出归档集(不再搬目录);`destroy` 只删会话目录(不再清 trash 残留);`purge` 不再把 trash 记录当“可恢复”; - 客户端删除 `listTrash` API 与“遗留搬运行”行模型; - 实机核查 `$DSH_HOME/better-webui/trash/` 为空(`trash.json` 仅 `[]`), 无遗留会话目录,故无数据需要迁移;删除该空目录。 - wire 版本维持 3。 --- ## 11. v0.20 决策记录:模型采样超参数控制(调研 → 暂缓,未来继续) > 用户需求(2026-08):调控模型超参(temperature / 惩罚系数 / logprobs 等), > 放设置面板,支持「全局」与「特定 provider 的特定 model」两级 + 全局一览。 > 原则:不改 dsh 源码、不硬编码。本文记录完整可行性调研(结论:**温度可行、 > 其余不可达、上游正在演进**)与「暂缓」裁决,作为未来继续的依据。 ### 11.1 机制事实(dsh 0.1.0-rc.7 源码逐层核实) 请求链路与注入点: - **`agent/request` waterfall**(`dsh-agent-loop` `buildRequest`,请求 frozen **前**) 是官方设计的「替换冻结调用配置」钩子:`await next()` 拿到机器本会用的 `LlmCallConfig { provider, model, reasoningEffort?, temperature?, maxTokens?, stop? }`,**返回替换配置即可切换**(注释原话 "request waterfalls replace them and the loop logs changed snapshots")。 - 全链路验证:`agent/request` 替换 → `prepareCall`(`resolveCallFor` 只补 maxTokens / reasoningEffort,其余字段 `...config` 原样保留)→ `canonicalHeader` (`config` 整块保留)→ 冻结请求(`...header.config`)→ `llm/stream` → pi-ai adapter 读 `options.temperature`(`dsh-llm-pi-ai/lib/index.js` L869)→ wire 请求体 `temperature` 字段。`callConfigEquals` 比对 temperature 但双方同源, 校验通过。**temperature 端到端可注入,零 dsh 源码修改**。 - 作用域:`agent/request` 只覆盖 agent 主线回合(compaction / session-title 等 辅助调用直接走 `llm.stream`,不经此钩子)——正是「精准控制智能体行为」所需。 不可达参数(**整条链路无代码路径**): | 参数 | 状态 | 依据 | |---|---|---| | temperature | ✅ 可注入 | `LlmCallConfig` 有字段;pi-ai adapter L869 逐字段透传;wire 构造器发 `temperature` | | maxTokens | ✅ 顺带可行 | 同路径 | | top_p | ❌ | 三个 wire 构造器(openai-completions / responses / anthropic-messages)都不发 `top_p`;adapter 不透传;schema 无字段 | | frequency/presence_penalty | ❌ | 同 top_p,pi-ai 根本没有这些字段 | | logprobs | ❌ | 同 top_p | README 此前「不做模型超参设置页」的旧结论(温度是请求级、top_p 无字段)**对 top_p 正确、对 temperature 过严**——漏掉了 `agent/request` 这个官方请求级注入点。 ### 11.2 现成方案调研(用户要求:搜一搜) - **`Semidia/dsh-sampling-sliders`**(GitHub,MIT,dsh 插件):与本文推导**完全同构** 的机制(`agent/request` 钩子 + `sampling-sliders` settings 命名空间),但: - **全局唯一值**(一组 temperature/maxTokens 作用于所有 provider), **没有 per-provider / per-model 配置**——不满足「单独设某个 provider 的某个 model」这一核心需求; - UI 是输入栏「采样」按钮弹层(`conversation.input.right`),**不在设置面板**, 没有「全局一览」表。 - 结论:是机制的最小可行性证明,不是本需求的现成答案。 - 生态其余 dsh 插件(routing / marketplace 等)无同类参数控制实现。 ### 11.3 上游演进(未来继续的关键) - **pi-ai 上游已实现**:`@earendil-works/pi-ai` 0.84.x 起 `StreamOptions` 新增 `samplingParams?: Record`(任意采样参数原样合并进请求体, 覆盖 `top_p`/`top_k`/`min_p`/`repetition_penalty` 等),另有模型级 `Model.samplingParams`。源头是 `earendil-works/pi` PR #7568(2026-08 合并, "Add support for generic sampling parameters in models.json");早年 issue #1392 / #1837 即为「temperature / top_p 等参数支持」的多年 feature request。 - **瓶颈在 dsh-llm-pi-ai**:最新 `@deepseek-ai/dsh-llm-pi-ai`(0.1.1-rc.2)仍 依赖 `pi-ai ^0.82.1`,**未升级到 0.84,也未把 `samplingParams` 从 profile schema / options 接出来**。deepseek-harness 上游无相关 issue。 - 含义:一旦 dsh 升级 pi-ai 并暴露 `samplingParams`(provider 级或模型级),本 功能即可**用与 retry 策略相同的方式**(设置页 → `llm-pi-ai.providers.*` `samplingParams` 写入 → 热加载)干净落地,且能覆盖 top_p / 惩罚系数;在那之前 只能做 temperature(+maxTokens)。 ### 11.4 拟定设计(供未来落地) - 新子包 `packages/modelparams`(host + client,遵循「一功能一包」)。 - **Host**:`agent/request` 拦截器(读 `next()` 返回的 config → 按解析结果返回 替换配置)+ 自有 settings 命名空间 `better-webui-modelparams` + RPC 通道 `/better-webui-modelparams`(Command 方法表,沿用 settings 包模式)。 - **配置模型**:三级解析 —— **全局 > provider > provider/model**,最具体者胜出 (Chain of Responsibility,纯函数可单测)。 - **客户端**:设置面板新页「模型采样参数」:全局默认(温度滑杆 + 跟随模型默认)+ **全局一览表**(`llm.listProviders()` + `listModels()` 枚举所有 provider/model, 每行显示生效值与来源层级)。应用后写 settings.yaml 热加载,无需重启。 - **设计模式**:拦截器/装饰器(agent/request)、责任链(三级解析)、Command (RPC 方法表)、策略(每参数一种应用策略)、纯函数决策核心(可单测)。 ### 11.5 用户裁决(2026-08)与 v0.21 落地 - **v0.20 裁决**:本次不做模型超参功能,记录探索以备未来继续。粒度确认: 全局 > provider > provider/model 三级(maxTokens 不纳入——配置 provider 时即可设)。 顺带裁决:现有 better-webui 设置页重构(重试拆独立页,原页只留提示音音量,v0.20 实施)。 - **v0.21 裁决(转向落地)**:按参考插件形态做一个**全局配置 + 输入框 UI** 的最小 版本(不做三级、不做设置页): - 参数:**temperature 可用**;**logprobs / penalty 显示为「暂不支持(等上游)」** (方案 A;方案 B npm override 无效——adapter 不转发;方案 C 重写 adapter 太脆弱)。 - 语义:**每个新会话取默认值,会话内固定**(host 按会话 id 在首请求钉住, `agent/disposed` 清理)。 - 输入框:`conversation.input.right` 常驻温度数字输入框(非滑杆)+ ▾ 面板 (启用开关 / logprobs/penalty 标注 / 持久化/热调 / 应用/恢复默认 / 双语)。 - **已实施**(`packages/modelparams`,v0.21,host + client):见根 README。 三级解析与设置页一览表仍留待未来(届时也可走「设置中心」)。 - **v0.21 UX 迭代(用户反馈)**:① 工具行只留一个「超参配置」按钮(编辑都在 面板里);② 温度「**留空 = 跟随模型默认,填写 = 覆盖**」,空输入用虚字 (placeholder)提示默认;③ **恢复默认 = 清空已保存配置**(温度回空);④ **去掉逐参数说明**(高级设置,用户已知用途)——移除启用开关、hint/desc, logprobs/penalty 只留「暂不支持」标签(原因放悬停 title)。schema 简化为 `{ temperature?, mode }`(temperature 空 = 不覆盖,写用 `settings.replace` 以便清空)。 - **v0.21 UX 二次微调(用户反馈)**:① 数值框**去掉上下箭头**(仅输入数字, CSS 隐藏 spin 按钮,保留 `type="number"`);② **默认值直接写具体数值**—— placeholder 从描述性文案改为直接显示默认值(如「1.0」),由 host `read` 返回 `defaultTemperature` 供客户端动态显示(避免文案与默认值漂移);语义 一致化:**留空 = 系统默认 1.0**(拦截器把空解析为 `DEFAULT_TEMPERATURE`, wire 总是携带具体值)。 - **设置中心规则(v0.21 裁决 → v0.22 实现)**:`settings` 包是 better-webui 设置面板 的独立承载包;**只有需要被 better-webui 配置页配置的包**才经 `slots.inject('better-webui.settings.card', …)` 把自己的配置卡挂入 settings 页的 卡片子插槽(由 settings 包承载);settings 没装则子插槽不存在、卡片不渲染、宿主 用默认配置优雅降级——即「依赖 settings 包」。v0.22 落地时把早期「client 启动 `ctx.get` 判空注册」的设想改成了**复用产品 GeneralSection 的 renderSlot 子插槽 模式**(零自定义机制),retry 与 repeater-detect 均按此挂卡。不需要设置页的包 (如 modelparams 输入框)不依赖。详见 monorepo.md §4。 - **v0.23 裁决:改用 dsh 原生「插件配置」页,settings 包删除**。用户发现 dsh 自带「设置 → 插件 → 插件配置」页(`settings.plugin.item` **键控**插槽, `ConfigurablePluginsTab` 按 `settingsScope.describe()` 已服务的命名空间派发 卡片),于是删除 `packages/settings`(不再有 better-webui 专属设置页与 `better-webui.settings.card` 子插槽)。需要配置的包各自把配置卡挂进 `settings.plugin.item`,`key` = 该包宿主已注册的 settings 命名空间: retry → `better-webui`、repeater-detect → `better-webui-repeater-detect`。 chime 原为纯客户端 localStorage,为出现在插件配置页需要**宿主已服务**的命名 空间,故新增 `better-webui-chime`(`{ enabled, volume }`),dock 与设置卡都 读同一绑定的 settings scope(同步 `getSnapshot`),旧 localStorage 键仅作一次 性迁移。宿主未注册命名空间则卡片不渲染、宿主用默认配置——仍是「依赖宿主命名 空间」的优雅降级。详见 monorepo.md §4 与各包 README。 ## 12. v0.21 决策记录:模型复读探测(repeater-detect;v0.24 前名复读卫士 repeater-guard) > 用户需求(2026-08):dsh 无法设惩罚系数(见 §11),模型经常复读。做会话内**复读 > 探测**:检测到复读就中断生成、提示模型自纠,同会话累计到上限则硬停报错。完整 > 需求/裁决/实现见 [repeater-detect.md](repeater-detect.md)。 ### 12.1 检测算法(用户多轮迭代定稿) 思路演进:整行相等 → 每行前 N 词哈希桶 → **匹配百分比**(95% 元素数量)→「本质是 文本向量化 + 距离;有本地 seq2vec 更好」→「可加依赖,只要实时检测到」。 **定稿**(`packages/repeater-detect`,v0.21 落地,v0.24 改名): - 向量化:每行**字符频率向量**(bag-of-characters,免分词、中英混合通用)。 - 相似度 = **元素重叠** `|A∩B|/max(|A|,|B|)`:较长一行里多少比例字符也在较短一行, 字面实现「匹配 X% 元素数量」。删/换一字仍 ≥95%;真正不同的行远低于此;max 归一 化让短句嵌长句不误报。**弃用字符二元组 cosine**(删一字只有 ~93%,达不到 95% 语义)。 - 哈希桶滑动窗口:新行加入最相似桶(≥ `similarity`,按桶代表向量匹配而非两两比对), 桶成员数 ≥ `threshold` 触发。多行各带一处小改的同一基准句进同一桶,正确累积成 复读(两两比对会因差异叠加 <95% 漏检)。 - seq2vec 决策:环境无本地 seq2vec(无 transformers.js / sentence-transformers; 神经 embedding 需运行时下载模型、非「本地可用」,且 10–50ms/行拖慢流)。用纯 JS 字符向量,留 `similarityFn` 可插拔 seam,未来有本地 embedding 直接替换。 ### 12.2 触发行为(两段式升级,用户裁决) - 前 `hardStop-1` 次:**软停**——`agent.cancel`(hook + keepInbox)掐断 + `agent.followup` 注入模型可见通知 + 抛普通 Error(agent-loop 按 aborted 回合处理,已核实源码)。 - 第 `hardStop` 次:**硬停**——抛 `LlmError('REPEATER_DETECT')`,回合 `kind:error`, UI 渲染 turn-error;不走 `agent/request-error` 重试,不烧 token。**每次硬停后计数 清零**(v0.24 修正:原实现硬停后不清计数,用户要求每次硬停清空)。 - `agent/created` 重置计数(新会话);每次触发后检测窗口重置。 ### 12.3 配置与 UI | 字段 | 说明 | 默认 | |---|---|---| | enabled | 总开关 | true | | windowSize | 检测窗口(行) | 50 | | threshold | 窗口内触发阈值(行) | 5 | | similarity | 相似度阈值(%) | 95 | | hardStop | 同会话硬停前触发次数 | 3 | 设置卡挂在 dsh 原生「插件配置」页的 `settings.plugin.item` 键控子插槽(key `better-webui-repeater-detect`),双语 zh/en;v0.22 起不再有独立 settings.section 页,v0.23 起 settings 包删除、改挂原生插件配置页。 接线 `llm/stream` waterfall 只包 `isAgentLoopRequest`(主循环),compaction/标题等 辅助调用不误伤。 ### 12.4 附带修复(用户裁决) `packages/modelparams` 的 `.bwm-pop` `right:0` → `left:0`(对齐原生「+」命令菜单, 向上、左对齐、向右伸展)+ `max-height` 溢出保护。 ### 12.5 验收 `npm run build` 9 包;`npm test` 16 脚本全绿(含 repeater-detect detector / host / smoke 三条新测试)。详见根 README 与 `packages/repeater-detect/README.md`。 ### 12.6 v0.25 裁决:只计「模型说出的话」(误报抑制补全) 用户裁决:「代码内重复和标签名重复不计数,主要计数的是模型说出的话,而不是思考」。 三管齐下: - **chunk 层**:包装器只喂 `text-delta`,原生 `reasoning-delta` / `tool-call-delta` 从不计数(dsh-llm `StreamChunk` 协议,思考与正文是不同类型)。 - **行层思考块**:`` / `` 可见思考块(无原生推理的模型以可见 标签输出思考)整体跳过,新增 `thinkBlock` 状态。 - **标签剥离**:向量化前 `stripTags` 剥掉 `<…>` 标签,重复的标签包装不再虚高相似度; 纯标签行剥后为空忽略;同标签包相同内容仍触发。 检测窗口只含正式回答正文。详见 `docs/discussion/repeater-detect.md` v0.25 段。 > **v0.28 反转(2026-08-27 用户裁决)**:「repeat detection should not skip > thinks, it will check thinks for avoiding constant thinkings」。本节 v0.25 的 > "思考不计"已被反转:`reasoning-delta`(原生思考)与可见 `` / > `` 块内容**计入**检测,模型原地打转的循环思考会被截停;仅代码围栏与 > `tool-call-delta`(结构化工具参数)仍不计数。实现见 `docs/discussion/repeater-detect.md` > v0.28 段。