dsh_BetterInput
Manifest validdsh-better-input
DSH Web GUI 插件:在模型选择器左侧加一个「AI 优化输入」按钮,点击后用可自定义的提示词优化输入框里的内容,并把结果写回输入框(撤销见路线图 P3)。
设计依据、座位/接口证据与分阶段计划见 DESIGN.md。
当前状态
| 阶段 | 内容 | 状态 |
|---|---|---|
| P0 | 骨架:座位注册 + 按钮出现在模型左侧 | ✅ 已在 Web GUI 目视确认(2026-09-10) |
| P1 | 宿主半:/api/dsh-input-optimizer/optimize + ctx.llm.stream() 一次性调用 | ✅ 已实现 |
| P2 | 前后端接线:读草稿 → POST → setDraft + CAS + 取消 + 失败提示 | ✅ 已实现 |
| P3 | 撤销栈 + CAS 校验 + 撤销按钮(含强制还原、10 层深度) | ✅ 已实现 |
| P4 | 设置页:配置模型(名称 + 调用参数)与提示词,持久化并即时生效 | ✅ 已实现 |
| P5.0 | 可运行性:dev 依赖软链脚本 + 真机安装验收 | ✅ 已实现(2026-09-11) |
| P5.1 | 缺陷修复:日志全丢、/check 契约判型、未知终态误判失败、405 误报、保存假成功、组合层区间、样式归属 | ✅ 已实现(2026-09-11) |
| P5.1b | 真机事故:设置页恒显示「设置服务不可用」——命名空间注册是激活时读一次,输给了 settings 服务就绪的竞态 | ✅ 已修复(2026-09-11,含真框架集成测试) |
| P5.2 | 规则单一来源:区间/上限/预设由 /catalog 下发,客户端不再维护会漂移的镜像 | ✅ 已实现(2026-09-11) |
| P5.3 | 预设菜单:输入框旁 ▾ 菜单,选中后请求带 presetId | ✅ 已实现(2026-09-11) |
| P5.4 | 信任判定改走框架 ctx.connection.requestRejection()(能力路由要浏览器会话) | ✅ 已实现(2026-09-11) |
| P5.5 | 并发闸门:同会话单航班(409)+ 全局并发上限(429) | ✅ 已实现(2026-09-11) |
| P5.7 | 工程化:Biome lint、约定守卫、真 React 渲染测试、CI | ✅ 已实现(2026-09-11) |
| P5.6 | 流式回填:/optimize/stream(SSE)边生成边替换草稿,失败还原原文 | ✅ 已实现(2026-09-11) |
| P6.1 | 多选优化风格:▾ 下拉框里勾选「精简 / 转规格」,可叠加,请求带 styleIds | ✅ 已实现(2026-09-11) |
| P6.2 | 逐风格提示词:设置页为每个风格单独配提示词(内置 ← 组合配置 ← 设置页) | ✅ 已实现(2026-09-11) |
| P6.3 | 打开插件配置文件:设置页一键用编辑器打开 cordis.patch.yml,失败有明确提示 | ✅ 已实现(2026-09-11) |
| P6.4 | Web 启动耗时排查:基线 1886ms/12 轮,插件边际成本 ~5ms,无阻塞(见 .perf/README.md) | ✅ 已完成(2026-09-11) |
| P7.0 | 系统提示词可见:设置页展示默认系统提示词(内置/组合层),可查看、可一键"以默认为基础编辑" | ✅ 已实现(2026-09-11) |
| P7.1 | 追加提示词:设置页可新增多条命名提示词,输入框旁 ▾ 菜单随时切换,落盘并即时生效 | ✅ 已实现(2026-09-11) |
| P7.2 | 恢复默认配置:一键还原全部用户设置(含追加提示词与内置覆盖),带确认 | ✅ 已实现(2026-09-11) |
| P8 | 风格并入清单:精简/转规格成为追加提示词的内置条目(单选切换),不再单独多选;styleIds 仅为旧客户端兼容保留 | ✅ 已实现(2026-09-11) |
| P9 | 命名与语义对齐:设置页改为「系统提示词 / 追加提示词」,追加条目只追加、不替换系统提示词 | ✅ 已实现(2026-09-11) |
| P10 | 设置界面重设计:输入框默认隐藏(点「编辑」才展开)、行距压到最小、四个分组 + 一行摘要同屏可见 | ✅ 已实现(2026-09-11) |
| P11 | 进度与思考过程:优化中显示「阶段 + 耗时 + 字数」,并把模型的思考增量透传成「思考过程」面板 | ✅ 已实现(2026-09-11) |
| P12 | 控件收敛:工具行只留 ✦ 与 ▾,撤销与思考回看收进 ▾ 菜单的「本次调用」分区;▾ 常驻并带"可撤销"角标;成功不再弹提示 | ✅ 已实现(2026-09-11) |
| P13 | 通用输入角标:主输入框之外的每个输入窗口(页面上的 textarea / 显式报名的控件)也挂一枚 ✦ 角标,点击即优化该输入框,成功后可 ↶ 撤销 | ✅ 已实现(2026-09-13) |
| P14 | 思考强度:设置页「调用参数」里可配优化时用的思考强度(内置默认 low);宿主按模型公布的能力核对后才透传,模型不支持则回落适配器默认 | ✅ 已实现(2026-09-13) |
| P5.7b / P5.7c / P5.8 | typecheck(缺 tsc)、vitest+jsdom、芯片保留 | ⬜ 待做(见「下一步」与「工程化」) |
检查:lint 零发现 · 约定守卫 29 条 · 测试 76 + 82 + 6 + 15 + 7 = 186 例,全绿
(npm run verify = lint + 全部检查;npm test 会先自动补齐 dev 依赖链接)。
运行前提:仓库里没有
node_modules时,npm test与link:方式安装后的运行时都跑不起来 (原因与自动修法见「开发循环」)。
目录结构
package.json 双半声明:main(lib/index.js) + exports["./client"] + dsh.client/bundle
biome.json lint 配置(只 lint 不 format,见「工程化」)
cordis.patch.yml bundle patch + 组合层配置(设置页的用户值优先于它)
lib/index.js 宿主半:6 条路由(一次性 JSON + 流式 SSE + 目录/试调 + 打开配置文件)+ LLM 调用 + 并发闸门
lib/settings.js 宿主半:设置命名空间 schema 与跨字段校验(注册挂在 settings 就绪时)
lib/policy.js 策略层:零依赖,配置校验/信任围栏/生效配置解析/风格提示词分层/追加提示词/显示思考过程/思考强度(可独立单测)
lib/client.js 浏览器半:输入框按钮 + ▾ 统一菜单(撤销 / 思考回看 / 追加提示词 / 预设)+ 撤销栈 + 进度行与思考面板 + 通用输入角标(P13)+ 设置页(手写 __ModuleLoader__ bundle)
lib/types/*.d.ts 对外类型
.perf/ Web 启动耗时基准脚本与测量报告(README.md 有方法与原始数据)
scripts/check-guards.mjs 约定守卫:把踩过的坑变成可自动检查的规则(29 条)
scripts/dsh-packages.mjs 定位 dsh 安装与其中的宿主包(脚本与测试共用)
scripts/link-dev-deps.mjs 把宿主依赖软链进本仓库(`npm test` 前自动跑;CI 里自动跳过)
scripts/lint.mjs 找 Biome 并跑 lint(仓库内 / 全局安装都能用)
test/smoke.mjs 宿主半冒烟测试(76 例)
test/client.smoke.mjs 浏览器半冒烟测试(82 例:接线、控件收敛、流式回填、进度行与思考面板、统一菜单、撤销栈、设置页紧凑布局与思考强度)
test/client.react.mjs 真 React 渲染测试(6 例:真 react/react-dom SSR,含"不得有 React 警告")
test/client.universal.mjs 通用输入角标测试(15 例:自带小 DOM 替身,覆盖扫描/定位/遮挡/写回/取消/手改/失败/回退/动态挂摘/卸载)
test/settings-activation.mjs 真框架集成测试(7 例:真实 cordis + 真实 settings 提供者,含追加提示词、思考透传、思考强度全链路)
.github/workflows/ci.yml CI:lint + 约定守卫 + 五个套件(Windows)
DESIGN.md 设计依据:座位/接口证据、撤销追加提示词、提示词分层、风险清单
LICENSE MIT
安装
前置:dsh 0.1.2-rc.1+,profile 为 web(即 ~/.dsh/profiles/web)。
第 1 步(两种方式都要做):把本包装进 profile,让包名能被解析到:
dsh plugin --profile web add link:F:\dsh\dsh_BetterInput
第 1.5 步(link: 专用,容易漏):在本仓库建好宿主依赖的软链:
npm run link-deps # = node scripts/link-dev-deps.mjs,自动定位 dsh 安装目录
为什么运行时也需要它:
link:在 profile 里放的是符号链接,Node 按 realpath 解析模块, 于是lib/index.js的import '@deepseek-ai/dsh-llm'是从本目录往上找node_modules的——$DSH_HOME/profiles/node_modules那个镜像根本不在解析路径上。缺了它宿主半会ERR_MODULE_NOT_FOUND(boot 直接失败),不是"只有测试受影响"。 从 npm 正式安装(非link:)时包会被拷进 profile 的node_modules,镜像在解析路径上,无需这一步。
⚠️ Windows 上不要在 patch 里写绝对路径当插件名:Loader 对非
./非cordis:的 specifier 直接交给import(name),D:\...会被当成 URL schemed:. 解析而失败; 以.开头的相对 specifier 又是相对 profile 目录(在 C: 盘)解析的,跨盘也无解。 所以唯一可靠的方式是「装进 profile 的 node_modules,再用包名引用」。
方式 A:bundle(与已上架的第三方插件一致)
dsh plugin --profile web add 已经自动把包名追加进 dsh.profile.bundles(实测 0.1.2-rc.1,
无需手改 JSON)。只有在手工拷贝/手改依赖的场景才需要自己往 dsh.profile.bundles 里加一行
"dsh-better-input"——而且绝不要重复添加:同一个 bundle 会被组合两次,插件的
(kind: 'exact', path) 路由重复注册会直接抛错(webserver: duplicate exact route ...),表现为起不来。
然后重启 dsh web。本包的 cordis.patch.yml 会作为 bundle 层被自动应用(dsh.bundle.patch)。
方式 B:直接 patch insert(不想动 bundles 时)
在第 1 步之后,把下面这段拷进 ~/.dsh/profiles/web/cordis.patch.yml(该文件里已有一个 mcp-everything 的同构例子),再重启:
- insert:
- id: better-input
name: 'dsh-better-input'
config:
systemPrompt: |
你是提示词工程师……
这段内容与本包自带的 cordis.patch.yml 等价;用方式 A 时改的是包里那份,用方式 B 时改的是 profile 那份。
验证
改动宿主半(
lib/index.js/lib/policy.js/lib/settings.js)或改安装方式后必须重启dsh web; 只改lib/client.js时靠 HMR,刷新即可。
- P0:刷新
http://127.0.0.1:3080,输入框工具行右侧、模型选择器紧左边出现 ✨ 按钮(data-dsh-better-input="better-input",可用 DevTools 搜到)。空输入时按钮为禁用态。 - P1:路由挂上后宿主日志会打印
better-input: mounted /api/dsh-input-optimizer/optimize(ctx.get('logger')恒为undefined,所以这一行只有在用ctx.logger时才真的会出现)。直接打路由:
curl.exe -s -X POST http://127.0.0.1:3080/api/dsh-input-optimizer/optimize `
-H 'content-type: application/json' `
-d '{"text":"帮我把那个脚本弄一下,快点"}'
# → {"text":"...优化后的提示词...","modelUsed":{"provider":"...","model":"..."}}
非本机来源(远程 IP / 异源 Host / Sec-Fetch-Site: cross-site)一律 403。
3. P4(设置页真的可用):重启后
curl.exe -s http://127.0.0.1:3080/api/dsh-input-optimizer/catalog
# 必须看到 "settings":{"available":true,...}
# 若为 false,会同时带出宿主侧原因:"reason":"..."
宿主日志里应同时出现两行:better-input: mounted /api/dsh-input-optimizer/optimize (+stream/catalog/check/open-config)
与 better-input: settings namespace "better-input" registered。
设置页保存后 $DSH_HOME/settings.yaml 里应出现 better-input: 段,catalog 的
sources.* 也从 config/default 变为 settings。
4. P8/P9(风格并入清单 + 系统提示词 / 追加提示词):✨ 右侧的 ▾ 菜单最上方是追加提示词区:
「不追加」+ 精简 / 转规格(内置条目,● = 当前启用)。点「转规格」→ 选中标记移动、提示"已切换"
(activeProfileId 落盘);点 ✨ 优化,请求体不带 styleIds,效果 = 系统提示词 +
本次额外要求(转规格):…(/catalog 的 effective.profileId === 'spec')。
到设置页「追加提示词」给「转规格」填正文并保存,再点 ✨:追加的那段换成你写的文字
(系统提示词本身不变);清空再保存则回到内置追加文案。
5. P6.3(打开配置文件):设置页底部点「打开插件配置文件」→ 用编辑器打开
F:\dsh\dsh_BetterInput\cordis.patch.yml(设置页上同时显示这个绝对路径)。
可用 node .perf/verify-open-config.mjs 走同一条链路做命令行验收。
6. P7(默认系统提示词可见 + 追加提示词切换):设置页「系统提示词」区点「查看默认系统提示词」→
展开宿主下发的默认正文;「追加提示词」区点「新增追加提示词」、填名称与正文、保存;
到输入框旁 ▾ 菜单点这条(● 移到它上面、提示"已切换")→ 点 ✨ 优化,DevTools → Network 里
这次请求虽不带追加提示词信息,但宿主 settings.yaml 的 activeProfileId 已变、
模型收到的 system 里多出 本次额外要求(名称):…(/catalog 的 effective.profileId 同步更新,
sources.prompt 仍如实标注基底来自哪一层)。
「恢复默认配置」点一下会先弹确认,确认后追加提示词、自定义系统提示词、内置条目覆盖全部清空,
/catalog 的 effective.sources.prompt 回到 config/default。
7. P10(设置界面重设计):打开 设置 → 输入优化:
- 默认视图里一个输入框都没有,只有操作条 + 四个分组标题 + 各自一行摘要 + 追加提示词清单; 同屏应当能看完全部主要设置(不用滚到底部找「保存」——它在最上面)。
- 点「系统提示词」右侧的「编辑」→ 开关与正文框出现;再点「收起」→ 控件消失。
- 在收起状态下直接点「保存」(若当前配置不合法)→ 出错的分组会自动展开并显示红字。
- 「追加提示词」清单常驻:直接点某一行的单选即可切换;点该行「编辑」才出现名称/正文框。
- P5.3(预设菜单):
cordis.patch.yml里配了非内置 id 的presets时,▾菜单下半部分 列出它们;点某一项 → 请求体里带presetId(可用 DevTools 的 Network 面板确认)。 一个都没配时只有追加提示词区,界面与从前一致。 - P5.4(宿主会话):
POST /optimize需要浏览器会话(页面 cookie 由dsh web打印的 带 token 的 URL 换取)。命令行只做排查时用只读路由:
curl.exe -s http://127.0.0.1:3080/api/dsh-input-optimizer/catalog # 元数据:免会话(仅环回)
curl.exe -s -X POST http://127.0.0.1:3080/api/dsh-input-optimizer/check `
-H 'content-type: application/json' -d '{"provider":"leihuo","model":"deepseek-v4.1-flash"}'
# POST /optimize 不带 cookie 会得到 401 unauthorized(这是有意的:凭据可能来自环境变量,
# 本机其它进程不该能借这条路由花掉它)。要从命令行调它,就把浏览器 DevTools →
# Application → Cookies 里那条 dsh 会话 cookie 用 `-b "<name>=<value>"` 带上。
- P11(进度与思考过程):在输入框里写一段草稿、点 ✨:
- 立刻出现「等待模型 0.0s」并每秒在涨(这就是原先完全静默的那一段);
- 模型开始输出后变成「写入中 n.ns · N 字」,草稿被逐句替换;
- 用的是推理模型时,中间会经过「思考中 n.ns · N 字」,按钮上方自动展开「思考过程」面板并实时滚动;
- 到设置页「调用参数」取消勾选「显示思考过程」并保存,再点 ✨:只有进度行,不再有面板
(宿主侧也可用
curl看/catalog的effective.showReasoning === false佐证); - DevTools → Network 里那条 SSE 请求应当按序出现
event: reasoning(若有思考)与event: delta, 且任何思考文本都不会出现在草稿或撤销记录里。
- P12(控件收敛):同一次带思考的优化之后,
- 工具行里只有 ✦ 与 ▾ 两个按钮(DevTools 里数一下
[data-dsh-better-input-wrap]的直接子按钮); - 菜单收起时 ▾ 右上角有一个小圆点(= 有可撤销记录);成功不再闪「已替换为优化结果」;
- 点 ▾:「本次调用」分区在最上面,含「↶ 撤销上次优化」与「💭 查看思考过程(n 字)」; 点撤销 → 草稿回到优化前且菜单收起;点思考 → 菜单收起、面板打开(面板里「收起思考过程」可再收起);
- 用手改一下草稿再点撤销:菜单项变成「↶ 强制还原原文」且菜单不收起,再点一次才真的还原;
- 断网(或让宿主半不可用)后刷新,再成功优化一次:▾ 仍然出现,撤销照样可达 (这条钉的是"菜单不该依赖 /catalog")。
- 工具行里只有 ✦ 与 ▾ 两个按钮(DevTools 里数一下
- P13(通用输入角标):打开任意一个不是主输入框的自由文本输入窗口——例如拿
「ask_user_question」的自由作答框、消息反馈框,或第三方插件的表单多行框:
- 该输入框右上角出现一枚 ✦ 角标(20px,
data-dsh-better-input-badge="better-input"), 只读/禁用/隐藏的输入框没有角标;滚动页面或缩放窗口时角标跟着输入框走; - 写点人话、点角标:角标呼吸(生成中再点 = 取消),文本边生成边替换; 成功角标变绿并多出一枚 ↶,点 ↶ 还原优化前的文本;内容在优化后被动过时, 第一次点 ↶ 只提示"再点一次强制还原";
- 手动改一下正在生成的输入框 → 立刻中止 + 提示「内容在优化过程中被修改」;
- 有插件的输入框不想被挂角标 → 给那个元素(或任一祖先)加
data-dsh-better-input-skip属性即可;非textarea的控件想接上, 加data-dsh-better-input-host; - 主输入框不会多出第二枚角标(它是 Lexical contenteditable,不是
textarea)。
- 该输入框右上角出现一枚 ✦ 角标(20px,
- 检查:
npm run verify # lint + 约定守卫 + 五个套件(推荐)
npm test # 约定守卫 + 宿主半 + 浏览器半 + 真 React + 通用角标 + 真框架集成
node test\smoke.mjs # 宿主半 70 例:生效配置、信任判定、并发闸门、六路由全链路、SSE 分帧、思考透传、追加提示词(含内置种子)、打开配置文件
node test\client.smoke.mjs # 浏览器半 78 例:座位、控件收敛、流式回填、进度行与思考面板、统一菜单、撤销栈、设置页紧凑布局
node test\client.react.mjs # 真 React 6 例:真 react/react-dom SSR 渲染(含"不得有 React 警告")
node test\client.universal.mjs # 通用角标 15 例:扫描/定位/遮挡/写回(原生 setter + input 事件)/取消/手改/失败还原/旧宿主回退/动态挂摘/卸载
node test\settings-activation.mjs # 真框架集成 6 例:真实 cordis + 真实 settings 提供者,钉住注册时机、提示词与思考透传链路
node .perf\measure-startup.mjs 12 # Web 启动耗时基准(12 轮冷启动;详见 .perf/README.md)
各项检查覆盖什么/不覆盖什么、以及 typecheck 为何还没上,见「工程化」一节。 宿主半测试与运行时都需要
@deepseek-ai/dsh-llm(设置半还需要@deepseek-ai/schemastery)可见; 集成测试还需要@deepseek-ai/cordis、@deepseek-ai/dsh-settings-file;真 React 套件需要配对好的react/react-dom。npm test的pretest会自动建这些软链(npm run link-deps); 脚本会在$DSH_HOME/profiles、~/.dsh/profiles、nvm 安装目录里找 dsh,找不到才报错 (也可用$env:DSH_INSTALL_ANCHOR显式指定含node_modules的目录)。
行为说明(已实现)
| 场景 | 行为 |
|---|---|
| 点击 ✨ | POST /api/dsh-input-optimizer/optimize/stream(默认;/optimize 是回退),body { text, sessionId, presetId? }(styleIds 仅为旧客户端兼容保留),成功后 setDraft 写回 |
| 工具行里有什么(P12) | 只有两个控件:✦ 优化(生成中 = 取消)与 ▾ 菜单。撤销、思考回看、追加提示词、预设全在 ▾ 里,见下「控件收敛」一节 |
| ▾ 菜单的「本次调用」分区 | 撤销(有可撤销记录时出现)与「查看思考过程(n 字)」(本次调用收到过思考时出现)。这一区随"上一次调用的产物"出现/消失,不依赖 /catalog |
| 可撤销角标 | 有可撤销记录时 ▾ 右上角有个 5px 圆点——撤销住进菜单后,菜单收起时这是唯一的提示 |
| ▾ 菜单里的「追加提示词」 | 单选:「不追加」+ 内置条目(精简 / 转规格)+ 设置页里保存的各条追加提示词,选中的那条接在系统提示词之后。切换即写进设置(activeProfileId)落盘,下一次优化生效;菜单不收起,选中项带 ● 标记 |
| 内置追加提示词(精简 / 转规格) | 追加提示词清单的常驻种子:未被覆盖时,它的正文就是该风格的追加要求原文(与旧"勾选风格"逐字节一致);在设置页改了它就是改这段追加文案。不可删除 |
| 追加提示词(设置页) | 内置条目与自定义条目在同一个清单里:改内容、单选「启用」;可新增多条命名追加提示词。系统提示词永远在场,追加只增不改。见下「追加提示词」一节 |
预设菜单(▾) | 与内置条目同 id 的预设不列进预设区(它们是内置条目的追加文案来源);其余预设点一次跑一次,请求带 presetId,宿主把该预设的 prompt 追加到 system。菜单向上弹出,点外面或 Esc 收起 |
| 草稿超过宿主上限 | 本地直接提示「草稿过长(n/上限)」,不发请求(上限来自 /catalog 的 limits) |
| 流式回填 | 默认走 SSE:增量到达即改写草稿(80ms 节流);失败/中断会还原原文(详见下节) |
| 优化中的进度 | 按钮旁常驻一行「阶段 + 已耗时 + 已收字数」(等待模型 / 思考中 / 写入中),耗时由本地计时器推进;按钮图标带呼吸动效。不提供百分比——maxOutputTokens 是上限不是目标,任何比例都是编的(详见「进度与思考过程」) |
| 思考过程 | 模型返回思考内容时,按钮上方自动展开「思考过程」面板(实时跟随、可滚动);收尾自动收起,之后从 ▾ 菜单的「查看思考过程(n 字)」回看。可在设置页「调用参数」里关掉(关掉后宿主根本不发思考内容) |
| 同会话重复请求 | 宿主返回 409 busy-session(多标签页同时点同一会话时可见),提示「这个会话已经在优化中了」 |
| 全局并发打满 | 宿主返回 429 too-many-requests(默认上限 4,可用 maxConcurrentCalls 调),提示带上限值 |
| 生成中再点 | 取消(abort;宿主侧同时取消上游模型调用,不产生费用累积) |
| 生成中用户继续打字 | 流式下用**"本次调用里我们写过的每一版文本"集合**做 CAS(draftRev 每次写入都会推进,不能当基线):当前草稿落在集合之外即认定用户手改 → 中止本次并提示「草稿已变化」 |
| 成功后 | 不弹提示(P12):草稿被替换是肉眼可见的;「还能撤销」由 ▾ 上的圆点表示。只有截断这类"界面上看不出来"的结果才提示 |
| 撤销 | 在 ▾ 菜单的「本次调用」分区里点一次;CAS 通过才回退到优化前草稿(判据是"当前草稿仍等于 after")。草稿被手改过时第一次点击只武装(菜单项文案变成「强制还原原文」,菜单保持展开),再点一次强制还原 |
| 撤销深度 | 每会话 10 层,可连按逐层回退;按会话隔离,最多保留 20 个会话(LRU) |
草稿含 @引用 芯片 | 拒绝发起(整体 setDraft 会把芯片拉平成纯文本),提示先删掉芯片。注意 occurrences 只覆盖引用芯片,/命令 是纯文本、不在其中 |
| 输入机非空闲(提交/裁决中) | 按钮禁用 |
| 宿主报错 | 直接展示宿主返回的 message(如「草稿 9001 字,超过上限 8000 字」);403 有专门文案;404/405 按「宿主路由未挂载」提示 |
| 宿主半没挂载 | 客户端提示「宿主路由未挂载(插件宿主半未启用?)」——实测这种情况下 POST 拿到的是 405 空体(SPA fallback 先拦非 GET/HEAD),不是 404 |
| 通用角标挂在哪(P13) | 页面上每个够格的 textarea(以及带 data-dsh-better-input-host 的元素)右上角一枚 ✦;只读/禁用/被 data-dsh-better-input-skip 覆盖/本插件自己界面里的不挂。详见「通用输入角标(P13)」 |
| 通用角标点击 | 读写该输入框(原生 setter + input 事件)→ 同一套宿主路由与流式回填 → 成功后多出 ↶ 撤销角标;生成中再点 = 取消 |
| 通用角标的撤销 | 按元素隔离(弱引用,元素被移除即回收);文本被动过时第一次点击只武装,与主按钮同语义 |
版本适配(真机踩坑记录):已安装的 dsh 0.1.2-rc.1 对
conversation.input.left/right调的是renderSlot(name, {}),没有 owner props——所以本插件一律通过框架注入的useInput读输入状态, 不读props.input(新版本源码才把InputZone传给这两个座位)。sessionId来自ui-session的 kit 合并,缺包时回落到全局单栈(有 CAS 兜底)。详见DESIGN.md的 R-3 / R-13。
提示文本用 GUI 的设计令牌上色(--dsw-alias-state-{success,warn,error}-primary),令牌缺失时回落 currentColor。
流式回填(P5.6)
点击 ✨ 后不再"转圈等一整段":宿主走 SSE 把文本增量推过来,客户端边收边写回草稿, 所以你能看着草稿被逐句替换。要点:
- 两条路由并存:
POST /optimize/stream(SSE,默认走它)与POST /optimize(一次性 JSON,回退)。 两者准入条件完全一致(共用同一段校验:信任判定、字数上限、并发闸门、生效配置), 否则"流式那条更松"就会变成绕过口子。 - 自动回退:旧宿主没有这条路由(404/405)、浏览器拿不到
response.body、或网络层失败时, 客户端自动改用一次性 JSON——流式是增强,不该在任何环境里变成新的失败面。 - 写入节流 80ms:不按 token 写,避免每个增量都触发一次编辑器整体重写(节流期间先攒着, 收尾时一定写最终文本)。
- 失败不留半截草稿:流中途 error 帧 / 连接被掐断时,把已经写进去的增量还原成原文, 再提示失败原因(提示里带「已还原原文」)。
- CAS 换了判据:流式下每次写入都会推进
draftRev,所以不能再拿它当基线(那会把自己写的东西 判成"用户改过")。改为记住"本次调用里我们写过的每一版文本":当前草稿落在集合之外才算用户手改, 此时立刻中止(宿主侧随之取消上游)并提示「草稿已变化」。 - 取消/超时:生成中再点 = 取消(照旧);超时会给客户端一个
error: timeout事件, 而客户端自己断了就不再往那条 socket 写。
进度与思考过程(P11)
点 ✨ 之后,"等模型"那一段原先完全没有反馈:按钮只是把 ✨ 换成一个静止的图标,草稿要等到模型 吐出第一个文本 token 才会动。推理模型的前几秒到几十秒就是这样白白静着的——看起来像卡死。 P11 补上两件事:
进度行(任何模型都有)
按钮旁常驻一行进度,槽位与结果提示共用(生成中显示进度,结束后按需显示失败/截断提示):
等待模型 4.2s ← 已发出请求,还没收到任何事件
思考中 8.7s · 312 字 ← 已收到思考增量(计数 = 已收思考字数)
写入中 12.1s · 156 字 ← 已开始回填草稿(计数 = 已写进草稿的字数)
- 阶段由客户端推导,宿主不发进度帧:
waiting(已发出请求、还没收到任何事件)→thinking(首个思考增量)→writing(首个文本增量)。等待期没有任何事件,本地时钟是唯一的进度来源, 所以耗时由客户端自己算——服务端心跳帧解决的是"连接是否活着",那条 fetch/abort 已经覆盖, 多一种帧只是多一处会漂移的契约。 - 不给百分比是有意的:
maxOutputTokens是上限而不是目标,任何"进度条"都是编的数字。 能诚实给的是阶段、耗时与计数。 - 计数用字数而不是 token:客户端自己就能算准,对用户也更直观。
- 按钮图标在生成中带透明度呼吸动效(
@keyframes dsh-bi-pulse):静止的图标本身就是"卡死"的观感来源之一。
思考过程面板(模型给才有)
模型返回 reasoning-delta(例如 DeepSeek 推理模型的 reasoning_content)时,按钮上方自动展开面板,
实时跟随最新思考(真实 DOM 上自动滚到底),收尾自动收起,之后从 ▾ 菜单的「💭 查看思考过程(n 字)」
回看(P12 之前那是按钮行里的一个独立图标)。
- 开关:设置页「调用参数」里的「显示思考过程」,默认开。关掉时宿主根本不发
reasoning事件——而不是"发到浏览器再藏起来"。思考正文(模型的推理内容)因此不出宿主。 - 正文绝不进草稿:
write()只由文本增量与done帧触发,宿主装配权威文本时也只取 text 块 (两条都有守卫钉着)。撤销栈同理:只有"优化前 → 优化后"的草稿,没有思考。 - 失败也留着:草稿照旧还原成原文,但思考内容留着可回看——失败原因常常就写在思考里。
- 上限 20000 字(保留尾部):生成中要跟着最新思考走;被截断时正文开头明确写一句, 面板标题里的累计字数仍如实显示总数(截断了却不说,用户会以为模型就想这么点)。
- 两个 100ms 节流:思考增量是逐 token 到的,每个都 setState 会让组件每秒重渲染上百次; 面板最多滞后 100ms,而阶段切换与收尾一律立即落地(收尾那次会把节流期间攒下的内容一并落地,不丢字)。
- 思考面板与 ▾ 预设菜单互斥:两者锚在同一处(按钮上方、右对齐),同时打开会叠在一起。
- 兼容性:老客户端收到未知的
reasoning事件会直接忽略(只认delta/done/error); 新客户端配老宿主则收不到这个事件,只有进度行——思考是增强,不是新的失败面。 回退用的一次性 JSON 路由(/optimize)不带思考内容:那条路本来就没有"流"可言。 - 为什么不做"思考中"的骨架动画:官方 GUI 的推理行是扫光条,但它背后有完整的推理展示组件; 本插件是第三方条目,只复用设计令牌与既定座位,所以用"真实进度 + 真实思考文本"代替装饰性动画。
控件收敛(P12)
一次带思考的成功优化之后,输入框工具行里同时挂着 4 个控件(撤销 / 思考 / ✦ / ▾), 再加上进度或提示行与弹出的面板——在一个还要放附件、模型选择器、上下文计量与发送键的行里, 这就是"拥挤"的来源。P12 把工具行收到 2 个:
✨ ▾ ← 静止态就是这两个
✨ ▾• ← • = 有可撤销记录(5px 圆点)
点 ▾ 后(菜单向上弹出、右对齐、超高时内部滚动):
┌ 本次调用 ────────────────────┐
│ ↶ 撤销上次优化 │ ← 有可撤销记录时才出现
│ 💭 查看思考过程(312 字) │ ← 本次调用收到过思考时才出现
├ 追加提示词 ──────────────────┤
│ ○ 不追加(只用系统提示词) │
│ ● 精简 │
│ ○ 转规格 │
├ 预设 ────────────────────────┤
│ 更短 │
└──────────────────────────────┘
四个功能的新路径:
| 功能 | 路径 | 备注 |
|---|---|---|
| 优化输入 | 点 ✦ | 不变;生成中再点 = 取消 |
| 优化选项(追加提示词 / 预设) | 点 ▾ → 对应区点选 | 两个区的内容与语义一字未改,只是排在「本次调用」之后 |
| 撤销上次优化 | 点 ▾ → 「↶ 撤销上次优化」 | 执行成功随即收起菜单;草稿被手改过时菜单项变成「↶ 强制还原原文」且菜单保持展开,再点一次即可 |
| 查看思考过程 | 点 ▾ → 「💭 查看思考过程(n 字)」 | 菜单关、面板开;面板里「收起思考过程」回原状 |
要点:
- 菜单必须常驻:撤销与思考回看都住在里面,所以它的显示条件不能只看
/catalog的profiles/presets——目录读取失败(离线、宿主半缺失)时菜单整个消失的话,一次成功的优化之后就 再也找不到撤销入口,而主按钮看起来一切正常。现在的条件是hasInvocation || profiles.length > 0 || oneShotPresets.length > 0,有守卫钉着。 - 撤销的返回值是给菜单用的:
onUndo()返回"是否真的执行了还原"。执行了 → 收起菜单; 只是武装了强制还原 → 菜单留着。这是"菜单里做两步确认"能成立的前提。 - 顺手修掉的脚枪:以前撤销是独立按钮且生成中仍可点,点它会
setDraft打断正在跑的那次流式调用 (CAS 判成"用户手改",结果被丢弃)。现在撤销在菜单里,而菜单在生成中本来就是禁用的。 - 成功不再弹提示:草稿被替换是肉眼可见的,再闪一句「已替换为优化结果」纯属噪声; "还能撤销"改由 ▾ 上的圆点表达。保留的提示只有界面上看不出来的那几类:截断、失败、 已还原原文、草稿已变化……(都是"你不看提示就无法判断"的结果)
- 菜单项用文字前缀而不是图标:
↶/💭直接写在文案里。菜单项本来就以文字为主, 塞 14px 图标既要对齐、又给每个图标多留一条降级路径,而文字前缀在任何图标集下长得一样。 - 点击钩子:菜单项的
data-dsh-better-input-undo/data-dsh-better-input-thinking-toggle沿用旧名字(外部排查脚本与测试都按它们定位),只是位置从工具行移到了菜单里。
通用输入角标(P13)
主输入框(composer)之外的输入窗口不在任何跨插件契约里:官方与第三方插件各自渲染自己的
自由文本控件,既没有统一座位,也没有共享的草稿 store——客户端的 bundle 也不能 import 别的插件。
所以 P13 换了一条唯一共同可用的契约来覆盖"大部分输入窗口":它们最终都是页面上的 <textarea>。
┌─────────────────────────────────────────┐
│ 这个插件窗口里的多行输入框… ✦ │ ← 角标:点一次 = 优化这段文本
│ │ (生成中 = 取消;成功变绿)
└─────────────────────────────────────────┘
↶ ← 成功后多出的撤销角标
| 项 | 规则 |
|---|---|
| 挂给谁 | 页面上的 textarea(DSH 里"人话输入"的标准控件);另外任何带 data-dsh-better-input-host 的元素都算输入窗口(非 textarea 的第三方控件可自己报名) |
| 不挂给谁 | 只读 / 已禁用 / 尺寸太小(< 80×28)或不在视口内(角标存在但不显示)/ 带 data-dsh-better-input-skip 的元素或其子孙 / 本插件自己的界面(设置页与主按钮工具行) |
| 角标位置 | 宿主右上角(视口坐标),滚动、缩放、DOM 变化后重排;被对话框/抽屉盖住时隐藏(用 elementFromPoint 判宿主中心点的命中元素,取不到就当可见);页面最多 40 个宿主(第三方页面可能有几十个 textarea) |
| 点击 | 读宿主的当前文本 → 走与主按钮相同的宿主路由(/optimize/stream,旧宿主自动回退 /optimize)→ 边生成边写回;生成中再点 = 取消 |
| 写回 | 平台原生 value setter + 冒泡 input 事件。第三方输入窗口几乎都是 React 受控组件,直接赋值会被 value tracker 忽略,组件 state 停在旧文本、下一次渲染把结果覆盖回去(表现为"点了没反应") |
| 撤销 | 成功后宿主旁出现 ↶(按元素隔离,弱引用);文本在优化后被动过时第一次点击只"武装"(换成「再点一次强制还原」),与主按钮同语义 |
| 失败 / 手改 | 与主按钮同文案:空输入 / 超长(本地拦下,不发请求)、流中途失败还原原文、用户手改立刻中止并提示「草稿在优化过程中被修改」 |
| 逃生口 | data-dsh-better-input-skip(一行 HTML 关掉某个输入框;给"挂了反而碍事"的插件用)、data-dsh-better-input-host(接入非 textarea 控件) |
| 会话键 | 通用角标没有会话上下文,按元素给一个稳定合成 id(data-dsh-better-input-session 可显式指定):同一输入窗口单航班,全局并发上限照旧(宿主的 maxConcurrentCalls) |
为什么不做成"每个插件各自集成":那要求每个输入窗口的作者改自己的代码——正是"下载很多插件"这个 前提最不可能发生的事。DOM 层方案不依赖任何插件配合,代价是没有类型契约:所以给了跳过标记与 显式报名两个开关,并把"受控组件写回"这条最容易踩的坑固化成一个约定守卫。
边界(v1 明确不做):contenteditable(Lexical 那类编辑器,含主输入框)不挂角标——整体替换
富文本节点的风险远高于收益;只优化选中片段、把通用角标的优化历史并入会话撤销栈也都不在范围内。
另外两处已知覆盖不到,以及一处会滞后(写在这里,免得被读成"全都能覆盖"):
- Shadow DOM / iframe 里的输入窗口:
document.querySelectorAll不穿透它们,所以扫不到; - 不动尺寸的布局动画:位置重算靠
MutationObserver+ 滚动/缩放事件,没有挂ResizeObserver—— 宿主在"没有 DOM 变化、也没有滚动/缩放"的情况下自己改变尺寸时,角标会滞后到下一次事件; - 顺带一个规模上限:一个页面最多挂 40 个角标(超出部分等前面的消失后由下一次扫描补上)。
为什么不复用主按钮的组件:主按钮的进度行 / 思考面板 / 菜单都是 React 座位组件(依赖
useInput 与 setDraft),而通用角标拿不到任何 React 树位置,也不该往别人的组件树里塞节点。
两者共用的是更该共用的那一层:路由调用、流式分帧、CAS 与撤销语义、词典文案。
优化风格与追加提示词的合并(P6.1/P6.2 → P8/P9)
P6.1/P6.2 时代的「优化风格」是可多选的改写口味:▾ 菜单里勾选「精简 / 转规格」(复选框、可叠加、
不落盘),逐风格提示词走"设置页 ← 组合配置同 id 预设 ← 内置文案"三层覆盖,请求带 styleIds。
P8 起这套东西并入了「追加提示词」:精简/转规格成为清单里的内置条目(见下节), 同一个清单、同一个 ▾ 菜单、同一个设置页区块,单选。变更点:
- 多选叠加取消:精简+转规格不再同时生效——它们是两条独立追加提示词(选其一)。 想要"既精简又条目化",把两段要求写进同一条自定义追加提示词即可。
- 新客户端不再发
styleIds;宿主仍兼容旧版浏览器半(styles清单照发、styleIds照收, 追加语义与旧版逐字节一致),两个方向的升级/回退都不破坏。 - 逐风格提示词的三层覆盖原样保留,只是现在的身份是"内置条目的默认追加文案":
stylePromptConcise/stylePromptSpec(遗留设置字段)←cordis.patch.yml同 id 预设 ← 内置文案。 /catalog的styles字段只为旧客户端存在;新客户端读profiles(含内置条目,带builtIn标记)。
追加提示词(P7 / P8 / P9)
系统提示词是基底,追加提示词是接在基底之后的那一条:设置里的系统提示词(见下节)永远在场,
▾ 菜单选中的追加提示词以 \n\n本次额外要求(名称):正文 接在它后面。两者在设置页里是两个区块,
关系一目了然——这也是 P9 把旧名「提示词 / 提示词方案」改成「系统提示词 / 追加提示词」的原因:
旧名里两个词都含"提示词",说不清谁是基底、谁是附加。
- 系统提示词(基底):设置页第一个区块。取值链:自定义系统提示词开关 →
cordis.patch.yml的systemPrompt→ 内置默认。追加提示词不会替换它(P9 之前自定义方案是整体替换,语义与名字不符)。 - 追加提示词清单 = 「不追加」+ 内置条目(精简 / 转规格)+ 你自建的条目:
- 内置条目常驻清单、不可删除;未自定义时正文 = 该风格的追加要求原文 (老三层:遗留设置字段 ← 组合配置同 id 预设 ← 内置文案),所以"选中精简"与旧版"勾选精简" 拼出的 system prompt 逐字节相同;
- 在设置页给内置条目填了正文 = 改用你写的追加文案(依旧只追加);清空 = 恢复内置文案 (存进设置段的覆盖条目可以省略 name,显示名由宿主按内置标签补齐);
- 自建条目:名称 + 正文,可增删;正文就是会接在系统提示词之后的那段话。
- 设置页(设置 → 输入优化 → 追加提示词):
- 内置行在前(名称固定、可改内容、可启用、不可删除),用户行在后(名称 + 正文 + 启用 + 删除);
- 「新增追加提示词」加一行;第一项是「不追加(只用系统提示词)」——选它 = 只发系统提示词;
- 全空的行保存时自动丢弃;填了一半的行(缺名称或缺正文)会在客户端就拦下;
- 上限 20 条(正文随设置文档整段读写,不给上限就没有刹车)。
- 使用时切换(输入框旁
▾菜单):列出全部条目(● = 当前启用), 点哪条就把activeProfileId写进设置落盘,下一次优化即生效,重启/刷新后仍保持; 写入失败(mutate 不 reject)时选中标记回退并给出错误提示——与设置页保存同一条"写后自查"的规矩。 - 拼装顺序:系统提示词 → 启用中的追加提示词 →(旧客户端的
styleIds)→ 预设。 固定顺序保证"同一组选择无论怎么点出来,system prompt 都逐字节相同"。 - 存储:整个清单是设置命名空间里的一个数组字段(
promptProfiles,每项{id,name?,prompt}), 加上activeProfileId记录启用项。设置通道的 path ops 支持对单字段 set 任意 JSON 值,整表一次 set 天然原子;settings.yaml里看到的就是这两个人类可读的字段。 - 正文不出清单:
/catalog的profiles行只含 id/名称/来源/是否内置(由profileRowsOf生成,守卫钉住); 设置页编辑的正文来自用户自己的设置镜像,与"风格/预设正文不下发"是同一条规矩—— 唯一的例外是defaults.systemPrompt(内置/组合层的默认系统提示词)有意下发,见下。
想要"整段换成完全不同的系统提示词":追加语义下做不到按条目替换,但可以直接改「系统提示词」 区块(自定义开关 + 正文,全局生效),或把那段话作为追加条目写进去——追加内容在指令序列里更靠后, 对模型的实际影响通常更大。
默认系统提示词可见(P7.0)
设置页「系统提示词」区块底部有「查看默认系统提示词」:展开后显示当前默认的系统提示词全文
(内置文案,或 cordis.patch.yml 里配置的 systemPrompt——即你什么都没自定义时会用的那一段)。
- 为什么它能下发:这是部署默认文案而不是用户私密;「系统提示词在设置里可见可编辑」这条需求 正需要它——否则没启用自定义的用户在设置页只能看到一只空输入框。
- 可一键编辑:「以默认为基础编辑」把默认正文填进输入框并启用自定义开关 (只填不启用会让人以为生效了,所以两步并作一步),之后随意修改保存即可。
- 恢复默认配置:设置页底部按钮(原「恢复默认」),点击先弹确认框(会清空全部用户设置,不可逆), 确认后清空所有用户字段——包括追加提示词与内置条目的追加文案覆盖——回到内置默认与组合配置的值。 恢复后系统提示词就是上面看到的那段默认文案,追加提示词回到内置文案。
打开插件配置文件(P6.3)
设置页底部有「打开插件配置文件」,一键用编辑器打开本插件的组合层配置 cordis.patch.yml
(就是 package.json 里 dsh.bundle.patch 声明的那个文件)。
-
路径由宿主解析,不是前端拼的:
lib/index.js用自己的模块位置推出包根 (link:安装指向仓库、正式安装指向 profile 的node_modules)。设置页同时把绝对路径 显示出来,方便手动打开或复制。 -
为什么不是简单的"交给系统默认关联":
.yml在不少 Windows 机器上根本没有关联 (本机实测assoc .yml→File association not found),此时explorer.exe <file>只会弹一个「你要如何打开这个文件?」——按钮就变成"点了没反应"。所以给了一条必然可用的候选链:平台 候选(按顺序试,起不来就落到下一个) Windows Code.exe(VS Code 的常见安装位置)→notepad.exe(系统自带,保证可用)macOS open -t(-t= 强制用默认文本编辑器)Linux xdg-openWindows 上刻意不用 PATH 上的
code:那是code.cmd,Node 从 18.20/20.12 起禁止在不开 shell 的情况下 spawn.cmd(EINVAL),而开 shell 又要把路径交给 cmd 解析(引号/&都是坑)。 -
这是一条能力路由(会在宿主上起进程),准入条件与
/optimize同级:必须有浏览器会话, 不能靠本机任意进程触发。 -
失败都有明确提示,且都带上可直接照做的绝对路径:文件不在(404
config-missing)、 平台不支持(501open-unsupported)、全都起不来(500open-failed,附每个候选的错误)。 前端只负责显示提示,不做任何静默失败。 -
真机验收(会真的打开文件):
node .perf/verify-open-config.mjs # status=200 body={"ok":true,"path":"...\\cordis.patch.yml","openedWith":"...\\Code.exe"}
设置页(设置 → 输入优化)
设置面板左侧导航里多一项「输入优化」,用来配置这个按钮用哪个模型、哪段系统提示词、追加什么。
布局(P10 重设计:默认只看摘要,点「编辑」才出控件)
┌ 操作条:保存 · 恢复默认配置 · 打开插件配置文件 提示文本 ┐
├ 系统提示词 自定义 · 42 字 [编辑] ┤
├ 追加提示词 周报 [新增] ┤
│ ◉ 不追加(只用系统提示词) │
│ ○ 精简 内置追加文案(设置页) [编辑] │
│ ○ 转规格 内置追加文案(内置默认) [编辑] │
│ ○ 周报 你是周报写手… [编辑][删除]│
├ 模型 acme / m1(设置页) [编辑] ┤
├ 调用参数 温度 默认 · 上限 1024 · 超时 30000ms
│ · 思考强度 low [编辑] ┤
└ 配置文件:…\cordis.patch.yml ────────────────────────────────┘
- 输入框默认隐藏:收起的分组里根本不会创建 input/textarea(不是 CSS 隐藏),点分组右侧的 「编辑」才展开控件;再点一次(此时按钮变成「收起」)即回收。追加提示词清单是选择而不是输入, 所以清单行常驻(单选 + 名称 + 正文摘要),只有某一行的名称/正文输入框需要展开那行。
- 行距压到最小:正文 12px / 行高 1.45,分组间距 8px,组头 24px,清单行 22px—— 四个分组 + 清单 + 操作条在一屏内同时可见,不必滚动。
- 一屏看全:每个分组标题右侧是一行只读摘要(当前生效值,如「自定义 · 42 字」「acme / m1(设置页)」 「温度 默认 · 上限 1024 · 超时 30000ms · 思考强度 low」「周报」), 收起状态也能看清现在用的是什么。
- 操作条常驻顶部:保存 / 恢复默认配置 / 打开插件配置文件不用滚到底部去找。
- 校验失败自动展开:出错的分组(含追加提示词的某一行)会自动展开并显示错误——折叠状态下的
"错误提示被藏起来"是这套布局最容易出的事故,有专门用例钉住(
groupsWithErrors)。 - 只读/不可用两态照旧:不可用时只给提示;只读时仍可展开查看(控件 disabled,不是藏起来)。
各区域
| 区域 | 能配什么 | 说明 |
|---|---|---|
| 系统提示词 | 「使用自定义系统提示词」开关 + 正文 + 「查看默认系统提示词」 | 开关关闭时用插件配置的 systemPrompt,再往下才是内置文案;这一段是基底,永远会发给模型。默认正文可展开查看,并能「以默认为基础编辑」一键填入 |
| 追加提示词 | 内置条目(精简 / 转规格,名称固定、可改内容、不可删)+ 多条自定义条目(名称 + 正文,可增删),单选「启用」 | 选中的那条接在系统提示词之后(本次额外要求(名称):正文);输入框旁 ▾ 菜单里随时切换。内置条目留空 = 用内置追加文案,填写 = 用你写的追加文案。见「追加提示词」一节 |
| 模型 | Provider + 模型名称 + 「测试」 | 输入框带候选(datalist):目录来自宿主已注册的适配器;目录为空或想用未列出的模型时直接手填。「测试」走宿主 resolveModelInfo 只做解析校验,不发真实请求、不产生费用 |
| 调用参数 | Temperature、输出 token 上限、超时(毫秒)、思考强度(P14)、显示思考过程(P11) | 前三个留空 = 用适配器/组合配置/内置默认;「思考强度」留空 = 内置 low,它是适配器所有的不透明 id(建议值 minimal/low/medium/high,可手填),宿主调模型前会核对模型公布的能力、不支持就省略并回落适配器默认(不会让调用失败,/catalog 的 effective.reasoningEffort 可查生效值);「显示思考过程」默认勾选,取消后宿主不再把模型的思考内容发给浏览器(见「进度与思考过程」) |
| 操作 | 保存 / 恢复默认配置 / 打开插件配置文件 | 「恢复默认配置」先确认,再清空本页所有用户设置(含追加提示词与内置覆盖);「打开插件配置文件」见上节 |
两层的取值:系统提示词(基底)= 自定义开关 → cordis.patch.yml 的 config.systemPrompt → 内置默认;
追加提示词(附加)= 启用中的那一条,接在基底之后。两者互不覆盖。
设置页保存或菜单切换后下一次优化即生效(宿主每次请求现读解析后的配置),不需要重启或刷新。
持久化:走 dsh 标准设置通道——宿主 ctx.settings.register('better-input', schema),文档由
dsh-settings-file 落在 $DSH_HOME/settings.yaml。所以「重启应用 / 刷新页面后配置仍在」是框架保证的:
本插件不自造存储,也不自己拼配置文件。
校验:客户端先行预校验(逐字段给中文提示,不合法就连写入都不会发出),宿主再用 schemastery schema
- 跨字段
validate复核。典型规则:
- 启用了自定义提示词但内容为空 → 拒绝;
- Provider 与模型名称只填了一个 → 拒绝(要么都填,要么都留空用默认);
- Temperature 不在 0–2、输出上限不在 1–200000、超时不在 1000–600000 ms、非整数 → 拒绝;
- 追加提示词:超过 20 套、某项缺名称/缺正文、id 重复、
activeProfileId指向不存在的追加提示词 → 拒绝 (全空的行不算——那是"刚点新增还没填",保存时自动丢弃)。 - 思考强度(P14):必须是文本(空串 = 未设置 = 内置
low);取值本身不做白名单—— 强度 id 归适配器所有,可用性由宿主按模型公布的能力核对。
宿主拒绝时不会抛错:
settingsScope.mutate()内部在!response.ok时只recover()然后正常返回 (只有装配错误才 reject),所以"保存成功"必须由调用方自己核对镜像里的值是否真的变了。 设置页用opsApplied()做这件事:没生效就报「宿主没有接受这次写入…」并保留用户的编辑, 绝不假报"已保存"。两侧的区间常量也保持同值,避免"客户端放行 → 宿主拒绝 → 静默失败"。
未配置时:全部字段留空即可——宿主回落到默认模型(agentDefaultModel.currentSelection())与
默认系统提示词,不报错。若部署确实没挂设置提供者,设置页会显示「设置服务不可用」(并附上宿主返回的原因,
如果有),而优化按钮照常工作。
注册时机(真机事故修复,2026-09-11):命名空间注册不能在
apply()里一次性ctx.get('settings')——SettingsProvider的async *[Service.init]()要await load()(读settings.yaml)之后服务才 ACTIVE,而ctx.get(name)等价于ctx.reflect.get(name, strict = true),对"已提供但未 ACTIVE"的服务返回undefined。 本插件只 injectwebServer/llm,可能先于设置提供者激活:一次读输掉竞态就永久降级, 表现正是"设置服务不可用,重启也照样"。现在改为ctx.inject(['settings'], …):服务就绪才注册、 服务卸载即回收(lib/settings.js的bindSettings)。
配置参考(cordis.patch.yml 的 config:)
| 字段 | 默认 | 说明 |
|---|---|---|
enabled | true | 总开关;false 时不挂路由 |
systemPrompt | 内置(见 lib/policy.js) | 默认系统提示词(基底);被设置页的自定义系统提示词覆盖。追加提示词不替换它,只接在它之后 |
model.provider / model.model | 省略 | 固定模型路由;必须成对出现。被设置页覆盖;都没配时用宿主当前默认选择 |
presets[].{id,label,prompt} | [] | 预设;请求带 presetId 时其 prompt 追加到 system。id/label 会经 /catalog 下发到输入框旁的 ▾ 菜单(prompt 不下发)。id 命中内置条目(concise/spec)时语义不同:它是那条内置条目的追加文案的组合层默认值(被遗留设置字段与设置页覆盖),且不再列进预设区。见「追加提示词」一节 |
stylePromptConcise / stylePromptSpec(用户设置,非本文件) | 空 | 逐风格提示词,写在设置页里;这里列出来只是说明它压过 presets 里同 id 的那一项 |
defaultReasoningEffort(用户设置,非本文件) | low | P14:优化时用的思考强度,归在「调用参数」里、对所有模型生效。未设置 = 内置 low;取值不做白名单,宿主核对模型公布的能力后才透传,不支持则省略并回落适配器默认 |
maxInputChars | 8000 | 输入字数上限(超限 400) |
maxOutputTokens | 1024 | 输出 token 上限,取值域 1–200000(截断仍返回文本并标 truncated: true) |
timeoutMs | 30000 | 单次调用超时,取值域 1000–600000 ms(超时 504) |
temperature | 不传 | 采样温度,取值域 0–2(缺省交给适配器决定) |
maxConcurrentCalls | 4 | 全局并发调用上限,取值域 1–64。同会话单航班始终生效(第二条得 409),超上限得 429 |
未知字段名、类型错误或超出取值域都会让启动失败并报出字段名(fail loud,避免「拼错字段却以为生效了」,
也避免 AbortSignal.timeout 超范围时每次请求都 502)。
这项配置是组合层:设置页里的用户值优先于它,改它需要重启 dsh web(patchReload: live 会重载 patch,但插件自身的 Node 代码不热重载)。
HTTP 契约
配置的读写不走这些路由(走标准设置通道),这里是能力路由与设置页的只读支撑路由:
POST /api/dsh-input-optimizer/optimize
body: { text: string, sessionId?: string, presetId?: string, styleIds?: string[] }
// styleIds:**旧客户端兼容**字段(P8 前的多选优化风格),去重后按风格清单顺序拼 system;
// 新客户端不发它——用哪套系统提示词由设置里的 activeProfileId 决定。
200 { text, modelUsed: { provider, model }, presetId?, styleIds?, truncated? }
400 { error: 'bad-request' | 'empty-text' | 'text-too-long' | 'unknown-preset' | 'unknown-style', message }
401 { error: 'unauthorized', message } // 缺浏览器会话(见下「安全」)
403 { error: 'forbidden' }
405 { error: 'method-not-allowed' }
409 { error: 'busy-session', message } // 同一会话已有优化在跑(单航班)
413 { error: 'body-too-large' }
429 { error: 'too-many-requests', message } // 全局并发达上限(maxConcurrentCalls)
499 { error: 'client-gone', message } // 客户端已断开(非标准码,nginx 习惯用法)
502 { error: 'no-model-route' | 'model-failed', message }
504 { error: 'timeout' }
POST /api/dsh-input-optimizer/optimize/stream // SSE,客户端默认走这条
body: 与 /optimize 完全相同
状态码:准入阶段(校验/闸门/信任判定)失败时与 /optimize 完全一致;**一旦开流就只走事件**:
: ok // 注释帧:流已开(立即 flush)
event: delta data: { text } // 文本增量(只发 text-delta)
event: reasoning data: { text } // 思考增量(P11;只在生效设置 showReasoning ≠ false 时发)
// 与 delta 同构、1:1 转发;**绝不进 done 的 text 或草稿**
event: done data: { text, modelUsed, presetId?, styleIds?, truncated? }
// text 是装配后的权威文本(只由 text 块装配),客户端以它为准
event: error data: { error, message } // 'model-failed' | 'timeout' | 其它请求级错误码
GET /api/dsh-input-optimizer/catalog
200 { namespace,
settings: { available, reason?, section },
providers: [{id,name}],
limits: { maxInputChars, temperature:{min,max}, maxOutputTokens:{min,max}, timeoutMs:{min,max},
reasoningEfforts:[...] }, // P14:思考强度的**建议**值(不是白名单;可用性由模型能力决定)
presets: [{id,label}], // prompt 不下发;客户端不再自己维护规则镜像
styles: [{id,label,source}], // 仅为旧客户端保留的兼容面;prompt 同样不下发
profiles: [{id,name,source,builtIn}], // 追加提示词清单行(内置条目在前);正文不下发(profileRowsOf 保证)
defaults: { systemPrompt, reasoningEffort }, // 内置默认系统提示词 / 内置默认思考强度(low)
configPath, // 插件配置文件绝对路径(给「打开配置文件」用)
effective: { provider, model, temperature, maxOutputTokens, timeoutMs,
showReasoning, // P11:是否透传思考过程(默认 true,只有设置页关掉才 false)
reasoningEffort, // P14:生效的思考强度(调用参数;未设置 = 内置 low,永远有值)
profileId, // 启用中的追加提示词 id;null = 未启用(不追加)
sources: { prompt, model, temperature, limits } } }
// sources.prompt 说的是**基底系统提示词**来自哪一层
// (settings/config/default);追加条目不改它
GET /api/dsh-input-optimizer/catalog/models?provider=<id>
200 { provider, models: [{id,name,efforts?,defaultEffort?}] } // 适配器没有目录时 models 为空数组,不是错误
400 { error: 'missing-provider', message }
502 { error: 'catalog-failed', message } // provider 未注册(listModels 抛 NO_ADAPTER)
POST /api/dsh-input-optimizer/check
body: { provider: string, model: string }
200 { ok: true, provider, model, name, context?, defaultMaxTokens?, efforts?, defaultEffort? }
// context 取自 LlmResolvedModelInfo.context.contextWindow(该字段是对象,不是数字)
// efforts/defaultEffort 取自 LlmResolvedModelInfo.reasoning(P14;模型不公布思考强度时缺席)
200 { ok: false, provider, model, message } // 解析不了的原因(不发真实请求、不计费)
400 { error: 'missing-model', message }
POST /api/dsh-input-optimizer/open-config // 用编辑器打开插件配置文件(会在宿主上起进程)
body: 无
200 { ok: true, path, openedWith } // path 是绝对路径,openedWith 是实际用的编辑器
401 { error: 'unauthorized', message } // 能力路由:必须有浏览器会话
403 { error: 'forbidden' }
404 { error: 'config-missing', message } // 配置文件不在(附绝对路径)
405 { error: 'method-not-allowed' }
500 { error: 'open-failed', message } // 候选编辑器全都起不来(附各候选的错误与路径)
501 { error: 'open-unsupported', message } // 未知平台(附绝对路径,让用户手动打开)
安全(P5.4 起):信任判定优先交给框架——ctx.connection.requestRejection(request) 给出
403(Host/Origin 围栏不过:DNS rebinding、异源 Host)、401(围栏过了但缺浏览器会话)或放行;
connection 不可用或抛错时回落到本插件原有的环回围栏(socket ∈ 127/8/::1/::ffff:127/8
且 Host 是本机名 且 无跨站标记;永不信任 X-Forwarded-For)。
401 的处置按路由是否会花掉凭据分级:
| 路由 | 是否要求浏览器会话 | 原因 |
|---|---|---|
POST /optimize、POST /optimize/stream | 是 | 会消耗模型凭据;而凭据可能来自环境变量(apiKeyEnv),本机其它进程读不到它,却能借这条路由花掉它 |
POST /open-config | 是 | 会在宿主上起进程(编辑器);本机任意进程都不该能触发 |
GET /catalog、GET /catalog/models、POST /check | 否(仅环回) | 只暴露 provider/模型名与本插件配置,不花凭据;保留"命令行就能排查"的能力 |
浏览器侧无需做任何事:会话 cookie 由 dsh web 打印的带 token 的 URL 换取,页面内同源 fetch
会自动带上。顺带一提,因为整体走框架围栏,LAN/trustedHosts 部署现在也能用(带会话的浏览器即可)。
请求体上限 256 KiB;调用超时与客户端断开都会取消上游。
开发循环
- 浏览器半:
dsh-client-hmr每 500ms stat-polllib/client.js(比对 mtime+size),保存即被热替换进运行中的页面(约 0.5s,无需刷新)。所以逻辑尽量放浏览器半。- 代价:插件内 React state 会丢(P3 的撤销栈因此放模块级 Map,而非组件 state)。
- 样式表必须自带
data-plugin/data-plugin-css:框架在物化期把所有未打标的<style>认领给当时正在物化的插件,不打标就会被别的插件抢走,那个插件 HMR 重载时顺手删掉它。
- 宿主半:改
lib/index.js/lib/policy.js需要重启dsh web。 - 每次改完先
npm test,再动 GUI。 - 宿主半要打印日志必须用
ctx.logger(不是ctx.get('logger'):logger 不是 reflect 注册的服务,后者恒为undefined,会让所有日志静默丢失);参数按 printf 风格传。
工程化
npm run verify # 一条命令跑完全部检查:lint → 约定守卫 → 五个套件
npm run lint # Biome lint(只 lint,不 format,理由见下)
npm run guards # 约定守卫:把踩过的坑变成可自动检查的规则
npm test # 约定守卫 + 宿主半 + 浏览器半 + 真 React + 真框架集成
npm run link-deps # 手动补 dev 依赖链接(pretest 会自动跑)
| 检查 | 覆盖什么 | 覆盖不到什么 |
|---|---|---|
| Biome lint | 未使用变量/导入、可选链、赋值混进表达式、等宽比较等 | 不做类型检查(Biome 不是类型检查器) |
约定守卫(scripts/check-guards.mjs) | 19 条规则,逐条对应真实事故:ctx.get('logger')、设置注册一次性读、样式未打 data-plugin、保存未自查、并发闸门占位/释放、闸门占位必须排在会抛的校验之后、客户端自带宿主区间常量、SSE 分帧与流式回退、风格 id 校验、风格提示词不得下发、清单行必须由 profileRowsOf 生成、打开配置文件的路径与准入、新套件没接进 npm test | 只认字面写法,不理解语义(所以规则要写"为什么") |
| 宿主半冒烟(67 例) | 配置校验、信任判定三分支、六路由全链路、SSE 分帧与断流、注册时机、并发闸门(含失败后名额必须归还的回归)、追加提示词(内置种子合成/分层/校验/catalog 行)、旧客户端 styleIds 兼容路径、catalog 不下发提示词正文、打开配置文件的候选链 | 不碰真实 LLM(ctx.llm.stream 是替身);不起真实进程 |
| 浏览器半冒烟(69 例) | 座位注册、组件契约、接线与 CAS、流式回填(节流/中止/还原/回退)、追加提示词切换菜单(内置条目单选 + 落盘)、菜单关闭手势(点内部不收起)、设置页紧凑布局(默认无输入框 / 展开后可用 / 行距与一屏项数 / 旧版式不残留 / 校验失败自动展开)、内置清单行编辑与覆盖、默认系统提示词查看与填入、打开配置文件按钮、撤销栈 | 用手写 React 替身:hook 语义是简化的(但 document 监听器是真的登记表,否则"点内部不收起"这条测不出来) |
| 真 React 渲染(6 例) | 用真 react/react-dom 走 SSR 真渲染路径,并把渲染期 console.error(React 的警告通道)当失败 | SSR 不跑 effect、也没有 DOM:拉目录/订阅/点击/菜单开合不在范围 |
| 真框架集成(5 例) | 真 cordis + 真 dsh-settings-file:提供者先到/后到/缺失三种时序、"注册后写得进 settings.yaml",以及逐风格提示词与追加提示词的全链路(写入 → 落盘 → 生效来源变 settings/profile → 下一次请求的 system 真的用它) | 不启真实 webserver、不调真实 LLM(两者都用替身捕获) |
启动耗时基准(.perf/) | dsh web 冷启动墙钟时间、插件边际成本、阶段归因;交替 A/B 消抖动 | 不起真实 GUI(--port 0 --no-open,不影响正在跑的实例) |
为什么只 lint 不 format:既有代码的排版是刻意的(CSS 片段逐条成行、测试里成组的紧凑断言、JSDoc 分组),
批量重排会产生上千行纯格式 diff,让 review 失去信号。需要时可对单个文件跑 npm run format。
CI(.github/workflows/ci.yml):Windows 上跑 lint + 约定守卫 + 五个套件,宿主依赖从 registry 装
(CI 里没有 dsh 安装,link-dev-deps 会检测到"依赖已可从仓库解析"而安静跳过)。
装完还会 npm ls --depth=0 再校验一次:npm 11 在某些 flag 下会静默跳过已在 package.json 里
声明为 peer 的那几个包(退出码仍是 0),这道校验专门把这种"假绿"变成红灯——2026-09-10 CI 首跑
exit 1 就是这么被放过去的(根因写在 workflow 的注释里)。
CI 里的 react/react-dom 是 18.3.1,而本机那对是 19.2.8——真 React 套件因此同时覆盖两个大版本。
typecheck 为何还没上(P5.7b):tsconfig.json + checkJs 是对症的(最近两轮多个缺陷是"类型判错/契约判错"),
但本机既没有 tsc 也没有网络可以安装(TypeScript 不在 dsh 安装里,registry 不可达),
所以这一轮不提交未校准的配置——那只会让 CI 首跑即红。补法(需要有网的环境,约半天):
npm i -D typescript,加tsconfig.json(allowJs+checkJs+noEmit,strict: false起步);- 给宿主 ctx 写一份最小 typedef(
webServer/llm/settings/logger/connection的用法面), 再把@param {object} ctx换成它——ctx.llm.resolveModelInfo().context这类误判就会被静态抓住; - 首次运行会暴露一批 JSDoc 类型需要校准(预期是"手写 bundle + 无类型依赖"的必然代价),
校准完再把
npm run typecheck接进 CI。
下一步
按 ROI 排序:
- P5.7b typecheck:见上(需要有网环境先装 tsc)。
- P5.7c vitest + jsdom:把浏览器半那套手写 React 替身换成真 React + DOM 环境(现在只覆盖了渲染契约, effect/点击/菜单开合仍由替身语义兜着)。不是必须——真 React SSR 套件已经补住了"组件是否合法"这一层。
- P5.8 芯片保留:草稿含
@引用时目前直接拒绝(整体setDraft会拉平芯片),后续可研究用insertReference重建。 - 启动耗时:
.perf/那套基准可以随时复跑;目前只有一次 29.7 s 的启动未能复现(见.perf/README.md第 5 节),若要坐实需要在慢启动现场抓 profile。 - 可选:把
presets的增删也搬进设置页(现在只能在cordis.patch.yml里加/删预设,改完要重启; 逐风格提示词已经可以在设置页改并即时生效);给宿主日志加文件落盘(dsh web只写 stdout,事故复盘只能用 API 反推)。
Comments
Loading…