pi-web-ui 0.75.0 → 0.77.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -1
- package/dist/server/agent-service.js +120 -48
- package/dist/server/client-state.js +19 -3
- package/dist/server/delegate-task.js +174 -0
- package/dist/server/dsh/dsh-agent-service.js +57 -20
- package/dist/server/edit-soft-tool.js +3 -1
- package/dist/server/index.js +17 -0
- package/dist/server/markers/builtins/todo.js +2 -2
- package/dist/server/orchestrator.js +109 -0
- package/dist/server/prompt-composer.js +14 -3
- package/dist/server/queue-utils.js +18 -0
- package/dist/server/settings-service.js +67 -19
- package/dist/server/subagent-templates.js +251 -2
- package/dist/server/terminals.js +2 -10
- package/dist/server/tool-manager.js +217 -0
- package/package.json +1 -1
- package/web/dist/assets/TerminalPanel-CDsBHVQ5.js +6 -0
- package/web/dist/assets/index-BsATrxLx.js +346 -0
- package/web/dist/assets/index-DeuPu917.css +10 -0
- package/web/dist/index.html +2 -2
- package/web/dist/sw.js +22 -7
- package/web/public/sw.js +22 -7
- package/web/dist/assets/TerminalPanel-CX6GKT5R.js +0 -6
- package/web/dist/assets/index-B-YjvCxZ.js +0 -335
- package/web/dist/assets/index-d1VZAfby.css +0 -10
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,59 @@
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
暂无未发布内容。
|
|
14
|
+
|
|
15
|
+
## [0.77.0] — 2026-09-11
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- 结构化派单工具 `delegate_task`:六段式派单(agent + TASK / EXPECTED OUTCOME / REQUIRED TOOLS / MUST DO / MUST NOT DO / CONTEXT,有最小长度)+ 服务端校验——模板不可用、缺段、太短直接报错打回,模型补全后重试。执行体复用子代理 spawn 通道(真会话、白名单、模型优先级、左栏徽标、等待/改向/停止)。前端派单卡片:卡头 ◈ agent 芯片 + 「查看子代理」一键跳转,六段式正文(脏参数不抛错)。
|
|
20
|
+
- 7 个 specialist 子代理模板(移植自 oh-my-pi 内置 agents,改写为真子代理提示词):`oracle`(只读架构/难 bug 顾问)、`librarian`(外部文档调研)、`explore`(代码库侦察)、`metis`(计划前澄清)、`momus`(计划评审)、`multimodal-looker`(PDF/图片/图表解读)、`sisyphus-junior`(单点执行)。`subagent_spawn(template=)` 直接选用;老用户已有模板文件时一次性自动补齐(sidecar 记录已播种名单,此后删除不再复活)。
|
|
21
|
+
- skill 全文注入(设置 → 技能页,按技能单独勾选“全文”):勾选的技能 `{{skills}}` 展开为正文(oh-my-pi 式 `### Skill:` / 引用描述 / 全文格式;单文件 8KB、总量 32KB 封顶,超限回落名录),不勾选的仍为名录由模型按需读取。改动下一轮即生效,随预设保存/应用。
|
|
22
|
+
- Agent 工具统一开关:设置新增「工具」tab,18 个工具(持久终端 7+子代理 7+`edit_soft`/`delegate_task`/`ask_user_question`/`markers_list`)逐个开关,标记管理(总开关+分组+查询工具)也并入该 tab(原标记页移除),后端收成 `tool-manager.ts` 单一出入口(`setAgentToolEnabled`/`applyAgentToolsGating`),改动 live 生效无需 reload,随预设保存/应用;旧的终端/编辑/问卷开关自动迁移,旧客户端照常用。
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- 排队气泡的 ✕ 只删一条(#113):`removeFirstOccurrence(list, text)`(`server/queue-utils.ts`,纯函数可单测)只移除第一处匹配,`removeQueued` 的两条队列(插队 / 排队)都改用它。旧实现重建队列时用值过滤(`filter((t) => t !== text)`),同一条文本被排队两次时点一次 ✕ 会把两条一起删掉,而气泡只消失一条(要等下一次 `queue_update` 才对齐);现在与气泡 UI、本地显示镜像、DSH 引擎的「删第一条」语义一致。回归 `tests/unit/queue-utils.test.ts`(6 例)。
|
|
27
|
+
- 手机端聊天内容贴边(列内缩被算成 0):两个原因都堵上了。① `--chat-pad` 回调到 14px(= 改前 `.msg` 自带的 14px 内边距)——中央列收敛成一条 token 时手机上取了 10px,消息文字/卡片比原来贴边 4px,输入框也跟着从 10px 调到 14px,两边仍齐平。② `--msgs-gutter` 不再只信首帧前的探针:`.messages` 挂载后改用真实元素实测并覆盖,窗口尺寸变化(含手机横竖屏)时再校一次——个别浏览器/设备上探针与真实滚动容器的 gutter 对不上,会把消息列多缩/少缩一条 gutter。另外 `.messages` 的左右内缩改成 `max(0px, calc(--chat-inset - --msgs-gutter))`、上下留白改用 `padding-block` 独立声明:相减出负值时旧写法会让整条 `padding` 声明失效(连上下留白一起丢,内容直接贴边),现在最坏只是不扣那一条 gutter。回归 `tests/chat-column-align-test.mjs` 增加「消息列不贴边」断言(内缩不得小于列留白)。
|
|
28
|
+
- 输入框底部工具条在窄屏下重叠:428px 左右「思考」chip 会压到右侧的 发送/停止(流式时右侧最宽)。两处修正:① 工具条里的 chip(含外层 `.dropdown` 锚点)补上 `min-width: 0` / `flex-shrink: 1`,模型名与思考等级先收缩、再省略号截断,不再溢出到右侧按钮上(桌面窗口窄到主列放不下时同样有用);② 纯图标阈值从 420px 提到 560px:窄屏直接隐藏 模型名/思考等级/下拉箭头,chip 放大到 34×30,只留图标(文字交给 title 悬浮)。回归 `tests/composer-overlap-test.mjs`(320–1200px 扫描,注入流式时的「排队|插队」对半胶囊,断言左侧不压右侧、胶囊 78px 且两半等宽、≤560px chip 只剩图标)。
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- 新增全局运行态 `web/src/app-globals.ts`(模块级 store + `useSyncExternalStore`,`useAppGlobals()` / `useIsDsh()` / `useIsManaged()`):`engine`、`managed`、`tabs`、`appVersion`、`serverVersion` 这些「整棵树都要知道、整个连接内只变一次」的信息不再从 App 逐层传 props —— GoalBar / SettingsModal / PiSetupModal / TopBar / FooterBar / ChatInput 改读全局(DSH 的四处 gating、受管实例的更新/插件入口都不再依赖“谁记得传这个 prop”)。写入点只有一处:`use-chat.ts` 收到 `ready` 时(同步于 dispatch 之前,不会闪一帧 pi)。顺带修正 DSH 下「插队」名不副实:DSH 无 mid-run steering(prompt 一律 followUp),运行中只渲染「排队」半段(收成 38px 圆),placeholder 也换成 `placeholderStreamingQueued`(回车与点排队都是本轮结束后才发)。回归 `tests/unit/app-globals.test.ts`。
|
|
33
|
+
- WebSocket 发送器也收进全局:`appSend`(`web/src/app-globals.ts` 下半部分,`use-chat` 用 `setAppSend` 装配)—— 19 个组件的 `send` prop 全部删除,`App.tsx` 少 19 处逐层传参(对话框/弹窗/面板/插件视图/终端/SCM/底栏全部自己取),`ClientMessage` 依赖也随之从这些文件消失;测试(`dsh-question-dialog.test.ts`)改用 `setAppSend` 注入并记录发出的消息,组件仍可测。两个例外是故意的:`LeftPanel` / `RightPanel` 的 prop 改名为 `panelSend`(它们拿的是 App 的包装函数,带「顺手关手机抽屉」的副作用,不能换成全局发送器);装配写在 render 期间而非 effect —— 子组件 effect 先于父组件跑,放 effect 里装配会让「挂载即发请求」的弹窗在 appSend 还是空的时候静默丢包。
|
|
34
|
+
- 全局运行态再扩三项:`ready` / `status` / `cwd`。`LeftPanel`(三个都收)、`RightPanel`(cwd)、`ChatInput`(ready)、`GlobalSearchModal`(cwd)不再要这些 prop,改从 `useAppField(key)` 单字段订阅 —— cwd 是低频字段,单字段订阅让「切项目」的通知只到真正读 cwd 的组件,不会连带重渲染只读 engine 的组件。写入点:`use-chat.ts` 里一个 effect 把 reducer 的真值镜像过去(单一来源,最多晚一帧;默认值只会是「未就绪 / 未连接 / 空目录」,看不出来)。本来就吃整个 ChatState 的 `App` / `TopBar` / `FooterBar` 仍直读 `chat.*`(自己就持有数据,不必绕一圈)。
|
|
35
|
+
- 运行中发送位改成「排队|插队」对半胶囊:空闲态那颗发送圆钮在流式中原地变形为 78×38 的蓝胶囊,两半各 38px、中间一条 1px 半透明白线——左半「排队」(列表图标,加入队列,整轮跑完才发)、右半「插队」(↑,回车语义,本回合立刻响应,与 Enter 同一条路径)。原文字版「排队」药丸及其 ≤560px「收成图标」的兜底一并删除(窄屏右侧最宽从文字药丸降到固定 78px);空输入时整颗胶囊变暗、两半禁用(尺寸位置不动,工具条不跳),有文字或纯附件时解锁。停止语义与它们相反,仍是右侧独立的蓝圆,不并入胶囊。`tests/supplement-test.mjs` 同步改为点左半,并断言「空输入两半禁用 → 输入后解锁」。文案变更:前端新增 key `steerTip`。
|
|
36
|
+
- 设置面板减负:常驻列表里的静态解释全部收进标题/开关旁的「?」悬浮提示(含 7 个「已关闭」后果说明、重试次数、子代理默认模型、全文注入说明等);行内只保留计数、空状态、报错与动态状态(如当前转写模型)。文案 key 无增减。
|
|
37
|
+
- 问卷(`ask_user_question`)不再被工具挂死看门狗剁掉:以前它跟普通工具一样被算作「一个工具跑了 20 分钟」(`PI_WEB_TOOL_TIMEOUT_MS`),到点就 abort 整轮对话并弹「工具执行超过…已自动终止」——把还在思考的用户连对话一起终止。现在按工具名豁免:问卷等的是人类回答,不是挂死的工具,收场只走用户回答/取消与会话 dispose,**不限时**。同理,问卷挂着也不再算「失联」(stall 告警默认 180s 无 SDK 事件,对该对话跳过)。同时补上「刷新/重连后问卷对话框不再消失」:`question_pending` 是即时通道,只推给提问那一刻在线的连接,刷新页面/新标签页都拿不到那条历史消息,而服务端还在阻塞等人回答;现在待答问卷同时挂在快照(`UiState.pendingQuestion`,标准引擎只带当前对话的那张,切回原对话会重推快照)上,两个引擎(标准 pi / DSH)重连后都会把面板恢复出来,由快照恢复的面板也能被快照收起(另一标签页答完/服务端取消),但即时通道弹出的面板不会被在途旧快照闪掉,已答过的问卷也不会被在途旧快照重新弹出。回归 `tests/question-bridge-test.mjs`(零 token,本地假模型驱动整条链路)+ `tests/unit/pending-question.test.ts`。
|
|
38
|
+
|
|
39
|
+
<!-- auto-i18n:start -->
|
|
40
|
+
### i18n
|
|
41
|
+
|
|
42
|
+
- 前端新增 key(28):`placeholderStreamingQueued`、`steerTip`、`settingsTools`、`toolsSectionTerminal`、`toolsSectionSubagent`、`toolsSectionOther`、`toolsSubagentDepHint`、`delegateTaskEnabledDesc`、`delegateTaskOffHint`、`todoListEnabledDesc`、`todoListOffHint`、`toolDescSubagentSpawn`、`toolDescSubagentGetResult`、`toolDescSubagentSteer`、`toolDescSubagentList`、`toolDescSubagentStop`、`toolDescSubagentWaitAll`、`toolDescSubagentTemplates`、`skillFullTextLabel`、`skillFullTextDesc`、`skillFullTextShort`、`delegateOpenSubagent`、`delegateSecTask`、`delegateSecExpected`、`delegateSecTools`、`delegateSecMustDo`、`delegateSecMustNotDo`、`delegateSecContext`
|
|
43
|
+
- 服务端新增 key(3):`delegate.validate.agent`、`delegate.validate.short`、`delegate.started`
|
|
44
|
+
<!-- auto-i18n:end -->
|
|
45
|
+
|
|
46
|
+
## [0.76.0] — 2026-09-11
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- 桌面通知的诊断能力(默认不显示在界面上):`sendTestNotification()` 会立刻发一条系统通知(不受「页面不在眼前」抑制影响,且带 `requireInteraction` 不会自己滑走),并汇报走的是 service worker 还是页面通知、失败原因、**浏览器到底有没有留下这条通知**(`getNotifications()`,区分「系统层面被压住」与「浏览器直接丢了」)与判定依据(焦点 / 可见性 / 是否最小化 / 空闲秒数)。界面在 `web/src/components/NotifyToggle.tsx` 的 `SHOW_NOTIFY_TEST_PANEL` 常量后面,排障时改成 `true`。
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- **Windows 上窗口最小化后依然收不到任何桌面通知**(v0.75.0 只修了一半):Win11 实测,窗口最小化后 `document.hasFocus()` 仍是 `true`、`visibilityState` 仍是 `"visible"`,连 `blur`/`visibilitychange` 都不发 —— 「只看焦点」和「焦点 **且** 可见」两种条件都在这个场景下把通知全部静默掉。现在改用只有最小化会变的那组信号判定(原生窗口矩形:`screenX/screenY` 跳到屏幕外的「最小化坐标」,`outerWidth/Height` 塌成标题栏;`isCollapsedWindow`,有单测),并且在 Windows 上额外要求「最近 2 分钟内有过页面交互」才背静默 —— 这个平台的焦点/可见性都不可信,宁可多提醒一次也不漏。非 Windows 平台行为不变(其焦点/可见性可信)。
|
|
55
|
+
- 通知发送路径不再因 service worker 抛错而彻底静默:注册存在但还没 active(首次加载 / 刚更新后)时 `showNotification` 会失败,现在会退回页面通知,两者都失败也会把原因带出来(诊断按钮里看得到)。
|
|
56
|
+
- 修掉「只有第一次弹、之后怎么都不弹」:通知带固定 `tag` 时,Windows 把同 tag 的新通知当成**替掉旧条目**,而且是静默的 —— 没有横幅、没有提示音,只要系统通知中心里还躺着一条 pi-web-ui 通知,后续每一条都会被无声替换(页面上看 `showNotification` 明明成功了)。现在干脆不用 tag(也不依赖 `renotify` —— 实测它在 Windows toast 这层不起作用),每条都是全新 toast;代价是通知中心里会累积几条。
|
|
57
|
+
|
|
58
|
+
<!-- auto-i18n:start -->
|
|
59
|
+
### i18n
|
|
60
|
+
|
|
61
|
+
- 前端新增 key(9):`notifyTest`、`notifyTestBody`、`notifyTestSent`、`notifyTestFailed`、`notifyTestState`、`notifyTestHeld`、`notifyTestDropped`、`notifyTestGateSuppressed`、`notifyTestGateOpen`
|
|
62
|
+
- 前端中文变更(1):`notifyEnableDesc`
|
|
63
|
+
- 前端英文变更(1):`notifyEnableDesc`
|
|
64
|
+
<!-- auto-i18n:end -->
|
|
65
|
+
|
|
13
66
|
## [0.75.0] — 2026-09-11
|
|
14
67
|
|
|
15
68
|
### Added
|
|
@@ -372,7 +425,9 @@
|
|
|
372
425
|
- 0.35.1(2026-08-27):编辑重问保留附件(#18)+ 全窗口拖放(#19)。
|
|
373
426
|
- 0.29.0(2026-08-23):全局搜索弹窗(Ctrl+K)+ 消息列表惰性窗口化。
|
|
374
427
|
|
|
375
|
-
[Unreleased]: https://github.com/xing-shuyin/pi-web-ui/compare/v0.
|
|
428
|
+
[Unreleased]: https://github.com/xing-shuyin/pi-web-ui/compare/v0.77.0...main
|
|
429
|
+
[0.77.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.77.0
|
|
430
|
+
[0.76.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.76.0
|
|
376
431
|
[0.75.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.75.0
|
|
377
432
|
[0.74.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.74.0
|
|
378
433
|
[0.73.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.73.0
|
|
@@ -25,6 +25,7 @@ import { Type } from "typebox";
|
|
|
25
25
|
import { BgServerTracker } from "./bg-servers.js";
|
|
26
26
|
import { checkAll as checkAllUpdates, collectTargets, compareVersions as compareSemver, } from "./update-check.js";
|
|
27
27
|
import { hasActiveSubagentRun, hasPendingWaitSubscription, shouldRetainActive } from "./wait-subscription-scan.js";
|
|
28
|
+
import { removeFirstOccurrence } from "./queue-utils.js";
|
|
28
29
|
import { syncPluginToolsIntoSession } from "./plugins.js";
|
|
29
30
|
import { SettingsService } from "./settings-service.js";
|
|
30
31
|
import { GoalService } from "./goal-service.js";
|
|
@@ -32,13 +33,16 @@ import { MarkerService } from "./marker-service.js";
|
|
|
32
33
|
import { SlashCommandsService, parseSlash } from "./slash-commands.js";
|
|
33
34
|
import { ModelAdminService } from "./model-admin.js";
|
|
34
35
|
import { FilesService, MACHINE_ROOT, workspacePath } from "./files-service.js";
|
|
35
|
-
import { isExtensionDisabled, isExtensionEnabled, normalizeRetryMaxAttempts, ClientStateStore, } from "./client-state.js";
|
|
36
|
-
import { pick, resolveServerLang } from "./i18n.js";
|
|
36
|
+
import { isExtensionDisabled, isExtensionEnabled, normalizeRetryMaxAttempts, normalizeSkillList, ClientStateStore, } from "./client-state.js";
|
|
37
|
+
import { bilingual, pick, resolveServerLang } from "./i18n.js";
|
|
37
38
|
import { SubagentTemplatesStore, pickTemplatePrompt } from "./subagent-templates.js";
|
|
38
|
-
import { applyHeadTail, makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE,
|
|
39
|
+
import { applyHeadTail, makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, } from "./terminals.js";
|
|
40
|
+
import { applyAgentToolsGating, ASK_USER_QUESTION_TOOL_NAME, effectiveDisabledAgentTools, isTerminalGuidanceOn, MARKERS_LIST_TOOL_NAME, } from "./tool-manager.js";
|
|
39
41
|
import { WebUIContext, mockThemeProxy } from "./webui-context.js";
|
|
40
|
-
import {
|
|
42
|
+
import { decodeText } from "./text-sniff.js";
|
|
43
|
+
import { makeEditSoftTool } from "./edit-soft-tool.js";
|
|
41
44
|
import { collectSubagentDescendantIds, makeSubagentTools, subagentTitle, withSubagentOwner, } from "./subagents.js";
|
|
45
|
+
import { makeDelegateTaskTool } from "./delegate-task.js";
|
|
42
46
|
import { buildAttachmentMessages } from "./attachments.js";
|
|
43
47
|
import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, buildToolsSchemaText, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
|
|
44
48
|
import { serializeMessage, serializeStreamingMessage, stripTransientRetryErrors, } from "./serialize.js";
|
|
@@ -195,11 +199,11 @@ export function makeAdaptiveBashTool(killable, terminalBacked, useTerminal) {
|
|
|
195
199
|
};
|
|
196
200
|
}
|
|
197
201
|
/**
|
|
198
|
-
*
|
|
202
|
+
* 任务列表只读查询工具(todo_list)— 读操作仍走真工具。
|
|
199
203
|
*/
|
|
200
204
|
function makeMarkersListTool(getActiveId, markerSvc) {
|
|
201
205
|
return {
|
|
202
|
-
name:
|
|
206
|
+
name: MARKERS_LIST_TOOL_NAME,
|
|
203
207
|
label: "List marker state",
|
|
204
208
|
description: "Read-only query of inline marker state. All WRITE operations must use inline markers ([[todo:new:...]] etc.) in the reply body — never use this tool for writes.\n只读查询内联标记状态。状态【写】操作请一律用内联标记([[todo:new:...]] 等)写在回答正文里,不要调用本工具做写操作。",
|
|
205
209
|
parameters: Type.Object({
|
|
@@ -235,7 +239,9 @@ function makeMarkersListTool(getActiveId, markerSvc) {
|
|
|
235
239
|
* 服务于整个 agent 生命周期,这里按「已中止即拒绝」的最小语义处理,避免与其它
|
|
236
240
|
* 工具的取消逻辑纠缠。
|
|
237
241
|
*/
|
|
238
|
-
export function makeAskUserQuestionTool(clientSession
|
|
242
|
+
export function makeAskUserQuestionTool(clientSession,
|
|
243
|
+
/** 本 runtime 所属会话:提问跟着对话走,快照只把当前对话的问卷推给客户端。 */
|
|
244
|
+
ownerId) {
|
|
239
245
|
const QuestionOptionSchema = Type.Object({
|
|
240
246
|
label: Type.String({ description: "Display label for the option" }),
|
|
241
247
|
description: Type.Optional(Type.String({ description: "Optional description shown below label" })),
|
|
@@ -255,6 +261,11 @@ export function makeAskUserQuestionTool(clientSession) {
|
|
|
255
261
|
name: "ask_user_question",
|
|
256
262
|
label: "Ask the user",
|
|
257
263
|
description: "Ask the user focused questions to pin down ambiguous requirements. Use for clarifying the task, confirming decisions, or getting preferences. Each question renders a browser dialog with markdown/HTML rich text; options may carry a `preview`. Submit or cancel to resume.",
|
|
264
|
+
promptSnippet: bilingual("ask the user focused questions to clarify ambiguous requirements (browser dialog with options/preview)", "向用户提问以澄清含糊的需求(浏览器对话框,支持选项/预览)"),
|
|
265
|
+
promptGuidelines: [
|
|
266
|
+
bilingual("When requirements are ambiguous, use ask_user_question to ask the user instead of guessing; prefer multiple-choice options, each option may carry a preview", "需求含糊时用 ask_user_question 向用户提问而不是猜测;优先给多选选项,选项可带 preview 预览"),
|
|
267
|
+
bilingual("A cancelled question comes back as a tool error — respect it and continue without re-asking immediately", "用户取消提问会以工具错误返回——尊重取消决定,不要马上重复追问"),
|
|
268
|
+
],
|
|
258
269
|
parameters: Type.Object({
|
|
259
270
|
questions: Type.Array(QuestionSchema, { description: "Questions to ask the user" }),
|
|
260
271
|
}),
|
|
@@ -265,7 +276,7 @@ export function makeAskUserQuestionTool(clientSession) {
|
|
|
265
276
|
}
|
|
266
277
|
const answers = await clientSession.askUser(qs, {
|
|
267
278
|
aborted: signal?.aborted,
|
|
268
|
-
});
|
|
279
|
+
}, ownerId);
|
|
269
280
|
if (answers === null) {
|
|
270
281
|
throw new Error("User cancelled the question.\n用户取消了提问。");
|
|
271
282
|
}
|
|
@@ -917,14 +928,41 @@ export class ClientSession {
|
|
|
917
928
|
piExamples: PI_DOC_PATHS.examples,
|
|
918
929
|
appendFiles: this.lastSdkAppendFiles,
|
|
919
930
|
windowsPersona: process.platform === "win32" ? WINDOWS_PERSONA : "",
|
|
920
|
-
terminalGuidance: this.settingsSvc.current
|
|
931
|
+
terminalGuidance: isTerminalGuidanceOn(effectiveDisabledAgentTools(this.settingsSvc.current))
|
|
932
|
+
? TERMINAL_TOOLS_GUIDANCE
|
|
933
|
+
: "",
|
|
921
934
|
markersGuidance: this.markerSvc.buildGuidance(),
|
|
922
935
|
// issue #91:组合模板各来源段按客户端 UI 语言渲染(英文默认)。
|
|
923
936
|
lang: this.getLang(),
|
|
924
937
|
contextFiles: src.contextFiles,
|
|
925
|
-
skills: src.skills,
|
|
938
|
+
skills: this.fillSkillContents(src.skills),
|
|
939
|
+
skillsFullText: normalizeSkillList(this.settingsSvc.current.skillsFullText),
|
|
926
940
|
};
|
|
927
941
|
}
|
|
942
|
+
/** skill 全文注入({{skills}} 全文模式):最好努力读名单里技能的文件正文。
|
|
943
|
+
* 单文件 8KB、总量 32KB 封顶,失败/超限/不在名单回落名录(无 content)。
|
|
944
|
+
* 名单为空时零开销:原样返回,不碰磁盘。 */
|
|
945
|
+
fillSkillContents(skills) {
|
|
946
|
+
const wanted = new Set(normalizeSkillList(this.settingsSvc.current.skillsFullText));
|
|
947
|
+
if (wanted.size === 0)
|
|
948
|
+
return skills;
|
|
949
|
+
let budget = 32 * 1024;
|
|
950
|
+
return skills.map((s) => {
|
|
951
|
+
if (!wanted.has(s.name) || !s.filePath || budget <= 0)
|
|
952
|
+
return s;
|
|
953
|
+
try {
|
|
954
|
+
const st = statSync(s.filePath);
|
|
955
|
+
if (!st.isFile() || st.size <= 0 || st.size > 8192)
|
|
956
|
+
return s;
|
|
957
|
+
const raw = decodeText(readFileSync(s.filePath).subarray(0, Math.min(st.size, budget))).trim();
|
|
958
|
+
budget -= raw.length;
|
|
959
|
+
return raw ? { ...s, content: raw } : s;
|
|
960
|
+
}
|
|
961
|
+
catch {
|
|
962
|
+
return s;
|
|
963
|
+
}
|
|
964
|
+
});
|
|
965
|
+
}
|
|
928
966
|
/** 渲染当前组合模板。模板为空且无任何覆盖时返回 undefined(用 SDK 默认拼装,
|
|
929
967
|
* 零开销且与原始行为逐字节一致)。 */
|
|
930
968
|
renderMainCompose(src) {
|
|
@@ -1094,6 +1132,8 @@ export class ClientSession {
|
|
|
1094
1132
|
// 工具结果(agent 循环阻塞)。一次只展示一个提问(agent 阻塞在工具执行)。
|
|
1095
1133
|
// -----------------------------------------------------------------------
|
|
1096
1134
|
questionSeq = 0;
|
|
1135
|
+
/** 待答提问(id → 载荷 + resolve)。一次正常只有一个(agent 阻塞在工具执行);
|
|
1136
|
+
* conversationId 记录谁问的:看门狗豁免、快照恢复都靠它。 */
|
|
1097
1137
|
pendingQuestions = new Map();
|
|
1098
1138
|
constructor(clientId, cwd, agentDir, stateStore) {
|
|
1099
1139
|
this.clientId = clientId;
|
|
@@ -1146,6 +1186,7 @@ export class ClientSession {
|
|
|
1146
1186
|
await this.pushSlashCommands();
|
|
1147
1187
|
},
|
|
1148
1188
|
applyRetryOverrides: () => this.applyRetryOverrides(),
|
|
1189
|
+
applyToolGating: () => this.applyToolGating(this.session),
|
|
1149
1190
|
promptSnapshot: () => this.promptSnapshot(),
|
|
1150
1191
|
getMarkerState: () => ({
|
|
1151
1192
|
markersEnabled: this.markerSvc.current.markersEnabled,
|
|
@@ -1275,9 +1316,10 @@ export class ClientSession {
|
|
|
1275
1316
|
// GBK 老中文文件让模型改用终端按正确编码读(iconv/chcp/Get-Content)。
|
|
1276
1317
|
out.push(WINDOWS_PERSONA);
|
|
1277
1318
|
}
|
|
1278
|
-
if (this.settingsSvc.current
|
|
1319
|
+
if (isTerminalGuidanceOn(effectiveDisabledAgentTools(this.settingsSvc.current))) {
|
|
1279
1320
|
// 终端工具使用引导(全平台):告诉模型什么场景该用持久终端
|
|
1280
1321
|
// 而不是一次性 bash——没有这段模型几乎从不主动选终端工具。
|
|
1322
|
+
// 组内工具全关时不注入(不教 AI 用不存在的工具)。
|
|
1281
1323
|
out.push(TERMINAL_TOOLS_GUIDANCE);
|
|
1282
1324
|
}
|
|
1283
1325
|
// bash 管道限制已并入 bash 工具自身的 description,不再作为独立提示段注入。
|
|
@@ -1395,12 +1437,17 @@ export class ClientSession {
|
|
|
1395
1437
|
...(ownerId
|
|
1396
1438
|
? makeSubagentTools(withSubagentOwner(this.subagentHost, ownerId), undefined, ownerId)
|
|
1397
1439
|
: makeSubagentTools(this.subagentHost)),
|
|
1440
|
+
// 结构化派单(六段式 + 服务端校验;执行体复用子代理 spawn 通道)。
|
|
1441
|
+
// owner 包装与上面同理:子代理记到真正的派发会话名下。
|
|
1442
|
+
...(ownerId
|
|
1443
|
+
? [makeDelegateTaskTool(withSubagentOwner(this.subagentHost, ownerId))]
|
|
1444
|
+
: [makeDelegateTaskTool(this.subagentHost)]),
|
|
1398
1445
|
// 内置标记只读查询工具(todo/svc 状态查询,写操作走内联标记)。
|
|
1399
1446
|
makeMarkersListTool(() => this.activeId, this.markerSvc),
|
|
1400
1447
|
// 标准引擎的 ask_user_question:模型调用 → 浏览器富渲染问卷(复用 DSH
|
|
1401
1448
|
// 的 question_pending/question_answer 协议,前端 DshQuestionDialog)。
|
|
1402
1449
|
// DSH 引擎不经此(它走 goal-rpc 的 userQuestions provider)。
|
|
1403
|
-
makeAskUserQuestionTool(this),
|
|
1450
|
+
makeAskUserQuestionTool(this, ownerId),
|
|
1404
1451
|
],
|
|
1405
1452
|
});
|
|
1406
1453
|
// 终端工具开关从创建起就生效(工具始终注册进注册表,只调活跃集)。
|
|
@@ -1574,7 +1621,11 @@ export class ClientSession {
|
|
|
1574
1621
|
return;
|
|
1575
1622
|
const now = Date.now();
|
|
1576
1623
|
for (const conv of this.convs.values()) {
|
|
1577
|
-
|
|
1624
|
+
// 正在等用户回答的对话本就该「无声」——那是人在想,不是失联。
|
|
1625
|
+
if (!conv.stallNoticed &&
|
|
1626
|
+
conv.session.isStreaming &&
|
|
1627
|
+
!this.isWaitingOnUser(conv.id) &&
|
|
1628
|
+
now - conv.lastSdkEventAt > STALL_NOTIFY_MS) {
|
|
1578
1629
|
conv.stallNoticed = true;
|
|
1579
1630
|
const mins = Math.round((now - conv.lastSdkEventAt) / 60_000);
|
|
1580
1631
|
this.emit({
|
|
@@ -1723,7 +1774,12 @@ export class ClientSession {
|
|
|
1723
1774
|
if (event.toolName === "bash") {
|
|
1724
1775
|
this.bg.snapshotBefore();
|
|
1725
1776
|
}
|
|
1726
|
-
|
|
1777
|
+
// 看门狗豁免:ask_user_question 阻塞等的是「人类回答」,不是挂死的工具
|
|
1778
|
+
// (默认 20 分钟会把还在思考的用户连对话一起剁掉)。它的收场自有路子:
|
|
1779
|
+
// 用户回答/取消、会话 dispose(cancelPendingQuestions),不限时。
|
|
1780
|
+
if (event.toolName !== ASK_USER_QUESTION_TOOL_NAME) {
|
|
1781
|
+
this.armToolWatchdog(conv, event.toolCallId);
|
|
1782
|
+
}
|
|
1727
1783
|
// 插件扩展点:工具开始执行(异常由 emitToolEvent 隔离)。
|
|
1728
1784
|
this.onToolEvent?.({
|
|
1729
1785
|
phase: "start",
|
|
@@ -2251,6 +2307,7 @@ export class ClientSession {
|
|
|
2251
2307
|
errorMessage: state.errorMessage,
|
|
2252
2308
|
retry: conv.retryState ?? null,
|
|
2253
2309
|
compaction: conv.compactionState ?? null,
|
|
2310
|
+
pendingQuestion: this.pendingQuestionForSnapshot(),
|
|
2254
2311
|
tools: state.tools.map((t) => t.name),
|
|
2255
2312
|
version: ++this.version,
|
|
2256
2313
|
piConfigured: this.isPiConfigured(),
|
|
@@ -2316,20 +2373,25 @@ export class ClientSession {
|
|
|
2316
2373
|
// -----------------------------------------------------------------------
|
|
2317
2374
|
/** 标准引擎模型调 ask_user_question:发 question_pending 给浏览器并阻塞等待
|
|
2318
2375
|
* question_answer。sig 为工具执行信号的当前状态(aborted → 立即 reject)。
|
|
2319
|
-
* 返回 answers(用户选中/自定义),或 null(用户取消)。
|
|
2320
|
-
|
|
2376
|
+
* 返回 answers(用户选中/自定义),或 null(用户取消)。
|
|
2377
|
+
*
|
|
2378
|
+
* 不设超时:等的是「人类回答」,不是挂死的工具。因此也不进工具挂死看门狗
|
|
2379
|
+
* (见 tool_execution_start)、不算 stall 失联(见 startStallTimer)。 */
|
|
2380
|
+
askUser(questions, sig, conversationId) {
|
|
2321
2381
|
return new Promise((resolve, reject) => {
|
|
2322
2382
|
if (sig?.aborted || this.disposed) {
|
|
2323
2383
|
reject(new Error("ask_user_question 已中止"));
|
|
2324
2384
|
return;
|
|
2325
2385
|
}
|
|
2326
2386
|
// 问卷开关(默认开):关 → 不弹对话框,立即报错让模型得知已禁用。
|
|
2327
|
-
|
|
2387
|
+
// 与统一工具门控双保险:工具 tab 里单独关掉 ask_user_question 也一样拒收。
|
|
2388
|
+
if (this.settingsSvc.current.questionnaireEnabled === false ||
|
|
2389
|
+
(this.settingsSvc.current.disabledAgentTools ?? []).includes(ASK_USER_QUESTION_TOOL_NAME)) {
|
|
2328
2390
|
reject(new Error("问卷功能已关闭,可在设置中重新开启"));
|
|
2329
2391
|
return;
|
|
2330
2392
|
}
|
|
2331
2393
|
const id = `q-${++this.questionSeq}`;
|
|
2332
|
-
this.pendingQuestions.set(id, resolve);
|
|
2394
|
+
this.pendingQuestions.set(id, { resolve, questions, conversationId });
|
|
2333
2395
|
this.emit({
|
|
2334
2396
|
type: "question_pending",
|
|
2335
2397
|
id,
|
|
@@ -2341,22 +2403,40 @@ export class ClientSession {
|
|
|
2341
2403
|
* pendingQuestions 中键;cancelled 或未匹配(例如用户早已切走)时按「取消」处理
|
|
2342
2404
|
* —— 把挂起的提问全部 reject,让模型知道用户离开了。 */
|
|
2343
2405
|
resolveQuestion(id, answers, cancelled) {
|
|
2344
|
-
const
|
|
2345
|
-
if (
|
|
2406
|
+
const pending = this.pendingQuestions.get(id);
|
|
2407
|
+
if (pending) {
|
|
2346
2408
|
this.pendingQuestions.delete(id);
|
|
2347
|
-
resolve(cancelled ? null : answers);
|
|
2409
|
+
pending.resolve(cancelled ? null : answers);
|
|
2348
2410
|
}
|
|
2349
2411
|
}
|
|
2412
|
+
/** 快照侧的待答提问(UiState.pendingQuestion):只带当前对话的问卷——切回
|
|
2413
|
+
* 原对话会重推快照,对话框随之回来(重连/刷新/第二标签页的恢复通道)。 */
|
|
2414
|
+
pendingQuestionForSnapshot() {
|
|
2415
|
+
for (const [id, p] of this.pendingQuestions) {
|
|
2416
|
+
if (p.conversationId !== undefined && p.conversationId !== this.activeId)
|
|
2417
|
+
continue;
|
|
2418
|
+
return { id, questions: p.questions };
|
|
2419
|
+
}
|
|
2420
|
+
return null;
|
|
2421
|
+
}
|
|
2422
|
+
/** 对话是否阻塞在等用户回答上(用于 stall 失联判定豁免)。 */
|
|
2423
|
+
isWaitingOnUser(conversationId) {
|
|
2424
|
+
for (const p of this.pendingQuestions.values()) {
|
|
2425
|
+
if (p.conversationId === undefined || p.conversationId === conversationId)
|
|
2426
|
+
return true;
|
|
2427
|
+
}
|
|
2428
|
+
return false;
|
|
2429
|
+
}
|
|
2350
2430
|
/** 标准引擎的 question_answer 路由入口(index.ts 经 cs.answerQuestion?. 转发)。
|
|
2351
2431
|
* DSH 引擎的 AgentService 也实现了同名方法,此处为 ClientSession 的转发。 */
|
|
2352
2432
|
answerQuestion(id, answers, cancelled) {
|
|
2353
2433
|
this.resolveQuestion(id, answers, cancelled);
|
|
2354
2434
|
return Promise.resolve();
|
|
2355
2435
|
}
|
|
2356
|
-
/**
|
|
2436
|
+
/** 关闭所有挂起提问(dispose 时清理):以「取消」解析,避免模型挂死。 */
|
|
2357
2437
|
cancelPendingQuestions() {
|
|
2358
|
-
for (const [,
|
|
2359
|
-
resolve(null);
|
|
2438
|
+
for (const [, p] of this.pendingQuestions) {
|
|
2439
|
+
p.resolve(null);
|
|
2360
2440
|
}
|
|
2361
2441
|
this.pendingQuestions.clear();
|
|
2362
2442
|
}
|
|
@@ -2927,29 +3007,19 @@ export class ClientSession {
|
|
|
2927
3007
|
async applyRuntimeSettings() {
|
|
2928
3008
|
return this.settingsSvc.applyRuntime();
|
|
2929
3009
|
}
|
|
2930
|
-
/**
|
|
2931
|
-
*
|
|
2932
|
-
*
|
|
3010
|
+
/** 统一工具门控(tool_manage 唯一落点):按 disabledAgentTools 把目录内工具
|
|
3011
|
+
* 逐个加回/剔除活跃集(工具仍留在注册表,重开可直接加回;live 生效无需
|
|
3012
|
+
* reload)。session.reload() 与新会话创建都会把 custom 工具加回活跃集,
|
|
3013
|
+
* 所以这两条路径之后都要重放本方法(见 reloadSession/创建处)。 */
|
|
2933
3014
|
applyToolGating(session) {
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
names.delete(n);
|
|
2943
|
-
}
|
|
2944
|
-
if (softEditEnabled)
|
|
2945
|
-
names.add(SOFT_EDIT_TOOL_NAME);
|
|
2946
|
-
else
|
|
2947
|
-
names.delete(SOFT_EDIT_TOOL_NAME);
|
|
2948
|
-
session.setActiveToolsByName([...names]);
|
|
2949
|
-
}
|
|
2950
|
-
catch {
|
|
2951
|
-
// Session 未就绪——下次创建/reload 会再应用。
|
|
2952
|
-
}
|
|
3015
|
+
applyAgentToolsGating(session, effectiveDisabledAgentTools(this.settingsSvc.current));
|
|
3016
|
+
// SDK 的 setActiveToolsByName 只改 agent.state.tools,不派发任何事件——门控后
|
|
3017
|
+
// 主动推一次快照,否则快照里的 tools 要等下一个 SDK 事件才对齐(会话空闲时永远
|
|
3018
|
+
// 等不到;回归:tests/terminal-smoke-test.mjs「agent exposes persistent terminal tools」)。
|
|
3019
|
+
// 只在被门控的就是活跃会话时推(创建早期活跃对话可能还没绑定;创建流程自带快照)。
|
|
3020
|
+
const active = this.convs.get(this.activeId);
|
|
3021
|
+
if (active && active.session === session)
|
|
3022
|
+
this.flushSnapshot();
|
|
2953
3023
|
}
|
|
2954
3024
|
/** 把插件 AI 工具同步进一个已存在的会话(新增/更新/移除)。
|
|
2955
3025
|
* 实际 diff 逻辑在 plugins.ts 的 syncPluginToolsIntoSession(可单测)。 */
|
|
@@ -3265,8 +3335,10 @@ export class ClientSession {
|
|
|
3265
3335
|
return;
|
|
3266
3336
|
}
|
|
3267
3337
|
const { steering, followUp } = s.clearQueue();
|
|
3268
|
-
|
|
3269
|
-
|
|
3338
|
+
// 只移除第一条匹配:气泡 ✕ 对应的是「一条」消息,重复文本不能连带删除
|
|
3339
|
+
// (旧实现用值过滤会把所有同文本项一起删掉,与本地显示镜像不一致)。
|
|
3340
|
+
const keptSteering = kind === "steer" ? removeFirstOccurrence(steering, text) : steering;
|
|
3341
|
+
const keptFollowUp = kind === "followUp" ? removeFirstOccurrence(followUp, text) : followUp;
|
|
3270
3342
|
// Re-queue the survivors in original order. Guard each call so a single
|
|
3271
3343
|
// failure can't leave the queue half-drained silently.
|
|
3272
3344
|
for (const t of keptSteering) {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { dirname } from "node:path";
|
|
11
|
+
import { deriveLegacy, legacyToDisabled, normalizeDisabledAgentTools } from "./tool-manager.js";
|
|
11
12
|
/** 大模型 API 出错自动重试次数的默认值(SDK 默认 3)。 */
|
|
12
13
|
export const DEFAULT_RETRY_MAX_ATTEMPTS = 6;
|
|
13
14
|
/** 归一化重试次数:非数值回落默认,钳制到 [0, 100] 整数。 */
|
|
@@ -17,6 +18,10 @@ export function normalizeRetryMaxAttempts(v) {
|
|
|
17
18
|
return DEFAULT_RETRY_MAX_ATTEMPTS;
|
|
18
19
|
return Math.min(100, Math.max(0, n));
|
|
19
20
|
}
|
|
21
|
+
/** 归一化技能名单:字符串数组原样过滤;其他(含旧 bool 开关)回落空数组。 */
|
|
22
|
+
export function normalizeSkillList(v) {
|
|
23
|
+
return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
24
|
+
}
|
|
20
25
|
/** Stable identity of an extension for the enable/disable toggle: the npm
|
|
21
26
|
* spec for packages (survives version bumps), the resolved entry path
|
|
22
27
|
* otherwise. */
|
|
@@ -229,14 +234,23 @@ export class ClientStateStore {
|
|
|
229
234
|
promptOverrides,
|
|
230
235
|
disabledSkills: stored?.disabledSkills ?? [],
|
|
231
236
|
disabledExtensions: stored?.disabledExtensions ?? [],
|
|
232
|
-
|
|
237
|
+
disabledAgentTools: legacyToDisabled(stored ?? {}),
|
|
238
|
+
// 新字段已存在时遗留三开关以它为准推导(旧文件才读遗留值),保证两边一致。
|
|
239
|
+
terminalToolsEnabled: stored?.disabledAgentTools !== undefined
|
|
240
|
+
? deriveLegacy(legacyToDisabled(stored)).terminalToolsEnabled
|
|
241
|
+
: (stored?.terminalToolsEnabled ?? false),
|
|
233
242
|
terminalBash: stored?.terminalBash ?? false,
|
|
234
243
|
terminalBashIdleMs: stored?.terminalBashIdleMs ?? 15_000,
|
|
235
|
-
editSoftEnabled: stored?.
|
|
236
|
-
|
|
244
|
+
editSoftEnabled: stored?.disabledAgentTools !== undefined
|
|
245
|
+
? deriveLegacy(legacyToDisabled(stored)).editSoftEnabled
|
|
246
|
+
: (stored?.editSoftEnabled ?? false),
|
|
247
|
+
questionnaireEnabled: stored?.disabledAgentTools !== undefined
|
|
248
|
+
? deriveLegacy(legacyToDisabled(stored)).questionnaireEnabled
|
|
249
|
+
: (stored?.questionnaireEnabled ?? true),
|
|
237
250
|
goalModeEnabled: stored?.goalModeEnabled ?? true,
|
|
238
251
|
thinkingWrap: stored?.thinkingWrap ?? false,
|
|
239
252
|
toolsWrap: stored?.toolsWrap ?? true,
|
|
253
|
+
skillsFullText: normalizeSkillList(stored?.skillsFullText),
|
|
240
254
|
visionBridgeEnabled: stored?.visionBridgeEnabled ?? true,
|
|
241
255
|
visionBridgeModel: stored?.visionBridgeModel ?? null,
|
|
242
256
|
visionBridgePromptMode: stored?.visionBridgePromptMode === "replace" ? "replace" : "append",
|
|
@@ -262,6 +276,7 @@ export class ClientStateStore {
|
|
|
262
276
|
promptOverrides: { ...(settings.promptOverrides ?? cur.promptOverrides) },
|
|
263
277
|
disabledSkills: settings.disabledSkills ?? cur.disabledSkills ?? [],
|
|
264
278
|
disabledExtensions: settings.disabledExtensions ?? cur.disabledExtensions ?? [],
|
|
279
|
+
disabledAgentTools: normalizeDisabledAgentTools(settings.disabledAgentTools ?? cur.disabledAgentTools),
|
|
265
280
|
terminalToolsEnabled: settings.terminalToolsEnabled ?? cur.terminalToolsEnabled ?? false,
|
|
266
281
|
terminalBash: settings.terminalBash ?? cur.terminalBash ?? false,
|
|
267
282
|
terminalBashIdleMs: settings.terminalBashIdleMs ?? cur.terminalBashIdleMs ?? 15_000,
|
|
@@ -270,6 +285,7 @@ export class ClientStateStore {
|
|
|
270
285
|
goalModeEnabled: settings.goalModeEnabled ?? cur.goalModeEnabled ?? true,
|
|
271
286
|
thinkingWrap: settings.thinkingWrap ?? cur.thinkingWrap ?? false,
|
|
272
287
|
toolsWrap: settings.toolsWrap ?? cur.toolsWrap ?? true,
|
|
288
|
+
skillsFullText: normalizeSkillList(settings.skillsFullText ?? cur.skillsFullText),
|
|
273
289
|
visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
|
|
274
290
|
visionBridgeModel: settings.visionBridgeModel ?? cur.visionBridgeModel ?? null,
|
|
275
291
|
subagentDefaultModel: settings.subagentDefaultModel ?? cur.subagentDefaultModel ?? null,
|