@youweichen/pi-web-ui 0.45.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 (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/bin/pi-web-ui.mjs +1818 -0
  4. package/deploy/com.xingshuyin.pi-web-ui.plist +48 -0
  5. package/deploy/nginx-subpath.conf +88 -0
  6. package/deploy/pi-web-ui-task.xml +71 -0
  7. package/deploy/pi-web-ui.service +31 -0
  8. package/dist/server/agent-service.js +2975 -0
  9. package/dist/server/attachments.js +672 -0
  10. package/dist/server/bg-servers.js +150 -0
  11. package/dist/server/client-state.js +234 -0
  12. package/dist/server/control-socket.js +177 -0
  13. package/dist/server/ensure-bash.js +80 -0
  14. package/dist/server/files-service.js +709 -0
  15. package/dist/server/goal-service.js +869 -0
  16. package/dist/server/index.js +879 -0
  17. package/dist/server/mcp-bridge.js +268 -0
  18. package/dist/server/model-admin.js +821 -0
  19. package/dist/server/patch-node-pty.js +80 -0
  20. package/dist/server/plugin-facilities.js +299 -0
  21. package/dist/server/plugin-updater.js +226 -0
  22. package/dist/server/plugins.js +893 -0
  23. package/dist/server/process-utils.js +112 -0
  24. package/dist/server/protocol-version.js +11 -0
  25. package/dist/server/protocol.js +6 -0
  26. package/dist/server/scm.js +298 -0
  27. package/dist/server/serialize.js +172 -0
  28. package/dist/server/settings-service.js +395 -0
  29. package/dist/server/slash-commands.js +263 -0
  30. package/dist/server/terminals.js +1372 -0
  31. package/dist/server/text-sniff.js +268 -0
  32. package/dist/server/uploads.js +107 -0
  33. package/dist/server/vision-bridge.js +131 -0
  34. package/dist/server/webui-context.js +222 -0
  35. package/extensions/webui.ts +192 -0
  36. package/package.json +99 -0
  37. package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +32 -0
  38. package/web/dist/assets/TerminalPanel-D4AktsEn.js +2 -0
  39. package/web/dist/assets/abnfDiagram-VCTEODGH-Ct5StJwt.js +1 -0
  40. package/web/dist/assets/arc-pkuxEN8A.js +1 -0
  41. package/web/dist/assets/architectureDiagram-5GKGNRK7-C6P6yn4y.js +36 -0
  42. package/web/dist/assets/blockDiagram-I7D4REHJ-BmgfH1jt.js +129 -0
  43. package/web/dist/assets/c4Diagram-7LVT6UL2-CgmwTFsK.js +38 -0
  44. package/web/dist/assets/channel-0Y__6sq2.js +1 -0
  45. package/web/dist/assets/chunk-2Q5K7J3B-DX_NqAiH.js +1 -0
  46. package/web/dist/assets/chunk-5VM5RSS4-gLeuQuYv.js +15 -0
  47. package/web/dist/assets/chunk-F27PBJKO-D4WX4mhI.js +1 -0
  48. package/web/dist/assets/chunk-IMKFNOWR-DBpDxGQD.js +231 -0
  49. package/web/dist/assets/chunk-JWPE2WC7-DIQ8iZN5.js +1 -0
  50. package/web/dist/assets/chunk-POPQ4Y6H-F_YTNoGz.js +1 -0
  51. package/web/dist/assets/chunk-SVP7TREG-CmDIvX-r.js +88 -0
  52. package/web/dist/assets/chunk-TICWLB2K-BQUzHQyS.js +206 -0
  53. package/web/dist/assets/chunk-XXDRQBXY-DqR9iffH.js +1 -0
  54. package/web/dist/assets/classDiagram-ZZMXUADV-BEfGIpzu.js +1 -0
  55. package/web/dist/assets/classDiagram-v2-VYDZK3BY-BEfGIpzu.js +1 -0
  56. package/web/dist/assets/cose-bilkent-JH36ORCC-Dl67W4Dx.js +1 -0
  57. package/web/dist/assets/cynefin-OW5HDTMX-fWdqaCpX.js +178 -0
  58. package/web/dist/assets/cynefinDiagram-5FMLGOSQ-CSUAXbaP.js +62 -0
  59. package/web/dist/assets/cytoscape.esm-Bch-eiPH.js +331 -0
  60. package/web/dist/assets/dagre-GXQ25YYZ-CqUVwu1X.js +4 -0
  61. package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  62. package/web/dist/assets/diagram-S7CK7UJ4-bRce-WYV.js +30 -0
  63. package/web/dist/assets/diagram-UQ7AKVKN-CiqPtPBE.js +41 -0
  64. package/web/dist/assets/diagram-VSXAHHWV-CbEA05LX.js +3 -0
  65. package/web/dist/assets/diagram-VX7I27RA-CRcY9MD-.js +24 -0
  66. package/web/dist/assets/diagram-Z3DM3KII-DmTwvZdS.js +24 -0
  67. package/web/dist/assets/ebnfDiagram-PWID7BFC-DbwmiGVj.js +1 -0
  68. package/web/dist/assets/erDiagram-RLTQ6QDP-Cxiaf87y.js +99 -0
  69. package/web/dist/assets/flowDiagram-HODETNUW-BQiecP1I.js +168 -0
  70. package/web/dist/assets/ganttDiagram-EL5Y4UJY-CSr8RRNw.js +292 -0
  71. package/web/dist/assets/gitGraphDiagram-WWUBYQGX-C4Tdis3g.js +106 -0
  72. package/web/dist/assets/index-CniHcFxT.js +22 -0
  73. package/web/dist/assets/index-DYz2iP_B.css +10 -0
  74. package/web/dist/assets/infoDiagram-27XIBGKW-ij_5A0pY.js +2 -0
  75. package/web/dist/assets/init-Gi6I4Gst.js +1 -0
  76. package/web/dist/assets/ishikawaDiagram-5VMMS53U-C8-tm2LV.js +70 -0
  77. package/web/dist/assets/journeyDiagram-3NMN7TZE-ZJedkNel.js +139 -0
  78. package/web/dist/assets/kanban-definition-UXKFOSKX-BEuoM53n.js +89 -0
  79. package/web/dist/assets/katex-C5jXJg4s.js +257 -0
  80. package/web/dist/assets/layout-zq_9DigZ.js +1 -0
  81. package/web/dist/assets/linear-D-QXJ5Wc.js +1 -0
  82. package/web/dist/assets/markdown-CUPbftfV.js +51 -0
  83. package/web/dist/assets/mermaid.core-CCiG-bOm.js +312 -0
  84. package/web/dist/assets/mindmap-definition-YA3MSWOX-VaAisUIe.js +96 -0
  85. package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  86. package/web/dist/assets/pegDiagram-XKGWAZYB-D5vmRtkn.js +1 -0
  87. package/web/dist/assets/pieDiagram-E7YTZNPT-loD6V8fn.js +39 -0
  88. package/web/dist/assets/quadrantDiagram-AXDQQJYC-B7E4mH1-.js +7 -0
  89. package/web/dist/assets/railroadDiagram-O6MQD6OU-DTALHvDa.js +1 -0
  90. package/web/dist/assets/react-ZkKJMDQQ.js +24 -0
  91. package/web/dist/assets/requirementDiagram-BXWQKSXE-ZjJKuEcR.js +84 -0
  92. package/web/dist/assets/sankeyDiagram-P5KCCOFB-DufTudDP.js +40 -0
  93. package/web/dist/assets/sequenceDiagram-WJ2MYXX4-B-Zk4alI.js +162 -0
  94. package/web/dist/assets/sizeCapture-INFHLROL-B1pjlxXN.js +1 -0
  95. package/web/dist/assets/stateDiagram-D77RDMKH-BeWg6PIR.js +1 -0
  96. package/web/dist/assets/stateDiagram-v2-MP3YSRHH-DagWhIFJ.js +1 -0
  97. package/web/dist/assets/swimlanes-42K2YHIH-4J4jmMmO.js +1 -0
  98. package/web/dist/assets/swimlanesDiagram-VR7AAH4N-Dsi9x540.js +8 -0
  99. package/web/dist/assets/timeline-definition-24CTP7MA-ByqLfxqe.js +120 -0
  100. package/web/dist/assets/vennDiagram-4TSXK5OY-B_EqVW4R.js +34 -0
  101. package/web/dist/assets/wardleyDiagram-VM6X3IG4-Bu7sA6F8.js +78 -0
  102. package/web/dist/assets/xterm-D1D2FVe3.js +38 -0
  103. package/web/dist/assets/xychartDiagram-S5SC5T6Z-w1KPVGKk.js +7 -0
  104. package/web/dist/favicon.svg +8 -0
  105. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  106. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  107. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  108. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  109. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  110. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
  111. package/web/dist/fonts/inter/inter-latin-400-normal.woff2 +0 -0
  112. package/web/dist/fonts/inter/inter-latin-500-normal.woff2 +0 -0
  113. package/web/dist/fonts/inter/inter-latin-600-normal.woff2 +0 -0
  114. package/web/dist/fonts/inter/inter-latin-700-normal.woff2 +0 -0
  115. package/web/dist/fonts/inter/inter-latin-800-normal.woff2 +0 -0
  116. package/web/dist/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
  117. package/web/dist/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
  118. package/web/dist/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
  119. package/web/dist/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
  120. package/web/dist/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
  121. package/web/dist/icon.ico +0 -0
  122. package/web/dist/index.html +17 -0
  123. package/web/public/favicon.svg +8 -0
  124. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  125. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  126. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  127. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  128. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  129. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
  130. package/web/public/fonts/inter/inter-latin-400-normal.woff2 +0 -0
  131. package/web/public/fonts/inter/inter-latin-500-normal.woff2 +0 -0
  132. package/web/public/fonts/inter/inter-latin-600-normal.woff2 +0 -0
  133. package/web/public/fonts/inter/inter-latin-700-normal.woff2 +0 -0
  134. package/web/public/fonts/inter/inter-latin-800-normal.woff2 +0 -0
  135. package/web/public/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
  136. package/web/public/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
  137. package/web/public/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
  138. package/web/public/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
  139. package/web/public/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
  140. package/web/public/icon.ico +0 -0
@@ -0,0 +1,395 @@
1
+ /**
2
+ * Settings service — 从 agent-service.ts 抽出(系统提示词 / 技能插件开关 /
3
+ * 目标审查提示词 / 预设 / 视觉桥偏好)。设置持久化在 client-state.json 按客户端隔离。
4
+ *
5
+ * 经 SettingsHost 回调与 ClientSession 解耦:本模块只管「设置状态 + 面板推送 +
6
+ * 预设存取 + 何时需要 reload」,真正动 runtime 的 session.reload() 走宿主回调
7
+ * (reloadSession 里还会刷新斜杠命令目录)。
8
+ */
9
+ import { existsSync, readdirSync } from "node:fs";
10
+ import { basename, dirname, join } from "node:path";
11
+ import { extensionKey } from "./client-state.js";
12
+ import { findVisionModels, SYSTEM_PROMPT } from "./vision-bridge.js";
13
+ export class SettingsService {
14
+ host;
15
+ settings;
16
+ presets;
17
+ knownSkills = new Map();
18
+ knownExtensions = new Map();
19
+ /** 流式中改了需要 reload 的设置 → agent_end 后延迟应用(防拆毁运行中 run)。 */
20
+ pendingReload = false;
21
+ constructor(host) {
22
+ this.host = host;
23
+ this.settings = host.stateStore.getSettings(host.clientId);
24
+ this.presets = host.stateStore.getPresets(host.clientId);
25
+ }
26
+ get current() {
27
+ return this.settings;
28
+ }
29
+ get reviewPrefs() {
30
+ return {
31
+ reviewPrompt: this.settings.reviewPrompt,
32
+ reviewDisabledSkills: this.settings.reviewDisabledSkills,
33
+ };
34
+ }
35
+ hasPendingReload() {
36
+ return this.pendingReload;
37
+ }
38
+ consumePendingReload() {
39
+ const v = this.pendingReload;
40
+ this.pendingReload = false;
41
+ return v;
42
+ }
43
+ /** 判断某个 skill 名是否仍存在于磁盘任何来源(agent 区 / 项目 .pi / 祖先
44
+ * .agents/skills / npm 包内 skills)。被禁用且文件已删除的名字不应再
45
+ * 出现在设置面板,也不应留在持久化记录里。 */
46
+ skillStillOnDisk(name) {
47
+ const cwd = this.host.cwd();
48
+ const agentDir = this.host.agentDir();
49
+ const check = (base) => existsSync(join(base, name)) || existsSync(join(base, `${name}.md`));
50
+ // ① 用户区 <agentDir>/skills ② 项目 .pi/skills
51
+ if (check(join(agentDir, "skills")))
52
+ return true;
53
+ if (check(join(cwd, ".pi", "skills")))
54
+ return true;
55
+ // ③ 祖先链 .agents/skills(SDK collectAncestorAgentsSkillDirs 语义,最多上溯 6 层)
56
+ let dir = cwd;
57
+ for (let i = 0; i < 6 && dir !== dirname(dir); i++, dir = dirname(dir)) {
58
+ if (check(join(dir, ".agents", "skills")))
59
+ return true;
60
+ }
61
+ // ④ npm 包内 skills(agent 级 + 项目级,含 @scope 两级子包)
62
+ for (const npmRoot of [
63
+ join(agentDir, "npm", "node_modules"),
64
+ join(cwd, ".pi", "npm", "node_modules"),
65
+ ]) {
66
+ try {
67
+ for (const entry of readdirSync(npmRoot, { withFileTypes: true })) {
68
+ if (!entry.isDirectory())
69
+ continue;
70
+ if (!entry.name.startsWith("@")) {
71
+ if (check(join(npmRoot, entry.name, "skills")))
72
+ return true;
73
+ }
74
+ else {
75
+ for (const sub of readdirSync(join(npmRoot, entry.name), { withFileTypes: true })) {
76
+ if (sub.isDirectory() && check(join(npmRoot, entry.name, sub.name, "skills"))) {
77
+ return true;
78
+ }
79
+ }
80
+ }
81
+ }
82
+ }
83
+ catch {
84
+ // npm 目录不存在/不可读 → 不是来源
85
+ }
86
+ }
87
+ return false;
88
+ }
89
+ push() {
90
+ const disabledSkills = new Set(this.settings.disabledSkills);
91
+ const reviewDisabledSkills = new Set(this.settings.reviewDisabledSkills);
92
+ const disabledExts = new Set(this.settings.disabledExtensions);
93
+ let loadedSkillNames = null;
94
+ try {
95
+ const loadedSkills = this.host.getSession().resourceLoader.getSkills().skills;
96
+ const loadedExts = this.host.getSession().resourceLoader.getExtensions().extensions;
97
+ loadedSkillNames = new Set(loadedSkills.map((s) => s.name));
98
+ // Prune entries that no longer exist on disk AND aren't disabled
99
+ // (e.g. a skill/extension file was deleted). Disabled entries are
100
+ // kept so they can be re-enabled even when filtered out of the loader.
101
+ const keepSkills = new Set([
102
+ ...loadedSkills.map((s) => s.name),
103
+ ...this.settings.disabledSkills,
104
+ ]);
105
+ const keepExts = new Set([
106
+ ...loadedExts.map((e) => extensionKey(e)),
107
+ ...this.settings.disabledExtensions,
108
+ ]);
109
+ for (const name of [...this.knownSkills.keys()]) {
110
+ if (!keepSkills.has(name))
111
+ this.knownSkills.delete(name);
112
+ }
113
+ for (const id of [...this.knownExtensions.keys()]) {
114
+ if (!keepExts.has(id))
115
+ this.knownExtensions.delete(id);
116
+ }
117
+ for (const s of loadedSkills) {
118
+ this.knownSkills.set(s.name, {
119
+ name: s.name,
120
+ description: s.description,
121
+ enabled: true,
122
+ });
123
+ }
124
+ for (const e of loadedExts) {
125
+ const id = extensionKey(e);
126
+ const p = e.sourceInfo?.path ?? e.path;
127
+ this.knownExtensions.set(id, {
128
+ id,
129
+ name: e.sourceInfo?.origin === "package" && e.sourceInfo.source
130
+ ? e.sourceInfo.source
131
+ : basename(p),
132
+ path: p,
133
+ enabled: true,
134
+ });
135
+ }
136
+ }
137
+ catch {
138
+ // Session not ready yet — keep whatever we already know.
139
+ }
140
+ // 清理“源文件已删除”的禁用残留记录:磁盘上已不存在的技能名从
141
+ // disabledSkills / reviewDisabledSkills 持久化记录中移除——否则每次
142
+ // 推送都会把已删除的 skill 以灰条形式永恒地补回面板(“关闭过的
143
+ // skill 被一直记录”)。session 未就绪时保守跳过。
144
+ if (loadedSkillNames !== null) {
145
+ const stale = [
146
+ ...new Set([...this.settings.disabledSkills, ...this.settings.reviewDisabledSkills]),
147
+ ].filter((name) => !loadedSkillNames.has(name) && !this.skillStillOnDisk(name));
148
+ if (stale.length > 0) {
149
+ this.settings.disabledSkills = this.settings.disabledSkills.filter((n) => !stale.includes(n));
150
+ this.settings.reviewDisabledSkills = this.settings.reviewDisabledSkills.filter((n) => !stale.includes(n));
151
+ this.host.stateStore.saveSettings(this.host.clientId, {
152
+ disabledSkills: this.settings.disabledSkills,
153
+ reviewDisabledSkills: this.settings.reviewDisabledSkills,
154
+ });
155
+ }
156
+ }
157
+ // Disabled entries that still exist on disk are re-added (with the
158
+ // last-known description) so they can be re-enabled; entries whose
159
+ // source file was deleted are dropped instead of being resurrected.
160
+ for (const name of this.settings.disabledSkills) {
161
+ if (this.knownSkills.has(name))
162
+ continue;
163
+ if (!this.skillStillOnDisk(name))
164
+ continue;
165
+ this.knownSkills.set(name, { name, description: "", enabled: false });
166
+ }
167
+ for (const id of this.settings.disabledExtensions) {
168
+ if (!this.knownExtensions.has(id)) {
169
+ this.knownExtensions.set(id, {
170
+ id,
171
+ name: id.startsWith("npm:") ? id : basename(id),
172
+ path: "",
173
+ enabled: false,
174
+ });
175
+ }
176
+ }
177
+ const skills = [...this.knownSkills.values()]
178
+ .map((s) => ({ ...s, enabled: !disabledSkills.has(s.name) }))
179
+ .sort((a, b) => a.name.localeCompare(b.name));
180
+ const reviewSkills = [...this.knownSkills.values()]
181
+ .map((s) => ({ ...s, enabled: !reviewDisabledSkills.has(s.name) }))
182
+ .sort((a, b) => a.name.localeCompare(b.name));
183
+ const extensions = [...this.knownExtensions.values()]
184
+ .map((e) => ({ ...e, enabled: !disabledExts.has(e.id) }))
185
+ .sort((a, b) => a.name.localeCompare(b.name));
186
+ this.host.emit({
187
+ type: "settings_state",
188
+ settings: {
189
+ promptMode: this.settings.promptMode,
190
+ customSystemPrompt: this.settings.customSystemPrompt,
191
+ terminalToolsEnabled: this.settings.terminalToolsEnabled,
192
+ terminalBash: this.settings.terminalBash,
193
+ terminalBashIdleMs: this.settings.terminalBashIdleMs,
194
+ thinkingWrap: this.settings.thinkingWrap,
195
+ toolsWrap: this.settings.toolsWrap,
196
+ visionBridgeEnabled: this.settings.visionBridgeEnabled,
197
+ visionBridgeModel: this.settings.visionBridgeModel,
198
+ visionBridgePromptMode: this.settings.visionBridgePromptMode,
199
+ visionBridgePrompt: this.settings.visionBridgePrompt,
200
+ reviewPrompt: this.settings.reviewPrompt,
201
+ reviewDisabledSkills: [...this.settings.reviewDisabledSkills],
202
+ disabledPlugins: [...(this.settings.disabledPlugins ?? [])],
203
+ // The built-in prompts, so the replace-mode editors can prefill the
204
+ // text they would otherwise replace (empty until the resource-loader
205
+ // has run once for the system prompt).
206
+ defaultSystemPrompt: this.host.effectiveDefaultSystemPrompt(),
207
+ effectiveSystemPrompt: this.host.effectiveSystemPrompt(),
208
+ visionBridgeDefaultPrompt: SYSTEM_PROMPT,
209
+ visionModels: this.collectVisionModels(),
210
+ disabledSkills: [...this.settings.disabledSkills],
211
+ disabledExtensions: [...this.settings.disabledExtensions],
212
+ skills,
213
+ reviewSkills,
214
+ extensions,
215
+ presets: this.presets.map((p) => ({ ...p })),
216
+ },
217
+ });
218
+ }
219
+ /** Vision-capable configured models, for the settings-panel picker. */
220
+ collectVisionModels() {
221
+ try {
222
+ return findVisionModels(this.host.getSession().modelRuntime).map((m) => ({
223
+ provider: m.provider,
224
+ id: m.id,
225
+ label: m.label,
226
+ }));
227
+ }
228
+ catch {
229
+ // Session not ready yet — the picker stays empty until next push.
230
+ return [];
231
+ }
232
+ }
233
+ /** Persist + apply a partial settings update (prompt text/mode, toggles). */
234
+ async set(partial) {
235
+ const needsReload = partial.promptMode !== undefined ||
236
+ partial.customSystemPrompt !== undefined ||
237
+ partial.disabledSkills !== undefined ||
238
+ partial.disabledExtensions !== undefined ||
239
+ partial.terminalToolsEnabled !== undefined;
240
+ if (partial.promptMode !== undefined)
241
+ this.settings.promptMode = partial.promptMode;
242
+ if (partial.customSystemPrompt !== undefined) {
243
+ this.settings.customSystemPrompt = partial.customSystemPrompt;
244
+ }
245
+ if (partial.disabledSkills !== undefined) {
246
+ this.settings.disabledSkills = partial.disabledSkills;
247
+ }
248
+ if (partial.disabledExtensions !== undefined) {
249
+ this.settings.disabledExtensions = partial.disabledExtensions;
250
+ }
251
+ // 插件开关是纯 UI 隐藏(不进 needsReload——运行时无需重载)。
252
+ if (partial.disabledPlugins !== undefined) {
253
+ this.settings.disabledPlugins = partial.disabledPlugins;
254
+ }
255
+ if (partial.terminalToolsEnabled !== undefined) {
256
+ this.settings.terminalToolsEnabled = partial.terminalToolsEnabled;
257
+ }
258
+ if (partial.terminalBash !== undefined) {
259
+ this.settings.terminalBash = partial.terminalBash;
260
+ }
261
+ if (partial.terminalBashIdleMs !== undefined) {
262
+ this.settings.terminalBashIdleMs = Math.max(0, Math.floor(partial.terminalBashIdleMs) || 0);
263
+ }
264
+ if (partial.thinkingWrap !== undefined) {
265
+ this.settings.thinkingWrap = partial.thinkingWrap;
266
+ }
267
+ if (partial.toolsWrap !== undefined) {
268
+ this.settings.toolsWrap = partial.toolsWrap;
269
+ }
270
+ if (partial.visionBridgeEnabled !== undefined) {
271
+ this.settings.visionBridgeEnabled = partial.visionBridgeEnabled;
272
+ }
273
+ if (partial.visionBridgeModel !== undefined) {
274
+ this.settings.visionBridgeModel = partial.visionBridgeModel ?? null;
275
+ }
276
+ if (partial.visionBridgePromptMode !== undefined) {
277
+ this.settings.visionBridgePromptMode = partial.visionBridgePromptMode;
278
+ }
279
+ if (partial.visionBridgePrompt !== undefined) {
280
+ this.settings.visionBridgePrompt = partial.visionBridgePrompt;
281
+ }
282
+ if (partial.reviewPrompt !== undefined) {
283
+ this.settings.reviewPrompt = partial.reviewPrompt;
284
+ }
285
+ if (partial.reviewDisabledSkills !== undefined) {
286
+ this.settings.reviewDisabledSkills = partial.reviewDisabledSkills;
287
+ }
288
+ this.host.stateStore.saveSettings(this.host.clientId, this.settings);
289
+ this.push();
290
+ if (needsReload)
291
+ await this.applyRuntime();
292
+ }
293
+ /** Save the CURRENT settings as a named preset (overwrites if exists). */
294
+ async savePreset(name) {
295
+ const n = name.trim();
296
+ if (!n) {
297
+ this.host.emit({ type: "notice", level: "error", text: "预设名称不能为空" });
298
+ return;
299
+ }
300
+ const preset = {
301
+ name: n,
302
+ promptMode: this.settings.promptMode,
303
+ customSystemPrompt: this.settings.customSystemPrompt,
304
+ disabledSkills: [...this.settings.disabledSkills],
305
+ disabledExtensions: [...this.settings.disabledExtensions],
306
+ terminalToolsEnabled: this.settings.terminalToolsEnabled,
307
+ terminalBash: this.settings.terminalBash,
308
+ terminalBashIdleMs: this.settings.terminalBashIdleMs,
309
+ reviewPrompt: this.settings.reviewPrompt,
310
+ reviewDisabledSkills: [...this.settings.reviewDisabledSkills],
311
+ };
312
+ const existing = this.presets.findIndex((p) => p.name === n);
313
+ if (existing >= 0)
314
+ this.presets[existing] = preset;
315
+ else
316
+ this.presets.push(preset);
317
+ this.host.stateStore.savePresets(this.host.clientId, this.presets);
318
+ this.push();
319
+ }
320
+ /** Replace the current settings with the named preset and apply it. */
321
+ async applyPreset(name) {
322
+ const p = this.presets.find((x) => x.name === name);
323
+ if (!p) {
324
+ this.host.emit({ type: "notice", level: "error", text: `预设不存在:${name}` });
325
+ return;
326
+ }
327
+ this.settings = {
328
+ promptMode: p.promptMode,
329
+ customSystemPrompt: p.customSystemPrompt,
330
+ disabledSkills: [...p.disabledSkills],
331
+ disabledExtensions: [...p.disabledExtensions],
332
+ // 旧版持久化的预设可能没有该字段——保留当前值。
333
+ terminalToolsEnabled: p.terminalToolsEnabled ?? this.settings.terminalToolsEnabled,
334
+ // 终端接管偏好随预设走;旧预设缺字段时保留当前值。
335
+ terminalBash: p.terminalBash ?? this.settings.terminalBash,
336
+ terminalBashIdleMs: p.terminalBashIdleMs ?? this.settings.terminalBashIdleMs,
337
+ reviewPrompt: p.reviewPrompt ?? this.settings.reviewPrompt,
338
+ reviewDisabledSkills: [
339
+ ...(p.reviewDisabledSkills ?? this.settings.reviewDisabledSkills),
340
+ ],
341
+ // 纯 UI 偏好不进预设——保留当前值。
342
+ thinkingWrap: this.settings.thinkingWrap,
343
+ toolsWrap: this.settings.toolsWrap,
344
+ // Presets don't capture vision-bridge prefs — keep the current ones.
345
+ visionBridgeEnabled: this.settings.visionBridgeEnabled,
346
+ visionBridgeModel: this.settings.visionBridgeModel,
347
+ visionBridgePromptMode: this.settings.visionBridgePromptMode,
348
+ visionBridgePrompt: this.settings.visionBridgePrompt,
349
+ };
350
+ this.host.stateStore.saveSettings(this.host.clientId, this.settings);
351
+ this.push();
352
+ await this.applyRuntime();
353
+ }
354
+ /** Remove a named preset. */
355
+ async deletePreset(name) {
356
+ this.presets = this.presets.filter((p) => p.name !== name);
357
+ this.host.stateStore.savePresets(this.host.clientId, this.presets);
358
+ this.push();
359
+ }
360
+ /**
361
+ * Make settings changes effective in the running runtime. The resource-loader
362
+ * overrides read this.settings at call time, so a reload re-applies them.
363
+ * Reloading mid-stream would tear down the in-flight run — defer instead.
364
+ */
365
+ async applyRuntime() {
366
+ if (this.host.isDisposed())
367
+ return;
368
+ if (this.host.isStreaming()) {
369
+ this.pendingReload = true;
370
+ this.host.emit({
371
+ type: "notice",
372
+ level: "info",
373
+ text: "当前回复进行中,设置将在回复结束后自动应用",
374
+ });
375
+ return;
376
+ }
377
+ await this.applyReload();
378
+ }
379
+ /** session.reload() + refresh the slash-command catalog + push state. */
380
+ async applyReload() {
381
+ try {
382
+ await this.host.reloadSession();
383
+ this.push();
384
+ this.host.flushSnapshot();
385
+ this.host.emit({ type: "notice", level: "info", text: "设置已应用" });
386
+ }
387
+ catch (err) {
388
+ this.host.emit({
389
+ type: "notice",
390
+ level: "error",
391
+ text: `设置应用失败:${err.message}`,
392
+ });
393
+ }
394
+ }
395
+ }
@@ -0,0 +1,263 @@
1
+ /** Slash commands implemented natively by the web server (the pi CLI's built-in
2
+ * interactive commands like /model and /new are NOT handled by the SDK's
3
+ * prompt() — without this they'd be sent to the model as plain text). Keep in
4
+ * sync with exec(). */
5
+ export const NATIVE_COMMANDS = [
6
+ { name: "new", description: "新建对话", descriptionEn: "New chat" },
7
+ { name: "model", description: "切换模型", descriptionEn: "Switch model", argumentHint: "[名称]", argumentHintEn: "[name]" },
8
+ { name: "compact", description: "压缩上下文", descriptionEn: "Compact context", argumentHint: "[说明]", argumentHintEn: "[instructions]" },
9
+ { name: "cwd", description: "切换工作目录", descriptionEn: "Switch workspace", argumentHint: "<路径>", argumentHintEn: "<path>" },
10
+ {
11
+ name: "thinking",
12
+ description: "设置思考强度",
13
+ descriptionEn: "Set thinking level",
14
+ argumentHint: "<off|low|medium|high|xhigh|max>",
15
+ argumentHintEn: "<off|low|medium|high|xhigh|max>",
16
+ },
17
+ { name: "resume", description: "刷新会话列表", descriptionEn: "Refresh session list" },
18
+ { name: "reload", description: "重新加载扩展、技能与模板", descriptionEn: "Reload extensions, skills & templates" },
19
+ { name: "help", description: "显示全部命令", descriptionEn: "Show all commands" },
20
+ { name: "copy", description: "复制上一条助手回复", descriptionEn: "Copy last assistant reply" },
21
+ { name: "pi-web-ui:quit", description: "退出服务", descriptionEn: "Quit server (supervisor will restart)" },
22
+ ];
23
+ /** Parse a prompt into "/command args" — returns null when it isn't one. */
24
+ export function parseSlash(text) {
25
+ const trimmed = text.trim();
26
+ if (!trimmed.startsWith("/"))
27
+ return null;
28
+ const m = trimmed.match(/^\/([^\s]+)\s*([\s\S]*)$/);
29
+ if (!m || !m[1])
30
+ return null;
31
+ return { name: m[1], args: m[2].trim() };
32
+ }
33
+ export class SlashCommandsService {
34
+ host;
35
+ constructor(host) {
36
+ this.host = host;
37
+ }
38
+ /**
39
+ * Catalog of slash commands for the chat input: web-native builtins first,
40
+ * then the SDK's invokable commands for the ACTIVE conversation (extension
41
+ * commands, prompt templates, skills) — the same set the SDK expands when a
42
+ * prompt text starts with "/" (see AgentSession.prompt).
43
+ */
44
+ async push() {
45
+ const commands = [];
46
+ const seen = new Set();
47
+ for (const c of NATIVE_COMMANDS) {
48
+ commands.push({ ...c, source: "builtin" });
49
+ seen.add(c.name);
50
+ }
51
+ try {
52
+ const s = this.host.getSession();
53
+ // Extension commands — the SDK already suffixes collisions with builtin
54
+ // names ("new:2"), and those still reach the SDK since exec() only
55
+ // intercepts the exact native names.
56
+ for (const cmd of s.extensionRunner.getRegisteredCommands()) {
57
+ if (seen.has(cmd.invocationName))
58
+ continue;
59
+ commands.push({
60
+ name: cmd.invocationName,
61
+ description: cmd.description,
62
+ source: "extension",
63
+ });
64
+ seen.add(cmd.invocationName);
65
+ }
66
+ // Prompt templates: /templatename args
67
+ for (const t of s.promptTemplates) {
68
+ if (seen.has(t.name))
69
+ continue;
70
+ commands.push({
71
+ name: t.name,
72
+ description: t.description,
73
+ source: "prompt",
74
+ });
75
+ seen.add(t.name);
76
+ }
77
+ // Skills: /skill:name args
78
+ for (const skill of s.resourceLoader.getSkills().skills) {
79
+ const name = `skill:${skill.name}`;
80
+ if (seen.has(name))
81
+ continue;
82
+ commands.push({
83
+ name,
84
+ description: skill.description,
85
+ source: "skill",
86
+ });
87
+ seen.add(name);
88
+ }
89
+ }
90
+ catch {
91
+ // Session not ready yet — native-only catalog still serves the picker.
92
+ }
93
+ // UI 插件注册的命令(host.registerCommand)——全局,不依赖会话就绪。
94
+ for (const cmd of this.host.pluginCommands?.() ?? []) {
95
+ if (seen.has(cmd.name))
96
+ continue; // 与内置/扩展重名时先到先得(内置优先)
97
+ commands.push({
98
+ name: cmd.name,
99
+ description: cmd.description,
100
+ descriptionEn: cmd.descriptionEn,
101
+ argumentHint: cmd.argumentHint,
102
+ argumentHintEn: cmd.argumentHintEn,
103
+ source: "plugin",
104
+ });
105
+ seen.add(cmd.name);
106
+ }
107
+ this.host.emit({ type: "slash_commands", commands });
108
+ }
109
+ /** Run a native slash command (see NATIVE_COMMANDS). Returns false when the
110
+ * name is not a native command (the prompt falls through to the SDK). */
111
+ async exec(name, args) {
112
+ switch (name) {
113
+ case "new":
114
+ await this.host.newChat();
115
+ return true;
116
+ case "model": {
117
+ if (!args) {
118
+ const current = this.host.getSession().model;
119
+ this.host.emit({
120
+ type: "notice",
121
+ level: "info",
122
+ text: current
123
+ ? `当前模型:${current.name}(${current.provider}/${current.id})。用法:/model <名称>`
124
+ : `用法:/model <名称>`,
125
+ });
126
+ return true;
127
+ }
128
+ const query = args.toLowerCase();
129
+ const available = await this.host.getSession().modelRuntime.getAvailable();
130
+ // Prefer an exact "provider/id" match, else id/name substring.
131
+ const exact = available.find((m) => m.provider + "/" + m.id === args.trim());
132
+ const matches = exact
133
+ ? [exact]
134
+ : available.filter((m) => m.id.toLowerCase().includes(query) ||
135
+ m.name.toLowerCase().includes(query) ||
136
+ m.provider.toLowerCase().includes(query));
137
+ if (matches.length === 0) {
138
+ this.host.emit({
139
+ type: "notice",
140
+ level: "error",
141
+ text: `没有匹配到模型:${args}(可用模型见顶栏模型列表)`,
142
+ });
143
+ return true;
144
+ }
145
+ const pick = matches[0];
146
+ if (matches.length > 1) {
147
+ this.host.emit({
148
+ type: "notice",
149
+ level: "warning",
150
+ text: `找到 ${matches.length} 个匹配模型,已选用:${pick.name}(精确匹配请用 provider/id)`,
151
+ });
152
+ }
153
+ await this.host.setModel(`${pick.provider}/${pick.id}`);
154
+ return true;
155
+ }
156
+ case "compact":
157
+ try {
158
+ await this.host.getSession().compact(args || undefined);
159
+ }
160
+ catch (err) {
161
+ this.host.emit({
162
+ type: "notice",
163
+ level: "error",
164
+ text: `压缩上下文失败:${err.message}`,
165
+ });
166
+ }
167
+ return true;
168
+ case "cwd":
169
+ if (!args) {
170
+ this.host.emit({
171
+ type: "notice",
172
+ level: "info",
173
+ text: `当前工作目录:${this.host.cwd()}。用法:/cwd <路径>`,
174
+ });
175
+ }
176
+ else {
177
+ await this.host.setCwd(args);
178
+ }
179
+ return true;
180
+ case "thinking": {
181
+ const ALIAS = {
182
+ off: "off",
183
+ minimal: "minimal",
184
+ low: "low",
185
+ medium: "medium",
186
+ high: "high",
187
+ xhigh: "xhigh",
188
+ max: "max",
189
+ 关闭: "off",
190
+ 极简: "minimal",
191
+ 低: "low",
192
+ 中: "medium",
193
+ 高: "high",
194
+ 极高: "xhigh",
195
+ 最大: "max",
196
+ };
197
+ const level = ALIAS[args.trim().toLowerCase()];
198
+ if (!level) {
199
+ this.host.emit({
200
+ type: "notice",
201
+ level: "error",
202
+ text: `无效的思考强度:${args || "(空)"}。可用:off / minimal / low / medium / high / xhigh / max`,
203
+ });
204
+ return true;
205
+ }
206
+ this.host.setThinking(level);
207
+ return true;
208
+ }
209
+ case "resume":
210
+ await this.host.refreshSessions();
211
+ this.host.emit({
212
+ type: "notice",
213
+ level: "info",
214
+ text: "会话列表已刷新,请在左侧「历史对话」中选择",
215
+ });
216
+ return true;
217
+ case "reload":
218
+ try {
219
+ // Re-discovers extensions / skills / prompt templates from disk and
220
+ // re-pushes the picker catalog (the CLI's /reload semantics).
221
+ await this.host.getSession().reload();
222
+ // reload() 会把 custom 工具加回活跃集——重放设置门控(终端开关等)。
223
+ this.host.afterReload?.();
224
+ await this.push();
225
+ this.host.emit({
226
+ type: "notice",
227
+ level: "info",
228
+ text: "已重新加载扩展、技能与提示模板",
229
+ });
230
+ }
231
+ catch (err) {
232
+ this.host.emit({
233
+ type: "notice",
234
+ level: "error",
235
+ text: `重新加载失败:${err.message}`,
236
+ });
237
+ }
238
+ return true;
239
+ case "pi-web-ui:quit": {
240
+ this.host.emit({
241
+ type: "notice",
242
+ level: "info",
243
+ text: "正在退出 pi-web-ui… supervisor 将自动重启服务",
244
+ });
245
+ setTimeout(() => {
246
+ const didSchedule = this.host.onQuit?.() ?? false;
247
+ if (!didSchedule) {
248
+ setTimeout(() => process.exit(0), 100);
249
+ }
250
+ }, 300);
251
+ return true;
252
+ }
253
+ case "help":
254
+ case "copy":
255
+ // Client-side UI actions — the client handles them before sending;
256
+ // swallow here so the SDK never sees them as plain prompt text.
257
+ return true;
258
+ default:
259
+ // 插件命令:拦截执行(纯配置动作,与内置命令同级,不到 SDK)。
260
+ return (await this.host.execPluginCommand?.(name, args)) ?? false;
261
+ }
262
+ }
263
+ }