# dsh-session-title-pattern 开发进度追踪 > 本文件用于追踪 `@deepseek-ai/dsh-session-title-pattern` 插件的开发进度。 > 格式:`[状态] 步骤名称 - 描述` > > 状态标记: > - `✅` 已完成 > - `🔄` 进行中 > - `⏳` 待开始 > - `❌` 阻塞/问题 --- ## 当前版本:v0.6.5 ### v0.6.5(本次) **修「解除锁定」必失败**: ``` title-unlock 解锁失败:Error: session-title provider must identify at least one source message seq ``` 解锁走的是「provider 原样返回当前标题」,而**用户改名产生的标题 `messageSeqs` 是空数组** —— 平台语义如此(手动命名不指认任何消息,`SessionTitleSnapshot` 的 注释明写 "empty for an explicit user rename")。服务的 `validateResult` 要求 provider 至少指认一条 seq,空数组直接被拒。 也就是说:**凡是通过 rename 锁定的会话,解锁必然失败** —— v0.6.0 起就没成功过, 只是此前锁定态显示都是本地假象,没人走到这一步。 改法:`messageSeqs` 为空时改用本次 `request.messages` 的 seq。解锁只换来源 (user → provider),指认哪些消息不影响标题文字;一条都没有才照常生成。 ### v0.6.4 **「host 明明执行了,客户端却拿不到返回值」—— 两个症状同一个根因。** 实机反馈:① 锁定态刷新仍回未锁定;② `/title-suggest` 成功了,但标题只出现在 下面的会话里,**没填进输入框**。第②条是决定性的证据 —— 命令确实跑了 (会话里出现了命令结果),可我们读到的返回值里没有文本。 **根因:远端返回值的形状我们认错了。** `runLine()` 一刀切地按 `execution.result` 取,认不出就 resolve undefined。而消费 `TYPERT_REMOTE` 的 运行时在 dsh 里、不在 node_modules —— 描述符声明的是 `CommandExecution` (`{ commandId, result }`),但实际到手的是哪一层封装**本地无法验证**。 之前的 v0.6.2 那条「未被执行:行必须以 / 开头,且命令名必须已注册」也因此是 **误报**:host 其实执行了,只是我们没剥开返回值。 **改法:不再猜,按可能的封装逐层剥,认出 `kind` 就算成功** —— `{ commandId, result }`、`{ kind, text }`、`{ ok, value }` / `{ value }` 三层都试; 认不出就把**原始返回 JSON** 打进控制台,下次一次就能看清真相。 `checkCommands()` 同样按「裸数组 / 包一层 value」两种都收。 顺带两处: - 文案不再断言「未被执行」(可能只是没认出返回值),改成「未取到执行结果 + 原始返回」 - `.catch()` 原来静默吞掉,现在也打一条 > 这条也是本项目的通用教训:**远端命名空间的实际返回形状本地验证不了** > (`LlmDirectory` 那个 `{ ok, value }` 同样是按猜写的,而 dsh-llm 描述符里 > `listProviders` 的结果其实是裸数组)。凡是靠远端返回值驱动界面的地方,都要 > 么容错、要么把原始结构打出来。 ### v0.6.3 三件事,都出在重命名卡片上。 **1. 修「自动生成」必报「没有可用的模型路由」。** `title-suggest` 为了「只出草稿不写标题」是**自己拼 request** 的,而 `request.route`(当前主请求路由)从来是**服务**替 provider 填的 —— 它读的是 `session.requestHeader()?.config`。草稿这条旁路没人填,于是 `resolveRoute()` 既找不到配置里的 provider/model 对(默认留空 = 跟随对话模型),也找不到 `request.route`,只能抛错。`/retitle` 走服务,所以一直是好的 —— 同一份代码里 只有「自动生成」坏了,很符合这个归因。 改法:新增 `draftRoute()`,按服务同款从 `session.requestHeader()` 取 `{ provider, model }` 塞进 request;仍然取不到(会话还没发过主请求)就**退回 关键词规则出草稿**并 warn —— 草稿只是个可编辑的起点,不值得为它报一个错误。 **2. 锁定态刷新后回到未锁定(v0.6.0 那条"已知妥协"的彻底解法)。** `title` 投影的 wire 类型是 `string | null`,**只带文本不带来源**;而「锁没锁」 = `session/title` 事件里 `source.kind === 'user'`,这个字段根本没推给浏览器 —— 所以开关全是本地 state,刷新即丢,而 host 端其实一直是锁着的。 改法:新增 `/title-state`(`recordInput: false`,不调模型)回答 `locked` / `unlocked`,面板**每次打开时问一次**作为初始值。v0.6.0 记的 「彻底解法要插件自供查询」就是它,走的是与其余面板命令相同的命令通道。 **3. 「点了没反应」的自检。** `execute()` 对「命令没注册」只回一个 undefined 且不留痕(admission miss), 排查全靠猜。现在面板打开时会 `commands.list(sessionId)` 一次,把**缺哪几条** 直接打进控制台(v0.6.2 修斜杠时就是靠这条才定位的)。 ### v0.6.2 **修重命名卡片四个按钮全部失灵**(用户反馈:点「自动生成」没反应;改了名字点 「确定保存」标题不变,只有锁定开关亮了)。 **根因:命令行少了前导斜杠。** 卡片发给 host 的是 `title-rename xxx`、 `title-suggest`,而 `CommandRuntime.execute()` 用 `parseCommand()` 解析,正则是 `^\/([a-z][a-z0-9_-]*)`:不以 `/` 开头就直接返回 undefined,且注释明写 "Admission misses (syntax or unknown name) log nothing" —— **连日志都不留**。 v0.2.1 那个直达按钮发的是 `'/retitle'`(带斜杠,一直好用),v0.6.0 重构成卡片时 新写的四条全漏了。 对上症状:`runLine()` 拿到 undefined 就静默 resolve,`suggest()` 没文本 → 输入框 不动;`rename()` 没写标题 → 标题不变,而 `save()` 里紧跟的 `setLocked(true)` 是 本地 state、照常执行 —— 所以开关显示「已锁定」,而那个状态刷新页面就消失。 **改动(两处,都在客户端)**: 1. 四条命令行常量补 `/` 前缀(`RENAME_LINE` / `LOCK_LINE` / `UNLOCK_LINE` / `SUGGEST_LINE`),并加注释说明为什么必须有它 2. `runLine()` 的 `result === undefined` 分支补一条 `console.warn` —— 这条静默 路径正是"点了没反应又查不到原因"的元凶 host 端未动:handler 里访问 `ctx.sessionTitle` 的写法与实机验证过的 `/retitle` 同款。 ### v0.6.1 重命名卡片的定位修正:原来是 `right:0`(右缘贴铅笔、向左铺开),左半截会伸过标题、 压进侧边栏底下(实机截图确认)。改为打开时**实测标题元素(`_crumbCurrent`,与 crumbWidth 覆盖同一识别方式)与锚点的横向差值**,把卡片左缘对齐会话标题左缘; 标题元素找不到时退回 0(贴锚点),并加 `maxWidth: calc(100vw - 48px)` 兜窄窗口。 ### v0.6.0 重命名卡片(用户嫌旧面板「做得太烂」,给了官方重命名对话框的截图作参照)。 **布局**:铅笔按钮点开一张 420px 的「重命名会话」卡片 —— 标题行(重命名会话 + ×)、 预填当前标题的输入框、底部一行:左下角「锁定」开关(Tooltip 讲清两边行为), 右下角三个按钮:取消 / 自动生成 / 确定保存。Esc / 点外部 / × / 取消都是关闭不动任何值。 **三个行为,全部按用户的需求定义**: 1. **预填**:当前标题走平台的 `title` 投影(`useProjection('title')`,实时推送)。 2. **自动生成 = 只出草稿**:新命令 `title-suggest` —— 从会话事件提取人类消息 (与服务内部 `sessionTitleUserMessageOf` 判定逐条对齐),LLM 模式下真调一次模型 (滚动状态传**浅拷贝**,绝不碰真身的 summary/seenCount/mainLine),rules 模式或 llm 未就绪退到关键词规则;结果文本随 `CommandResult` 带回浏览器,**只填进输入框、 不写标题** —— 保存与否由用户决定。在途时按钮显示「生成中…」防连点。 3. **解锁 = 单纯解锁,不重新生成**:新命令 `title-unlock` —— 平台解锁的唯一切入点是 `refresh()`(会驱动一次 provider 调用),所以解锁命令先在 provider 的 `pendingUnlocks` 里挂号,随后的 `generate()` 看到挂号就**原样返回当前标题**: 文字一个字不变、不调模型,只是来源从「用户」变回「provider」,自动更新恢复。 若 refresh 在调 generate 前失败,挂号留到下次重算生效(延迟解锁,无害)。 **「确定保存」= rename = 写入即锁定**(平台唯一的手动写入语义),开关随之显示锁定。 命令结果回传:`commands.execute()` 的 resolve 值带 `result.text`,`runLine` 改为 把成功文本 resolve 给调用方(`/retitle` 的直发按钮被卡片取代,host 命令保留)。 **已知妥协(记下来)**:锁定态的**初始显示**只有本地记忆 —— 平台的标题投影只有文本 不带来源,客户端读不到「当前是否锁定」;刷新页面后开关按未锁定显示,面板内操作过后 就是准的。彻底解法要插件自供 remote 查询(credentials 的提供方式),留待以后。 ### v0.5.24 **v0.5.23 会让 dsh 起不来,本次修复。** 用户实机确认:禁用本插件 dsh 正常、启用后连 界面都进不了。容器日志抓到根因: ``` Error: dsh: plugin tree failed to load: failed to apply loader entry session-title-pattern: cannot get property "commands" without inject at registerTitleEditCommands ``` 根因:v0.5.23 新加的 `registerTitleEditCommands(ctx, …)` 在 **apply 里拿原始 ctx 直接访问 `ctx.commands`**。cordis 的 Context 是受保护代理,未声明的服务属性一访问就同步抛错, apply 一抛 → 插件 fiber 失败 → **整个 dsh 启动失败** → 容器反复重启。 讽刺的是我们自己的注释里早就写了这条禁忌(「也不能直接写 ctx.llm —— 受保护代理」), `/retitle` 也一直用正确写法(在 `ctx.inject(['commands'], (commandCtx) => …)` 里注册), 新代码偏偏没照抄。 **修复**:把 `registerTitleEditCommands` 挪进与 `/retitle` 同一条 `ctx.inject(['commands'], …)` 里,用 inject 给回的 commandCtx 注册。 并全文扫描了其余 `ctx.*` 服务访问点:handler/事件回调里访问 `sessionTitle`、 inject 内访问 `commands` 都是有实跑记录的安全模式。 > 教训:本项目里每新增一个「ctx.服务」访问点,都必须落在某条 `ctx.inject([…], …)` > 的作用域内,或至少与一个已在实机跑通过的写法逐字对齐。仅靠类型检查查不出这类错误。 ### v0.5.23 四件一起落地:**标题面板(锁定 / 改名)**、**主线改写高门槛**、**等距采样**、**重启恢复**。 **1. 标题旁的铅笔按钮 → 小面板**(自动生成 / 锁定 / 手动改名)。 - 全部基于平台现成能力:`SessionTitleService.rename()` 写入的标题来源是「用户」, **写入即自动锁定**(自动命名停止)——所以「锁定不改字」就是把当前标题原样写回, 「手动改完自动锁定」是平台内置行为;解锁 = `/retitle`(refresh 覆盖已固定的用户标题) - 宿主端新增两条命令:`/title-lock`(锁定当前标题)、`/title-rename <文本>` (`normalizeSessionTitle` 截到 maxBytes,rename 不会因长度抛错;处理器返回 `CommandResult`,成败都有可见回执) - 客户端面板:铅笔按钮点开一个自绘浮层(内联样式,不依赖额外 CSS 注入),含 「根据对话重新生成 / 锁定当前标题 / 手动改标题」三项;点外部收起 - 覆盖系统重命名弹窗**做不到**(封闭组件、无 slot),但系统入口改名走同一个 `rename()`,行为一致、自动锁定 ✓ **2. 主线改写高门槛(提示词)**。用户场景:开发插件 → bug 修了一两百轮 → 主线被 最近的 debug 内容带偏。提示词明确:mainLine 是会话**最初的最大目标**,解决主线过程中的 报错 / bug / 调试都是主线的**子任务**,不属于目标变化;只有出现并列的新目标才改。 **3. 等距采样(B 方案)**。`buildPromptInput` 从「新鲜窗口之前」的历史里等距抽 2 条 (每条截 200 字节)放进 `sampledHistory`,让模型看到会话怎么演化 —— 长会话归纳与 重启恢复(seenCount 归零、历史全部落在新鲜窗口之外)时尤其重要。 **4. 重启恢复主线。** `mainLine`/`summary` 原来只在内存,dsh 重启即失,下一次重算 只能从「首条 + 最近几轮」猜 —— 主线在会话中段的场景必然漂。 - 恢复路径:`generate()` 里发现内存态为空时,用现成的 `foldSessionTitle(session.snapshotEvents())` 取**上一次的标题**,剥掉日期段还原出 `类型|主题`(作摘要)与主题(兼任主线)—— 近似恢复,足够把方向锚住 - 为什么不用自定义事件持久化:`SessionEventType` 是封闭联合,第三方插件追加自定义 事件类型需要验证运行时是否接受(本轮未验证),而标题恢复零风险、零成本,先落地 - 每个进程生命周期只恢复一次(`restored` 标记),之后照常滚动 README 同步(功能一览、面板说明、输入结构)。 ### v0.5.22 **修解析器被模型「将了一军」**。实机出现 `0913|其他|类型:编程` —— 主题变成了字面的 「类型:编程」。 **根因**:v0.5.20 把提示词改成两行输出后,**模型经常照抄提示词的措辞**,把行写成 `主线:xxx` / `类型:编程` / `主题:xxx`(带标签),而不是裸的 `主线内容` + `类型|主题`。 老解析器死认行序(第二行必须是 `类型|主题`),遇到没有竖线的 `类型:编程` 就认不出类型, 整行落进主题、类型回退到规则的「其他」。时好时坏 —— 模型加不加标签是随机的。 **两层修复:** 1. **提示词给出输出示例**(假设主线/类型/主题,明确写出那两行长什么样)—— 照抄示例比照抄说明可靠得多,从源头减少带标签的输出。 2. **解析器按行首标签归类**,不再死认行序: - `主线:` / `类型:` / `主题:` 开头的行各自归位(中英冒号都认) - 类型与主题挤在一行(`类型:编程|主题:配置损坏`)也能拆开 - 行首的编号 / 项目符号(`1.`、`-`)先剥掉 - 只吐了一行主线时,把主线当主题用(总比把「主线:」留在标题里强) - 完全认不出标签才退回老约定(两行 = 主线 + 标题行;一行 = 标题行) 六种形态验证(与代码同款逻辑):事故原始形态、三行带标签、两段挤一行、理想两行、 理想一行、带编号 —— 全部解析正确。 ### v0.5.21 **手动重算不再清空主线。** 起因是用户问「手动重算传什么、主线怎么找回来」,一算就暴露了: - 手动重算的输入 = 首条消息(200 字节)+ 最「新」的一批(`maxInputBytes` 4096 字节内, **从旧往新丢**)—— 8MB 的会话也只会发这么多,一次约 1200~1600 token,成本无忧 ✓ - 但主线也被清空了 → 模型手里只剩「首条 + 最近一批」。**主线出现在会话中段时 (最常见的那种情形)它再也找不回来** —— 而 v0.5.20 好不容易让主线逐轮延续了下来, 等于自己拆自己的锚 改为:手动重算**保留主线**,只清摘要与计数。 - 成本一模一样(主线就一行、几十字节) - 「重新归纳」的能力没丢:提示词允许模型在会话目标真变了时更新主线 - 内存态语义不变:dsh 进程活着期间主线一直延续、每轮重算被重新确认; 进程重启后归零,下一次重算重新归纳 ### v0.5.20 两件事:**模型维护「主线」(C 方案)** 与 **「输出标题最大Token」退出界面**。 **1. 主线锚,解决标题漂移。** 用户实测:一条 8MB 的长会话,主线是「开发插件」, 标题却漂成了「排查|安装失败、依赖冲突」。归因三条: - **主线不在开头** —— 那条会话的第一条消息是问 dsh-context,不是开发插件;锚首条消息接不住 - **输入约 20:1 偏向最近** —— 首条 200 字节 vs 最近几轮 ~3600 字节,模型自然总结最近的事 - **摘要自我强化** —— 上一版输出「排查」后,下一轮就带着「排查」当历史 做法(C 方案): - `SessionState` / `RollState` 增加 `mainLine`:模型逐轮判断的主线,随摘要一起滚动传递 - 输入 JSON 增加 `mainLine` 字段;提示词改为**输出两行**:第一行主线(会话目标没变就 **原样返回**,真变了才改),第二行 `类型|主题`(**主线优先**,不要被最近几轮的具体问题带偏) - 解析新增 `parseTitleOutput()`:两行 → 更新主线并取标题行;只有一行 → 按老格式解析、 **主线沿用上一次的**(不猜那一行是什么);`toSummary` 改用标题行而不是整段输出 - 手动重算(`/retitle`)连主线一起清空,让模型重新归纳 成本:输出多 ~20–30 token,输入多 ~50 字节。 **2. 「输出标题最大Token」从界面拿掉,内部默认 512。** 用户想明白了自己要管什么:**标题长度上限**(拿到文字后我们自己截),而不是输出预算。 顺带纠正一个概念:`maxOutputTokens` 是**服务端硬切断**(到点就停,后面的内容根本没生成), 不是「模型不遵守就返回多少用多少」的软限制 —— 所以「超了就截断」救不回没生成出来的部分。 - FIELDS 里移除,配置键保留,要改走 `cordis.patch.yml` - 默认 `128 → 512`:调大不花钱(上限不是预扣费),给任何模型把一行标题写完留足余量, 同时仍是防跑飞的保险丝 README 同步(设置表、配置表、截图说明)。 > 备注:`maxInputBytes`(4096)**维持不变** ——「最近 10 轮」仍会占满它。先观察主线锚 > 能否止住漂移;不够再考虑放宽预算或加历史采样(B 方案:等距采 2–3 条历史消息)。 ### v0.5.19 实机报错:`生成标题失败:Error: 标题模型未正常结束(max-tokens)`。 **根因**:输出预算被用光(默认 `maxOutputTokens = 64`)。**带推理(thinking)的模型, 思考同样计入 `maxTokens`** —— 64 很容易被思考吃掉,真正开始写标题时就被截断了。 免费档 / 推理型模型尤其容易触发。 而原来的处理**过严**:`finish.kind !== 'stop'` 就抛错,等于「标题永远不更新」, 用户只看到一句"生成标题失败",很难联想到是输出预算不够。 **两处改动:** 1. **`max-tokens` 不再算失败**:模型写了超预算的废话、或预算花在推理上,都照用首行 —— 我们本来就只取第一行,而**第一行通常是完整的**(提示词要求单行输出)。 同时返回 `truncated`,调用方记一条 warn 说明「触达上限、已改用首行、建议调大或换不带推理的模型」。 其余结束原因(error、被内容策略拦下等)仍然抛错,走「保留上一个标题」的降级。 2. **默认值 `64` → `128`**:给推理留出余量。客户端兜底值、说明文案、README 的配置表同步。 > 交互上还有个改进空间(本轮没做):失败时界面只显示一行错误,用户看不到"为什么"。 > 现在这条 warn 会进日志,排查时能对上。 ### v0.5.18 **按 dsh-market 的上架要求整备**(读的是 dshmarket 自己的 README + awesome-dsh-plugin 的 `contributing.md`)。 **上架路径(与 npm 无关)**:dsh-market 是市场应用本身,插件列表来自 curated 仓库 **awesome-dsh-plugin**。上架 = 往那个仓库提 PR,**新增一个文件** `data/plugins/cq-guojia__dsh-session-title-pattern.yml`(**不要**改它的 README,那是脚本生成的)。 收录后 **GitHub 直装是一等公民**:条目里 `npm: null`、`install: dsh plugin --profile web add github:owner/repo` —— **不发 npm 也能上架**。 **本轮改动:** 1. **修 peer 依赖范围(真问题)**:`@deepseek-ai/dsh-*` 原本写的是 `^0.1.5-rc.2`。 官方 contributing 明确点出这是坑:**不带显式预发布分支的范围会静默排除 harness 的所有 预发布构建** —— `^0.1.5-rc.2` 只覆盖 `0.1.5` 这一个 patch 的预发布,等 dsh 升到 `0.1.6-rc.1` 就会把用户直接挡在 `ERESOLVE` 上。改成 `>=0.1.0-rc.0 <0.2.0-0` (覆盖 0.1.x 的全部正式版与预发布)。 2. **新增 `screenshots.json`**(仓库根,1–8 张仓库内图片路径):市场优先读它,没有才去 README 里抽。路径相对该文件、不能跳出插件目录。 3. **package.json 元数据补全**:`keywords`(含 `dsh-plugin`)、`repository`、`homepage`、 `author`;`files` 加上 `screenshots.json`。 4. 清理工作区:删掉 27 个历史 `.tgz`(都在 `.gitignore` 里,本来就没进版本库)。 **待用户在做的事(我这边做不了):** - 给 GitHub 仓库加 **`dsh-plugin`** topic(上架 checklist 明确要求) - **仓库满 1 天**后再提 PR(CI 硬门槛;首次提交 2026-09-12 19:15) - 提 PR:新增 `data/plugins/cq-guojia__dsh-session-title-pattern.yml` > **对外一句话描述定稿**(用户要求:**不提**零 token 的关键词规则模式 —— 那条路实际几乎用不上, > 第一屏应该讲模型总结): > > 「自动管理 dsh 会话标题,统一成「日期|类型|主题」的格式:类型与主题由模型对整段对话总结。」 > > 同一口径用在三处:GitHub 仓库 About、`package.json` 的 `description`、market 条目的 `zh`/`en`。 > README 里**仍保留**规则模式的说明 —— 它是真实存在的配置项,文档讲清楚比藏着好; > 不放进一句话简介只是取舍,不是隐瞒。 ### v0.5.17 **为发布到插件市场重排 README**(只动文档与仓库内图片,代码未变)。 - **修掉开头的硬伤**:原首段还写着「用确定性规则把首条人类消息格式化……**不调用模型、 零 token 开销、零网络依赖**」—— 那是最早规则版的描述,而默认早已是 LLM 模式。 这是别人点进仓库看到的第一屏,必须改。 - 重写成市场向的结构:一句话定位(带真实标题示例)→ 侧边栏截图 →「它解决什么问题」→ 「功能一览」表 →「标题格式」→ 安装 → 配置(带设置面板截图)→ … → 手动重算(带按钮截图) - 三张截图入库 `docs/images/`(`session-list.png` / `settings-card.png` / `retitle-button.png`), README 用相对路径引用,GitHub 与市场页都能显示 - 「功能一览」把真正会被问到的点摆在前面:成本恒定、零 token 开关、可单独指定模型、 失败不伤标题(一律保留上一个标题) ### v0.5.16 两处**改名**,纯文案、行为不变: | 位置 | 原名 | 现在 | | --- | --- | --- | | 每个字段右侧的标签 | 已覆盖 | **自定义** | | 底部按钮 | 重置为默认值 →(v0.5.15)撤销改动 | **放弃修改** | - 「已覆盖」是 override 的直译,属于技术词;「自定义」更直白,也准确表达「这一项的值和默认不一样」 - 底部按钮:**名字必须与行为一致**。它做的是「丢掉没保存的草稿、回到已保存的状态」, 「放弃修改」说的正是这件事;「恢复默认」这个名字留给每个字段自己那个按钮 (把该项清回 schema 默认值) 到这一版,三个动作各归其位、不再互相暗示错误的东西: | 动作 | 位置 | 回到哪里 | | --- | --- | --- | | **放弃修改** | 底部 | 回到**你保存过的**值 | | **恢复默认** | 每项右侧 | 回到**该项的 schema 默认值** | | *(已去掉)* | ~~底部~~ | ~~把所有项清回默认~~ | ### v0.5.15 **底部的「重置为默认值」改成「撤销改动」。** 手动改值来回切已经正常了,但一点这个按钮 「未保存」就永远挂着。查清楚了:**这不是判定逻辑的问题,是按钮语义本身超出了预期**。 旧行为是把**所有**字段填回 schema 默认值,其中包括 `provider` 与 `model`。用户存过自定义的 供应商 + 具体模型,它们都不等于默认值,于是点一下这个按钮就等于「把模型选择也清空了」—— 草稿与已存值**确实不同**,所以「未保存」亮起是"正确的",但完全不是用户想要的效果; 更糟的是之后改任何字段都清不掉它(那两个草稿一直在)。 **新行为:`撤销改动` = `setDrafts({})`,丢掉所有未保存的草稿,回到已保存的状态。** 点完标记必然消失 —— 这也正是用户点这个按钮时真正做的事(他就是为了撤销刚改的那个 8)。 - 「把某一项清回默认」原本就有更精确的入口:那一项自己的「恢复默认」(顺带清掉 该字段在用户层的覆盖) - **全局一键清空所有覆盖**这个能力去掉了:它既危险(会连带清掉模型选择)又少见, 逐项清覆盖已经够用 - 按钮禁用条件收紧为 `busy || !dirty`:没有改动可撤销时它是灰的 ### v0.5.14 **根治「未保存」误报。** v0.5.13 只堵住了「已存的值不在当前列表里」那条路径,但 「模型为空 → 自动落到第一个」**仍然会写进草稿**,同一个症状因此还在。 这一版换了思路:**自动补的值不再写草稿,改为「只算不写」**—— - `pair*`(`pairProvider` / `pairModels` / `pairModelResolved`)照常解析,界面显示的就是它, 但**没有任何 effect 回写草稿**(那个 `useEffect` 整段删掉了) - 保存时由 `save()` 按**解析值**写 `provider` / `model`(把它们从 `FIELDS` 循环里摘出来单独处理), 所以「界面上看到的 = 保存后会写进去的」这条性质仍然成立 - 于是 `drafts` 里**只会出现用户真正编辑过的字段**,「未保存」的口径彻底干净: 改回原值会消失,点「恢复默认」填回原值也会消失 顺带:「未保存」标签**加了悬停提示**,列出到底哪几项不同(形如「未保存:标题格式、超时」)。 以后再遇到"它怎么不消失",鼠标停一下就知道该查哪个字段,不用靠猜。 ### v0.5.13 **1. 「输出标题最大Token」的说明去掉「标题只占一行,」**(用户要求):现在只剩 「单位是 token,64 大致相当于 100 个汉字。这一项只是防止模型啰嗦,一般不用改。」 **2. 修掉「什么都没动、却一直显示未保存」。** 根因不在判定逻辑,而在**渲染前就污染草稿** 的那段「自动补具体模型」兜底: 它把「已存的模型不在当前列表里」也当成需要修正的情况,顺手换成了列表第一个 —— 而这一步会 **写进草稿**,于是卡片一打开就挂着「未保存」(草稿值与已存值确实不同)。此时用户改别的字段、 甚至改回原值,标记都不会消失 —— 正是用户描述的现象。 现在只在**值为空**时兜底(对应用户要求的「必须选一个、不给留空」),**已存的值一律原样保留**: - 值为空 → 自动落到列表第一个(仍进草稿,保证「界面上看到的 = 保存后会写进去的」) - 有值但不在当前列表 → 原样显示,并给一个「(不在已配置列表)」选项,让用户自己决定换不换 - 连带把 `pairBlocked`(禁用保存)收紧为「列表为空 **且** 手上也没有存过的值」—— 已经存过模型的人,不该因为目录里读不到模型而被拦住保存 > 「未保存」的判定口径(v0.5.8 起)本来就是用户要的那个:把草稿用 `spec.parse()` 解析成值, > 与已保存的 `section` 比一次,一样就不算改动。本次修的是**另一处**在渲染前写草稿的逻辑。 ### v0.5.12 **修正 v0.5.11 的错误归因。** v0.5.11 把「更新失败」归到 pnpm v11 的构建许可上并写进了 README, **那是错的**。实机日志给出的证据链: - 最后一次成功更新是 **07:05 UTC**(commit `5b4da54` = v0.5.7),从 07:16 起每次更新都失败 - **v0.5.8 只改了客户端代码与文档,`package.json` 只动了版本号** —— 依赖、脚本一字未改。 若真是「这个包的构建要被批准」,v0.5.7 就该同样被拦,可它装得好好的 - 同一时刻的系统日志里有 `fatal: unable to access 'https://github.com/...': gnutls_handshake() failed: The TLS connection was non-properly terminated.` - `pnpm approve-builds` 输出 **There are no packages awaiting approval**, 且 `allowBuilds` 里**根本没有本插件的条目**(真有构建脚本的包会被 pnpm 自动写入占位条目) - 网络恢复后,**同样的更新一次就成功** 结论:**病因是这台机器到 GitHub 的链路不稳** —— `github:` 安装要从 `codeload.github.com` 下 tarball。dsh 那段 `allowBuilds` 文案是它对「pnpm 失败」的**通用提示**,不是病因, 照着它改白折腾了一轮。 改动: 1. README 那一节改写为「**更新失败怎么办(先查网络)**」:第一步用 `curl` 与 `git ls-remote` 验证链路,第二步才谈构建许可,并写清「待批准列表为空 = 不是这里的问题」 2. 顺手补回被 v0.5.11 误删的 `### 关于版本锁定(重要)` 小标题 3. 补一条:GitHub 长期不稳时,从 registry(国内镜像)安装会明显更稳 > 顺带排除掉另一个猜测:`minimumReleaseAge`(最小发布年龄,默认 1440 分钟)也不是原因 —— > 04:13 至 07:05 的 11 次成功更新全是「刚推完提交就装」,若真有一天的时间闸,那些都会失败。 ### v0.5.11 **只加文档**:README 新增「更新时报『pnpm 阻止了构建脚本』怎么办」。 实机更新失败,报 `ERR_PNPM_IGNORED_BUILDS`,dsh 提示去 profile 的 `pnpm-workspace.yaml` 里加 `allowBuilds` 条目。查 pnpm 官方文档后确认了三件事,记进 README 以免下次再查: 1. `allowBuilds` 是 **pnpm v11** 的设置(v10.26.0 引入),形状是 **map(包 → 布尔)**; v11 已**移除** `onlyBuiltDependencies` / `neverBuiltDependencies` / `ignoredBuiltDependencies` 这些数组式旧设置 —— 所以网上 v10 的写法在 v11 上不会被识别 2. **git 托管的包不能用包名批准**(名字不足以标识产物),必须写成 `包名@git 地址`(不带 `#ref`)或精确到 commit;同一仓库的 `git+ssh://` 与 `git+https://` 是两个不同的键 3. 安装时 pnpm 会把未审核的条目**以占位值自动写进** `pnpm-workspace.yaml`, 多数情况下只要把那个值改成 `true` 就行 顺带说明了两点:本插件产物 `lib/` 已入库、安装并不需要真编译,这道许可来自 pnpm 对 git 托管包的一刀切策略;更新失败后 profile 可能停在中间状态,修好后 `add` 一次即可。 ### v0.5.10 **`retitleEvery` 默认 5 → 10。** 用户实跑后觉得 5 轮偏短,改为 10。 只改默认值,语义不变:仍是最小 `0`(= 不自动重算),仍可在设置卡片或 `cordis.patch.yml` 里改。 注意**间隔只影响「多久更新一次」,不改变总量级** —— 每次重算的输入是「上次摘要 + 新增几轮」, 间隔翻倍后单次输入变大、但次数减半,总量大致持平。 ### v0.5.9 实跑第一次就抓到的问题:**手动重算标题超时**。 ``` 生成标题失败:TimeoutReason: SESSION_TITLE_TIMEOUT after 15000ms ``` 失败本身的行为是对的(不覆盖已有标题、日志留一条 warn),问题只是**默认超时给得太紧**。 两个原因叠加: 1. 手动重算会**重置滚动状态**(摘要清空、`seenCount` 归零),等于基于整段对话重来一遍, 输入比平时的增量重算大(虽然有 `maxInputBytes` 兜底) 2. **免费档模型可能正在为主会话排队** —— 实测那轮主对话跑了 47 秒、19 次工具调用, 同时再挤一个辅助请求进去很容易超时 **改动:`timeoutMs` 默认 `15000` → `30000`**,客户端提示补一句「模型慢的时候(比如免费档 在排队)就往大调」。卡片上的「超时」本来就能改,这次只是把默认值调到够用的位置。 > 另一个可选方向(本轮没做):给标题调用配一个**专用的小模型**(设置里别选「跟随对话模型」), > 既避开与主会话争抢,也更快更便宜。 ### v0.5.8 **1. 具体模型为空时不再往框里写字。** 原先该厂家一个模型都没有时,右边下拉里放了个 「(这家没有可选模型)」的选项 —— 但一个空框本身就说明问题了,不必再配一行文字。 现在只留一个空选项,框是空的(仍置灰)。下面那条红色提示保留:它解释的是 「**为什么**保存不过」,和框里的空白是两件事。 > 空选项不能直接删掉:受控 `` 画的原生箭头**, 我们的 `.stp-input` 既没写 `appearance:none` 也没有任何箭头或背景图。按要求加上 `appearance:none` 去掉。 副作用:下拉从此与文本输入框外观一致(用户明确接受)。 **3. 修掉「没配 DeepSeek 却列出 DeepSeek」。** 这是上一版筛选逻辑的真 bug,两处原因: - **凭据域没接上**:`credentials/describe` 由 `@deepseek-ai/dsh-api-settings-controller` 提供, 入参 `string[]`、返回 `{ [ref]: { configured, source?, writable } }` —— schema 就在 `dsh-api-remotes/lib/client.js` 里,上一版没查到,导致一直走回落路径。现在只在该请求真正 成功(`ok === true` 且形状认得)时才把它当依据。 - **回落判定太松**:`settingsPath` 为空时 `walk(user, [])` 返回 `user` 本身,于是只要平台给内置 供应商预置过一个**空壳 profile**,就被判成「我配过」。现在改用 `isMeaningful()`:**空对象不算写过**。 新规则:profile 命名了 `apiKeyEnv` → **以凭据域为准**(配没配 key 只有它知道),凭据域读不到才退回 用户层判定;没命名任何引用 → 只有用户层真的写过才算「我配了」。 另加一条自诊断:凭据状态没读到时,在「标题总结大模型」那行下面提示 「列表只按设置文档判断,可能多列出没配好的供应商」——下次再遇到就能一眼看出是哪条路径。 **4. 关掉「用模型总结标题」后,相关的项全部隐藏。** 重算间隔 / 标题总结大模型 / 具体模型 / 超时 / 输出上限都标了 `modelOnly`,开关一关(以**草稿**为准,不等保存)整行消失;分隔符与长度上限在两种 模式下都有效,保留。开关下面补一句说明:改用关键词规则后类型按关键词匹配、主题取首条消息、 标题不会随对话更新——关掉之后确实没什么可配的了,留着只会让人以为还生效。 ### v0.5.1 设置卡片按用户逐条反馈做的 7 处调整。 1. **开关行改成「标题与说明在左、开关在右」**(对齐官方的 MCP 连接器 / Subagent 卡片)。 先查了平台有没有现成组件:`primitives` 里 `DisclosureRow` 是折叠头、`FoldToggle` 是折叠 控件,**没有**「标签 + 说明 + 控件」这种行组件。但官方 `SubagentModelSelectionCard` 里有 现成的行布局,照抄它的 `.toggleRow{justify-content:space-between;gap:16px}` + `.toggleLabel{flex:1;min-width:0}`,开关本身用系统自带的 `Switch`。 2. **去掉二级折叠**:「模型」「标题格式」两组不再需要再点一次展开,卡片展开即全部可见 (一共就 8 项,再折一层是负担)。顺带删掉了 `COLLAPSIBLE_GROUPS`、`openGroups`、 `renderGroup` 与 `FieldDesc.group`。 3. **删掉 provider 的多余说明**。 4. **模型行按需出现**:provider 留空(跟随会话主模型)时根本不渲染 model 那一行 —— 此时没有第二个选择可做。 5. **「恢复默认」填默认值,而不是清空**。原先 `stage(field, '')` 会把框清空,用户看不到 默认到底是多少。现在填 `defaultOf(field)`;默认值取自快照的 **`base` 层** (文档定义就是「清空该字段后会回落到的值」),拿不到才用客户端的 `FALLBACK_DEFAULTS` 兜底。 连带把「已覆盖」的判定也改了:**存在 user 键 且 值不等于默认值**才算覆盖;保存时若值等于 默认值则走 `unset`,不往用户层记多余的一笔。 6. **底部按钮语义改对**:「放弃修改」→「**重置为默认值**」,把各字段填回默认(暂存), 点保存时一并写回。原先的「放弃」与用户想要的行为不符。 7. **标题带上插件名**:「会话标题(session-title-pattern)」,让人知道这是哪个插件的设置。 ### v0.5.0 设置卡片:样式全面对齐官方,模型列表改为严格筛选。 **样式重做**。v0.4.x 的卡片样式是我自己编的,三处都不对(用户截图指出): header 用透明背景所以永远显黑;body 浮在卡片外面所以断开;字段用「左标签 + 右输入 + 右侧按钮」的表格布局所以很乱。 现在逐条照抄官方 `@deepseek-ai/dsh-client-ui-settings-plugins` 里 `PluginCard.module.css` 与 `fields.module.css` 的规则(新建 `src/client/settings-css.ts`): - `.card` 收起态 `background:var(--dsw-alias-bg-layer-3)`(灰);`.cardOpen` 展开切 `bg-layer-2`(深)—— 这就是「没展开是灰的、展开了才黑」 - `.body{border-top:.5px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}` —— 正文落在**同一张卡片内**,只隔一条细线 - `.field{flex-direction:column;gap:6px;padding:12px 0}` + `.field+.field{border-top:…}` —— **标签在上、控件整宽在下、hint 再下一行**,字段之间用细线分隔 - 「恢复默认」是 label 行里的 12px 文字按钮;底部是右对齐的「放弃修改 / 保存」 (`.save` 实心、`.discard` 描边) - 保存成功后**自动收起**(官方 PluginCard 的行为) 类名用我们自己的 `stp-` 前缀、规则照抄,**不直接借用官方的 hash 类名** (`YyYd_a_card` 这类由上游构建生成,改版即静默失效)。 **模型列表严格筛选**。v0.4.2 把 `listProviders()`(适配器注册的**全部内置供应商**) 直接列了出来,于是用户只配了一个 DeepSeek key,却看到一大堆用不了的模型。现在加两道闸: 1. 路由必须**已注册**(active); 2. 该 provider 的 profile 要么在设置文档的**用户层**被写过,要么它引用的 `apiKeyEnv` 在**凭据域**里 `configured === true`。 第 2 条与官方模型页 `providerUsable` 的口径一致,只是我们更严一档:官方对「profile 未命名任何凭据」的路由直接放行(留给 Bedrock/Vertex 这类走自身凭据链的场景), 而这里的目标是「我配了什么就出什么」,所以不放行。 凭据域通过 `remote.credentials.describe(refs)` 读取,但**该命名空间的签名本地无法验证** (声明它的包只在部署侧组合),因此做结构化调用 + 形状解析 + 整体 try/catch: 拿不到就回退成「只看用户层」,并在卡片上说明。这一条是本次唯一未经实机验证的部分。 ### v0.4.2 provider / model 改成**只能选的下拉**,选项来自用户已经配好的模型。 **为什么**:原先让用户手打 provider 与 model id,等于把「哪个供应商配了哪些模型」这件事 在插件里重录一遍;而手打错一个字母的后果是运行时调用失败。 **数据源**(都在客户端可得,不需要新增依赖): - `remote.llm.listProviders()` —— 当前已注册的路由(`{ id, name }`) - `remote.llm.listConfigurableProviders()` —— 已声明可配置的路由,带 `displayName`、 `settingsNs`、`settingsPath` - **具体配了哪些模型不在上面两个接口里**,而在各 provider 自己的 settings section: 按 `settingsNs` 从设置镜像(`settingsScope.describe()`)找到 `SettingsNamespaceView`, 读它的 `value`(`SettingsNamespaceView.value` 就是「schema 默认值 → 组合层 → 用户层」 解析后的结果),再按 `settingsPath` 走进去取 `models` 数组。 这点由官方 `ModelListEditor` 的注释确认:the profile's `models` array。 **降级**:模型页那个包(`dsh-client-ui-settings-models`)**没有对外暴露可复用服务** (无 cordis 服务增强,导出都是页面内部类型与 store,跨插件值导入又会被纯度闸门拒绝), 所以目录得我们自己拼。任何一步拿不到(llm 远端不存在、镜像读不到、profile 结构对不上) 就**把这两行退回文本输入**并在下方说明原因 —— 最坏情况等于 v0.4.1 的行为,不会把用户卡死。 **细节**:换供应商时清掉已选模型,避免留下属于上一个供应商的 model id; 当前值不在候选里时额外插一条「(不在已配置列表)」选项,防止 select 显示空白。 ### v0.4.1 设置卡片两处修复。 **1. 卡片默认折叠。** v0.4.0 把表单整片摊开,与官方的插件卡片不符 —— 官方 `PluginCard` 的 头部是「插件名 + 一行描述 + 折叠箭头」,点标题行才展开控件(`PluginCardProps` 的 `titleKey` / `descriptionKey` / `children` 就是这个结构)。现在照此实现:标题行是一个按钮, 默认收起;折叠**不影响暂存的改动**,所以标题行右侧会显示「未保存」徽标 (官方注释:staged edits outlive collapsing)。 **2. 模式开关补上可见文字。** v0.4.0 用 `Switch` 的 `label` 属性当字段名,但截图显示 **它根本不渲染可见文字** —— 结果那一行只剩一个没有说明的开关,用户完全不知道它控制什么。 现在按其它行的布局补一个可见的字段名,并在开关右侧显示当前状态(「模型总结」/「关键词规则」)。 ### v0.4.0 设置界面:在 dsh「设置 → 插件」里为本插件提供一张配置卡片。 **配对机制**:卡片由同一个包的两半配对 —— host 半用 `ctx.settings.installSection` 注册 命名空间 `session-title-pattern`,client 半把卡片注册到 `settings.plugin.item` 槽位并以 **同一个命名空间为 `key`**。设置页的「插件」标签页遍历 host 提供的命名空间,逐个 `renderSlot("settings.plugin.item", {}, { entryKey: ns })` 拉取卡片。 **8 项可配置**:mode(开关)、retitleEvery、provider、model、timeoutMs、maxOutputTokens、 separator、maxBytes。分三组:前两项常显,「模型」与「标题格式」默认折叠。 `maxInputBytes` 刻意不露出(滚动摘要的内部成本预算,需要时走 `cordis.patch.yml`)。 **表单语义**:官方 `CardForm` 用「暂存 + 保存」而不是改一下即提交,理由是每次写入都是 可持久化的、带修订号栅栏的文档变更,边改边写会把一次输入变成用户没要求、也无法预览的写入。 卡片照此实现:草稿 + 保存/放弃 + 单字段恢复默认 + 全部恢复默认。覆盖状态按 `user` 层的 **键存在性**判断(不是比值 —— 等于默认值的覆盖仍然是覆盖),有草稿时按草稿预演, 徽标不与屏幕上的输入自相矛盾。 **配置改成动态读取**:provider 原先在构造时把 config 冻结进字段;现在持有 `source()`, 每次 `generate` 现读。`onChange` 里替换来源并**清空滚动摘要**(换了模型或间隔后旧摘要 不再匹配新设置)。`trackRecomputes` 也不再只在 apply 时判断 mode —— 模式可以随时切换。 **跨字段校验**:`validate` 拒绝 provider/model 只填一项的写入(schema 表达不了的约束), 用户在设置页立刻收到失败提示。 **新增依赖**:`@deepseek-ai/dsh-settings`(peer + dev)、`@deepseek-ai/dsh-client-ui-settings` 与 `@deepseek-ai/dsh-client-ui-settings-plugins`(dev,仅类型)。三者都加了 `dsh.client.inject`(后两者是客户端插件)。 **三个踩坑记录**: 1. **版本标签陷阱**:`npm view version` 拿到的是 `latest` 标签,而这两个设置包 `latest` 指向旧的 `0.0.1-rc.x` 版本线,与整条 `0.1.5-rc.2` 栈 peer 冲突(ERESOLVE 装不上)。 正确版本在 **`next` 标签 = `0.1.5-rc.2`**。 2. **类型增强必须显式 import**:`ctx.settings` 的声明来自 `@deepseek-ai/dsh-settings`, 而 tsconfig 的 `types` 是空的 —— 不写 `import type {} from '@deepseek-ai/dsh-settings'` 就报 TS2339。 3. **受保护代理**:`ctx.settings` 与 `ctx.settingsScope` 都不能直接访问,一律走 `ctx.inject([...])` 延迟注入(同 v0.3.1 的 `ctx.llm`)。 ### v0.3.2 文案细化。悬浮气泡不占布局空间,所以把说明写全: - 客户端按钮的 `ACTION_LABEL`:`生成标题` → **`根据对话重新生成标题`** - `/retitle` 的命令描述同步改成同一句,保持一致 刻意不用「自动生成标题」:本按钮是**手动**触发,写「自动」会让人以为它自己会跑。 ### v0.3.1 修复 v0.3.0 的致命缺陷:**`/retitle` 与自动生成全部报 `Error: cannot get property "llm" without inject`**。 **根因**:cordis 的 `Context` 是受保护的代理,**未在 `inject` 里声明的服务属性一经访问 就抛错**。v0.3.0 在 `callTitleModel` 里直接写了 `ctx.llm.stream(...)`,而本插件的 `inject` 只有 `['sessionTitle']`。 **修法**:没有简单地把 `llm` 加进 `inject` 声明,而是改用**延迟注入**: ```ts let llm: LlmService | undefined; ctx.inject(['llm'], (llmCtx) => { llm = llmCtx.llm; }); ``` 理由:`mode: rules` 时我们根本不需要 LLM,而**声明式依赖会让本 entry 在缺少该服务的 组合里一直 pending**。延迟注入让「没有 llm」只表现为「LLM 模式不可用」,rules 模式照常工作。 与项目对 `commands` 的处理一致。 配套改动:`callTitleModel` 的第一个参数从 `Context` 改为 `LlmService`(服务由参数传入, 不再从 ctx 上取),`LlmService` 类型用 `Context['llm']` 表达;provider 构造函数新增 `getLlm: () => LlmService | undefined`;取不到时抛带说明的错误,走既有的「保留上一次标题」 降级路径。 **验证**:`npm run typecheck` 通过;产物核对 —— 含 `inject(["llm"]`,`llm.stream`, 且 `grep "ctx\.llm" lib/index.mjs` **无结果**。 **教训**:这个错误不是"配置问题",而是插件在开发环境里无法暴露的一类错误(本地没有 dsh 运行环境,typecheck 也查不出受保护代理的运行时约束)。凡是访问 `ctx.<服务>`,都必须先确认 该服务已在 `inject` 声明里,或通过 `ctx.inject` 拿到。 ### v0.3.0 接入大模型:类型与主题改为模型对**整段对话**的总结,首条消息先生成一次,之后每 N 轮滚动重算。 **用户需求**:分类交给模型总结(两个汉字,可给示例但不硬性约束);标题要成为对整段对话的 总结而非首条消息前几个字;首条消息先生成,之后每 5 轮重算;必须控制 token(不能把全部对话 都发一遍);提供"每 5 轮 / 每 10 轮"这类配置项。本轮先实现功能,配置面板押后。 **四项决策**:默认开启 LLM 且可关;独立调用 + 滚动摘要;模型可配置、留空跟随主模型; 失败保留上一次标题。 **为什么不做「把任务偷偷塞给下一轮对话」**:dsh 的主对话请求由 agent loop 构建,传给 `llm/stream` 时深度冻结只读,第三方插件无法注入隐藏轮次;`GenerateOptions.purpose` 也是 封闭枚举(只有 `compaction` / `session-title`)。唯一能贴近该设想的是注册工具让主模型顺带 调用,但工具定义与提示说明会随每轮主请求一起发给模型,token 随轮数线性增长,按 N=5 估算 比滚动摘要更贵,故弃用。 **成本模型**:每次重算只发固定三段 —— ① 首条消息(截断 200 字节)② 上次摘要(一行) ③ 上次之后的新增人类消息。② 就是模型上一次的输出本身,不是额外生成的东西。输入大小与 会话总长度无关:每 5 轮约 300 token 输入 + ≤64 token 输出,100 轮也就三四万 token。 **实现要点**: - `automatic` 保持 `first-prompt`:首条消息由服务自动调度;之后的重算由本插件订阅 `session/event` 数轮次,每满 `retitleEvery` 条显式调 `ctx.sessionTitle.refresh()`。 这样非重算轮根本不会被调用,也不会写重复的 `session/title` 事件 (改成 `all-prompts` 会每轮写一条重复标题事件) - 模型调用照官方 `session-title-llm` 范式:`ctx.llm.stream` + `BlockAssembler` + `deadline(request.signal, timeoutMs, 'SESSION_TITLE_TIMEOUT')`,`purpose: 'session-title'` - 输入用 JSON 承载(防注入,官方同做法);超预算时从**最旧**的新增消息开始丢 - 输出解析容错:多行只取首行、剥编号/引号/反引号、兼容全角竖线;类型缺失时回退规则分类 - **降级**:provider 抛错即可 —— 服务的 `runProvider` 只在成功后 `append('session/title')`, 抛错天然保留旧标题(已读 `dsh-session-title/lib/index.js` 确认);轮次仍推进, 避免失败后每轮重试 - **只处理顶层会话**:`session.header.parentSession !== undefined` 直接跳过,否则每次 fork(子代理)都要多付一次模型调用 - 内存有界:每会话只留一行摘要 + 两个计数,`Map` 上限 64 个会话,按插入序淘汰 - 手动 `/retitle` 会重置滚动状态,让这次尽量基于全部对话重来 **新增依赖**:`@deepseek-ai/dsh-llm`、`@deepseek-ai/dsh-timeout`(peer + dev,均为 0.1.5-rc.2); tsconfig 的 `lib` 增加 `ESNext.Disposable`(`deadline` 的 `[Symbol.dispose]`)。 字节计算用 `TextEncoder` 而非 `Buffer.byteLength`,避免为 `types: []` 引入 `@types/node`。 **新增文件**:`src/host/rules.ts`(规则模式与标题拼装,供 LLM 模式复用)、 `src/host/llm.ts`(提示组装、模型调用、输出解析、路由解析)。 **顺带**:客户端按钮图标由 `IconRefreshOutline16` 换成 `IconEditOutline16`(铅笔)。 **已用临时脚本验证**(跑完即删):`parseTitleLine` 的 6 种输入、`buildPromptInput` 的 首轮/第 5 轮/极小预算、`composeTitle` 的字节安全截断与空主题分支。 **未做(下一轮)**:配置面板(settings card)、分类规则重构、单测。 ### v0.2.9 「生成标题」按钮三项改造: 1. **图标**换成官方 `IconRefreshOutline16`。原来是我手绘的四角星内联 SVG,16px 下会被 误认成加号。primitives 其实自带 49 个官方 `IconXxx16`(侧边栏开关等内置按钮用的就是 同一套),手绘属于重复造轮子,已删除。 2. **位置**从 `conversation.session.header.utilities` 挪到 `conversation.session.header.actions`, `order` 由 `100` 改为常量 `ACTION_ORDER = -1000`。依据:上游头部结构是 `titleCluster > (crumbs, headerActions)`,该组紧贴标题;同区内按 order 升序排列, 取足够小的负数即可成为**标题右边第一个**,「标准模式」落在其右侧,其他插件后挂的 条目也都在右边。 3. **悬浮提示**改用官方 `Tooltip`(`label` / `side: 'bottom'` / `delayMs: 500`),与内置按钮 同款。注意 `Button` 是普通函数组件、**不转发 ref**,不能直接当 Tooltip 的锚点,因此套了 一层 `span`(`display:inline-flex`)当中介。禁用态额外挂原生 `title` —— 禁用的原生表单 控件不派发鼠标事件,Tooltip 不会出现。 顺带清掉:手绘 SVG 常量、以及那条「ui-primitives 没有通用图标集」的错误注释。 ### v0.2.8 解除会话标题的宽度硬上限。客户端激活时自动注入: ```css [class*="_crumbCurrent"]{max-width:min(640px, 60vw) !important;} ``` **根因**:会话标题是头部面包屑的最后一段(`_crumb` + `_crumbCurrent`),上游给它写死了 `max-width:220px`,与窗口缩放无关。核算:`220 − 16(左右内边距) = 204px` 可用; `0913 ` 约 38px + `测试` 约 28px + 两个分隔符约 28px = 前缀约 94px; 留给主题约 110px,14px 字号下即 **7~8 个中文字** —— 与用户观察一致。 **为什么不能用槽位解决**(已核实):解析 ui-conversation 的面包屑渲染代码 ```js const lineage = last || summary.subagent; lineage ? (summary.subagent ? renderSlot('conversation.session.header.lineage', owner, { fallback: title }) : <>{title}{renderSlot('conversation.session.header.lineage', owner, { fallback: null })}) : title ``` `conversation.session.header.lineage` 的契约是「单个面包屑标题的可选渲染器」(`kind: 'single'`): 祖先面包屑不渲染该槽位;子代理面包屑可整体替换标题;**当前会话的 `title` 元素由上游无条件 原生渲染**,槽位只能作为其后的兄弟节点存在。即**没有任何槽位能改写当前会话标题本身**, CSS 覆盖是唯一可行手段。 **为什么必须 `!important`**:属性选择器与上游 `.wSkVaW_crumb` 特异性相同(都是 0,1,0), 平局按源码顺序决胜,而注入顺序无法保证。 **为什么不会溢出**:`.crumb` 自带 `overflow:hidden`,flex 项的 `min-width:auto` 因此解析为 0, 宽度不足时会自动收缩并省略,不会挤占同行其他控件。 **失效模式**:选择器依赖 CSS Modules 生成的局部类名后缀 `_crumbCurrent`。上游若重命名该类名, 规则会**静默失效** —— 不报错、不崩溃,只是标题又变短。排查方法:DevTools 选中标题元素, 看它 `class` 属性里是否还有 `_crumbCurrent`。 ### v0.2.7 按钮改为**图标按钮**(内联四角星 SVG + `title="生成标题"`),不再显示「生成标题」文字, 目的是与同排的 `...` 等控件观感统一(约 36px vs 约 68px)。 > 修正:v0.2.7 当时认为图标按钮「能还给标题 30 多像素」,**这个结论是错的**。 > 标题宽度由上游 `.crumb` 的 `max-width:220px` 决定,只要可用宽度 ≥ 220px, > 头部控件宽窄完全不影响标题,多出来的空间只会留在 `.titleCluster` 里。 > 真正的宽度问题由 v0.2.8 的 CSS 覆盖解决。 ### v0.2.6 按钮改用官方 `@deepseek-ai/dsh-client-ui-primitives` 的 `Button`(`variant="ghost"` + `size="sm"`),与头部其它控件风格一致。原先渲染的是裸 `