pi-web-ui 0.94.1 → 0.96.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.
Files changed (100) hide show
  1. package/CHANGELOG.md +91 -2
  2. package/README.md +5 -6
  3. package/README.zh-CN.md +4 -5
  4. package/bin/pi-web-ui.mjs +910 -237
  5. package/dist/server/agent-service.js +2655 -231
  6. package/dist/server/approval-rules.js +596 -0
  7. package/dist/server/attachment-store.js +129 -0
  8. package/dist/server/attachments.js +92 -174
  9. package/dist/server/bg-servers.js +45 -7
  10. package/dist/server/claim-files-tool.js +4 -8
  11. package/dist/server/claim-store.js +4 -2
  12. package/dist/server/client-state.js +67 -14
  13. package/dist/server/compact-context-tool.js +126 -0
  14. package/dist/server/composer-drafts.js +9 -0
  15. package/dist/server/context-budget.js +317 -0
  16. package/dist/server/control-socket.js +57 -28
  17. package/dist/server/conversation-read-tool.js +7 -17
  18. package/dist/server/dangling-tools.js +229 -0
  19. package/dist/server/delegate-task.js +25 -28
  20. package/dist/server/dsh/dsh-agent-service.js +69 -66
  21. package/dist/server/edit-soft-tool.js +84 -16
  22. package/dist/server/eval-tool.js +588 -0
  23. package/dist/server/file-archives.js +16 -5
  24. package/dist/server/files-service.js +65 -18
  25. package/dist/server/goal-service.js +371 -77
  26. package/dist/server/hashline-engine.js +703 -0
  27. package/dist/server/host-guard.js +94 -0
  28. package/dist/server/host-metrics.js +26 -2
  29. package/dist/server/i18n.js +3 -3
  30. package/dist/server/index.js +456 -80
  31. package/dist/server/lsp-tool.js +1371 -0
  32. package/dist/server/mcp-bridge.js +47 -3
  33. package/dist/server/model-admin.js +112 -32
  34. package/dist/server/office-parse.js +375 -0
  35. package/dist/server/patch-tool.js +85 -0
  36. package/dist/server/permission-preset.js +25 -0
  37. package/dist/server/plan-manager.js +114 -0
  38. package/dist/server/plugin-api-catalog.js +296 -0
  39. package/dist/server/plugin-catalog-sync.js +36 -19
  40. package/dist/server/plugin-catalog.js +11 -4
  41. package/dist/server/plugin-facilities.js +92 -21
  42. package/dist/server/plugin-install-spec.js +196 -0
  43. package/dist/server/plugin-installer.js +73 -0
  44. package/dist/server/plugin-llm.js +71 -65
  45. package/dist/server/plugin-manifest-validate.js +305 -0
  46. package/dist/server/plugin-project.js +117 -13
  47. package/dist/server/plugin-tool-guard.js +120 -0
  48. package/dist/server/plugin-updater.js +115 -12
  49. package/dist/server/plugins.js +950 -205
  50. package/dist/server/present-files-tool.js +9 -12
  51. package/dist/server/process-utils.js +16 -6
  52. package/dist/server/prompt-composer.js +9 -0
  53. package/dist/server/protocol-version.js +1 -1
  54. package/dist/server/read-tool.js +69 -30
  55. package/dist/server/resolve-global-sdk.js +30 -16
  56. package/dist/server/schedule-agent-tool.js +12 -15
  57. package/dist/server/scheduler-tasks.js +6 -0
  58. package/dist/server/sdk-origin.js +18 -2
  59. package/dist/server/serialize.js +111 -14
  60. package/dist/server/settings-service.js +112 -3
  61. package/dist/server/skill-tool.js +5 -7
  62. package/dist/server/subagent-templates.js +51 -0
  63. package/dist/server/subagents.js +83 -66
  64. package/dist/server/terminals.js +248 -81
  65. package/dist/server/tool-approval.js +84 -0
  66. package/dist/server/tool-manager.js +237 -12
  67. package/dist/server/tool-overrides.js +59 -0
  68. package/dist/server/update-check.js +28 -1
  69. package/dist/server/uploads.js +17 -2
  70. package/dist/server/wait-subscription-scan.js +18 -21
  71. package/dist/server/workspace-snapshot.js +113 -0
  72. package/dist/server/ws-client-id.js +29 -0
  73. package/dist/server/ws-pending-queue.js +60 -0
  74. package/extensions/webui.ts +60 -2
  75. package/package.json +5 -3
  76. package/plugin-sdk/README.md +25 -0
  77. package/plugin-sdk/index.d.ts +31 -38
  78. package/plugin-sdk/index.mjs +35 -19
  79. package/plugins/catalog.json +40 -0
  80. package/themes/aetheris.css +457 -0
  81. package/themes/ayu-light.css +6 -6
  82. package/themes/catppuccin-latte.css +6 -6
  83. package/themes/claude-code-dark.css +144 -0
  84. package/themes/codex.css +6 -6
  85. package/themes/everforest-light.css +6 -6
  86. package/themes/geist.css +6 -6
  87. package/themes/gruvbox-light.css +6 -6
  88. package/themes/kanagawa-lotus.css +6 -6
  89. package/themes/rose-pine-dawn.css +6 -6
  90. package/themes/solarized-light.css +6 -6
  91. package/themes/vs-code-dark.css +146 -0
  92. package/themes/zhupi-dark.css +658 -0
  93. package/themes/zhupi.css +711 -0
  94. package/web/dist/assets/{TerminalPanel-MVoxpJOA.js → TerminalPanel-DDChcYOh.js} +1 -1
  95. package/web/dist/assets/index-Do9RgJC3.js +366 -0
  96. package/web/dist/assets/index-DryOsILO.css +1 -0
  97. package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
  98. package/web/dist/index.html +3 -3
  99. package/web/dist/assets/index-B3S9MxnN.css +0 -1
  100. package/web/dist/assets/index-DVLrHI2E.js +0 -364
@@ -31,21 +31,21 @@ function truncate(s, cap) {
31
31
  */
32
32
  function imageBlockToUi(b, cap = Number.POSITIVE_INFINITY) {
33
33
  const img = b;
34
+ const src = img.source;
35
+ if (typeof src?.url === "string" && src.url)
36
+ return { type: "image", dataUrl: src.url };
34
37
  if (typeof img.data === "string" && img.data.length > 0) {
35
38
  const dataUrl = `data:${img.mimeType ?? "image/png"};base64,${img.data}`;
36
39
  if (dataUrl.length > cap)
37
40
  return undefined;
38
41
  return { type: "image", dataUrl, mimeType: img.mimeType };
39
42
  }
40
- const src = img.source;
41
43
  if (src?.type === "base64" && src.data) {
42
44
  const dataUrl = `data:${src.mediaType ?? "image/png"};base64,${src.data}`;
43
45
  if (dataUrl.length > cap)
44
46
  return undefined;
45
47
  return { type: "image", dataUrl, mimeType: src.mediaType };
46
48
  }
47
- if (typeof src?.url === "string" && src.url)
48
- return { type: "image", dataUrl: src.url };
49
49
  return undefined;
50
50
  }
51
51
  function serializeUserContent(content) {
@@ -65,7 +65,11 @@ function serializeAssistantContent(content) {
65
65
  return { type: "text", text, truncated };
66
66
  }
67
67
  if (b.type === "thinking") {
68
- return { type: "thinking", thinking: b.thinking };
68
+ // thinking 也走 TEXT_CAP:思维链没有长度保证(长任务能刷出远超正文的
69
+ // 体量),不截断会把快照推送撑爆。UiThinkingBlock 没有 truncated 字段
70
+ // (protocol 不动),截断语义靠 truncate 自带的 "… [truncated]" 尾标。
71
+ const { text } = truncate(b.thinking, TEXT_CAP);
72
+ return { type: "thinking", thinking: text };
69
73
  }
70
74
  if (b.type === "toolCall") {
71
75
  if (b.arguments === undefined) {
@@ -107,18 +111,98 @@ export function stripTransientRetryErrors(messages, retryActive) {
107
111
  }
108
112
  return end === messages.length ? messages : messages.slice(0, end);
109
113
  }
114
+ /**
115
+ * Single source for rendered message ids. Both serializeMessage (下发) and
116
+ * resolveMessageEntry (解析) must derive ids through this function — recomputing
117
+ * the format anywhere else is how the two sides drifted apart and fork/rollback
118
+ * on assistant bubbles stopped resolving (issue #381).
119
+ */
120
+ export function uiMessageId(m, seq) {
121
+ switch (m.role) {
122
+ case "user":
123
+ return `u-${m.timestamp}-${seq}`;
124
+ case "assistant":
125
+ return `a-${m.timestamp}-${seq}`;
126
+ case "toolResult":
127
+ return `t-${m.toolCallId}`;
128
+ case "bashExecution":
129
+ return `b-${m.timestamp}-${seq}`;
130
+ case "custom":
131
+ return `c-${m.timestamp}-${seq}`;
132
+ case "branchSummary":
133
+ return `bs-${m.timestamp}-${seq}`;
134
+ case "compactionSummary":
135
+ return `cs-${m.timestamp}-${seq}`;
136
+ default:
137
+ return `x-${seq}`;
138
+ }
139
+ }
140
+ /**
141
+ * Find the session entry a rendered message id points at, by re-deriving each
142
+ * entry's rendered id through uiMessageId() — the same function serializeMessage
143
+ * used to hand ids to the browser. `seqOf` supplies the per-message seq; the
144
+ * caller injects its counter there (agent-service passes uiMessageKey().n, which
145
+ * is exactly the counter serialization drew from — issue #381).
146
+ */
147
+ export function findEntryByUiId(entries, messageId, seqOf) {
148
+ const userSeqByTs = new Map();
149
+ for (const entry of entries) {
150
+ if (entry.id === messageId)
151
+ return entry;
152
+ if (entry.type === "message" && entry.message) {
153
+ const m = entry.message;
154
+ let seq;
155
+ if (m.role === "user") {
156
+ // User ids count messages sharing a timestamp — mirrors the
157
+ // special-case in serializeCachedFor() (see its comment).
158
+ const ts = m.timestamp ?? 0;
159
+ seq = (userSeqByTs.get(ts) ?? 0) + 1;
160
+ userSeqByTs.set(ts, seq);
161
+ }
162
+ else {
163
+ seq = seqOf(m);
164
+ }
165
+ if (uiMessageId(m, seq) === messageId)
166
+ return entry;
167
+ }
168
+ else if (entry.type === "custom_message" && entry.display !== false) {
169
+ // Custom messages carry content/timestamp on the entry itself (no
170
+ // message object); rebuild the shape uiMessageId()/seqOf() key on —
171
+ // the same conversion createCustomMessage() uses on reload.
172
+ const m = {
173
+ role: "custom",
174
+ content: entry.content,
175
+ timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : 0,
176
+ };
177
+ if (uiMessageId(m, seqOf(m)) === messageId)
178
+ return entry;
179
+ }
180
+ }
181
+ return null;
182
+ }
110
183
  export function serializeMessage(m, seq) {
184
+ // SDK 的 system 消息是 prompt sections 的内部差量
185
+ // (content 空串 + sections 结构化内存)、compaction 的
186
+ // systemMessage 等——从来不面向用户。不过滤的话
187
+ // 会掉进 default 分支被序列化成 content: []
188
+ // 的空气泡、前端顶着 system 标题白显示一条
189
+ // (对话结束后底部冒出的空 SYSTEM 气泡就是它)。
190
+ // LLM 上下文不受影响——这里只决定浏览器看到什么。
191
+ if (m.role === "system") {
192
+ return null;
193
+ }
194
+ const id = uiMessageId(m, seq);
111
195
  switch (m.role) {
112
196
  case "user":
113
197
  return {
114
- id: `u-${m.timestamp}-${seq}`,
198
+ id,
115
199
  role: "user",
116
200
  content: serializeUserContent(m.content),
117
201
  timestamp: m.timestamp,
118
202
  };
119
203
  case "assistant":
120
204
  return {
121
- id: `a-${m.timestamp}-${seq}`,
205
+ id,
122
206
  role: "assistant",
123
207
  content: serializeAssistantContent(m.content),
124
208
  timestamp: m.timestamp,
@@ -152,7 +236,7 @@ export function serializeMessage(m, seq) {
152
236
  const { text, truncated } = truncate(raw, TOOL_OUTPUT_CAP);
153
237
  const content = raw || images.length === 0 ? [{ type: "text", text, truncated }, ...images] : [...images];
154
238
  const msg = {
155
- id: `t-${m.toolCallId}`,
239
+ id,
156
240
  role: "toolResult",
157
241
  content,
158
242
  toolCallId: m.toolCallId,
@@ -177,7 +261,7 @@ export function serializeMessage(m, seq) {
177
261
  case "bashExecution": {
178
262
  const { text, truncated } = truncate(m.output, TOOL_OUTPUT_CAP);
179
263
  return {
180
- id: `b-${m.timestamp}-${seq}`,
264
+ id,
181
265
  role: "bashExecution",
182
266
  content: [
183
267
  {
@@ -199,19 +283,32 @@ export function serializeMessage(m, seq) {
199
283
  return null;
200
284
  }
201
285
  const content = serializeUserContent(m.content);
202
- return {
203
- id: `c-${m.timestamp}-${seq}`,
286
+ const msg = {
287
+ id,
204
288
  role: "custom",
205
289
  content,
206
290
  customType: m.customType,
207
- details: m.details,
208
291
  timestamp: m.timestamp,
209
292
  };
293
+ // custom details 与 toolResult 的 details 同一闸门(TOOL_DETAILS_CAP):
294
+ // details 随每 60ms 一发的快照推送,扩展塞进来的大对象不能无节制;
295
+ // 超限/序列化失败整丢(截断后的 JSON 不可解析,前端还得写容错)。
296
+ const rawDetails = m.details;
297
+ if (rawDetails !== undefined) {
298
+ try {
299
+ if (JSON.stringify(rawDetails).length <= TOOL_DETAILS_CAP)
300
+ msg.details = rawDetails;
301
+ }
302
+ catch {
303
+ // 循环引用等序列化不了的值:details 是附加信息,丢掉不影响消息本体。
304
+ }
305
+ }
306
+ return msg;
210
307
  }
211
308
  case "branchSummary": {
212
309
  const { text, truncated } = truncate(m.summary, TEXT_CAP);
213
310
  return {
214
- id: `bs-${m.timestamp}-${seq}`,
311
+ id,
215
312
  role: "branchSummary",
216
313
  content: [{ type: "text", text, truncated }],
217
314
  timestamp: m.timestamp,
@@ -220,7 +317,7 @@ export function serializeMessage(m, seq) {
220
317
  case "compactionSummary": {
221
318
  const { text, truncated } = truncate(m.summary, TEXT_CAP);
222
319
  return {
223
- id: `cs-${m.timestamp}-${seq}`,
320
+ id,
224
321
  role: "compactionSummary",
225
322
  content: [{ type: "text", text, truncated }],
226
323
  timestamp: m.timestamp,
@@ -229,7 +326,7 @@ export function serializeMessage(m, seq) {
229
326
  }
230
327
  default:
231
328
  return {
232
- id: `x-${seq}`,
329
+ id,
233
330
  role: String(m.role ?? "unknown"),
234
331
  content: [],
235
332
  timestamp: m.timestamp,
@@ -18,6 +18,7 @@ import { deriveLegacy, foldLegacyIntoDisabled, normalizeDisabledAgentTools } fro
18
18
  export class SettingsService {
19
19
  host;
20
20
  templates;
21
+ approvalRules;
21
22
  settings;
22
23
  presets;
23
24
  knownSkills = new Map();
@@ -26,9 +27,12 @@ export class SettingsService {
26
27
  pendingReload = false;
27
28
  constructor(host,
28
29
  /** 全局子代理模板库(所有客户端共享;模板改动无需 reload runtime)。 */
29
- templates) {
30
+ templates,
31
+ /** 全局审批规则库(所有客户端共享;修改实时生效无需 reload runtime)。 */
32
+ approvalRules) {
30
33
  this.host = host;
31
34
  this.templates = templates;
35
+ this.approvalRules = approvalRules;
32
36
  this.settings = host.stateStore.getSettings(host.clientId);
33
37
  this.presets = host.stateStore.getPresets(host.clientId);
34
38
  }
@@ -289,8 +293,12 @@ export class SettingsService {
289
293
  terminalToolsEnabled: legacyTools.terminalToolsEnabled,
290
294
  terminalBash: this.settings.terminalBash,
291
295
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
296
+ terminalBashMaxForegroundMs: this.settings.terminalBashMaxForegroundMs,
292
297
  toolWatchdogTimeoutMs: this.settings.toolWatchdogTimeoutMs,
293
298
  readDirEnabled: this.settings.readDirEnabled !== false,
299
+ toolApprovalEnabled: this.settings.toolApprovalEnabled !== false,
300
+ approvalPolicy: this.host.getApprovalPolicy?.() ?? { allowAll: false, categories: [] },
301
+ approvalRules: this.approvalRules?.list() ?? [],
294
302
  editSoftEnabled: legacyTools.editSoftEnabled,
295
303
  questionnaireEnabled: legacyTools.questionnaireEnabled,
296
304
  parallelReminderEnabled: this.settings.parallelReminderEnabled ?? true,
@@ -461,6 +469,9 @@ export class SettingsService {
461
469
  if (partial.terminalBashIdleMs !== undefined) {
462
470
  this.settings.terminalBashIdleMs = Math.max(0, Math.floor(partial.terminalBashIdleMs) || 0);
463
471
  }
472
+ if (partial.terminalBashMaxForegroundMs !== undefined) {
473
+ this.settings.terminalBashMaxForegroundMs = Math.max(0, Math.floor(partial.terminalBashMaxForegroundMs) || 0);
474
+ }
464
475
  if (partial.toolWatchdogTimeoutMs !== undefined) {
465
476
  this.settings.toolWatchdogTimeoutMs = normalizeToolWatchdogTimeoutMs(partial.toolWatchdogTimeoutMs);
466
477
  }
@@ -468,6 +479,10 @@ export class SettingsService {
468
479
  if (partial.readDirEnabled !== undefined) {
469
480
  this.settings.readDirEnabled = partial.readDirEnabled;
470
481
  }
482
+ // 工具执行审批总开关:审批入口每次实时读取(askApproval 顶部门禁),无需 reload。
483
+ if (partial.toolApprovalEnabled !== undefined) {
484
+ this.settings.toolApprovalEnabled = partial.toolApprovalEnabled;
485
+ }
471
486
  // 目标模式总开关:运行时无需重载(goal bar / 服务端入口实时读取)。
472
487
  if (partial.goalModeEnabled !== undefined) {
473
488
  this.settings.goalModeEnabled = partial.goalModeEnabled;
@@ -554,13 +569,15 @@ export class SettingsService {
554
569
  if (partial.quickPhrasesEnabled !== undefined) {
555
570
  this.settings.quickPhrasesEnabled = partial.quickPhrasesEnabled;
556
571
  }
557
- this.host.stateStore.saveSettings(this.host.clientId, this.settings);
558
- this.push();
559
572
  // 统一工具开关 live 生效(ActiveSet 加减;失败静默,下次创建/reload 重放)。
573
+ // 必须在 this.push() 前生效,保证 promptSnapshot 收集到的是最新的活跃工具集与完整提示词!
560
574
  if (toolGatingChanged)
561
575
  this.host.applyToolGating();
562
576
  if (needsReload)
563
577
  await this.applyRuntime();
578
+ this.host.stateStore.saveSettings(this.host.clientId, this.settings);
579
+ this.push();
580
+ this.host.flushSnapshot();
564
581
  }
565
582
  /** Save the CURRENT settings as a named preset (overwrites if exists). */
566
583
  async savePreset(name) {
@@ -587,6 +604,7 @@ export class SettingsService {
587
604
  terminalToolsEnabled: this.settings.terminalToolsEnabled,
588
605
  terminalBash: this.settings.terminalBash,
589
606
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
607
+ terminalBashMaxForegroundMs: this.settings.terminalBashMaxForegroundMs,
590
608
  editSoftEnabled: this.settings.editSoftEnabled,
591
609
  retryMaxAttempts: this.settings.retryMaxAttempts,
592
610
  softCapTokens: this.settings.softCapTokens,
@@ -636,8 +654,11 @@ export class SettingsService {
636
654
  // 终端接管偏好随预设走;旧预设缺字段时保留当前值。
637
655
  terminalBash: p.terminalBash ?? this.settings.terminalBash,
638
656
  terminalBashIdleMs: p.terminalBashIdleMs ?? this.settings.terminalBashIdleMs,
657
+ terminalBashMaxForegroundMs: p.terminalBashMaxForegroundMs ?? this.settings.terminalBashMaxForegroundMs,
639
658
  // read 读目录是纯运行行为开关,不进预设——保留当前值。
640
659
  readDirEnabled: this.settings.readDirEnabled !== false,
660
+ // 工具审批总开关同样是纯运行开关,不进预设——保留当前值。
661
+ toolApprovalEnabled: this.settings.toolApprovalEnabled !== false,
641
662
  // toolWatchdogTimeoutMs 是纯运行行为参数,不进预设——保留当前值。
642
663
  toolWatchdogTimeoutMs: this.settings.toolWatchdogTimeoutMs,
643
664
  editSoftEnabled: presetLegacy.editSoftEnabled,
@@ -725,6 +746,94 @@ export class SettingsService {
725
746
  textEn: `Subagent template deleted: ${name}`,
726
747
  });
727
748
  }
749
+ /** Upsert 一条审批规则(全局共享)。 */
750
+ async saveApprovalRule(rule) {
751
+ if (!this.approvalRules)
752
+ return;
753
+ const err = this.approvalRules.upsert(rule);
754
+ if (err) {
755
+ this.host.emit({
756
+ type: "notice",
757
+ level: "error",
758
+ text: `审批规则保存失败:${err}`,
759
+ textEn: `Failed to save approval rule: ${err}`,
760
+ });
761
+ return;
762
+ }
763
+ this.push();
764
+ this.host.emit({
765
+ type: "notice",
766
+ level: "info",
767
+ text: `审批规则已保存:${rule.label}`,
768
+ textEn: `Approval rule saved: ${rule.labelEn || rule.label}`,
769
+ });
770
+ }
771
+ /** 批量更新审批规则列表(重排或批量保存,全局共享)。 */
772
+ async saveApprovalRules(rules) {
773
+ if (!this.approvalRules)
774
+ return;
775
+ const err = this.approvalRules.saveAll(rules);
776
+ if (err) {
777
+ this.host.emit({
778
+ type: "notice",
779
+ level: "error",
780
+ text: `审批规则列表保存失败:${err}`,
781
+ textEn: `Failed to save approval rules: ${err}`,
782
+ });
783
+ return;
784
+ }
785
+ this.push();
786
+ this.host.emit({
787
+ type: "notice",
788
+ level: "info",
789
+ text: "审批规则列表已更新",
790
+ textEn: "Approval rules updated",
791
+ });
792
+ }
793
+ /** 删除一条自定义审批规则。 */
794
+ async deleteApprovalRule(id) {
795
+ if (!this.approvalRules)
796
+ return;
797
+ const ok = this.approvalRules.remove(id);
798
+ if (!ok) {
799
+ this.host.emit({
800
+ type: "notice",
801
+ level: "error",
802
+ text: "审批规则删除失败(内置规则不可删除)",
803
+ textEn: "Failed to delete approval rule (built-in rules cannot be deleted)",
804
+ });
805
+ return;
806
+ }
807
+ this.push();
808
+ this.host.emit({
809
+ type: "notice",
810
+ level: "info",
811
+ text: "审批规则已删除",
812
+ textEn: "Approval rule deleted",
813
+ });
814
+ }
815
+ /** 恢复某条内置审批规则到系统默认设定。 */
816
+ async resetBuiltinApprovalRule(id) {
817
+ if (!this.approvalRules)
818
+ return;
819
+ const ok = this.approvalRules.resetBuiltin(id);
820
+ if (!ok) {
821
+ this.host.emit({
822
+ type: "notice",
823
+ level: "error",
824
+ text: "恢复默认失败:未找到对应内置规则",
825
+ textEn: "Failed to reset: built-in rule not found",
826
+ });
827
+ return;
828
+ }
829
+ this.push();
830
+ this.host.emit({
831
+ type: "notice",
832
+ level: "info",
833
+ text: "内置规则已恢复默认",
834
+ textEn: "Built-in rule reset to default",
835
+ });
836
+ }
728
837
  /**
729
838
  * Make settings changes effective in the running runtime. The resource-loader
730
839
  * overrides read this.settings at call time, so a reload re-applies them.
@@ -14,13 +14,13 @@
14
14
  // 查不到被禁用的技能;
15
15
  // - 与 SDK 同名工具撞名时 customTools 按 name 覆盖(bash 本体是先例)。
16
16
  //
17
- // 双语约定(issue #91):definition 走 bilingual(en, zh) 内联双语;per-call
17
+ // 文案约定:工具 definition(description/promptSnippet/promptGuidelines)为纯英文;per-call
18
18
  // 返回文本按 lang 取 pick(lang, zh, en, key),缺表回落英文内联。
19
19
  // ---------------------------------------------------------------------------
20
20
  import { statSync, readFileSync } from "node:fs";
21
21
  import { defineTool } from "@earendil-works/pi-coding-agent";
22
22
  import { Type } from "typebox";
23
- import { bilingual, pick } from "./i18n.js";
23
+ import { pick } from "./i18n.js";
24
24
  import { SKILL_TOOL_NAME } from "./tool-manager.js";
25
25
  import { decodeText } from "./text-sniff.js";
26
26
  /** 单文件正文上限(与 agent-service fillSkillContents 同口径:8KB)。 */
@@ -61,12 +61,10 @@ export function makeSkillTool(host, lang) {
61
61
  return defineTool({
62
62
  name: SKILL_TOOL_NAME,
63
63
  label: "Load a skill",
64
- description: bilingual("Load a skill's full text by its exact name (see the <available_skills> catalog in the system prompt). " +
64
+ description: "Load a skill's full text by its exact name (see the <available_skills> catalog in the system prompt). " +
65
65
  "Prefer this over reading the skill file with the read tool — no path guessing needed. " +
66
- "Call without a name to list the current catalog.", "按技能精确名加载其全文(名录见系统提示词里的 <available_skills>)。" +
67
- "优先用它,不要自己拼路径调 read 工具读技能文件。" +
68
- "不给名则返回当前名录。"),
69
- promptSnippet: bilingual("load a skill's full text by name (skill tool, preferred over read)", "按名加载技能全文(skill 工具,优先于 read)"),
66
+ "Call without a name to list the current catalog.",
67
+ promptSnippet: "load a skill's full text by name (skill tool, preferred over read)",
70
68
  parameters: Type.Object({
71
69
  name: Type.Optional(Type.String({
72
70
  description: "Exact skill name from the catalog. Omit to list the current catalog.",
@@ -42,6 +42,53 @@ export function pickTemplatePrompt(t, lang) {
42
42
  }
43
43
  /** 名字去空白折叠后非空且 ≤ 60 字符(工具参数可读,允许中文)。 */
44
44
  const NAME_MAX = 60;
45
+ /**
46
+ * 模板体积上限(保存期拒绝,不静默截断):模板会整体进入设置面板快照并被
47
+ * subagent_templates 工具列给 AI,systemPrompt 还会拼进子代理上下文,无上限
48
+ * 的单条模板能把快照推送与子代理首响都拖爆。对齐常见上下文预算的保守值。
49
+ */
50
+ export const TEMPLATE_LIMITS = {
51
+ /** 模板系统提示词上限(systemPrompt / systemPromptEn 各自计)。 */
52
+ systemPrompt: 32_768,
53
+ /** 模板简介上限(description / descriptionEn 各自计)。 */
54
+ description: 2_000,
55
+ /** 技能/扩展白名单各自最多条目数。 */
56
+ whitelistEntries: 64,
57
+ /** 白名单单条字符上限。 */
58
+ whitelistEntryLength: 200,
59
+ };
60
+ /**
61
+ * 保存期体积校验:超限返回明确错误文本(让设置面板能原样提示),null = 通过。
62
+ * 只拦新保存,不拦读盘老数据(向后兼容,与 replace 空提示词的拦截口径一致);
63
+ * 拒绝而非截断——静默截断会让用户以为保存的是完整内容。
64
+ */
65
+ export function validateTemplateLimits(t) {
66
+ if (t.systemPrompt.length > TEMPLATE_LIMITS.systemPrompt) {
67
+ return `模板 "${t.name}" 的系统提示词超长(${t.systemPrompt.length} > ${TEMPLATE_LIMITS.systemPrompt} 字符),请精简后再保存`;
68
+ }
69
+ if ((t.systemPromptEn ?? "").length > TEMPLATE_LIMITS.systemPrompt) {
70
+ return `模板 "${t.name}" 的英文系统提示词超长(${(t.systemPromptEn ?? "").length} > ${TEMPLATE_LIMITS.systemPrompt} 字符),请精简后再保存`;
71
+ }
72
+ if (t.description.length > TEMPLATE_LIMITS.description) {
73
+ return `模板 "${t.name}" 的简介超长(${t.description.length} > ${TEMPLATE_LIMITS.description} 字符),请精简后再保存`;
74
+ }
75
+ if ((t.descriptionEn ?? "").length > TEMPLATE_LIMITS.description) {
76
+ return `模板 "${t.name}" 的英文简介超长(${(t.descriptionEn ?? "").length} > ${TEMPLATE_LIMITS.description} 字符),请精简后再保存`;
77
+ }
78
+ for (const [label, list] of [
79
+ ["技能白名单", t.enabledSkills],
80
+ ["扩展白名单", t.enabledExtensions],
81
+ ]) {
82
+ if (list.length > TEMPLATE_LIMITS.whitelistEntries) {
83
+ return `模板 "${t.name}" 的${label}条目过多(${list.length} > ${TEMPLATE_LIMITS.whitelistEntries} 条)`;
84
+ }
85
+ const over = list.find((entry) => entry.length > TEMPLATE_LIMITS.whitelistEntryLength);
86
+ if (over) {
87
+ return `模板 "${t.name}" 的${label}有超长条目(${over.length} > ${TEMPLATE_LIMITS.whitelistEntryLength} 字符)`;
88
+ }
89
+ }
90
+ return null;
91
+ }
45
92
  /**
46
93
  * 内置默认模板(第一次运行时种子进列表;用户改动后以 <dataDir> 文件为准)。
47
94
  * 文案改编自 pi-subagents 社区项目(tintinweb / nicobailon)的角色提示词,
@@ -629,6 +676,10 @@ export class SubagentTemplatesStore {
629
676
  if (t.promptMode === "replace" && !t.systemPrompt.trim() && !(t.systemPromptEn ?? "").trim()) {
630
677
  return `模板 "${t.name}" 用了 replace 模式但系统提示词为空:replace 会整体替换子代理 persona,空提示词等于模板没生效。请填写提示词(中文或英文至少一个),只想限定技能/扩展白名单请用 append 模式`;
631
678
  }
679
+ // 体积上限同样只在保存期拦:超限明确报错,绝不静默截断。
680
+ const limitError = validateTemplateLimits(t);
681
+ if (limitError)
682
+ return limitError;
632
683
  const list = this.load();
633
684
  const i = list.findIndex((x) => x.name === t.name);
634
685
  if (i >= 0)