pi-web-ui 0.86.2 → 0.87.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 (36) hide show
  1. package/CHANGELOG.md +59 -21
  2. package/README.md +1 -1
  3. package/bin/pi-web-ui.mjs +312 -40
  4. package/dist/server/agent-service.js +297 -6
  5. package/dist/server/client-state.js +8 -0
  6. package/dist/server/composer-drafts.js +138 -0
  7. package/dist/server/dsh/dsh-agent-service.js +531 -54
  8. package/dist/server/dsh/dsh-client.js +28 -0
  9. package/dist/server/dsh/dsh-sessions.js +28 -0
  10. package/dist/server/dsh/dsh-usage.js +82 -0
  11. package/dist/server/dsh/preset-clones.js +260 -0
  12. package/dist/server/dsh/runtime/custom-prompt.mjs +33 -0
  13. package/dist/server/dsh/runtime/goal-rpc.mjs +406 -22
  14. package/dist/server/dsh/runtime/launcher.mjs +17 -0
  15. package/dist/server/dsh/runtime/override.patch.yml +19 -1
  16. package/dist/server/files-service.js +259 -1
  17. package/dist/server/index.js +219 -3
  18. package/dist/server/mcp-bridge.js +126 -22
  19. package/dist/server/mcp-hot-reload.js +117 -0
  20. package/dist/server/model-admin.js +93 -3
  21. package/dist/server/plugin-dom.js +83 -0
  22. package/dist/server/plugin-facilities.js +15 -1
  23. package/dist/server/plugin-installer.js +6 -0
  24. package/dist/server/plugins.js +781 -74
  25. package/dist/server/protocol-version.js +1 -1
  26. package/dist/server/provider-oauth-flow.js +157 -0
  27. package/package.json +1 -1
  28. package/plugins/catalog.json +9 -0
  29. package/themes/dark-teal.css +63 -22
  30. package/web/dist/assets/index-DePnXpq-.js +374 -0
  31. package/web/dist/assets/index-F86qWlJy.css +41 -0
  32. package/web/dist/index.html +3 -2
  33. package/web/dist/assets/TerminalPanel-BytY8dx7.js +0 -6
  34. package/web/dist/assets/TerminalPanel-DOrYoP_4.css +0 -32
  35. package/web/dist/assets/index-CKoVyDkP.css +0 -10
  36. package/web/dist/assets/index-Dmwji4Cr.js +0 -361
@@ -31,7 +31,7 @@ import { homedir } from "node:os";
31
31
  import { BgServerTracker } from "../bg-servers.js";
32
32
  import { ClientStateStore, DEFAULT_RETRY_MAX_ATTEMPTS } from "../client-state.js";
33
33
  import { normalizeUiLayout } from "../client-state.js";
34
- import { FilesService, workspacePath } from "../files-service.js";
34
+ import { FilesService, workspacePath, desktopDirWire } from "../files-service.js";
35
35
  import { QuiesceRejectedError } from "../agent-service.js";
36
36
  import { NATIVE_COMMANDS, parseSlash } from "../slash-commands.js";
37
37
  import { bilingual, pick, resolveServerLang } from "../i18n.js";
@@ -42,9 +42,19 @@ import { previewKind } from "../text-sniff.js";
42
42
  import { launchOrigin, toServiceInfo } from "../launch-origin.js";
43
43
  import { DshRuntime, loadDeepSeekKey } from "./dsh-client.js";
44
44
  import { DshStreamAccumulator, assistantMessageEventToUiMessage, toolResultEventToUiMessage, userMessageEventToUiMessage, } from "./dsh-serialize.js";
45
- import { firstUserText, findSessionFilesForCwd, readSessionLog, replayEventsToMessages } from "./dsh-sessions.js";
45
+ import { firstUserText, findSessionFilesForCwd, readSessionLog, replayEventsToMessages, sessionLogPermission, sessionLogPreset, } from "./dsh-sessions.js";
46
+ import { generatePresetClones, PRESET_DEFAULT_ID } from "./preset-clones.js";
47
+ import { dshContextUsage, lastUsageFromEvents, normalizeDshUsage } from "./dsh-usage.js";
46
48
  const SNAPSHOT_INTERVAL_MS = 60;
49
+ /** 用户主目录(wire 格式):进程内不变,模块加载时求值一次(右栏 🏠)。 */
50
+ const HOME_WIRE = homedir().replace(/\\/g, "/");
51
+ /** 桌面目录(wire 格式):进程内不变,不存在则空串 → 前端不渲染 🖥️。 */
52
+ const DESKTOP_WIRE = desktopDirWire(HOME_WIRE);
47
53
  const MAX_OPEN_CONVERSATIONS = 8;
54
+ /** 新会话默认权限预设(沙箱内 + 无审批弹窗;无头运行的当前行为,保持不变)。 */
55
+ const PERMISSION_DEFAULT_PRESET = "workspace-write-never";
56
+ /** 前端提供的三档(官方 workspace-write 走 ask,无应答者时是死路,不提供)。 */
57
+ const PERMISSION_OFFERED = ["read-only", "workspace-write-never", "danger-full-access"];
48
58
  const DEFAULT_CONV_TITLE = "新对话";
49
59
  const DEFAULT_CONV_TITLE_EN = "New chat";
50
60
  const DEFAULT_MODEL = "deepseek-v4-flash";
@@ -119,6 +129,8 @@ const DEFAULT_SETTINGS = {
119
129
  disabledPlugins: [],
120
130
  uiLayout: {},
121
131
  reviewPrompt: "",
132
+ defaultAgentPreset: PRESET_DEFAULT_ID,
133
+ defaultPermissionPreset: PERMISSION_DEFAULT_PRESET,
122
134
  quickPhrases: [],
123
135
  quickPhrasesEnabled: true,
124
136
  };
@@ -173,6 +185,16 @@ export class DshClientSession {
173
185
  settings = { ...DEFAULT_SETTINGS };
174
186
  /** 最近一次从运行时拉取的技能清单(UiSkillInfo,含 enabled 由 disabledSkills 推导)。 */
175
187
  skillsCache = [];
188
+ /** 运行时世代(每次 start 成功 +1;conv.assignedGen 用它判断 preset/assign 是否过期)。 */
189
+ runtimeGen = 0;
190
+ /** Agent 预设名录缓存(运行时 preset/list;unavailable/空 = legacy,UI 隐藏预设条)。 */
191
+ agentPresets = [];
192
+ agentPresetDefault = PRESET_DEFAULT_ID;
193
+ agentPresetsAvailable = false;
194
+ /** 权限选项表缓存(组合静态;空 = 未就绪/legacy,前端隐藏权限条)。 */
195
+ permissionOptions = [];
196
+ /** clone 出来的预设 id(roster 名录校验用;空 = 未生成,走 legacy)。 */
197
+ presetCloneIds = [];
176
198
  files;
177
199
  bg;
178
200
  /** 插件扩展点(index.ts 注入)。 */
@@ -253,20 +275,28 @@ export class DshClientSession {
253
275
  reviewPrompt: savedSettings.reviewPrompt,
254
276
  quickPhrases: savedSettings.quickPhrases ?? [],
255
277
  quickPhrasesEnabled: savedSettings.quickPhrasesEnabled ?? true,
278
+ defaultAgentPreset: savedSettings.defaultAgentPreset ?? PRESET_DEFAULT_ID,
279
+ defaultPermissionPreset: savedSettings.defaultPermissionPreset && PERMISSION_OFFERED.includes(savedSettings.defaultPermissionPreset)
280
+ ? savedSettings.defaultPermissionPreset
281
+ : PERMISSION_DEFAULT_PRESET,
256
282
  };
257
283
  }
258
284
  // 第一个 conversation = 新会话(每客户端独立 sessionId,避免多标签页/多
259
285
  // 客户端共享同一 JSONL 互相串会话)。历史会话经 switch_session 恢复。
260
286
  cs.makeRuntime();
261
- const first = cs.addConversation(`web-${randomUUID().slice(0, 12)}`, cwd, false);
287
+ const first = cs.addConversation(`web-${randomUUID().slice(0, 12)}`, cwd, false, cs.settings.defaultAgentPreset);
262
288
  cs.activeId = first.id;
263
289
  cs.attachRuntimeEvents();
264
290
  // 每次启动成功(含初次/换模型/watchdog 重启)后重新注册插件工具桥,
265
291
  // 因为重 spawn 后的 ctx.tools 是全新的,需要重新 sync 插件工具。
266
292
  cs.runtime.onStarted = () => {
293
+ cs.runtimeGen += 1;
267
294
  void cs.syncPluginTools();
268
295
  void cs.pushDisabledSkillsToRuntime();
269
296
  void cs.refreshSkillsFromRuntime();
297
+ void cs.refreshAgentPresets();
298
+ void cs.refreshPermissionOptions().catch(() => { });
299
+ void cs.refreshActivePermission().catch(() => { });
270
300
  };
271
301
  // P0-1 watchdog:意外退出(非 kill/close 主动触发)→ 限频自动重启,保持可用。
272
302
  cs.runtime.onExit = (code, signal, intentional) => {
@@ -322,6 +352,8 @@ export class DshClientSession {
322
352
  for (const conv of this.convs.values()) {
323
353
  conv.isStreaming = false;
324
354
  conv.streaming = null;
355
+ // 直播帧能力跟着运行时进程走:重启后重新探测(换运行时版本也能回落到持久 assistant/chunk)。
356
+ conv.liveChunks = false;
325
357
  }
326
358
  const now = Date.now();
327
359
  if (now - this.runtimeRestart.windowStart > DshClientSession.RUNTIME_RESTART_WINDOW_MS) {
@@ -447,12 +479,37 @@ export class DshClientSession {
447
479
  }
448
480
  }
449
481
  makeRuntime() {
482
+ // Agent 预设 clone(file: 改写 shipped 预设,供 preset-plane patch 的 roster 用)。
483
+ // 成功 → 下发 patch(预设时代);失败/null → 不下发(回落 legacy 单组合)。
484
+ let presetPatch;
485
+ try {
486
+ const clones = generatePresetClones(this.dataDir);
487
+ if (clones) {
488
+ presetPatch = clones.patchFile;
489
+ if (clones.warnings.length > 0) {
490
+ console.error(`[dsh] preset clone warnings: ${clones.warnings.join("; ")}`);
491
+ }
492
+ this.presetCloneIds = clones.presetIds;
493
+ }
494
+ else {
495
+ this.presetCloneIds = [];
496
+ }
497
+ }
498
+ catch (err) {
499
+ console.error(`[dsh] preset clone 生成失败,回落 legacy: ${err.message}`);
500
+ this.presetCloneIds = [];
501
+ }
450
502
  this.runtime = new DshRuntime({
451
503
  cwd: this.cwd,
452
504
  provider: "deepseek-official",
453
505
  model: this.model,
454
506
  sessionRoot: this.sessionRoot,
455
507
  dataDir: this.dataDir,
508
+ agentDir: this.agentDir,
509
+ env: {
510
+ ...(presetPatch ? { PI_WEB_DSH_PRESET_PATCH: presetPatch } : {}),
511
+ PI_WEB_DSH_CUSTOM_PROMPT: this.settings.customSystemPrompt.trim(),
512
+ },
456
513
  });
457
514
  }
458
515
  attachRuntimeEvents() {
@@ -463,6 +520,11 @@ export class DshClientSession {
463
520
  if (method === "session.event") {
464
521
  this.handleSessionEvent(params);
465
522
  }
523
+ else if (method === "assistant.stream") {
524
+ // 新版运行时(0.1.1-rc.2 之后)的直播流:wrapper 把 agent/assistant-stream 帧转成
525
+ // assistant.stream 通知(老运行时的持久 assistant/chunk 已不存在)。
526
+ this.handleAssistantStream(params);
527
+ }
466
528
  else if (method === "session.status") {
467
529
  this.handleSessionStatus(params);
468
530
  }
@@ -683,11 +745,15 @@ export class DshClientSession {
683
745
  return title === DEFAULT_CONV_TITLE || title === DEFAULT_CONV_TITLE_EN;
684
746
  }
685
747
  /** 新建(或切换)一个 conversation。existing 的 sessionId 续聊最近 JSONL。 */
686
- addConversation(sessionId, cwd, replay = true) {
748
+ addConversation(sessionId, cwd, replay = true, preset) {
687
749
  const id = this.nextConversationId();
688
750
  const conv = {
689
751
  id,
690
752
  sessionId,
753
+ agentPreset: this.resolvePreset(preset),
754
+ presetLocked: false,
755
+ assignedGen: -1,
756
+ permissionPreset: null,
691
757
  dsGoal: null,
692
758
  goal: this.makeGoalStatus(),
693
759
  title: this.defaultTitle(),
@@ -707,17 +773,36 @@ export class DshClientSession {
707
773
  () => this.getLang()),
708
774
  toolStartTimes: new Map(),
709
775
  tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
776
+ usageSeen: false,
777
+ liveChunks: false,
778
+ streamAnchor: 0,
779
+ streamTurn: 0,
710
780
  };
711
781
  if (replay) {
712
782
  // 从磁盘 JSONL 回放历史消息(DSH 事件流不重放历史)。
713
783
  try {
714
784
  const files = findSessionFilesForCwd(this.sessionRoot, cwd).filter((f) => basename(dirname(f)) === sessionId);
715
785
  if (files.length > 0) {
716
- const { events } = readSessionLog(files[0]);
786
+ const { header, events } = readSessionLog(files[0]);
717
787
  conv.messages = replayEventsToMessages(events);
718
788
  for (const m of conv.messages)
719
789
  conv.messageIds.add(m.id);
720
790
  conv.title = firstUserText(events, this.getLang());
791
+ // 回放定预设:日志记录(selected 事件/header)> 调用方指定 > 默认。
792
+ const logged = sessionLogPreset({ header, events });
793
+ conv.agentPreset = this.resolvePreset(logged ?? preset);
794
+ // 权限预设同理:permission/preset 事件最后一条为准(无则 null,激活时拉取)。
795
+ conv.permissionPreset = sessionLogPermission({ header, events });
796
+ // 底栏统计:日志里最后一次 usage(新版在 assistant/message.usage,0.1.1 在
797
+ // assistant/chunk 的 usage chunk)→ 切回历史会话立刻有上下文 / 缓存命中数字。
798
+ const loggedUsage = lastUsageFromEvents(events);
799
+ if (loggedUsage) {
800
+ conv.tokens = loggedUsage;
801
+ conv.usageSeen = true;
802
+ }
803
+ // 有历史消息 = 已开始的会话,预设锁定(官方语义)。
804
+ if (conv.messages.length > 0)
805
+ conv.presetLocked = true;
721
806
  }
722
807
  }
723
808
  catch {
@@ -806,6 +891,8 @@ export class DshClientSession {
806
891
  conv.streaming = null;
807
892
  this.appendMessage(conv, msg);
808
893
  }
894
+ // 新版运行时的 usage 挂在这条持久事件上(老运行时没有 → undefined 保持原统计)。
895
+ this.applyUsage(conv, ev.data.usage);
809
896
  break;
810
897
  }
811
898
  case "tool/result": {
@@ -838,38 +925,14 @@ export class DshClientSession {
838
925
  break;
839
926
  }
840
927
  case "assistant/chunk": {
928
+ // 老运行时(0.1.1-rc.2)的持久直播 chunk;新版运行时没有这个事件(改发
929
+ // agent/assistant-stream 直播帧)→ 过渡期两边都在时只认直播帧,避免喂两遍。
930
+ if (conv.liveChunks)
931
+ break;
841
932
  const chunk = ev.data?.chunk;
842
933
  if (!chunk)
843
934
  break;
844
- if (chunk.type === "usage") {
845
- const u = chunk.usage;
846
- conv.tokens.input = u.inputTokens ?? 0;
847
- conv.tokens.output = u.outputTokens ?? 0;
848
- conv.tokens.cacheRead = u.cacheReadTokens ?? 0;
849
- conv.tokens.cacheWrite = u.cacheWriteTokens ?? 0;
850
- break;
851
- }
852
- if (!conv.streaming) {
853
- conv.streaming = new DshStreamAccumulator(ev.seq, ev.data?.turn ?? 0);
854
- }
855
- conv.streaming.apply(chunk);
856
- // message_delta 实时通道:本机快速完成时 60ms 延迟快照总被
857
- // assistant/message 抢跑(streaming 从未被捕捉)——delta 直接
858
- // 走独立通道,保证逐 token 渲染(前端 patch streamingMessage)。
859
- if (conv.id === this.conv.id && (chunk.type === "text-delta" || chunk.type === "reasoning-delta")) {
860
- this.emit({
861
- type: "message_delta",
862
- conversationId: conv.id,
863
- seq: ++conv.deltaSeq,
864
- messageId: conv.streaming.id,
865
- usage: null,
866
- assistantMessageEvent: {
867
- type: chunk.type === "text-delta" ? "text_delta" : "thinking_delta",
868
- contentIndex: chunk.index,
869
- delta: chunk.text,
870
- },
871
- });
872
- }
935
+ this.applyStreamChunk(conv, chunk, ev.seq, ev.data?.turn ?? 0);
873
936
  break;
874
937
  }
875
938
  case "tool/call": {
@@ -939,6 +1002,85 @@ export class DshClientSession {
939
1002
  this.scheduleSnapshot();
940
1003
  }
941
1004
  }
1005
+ /**
1006
+ * 新版运行时的直播帧处理。运行时逐 chunk 发 `agent/assistant-stream`(agent scope,
1007
+ * host 侧要 `{ global: true }` 才收得到),wrapper(goal-rpc.mjs)转成本通知:
1008
+ * { type: "start" | "chunk" | "end", attemptId, revision, index, time, chunk }
1009
+ * chunk 形状与老运行时的 `assistant/chunk.data.chunk` 同构 → 共用 applyStreamChunk。
1010
+ */
1011
+ handleAssistantStream(params) {
1012
+ const conv = this.findConv(params.sessionId);
1013
+ const frame = params.frame;
1014
+ // 别家客户端的会话 / 子代理会话(不在本客户端 convs 里)→ 忽略。
1015
+ if (!conv || !frame || typeof frame.type !== "string")
1016
+ return;
1017
+ conv.lastEventAt = Date.now();
1018
+ if (frame.type === "start") {
1019
+ // 新 attempt(含重试)→ 换锚点,丢掉上一 attempt 的累计内容(与官方 dsh-web 一致)。
1020
+ conv.liveChunks = true;
1021
+ conv.streaming = null;
1022
+ conv.streamAnchor = typeof frame.time === "number" && Number.isFinite(frame.time) ? frame.time : Date.now();
1023
+ conv.streamTurn = typeof frame.turn === "number" ? frame.turn : 0;
1024
+ this.scheduleSnapshot();
1025
+ return;
1026
+ }
1027
+ if (frame.type !== "chunk")
1028
+ return; // end:结算由 assistant/message / turn/end 清
1029
+ conv.liveChunks = true;
1030
+ const chunk = frame.chunk;
1031
+ if (!chunk)
1032
+ return;
1033
+ if (!conv.streamAnchor) {
1034
+ // 没收到 start(重连晚到)→ 用首帧补锚点。
1035
+ conv.streamAnchor = typeof frame.time === "number" && Number.isFinite(frame.time) ? frame.time : Date.now();
1036
+ conv.streamTurn = typeof frame.turn === "number" ? frame.turn : 0;
1037
+ }
1038
+ this.applyStreamChunk(conv, chunk, conv.streamAnchor, conv.streamTurn);
1039
+ // 60ms 节流快照:与持久事件那条路一样,让 isStreaming / streamingMessage 进快照
1040
+ // (delta 通道只负责内容增量,靠它保证状态一致 + 背压时仍能收敛)。
1041
+ this.scheduleSnapshot();
1042
+ }
1043
+ /**
1044
+ * 一个 model stream chunk → 会话:usage 进底栏统计,其余进 streamingMessage 累计器 +
1045
+ * 实时 delta 通道(message_delta)。两条投递路径(老 assistant/chunk / 新直播帧)共用。
1046
+ *
1047
+ * @param anchorId - streamingMessage id 的锚(老 = chunk 事件 seq;新版 = 首帧 time)。
1048
+ * @param turn - 所属 turn(累计器 id 用)。
1049
+ */
1050
+ applyStreamChunk(conv, chunk, anchorId, turn) {
1051
+ if (chunk.type === "usage") {
1052
+ this.applyUsage(conv, chunk.usage);
1053
+ return;
1054
+ }
1055
+ if (!conv.streaming)
1056
+ conv.streaming = new DshStreamAccumulator(anchorId, turn);
1057
+ conv.streaming.apply(chunk);
1058
+ // message_delta 实时通道:本机快速完成时 60ms 延迟快照总被 assistant/message 抢跑
1059
+ // (streaming 从未被捕捉)→ delta 直接走独立通道,保证逐 token 渲染
1060
+ // (前端 patch streamingMessage)。只推给当前对话,其他对话靠快照。
1061
+ if (conv.id === this.conv.id && (chunk.type === "text-delta" || chunk.type === "reasoning-delta")) {
1062
+ this.emit({
1063
+ type: "message_delta",
1064
+ conversationId: conv.id,
1065
+ seq: ++conv.deltaSeq,
1066
+ messageId: conv.streaming.id,
1067
+ usage: null,
1068
+ assistantMessageEvent: {
1069
+ type: chunk.type === "text-delta" ? "text_delta" : "thinking_delta",
1070
+ contentIndex: chunk.index,
1071
+ delta: chunk.text,
1072
+ },
1073
+ });
1074
+ }
1075
+ }
1076
+ /** usage → 底栏统计(最近一次模型调用口径;缺字段 / 非对象保持原值)。 */
1077
+ applyUsage(conv, usage) {
1078
+ const next = normalizeDshUsage(usage);
1079
+ if (!next)
1080
+ return;
1081
+ conv.tokens = next;
1082
+ conv.usageSeen = true;
1083
+ }
942
1084
  handleSessionStatus(params) {
943
1085
  const conv = this.findConv(params.sessionId);
944
1086
  if (!conv)
@@ -1047,18 +1189,15 @@ export class DshClientSession {
1047
1189
  total: tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite,
1048
1190
  },
1049
1191
  cost: estimateCost(tokens),
1050
- contextUsage: {
1051
- tokens: tokens.input + tokens.output + tokens.cacheRead,
1052
- contextWindow: DSH_CONTEXT_WINDOW,
1053
- percent: DSH_CONTEXT_WINDOW > 0
1054
- ? Math.min(100, ((tokens.input + tokens.output + tokens.cacheRead) / DSH_CONTEXT_WINDOW) * 100)
1055
- : null,
1056
- },
1192
+ // 最近一次请求的 prompt + 输出(usageSeen=false → tokens=null,前端显示 `—`)。
1193
+ contextUsage: dshContextUsage(conv.usageSeen ? tokens : null, DSH_CONTEXT_WINDOW),
1057
1194
  };
1058
1195
  return {
1059
1196
  clientId: this.clientId,
1060
1197
  cwd: this.cwd,
1061
1198
  workspaceRoots: this.roots,
1199
+ homeDir: HOME_WIRE,
1200
+ desktopDir: DESKTOP_WIRE,
1062
1201
  sessionId: conv.sessionId,
1063
1202
  conversationId: this.activeId,
1064
1203
  rev,
@@ -1073,12 +1212,15 @@ export class DshClientSession {
1073
1212
  },
1074
1213
  thinkingLevel: this.thinkingLevel,
1075
1214
  availableThinkingLevels: ["high"],
1215
+ agentPreset: { id: conv.agentPreset, name: this.presetName(conv.agentPreset), locked: conv.presetLocked },
1216
+ permission: conv.permissionPreset ?? null,
1076
1217
  queue: { steering: conv.queue.steering, followUp: conv.queue.followUp },
1077
1218
  pendingQuestion: this.pendingQuestionForSnapshot(),
1078
1219
  tools: [],
1079
1220
  version: ++this.version,
1080
- piConfigured: !!loadDeepSeekKey(),
1081
- piAgentInstalled: false,
1221
+ piConfigured: this.isDshConfigured(),
1222
+ // DSH 不需要 pi CLI:报 true 让 PiSetupModal 跳过“安装 pi”步骤,直达填 key 表单。
1223
+ piAgentInstalled: true,
1082
1224
  stats,
1083
1225
  };
1084
1226
  }
@@ -1093,6 +1235,10 @@ export class DshClientSession {
1093
1235
  this.emitConversations();
1094
1236
  this.emitGoalStatus();
1095
1237
  this.pushSettings();
1238
+ this.pushAgentPresets();
1239
+ this.pushPermission();
1240
+ void this.refreshPermissionOptions().catch(() => { });
1241
+ void this.refreshActivePermission().catch(() => { });
1096
1242
  this.bg.push();
1097
1243
  this.pushTerminals();
1098
1244
  }
@@ -1195,6 +1341,9 @@ export class DshClientSession {
1195
1341
  messageCount: conv.messages.length,
1196
1342
  isStreaming: conv.isStreaming,
1197
1343
  isSubagent: false,
1344
+ // DSH Agent 预设(左栏徽标/详情用;pi 引擎不填)。
1345
+ agentPreset: conv.agentPreset,
1346
+ presetLocked: conv.presetLocked,
1198
1347
  });
1199
1348
  }
1200
1349
  // issue #145:流式集合签名变化 → 通知其他客户端重推(左栏「另一处正在运行」近实时)
@@ -1216,13 +1365,17 @@ export class DshClientSession {
1216
1365
  });
1217
1366
  }
1218
1367
  /** 语义同 pi 引擎的 newChat:true = 当前活动对话是可接收首条的空白新对话
1219
- * (/new <prompt> 靠它决定要不要把首条提示发出去)。 */
1220
- async newChat() {
1368
+ * (/new <prompt> 靠它决定要不要把首条提示发出去)。preset = 新会话预设
1369
+ * (复用空白会话时等价一次空白切换)。 */
1370
+ async newChat(preset) {
1221
1371
  if (this.quiesceBlocked())
1222
1372
  return false;
1223
1373
  const active = this.conv;
1224
1374
  if (active.messages.length === 0 && active.terminals.list().length === 0) {
1225
- this.flushSnapshot();
1375
+ if (preset)
1376
+ await this.selectAgentPreset(preset);
1377
+ else
1378
+ this.flushSnapshot();
1226
1379
  return true;
1227
1380
  }
1228
1381
  for (const conv of this.convs.values()) {
@@ -1230,7 +1383,10 @@ export class DshClientSession {
1230
1383
  continue;
1231
1384
  if (conv.messages.length === 0) {
1232
1385
  this.switchConversation(conv.id);
1233
- this.flushSnapshot();
1386
+ if (preset)
1387
+ await this.selectAgentPreset(preset);
1388
+ else
1389
+ this.flushSnapshot();
1234
1390
  return true;
1235
1391
  }
1236
1392
  }
@@ -1247,13 +1403,14 @@ export class DshClientSession {
1247
1403
  // 旧对话保留(listed 生命周期简化:不主动移除)。
1248
1404
  const prevModel = this.model;
1249
1405
  active.listed = active.isStreaming || active.terminals.list().length > 0 || active.promptedSinceActive;
1250
- const conv = this.addConversation(`chat-${randomUUID().slice(0, 12)}`, this.cwd, false);
1406
+ const conv = this.addConversation(`chat-${randomUUID().slice(0, 12)}`, this.cwd, false, preset);
1251
1407
  this.activeId = conv.id;
1252
1408
  this.model = prevModel;
1253
1409
  this.emitConversations();
1254
1410
  this.emitGoalStatus();
1255
1411
  this.pushTerminals();
1256
1412
  this.flushSnapshot();
1413
+ void this.refreshActivePermission().catch(() => { });
1257
1414
  return true;
1258
1415
  }
1259
1416
  async switchConversation(id) {
@@ -1286,6 +1443,8 @@ export class DshClientSession {
1286
1443
  this.emitGoalStatus();
1287
1444
  this.pushTerminals();
1288
1445
  this.flushSnapshot(true);
1446
+ // 切会话带上权限值(cwd 变化走运行时重启,onStarted 会重拉)。
1447
+ void this.refreshActivePermission().catch(() => { });
1289
1448
  }
1290
1449
  removeConversation(id) {
1291
1450
  const conv = this.convs.get(id);
@@ -1438,6 +1597,20 @@ export class DshClientSession {
1438
1597
  return;
1439
1598
  conv.promptedSinceActive = true;
1440
1599
  conv.lastEventAt = Date.now();
1600
+ // Agent 预设:运行时世代过期 → 重登 preset/assign(创建时消费;
1601
+ // 已存在会话的登记永不消费,重启后新运行时建会话时正好用上)。
1602
+ if (conv.assignedGen !== this.runtimeGen) {
1603
+ try {
1604
+ await this.runtime.assignPreset(conv.sessionId, conv.agentPreset);
1605
+ conv.assignedGen = this.runtimeGen;
1606
+ }
1607
+ catch {
1608
+ /* legacy/运行时未就绪:创建走默认;best effort */
1609
+ }
1610
+ }
1611
+ // 新会话默认权限预设(与当前一致时 apply 无事件零噪音)。
1612
+ await this.ensurePermissionDefault(conv);
1613
+ this.flushSnapshot();
1441
1614
  const blocks = await this.buildContentBlocks(sysPrefix ? `${sysPrefix}${text}` : text, attachments);
1442
1615
  // 乐观落地用户消息(id 用暂定值;user/message 事件到达时按内容去重)。
1443
1616
  const optimistic = {
@@ -1463,6 +1636,11 @@ export class DshClientSession {
1463
1636
  else {
1464
1637
  await this.runtime.prompt(conv.sessionId, blocks);
1465
1638
  }
1639
+ // 首轮用户发言送达 → 预设锁定(官方语义;回放会话建时已锁)。
1640
+ if (!conv.presetLocked) {
1641
+ conv.presetLocked = true;
1642
+ this.emitConversations();
1643
+ }
1466
1644
  }
1467
1645
  catch (err) {
1468
1646
  this.emit({
@@ -1486,7 +1664,7 @@ export class DshClientSession {
1486
1664
  }
1487
1665
  /** 新建 fork 会话并切换到它(DSH 无法原地续聊旧会话)。 */
1488
1666
  forkConversation(prev) {
1489
- const fork = this.addConversation(`fork-${randomUUID().slice(0, 12)}`, this.cwd, false);
1667
+ const fork = this.addConversation(`fork-${randomUUID().slice(0, 12)}`, this.cwd, false, prev.agentPreset);
1490
1668
  fork.title = prev.title;
1491
1669
  this.activeId = fork.id;
1492
1670
  // P2-19:原会话有 active goal(DSH same-session 语义)→ 提示随会话存档。
@@ -2240,6 +2418,19 @@ export class DshClientSession {
2240
2418
  async uploadFile(dirRel, name, data) {
2241
2419
  await this.files.uploadFile(dirRel, name, data);
2242
2420
  }
2421
+ /** 文件树右键菜单:新建/重命名/删除/复制移动(FilesService 直透传)。 */
2422
+ async createEntry(dir, name, kind) {
2423
+ await this.files.createEntry(dir, name, kind);
2424
+ }
2425
+ async renameEntry(path, newName) {
2426
+ await this.files.renameEntry(path, newName);
2427
+ }
2428
+ async deleteEntry(path) {
2429
+ await this.files.deleteEntry(path);
2430
+ }
2431
+ async copyEntry(src, destDir, move) {
2432
+ await this.files.copyEntry(src, destDir, move);
2433
+ }
2243
2434
  async completePath(input) {
2244
2435
  await this.files.completePath(input);
2245
2436
  }
@@ -2481,6 +2672,7 @@ export class DshClientSession {
2481
2672
  reviewPrompt: this.settings.reviewPrompt,
2482
2673
  quickPhrases: this.settings.quickPhrases,
2483
2674
  quickPhrasesEnabled: this.settings.quickPhrasesEnabled,
2675
+ defaultAgentPreset: this.settings.defaultAgentPreset,
2484
2676
  });
2485
2677
  // 仅系统提示词变化才重启运行时(DSH_PERSONA 由 launcher env 注入);
2486
2678
  // 其他设置(开关/隐藏插件等)只存不回写运行时。
@@ -2502,7 +2694,10 @@ export class DshClientSession {
2502
2694
  : this.settings.promptMode === "append" && custom
2503
2695
  ? `\n\n${custom}`
2504
2696
  : "";
2505
- this.runtime.env = { ...this.runtime.env, DSH_PERSONA: persona };
2697
+ // 自定义提示词双通道:DSH_PERSONA(host 部署人设;预设 persona shadow 它,
2698
+ // minimal 更全压住)+ PI_WEB_DSH_CUSTOM_PROMPT(独立 host section,随
2699
+ // standard/ptc/cordis 下发;minimal 下被 complete 压住,官方语义)。
2700
+ this.runtime.env = { ...this.runtime.env, DSH_PERSONA: persona, PI_WEB_DSH_CUSTOM_PROMPT: custom };
2506
2701
  if (this.runtime.alive) {
2507
2702
  return this.runtime.restart(this.model).catch(() => {
2508
2703
  /* keep old runtime */
@@ -2519,6 +2714,255 @@ export class DshClientSession {
2519
2714
  textEn: "The DSH engine does not support pi extension hot-reload",
2520
2715
  });
2521
2716
  }
2717
+ // -----------------------------------------------------------------------
2718
+ // Agent 预设(dsh-web 四模式;Node 侧是真相源,运行时只负责挂载)
2719
+ // -----------------------------------------------------------------------
2720
+ /** 从运行时拉取预设名录(每次启动后跑;失败/legacy → UI 隐藏预设条)。 */
2721
+ async refreshAgentPresets() {
2722
+ try {
2723
+ const res = await this.runtime.listPresets();
2724
+ if (res.unavailable || !Array.isArray(res.presets) || res.presets.length === 0) {
2725
+ this.agentPresets = [];
2726
+ this.agentPresetsAvailable = false;
2727
+ this.agentPresetDefault = PRESET_DEFAULT_ID;
2728
+ }
2729
+ else {
2730
+ this.agentPresets = res.presets;
2731
+ this.agentPresetDefault = res.defaultPreset || PRESET_DEFAULT_ID;
2732
+ this.agentPresetsAvailable = true;
2733
+ }
2734
+ }
2735
+ catch {
2736
+ this.agentPresets = [];
2737
+ this.agentPresetsAvailable = false;
2738
+ this.agentPresetDefault = PRESET_DEFAULT_ID;
2739
+ }
2740
+ this.pushAgentPresets();
2741
+ this.flushSnapshot();
2742
+ }
2743
+ /** 校验一个预设 id(名录 healthy 优先;legacy/未知 → 默认)。 */
2744
+ resolvePreset(requested) {
2745
+ if (requested) {
2746
+ const hit = this.agentPresets.find((p) => p.id === requested && !p.broken);
2747
+ if (hit)
2748
+ return hit.id;
2749
+ // 名录还没拉到(启动中)但 clone 有它 → 先信 clone,挂载期再定。
2750
+ if (this.presetCloneIds.includes(requested))
2751
+ return requested;
2752
+ }
2753
+ const def = this.settings.defaultAgentPreset;
2754
+ if (def) {
2755
+ const hit = this.agentPresets.find((p) => p.id === def && !p.broken);
2756
+ if (hit)
2757
+ return hit.id;
2758
+ if (this.presetCloneIds.includes(def))
2759
+ return def;
2760
+ }
2761
+ return PRESET_DEFAULT_ID;
2762
+ }
2763
+ /** 推送预设名录 + 默认(attach/变化后;legacy 下 presets 空,前端隐藏)。 */
2764
+ pushAgentPresets() {
2765
+ this.emit({
2766
+ type: "dsh_presets",
2767
+ presets: this.agentPresets.map((p) => ({ ...p })),
2768
+ defaultPreset: this.resolvePreset(),
2769
+ });
2770
+ }
2771
+ /** 新会话默认预设(设置面板;非法 id 拒绝并提示)。 */
2772
+ async setDefaultAgentPreset(preset) {
2773
+ const hit = this.agentPresets.find((p) => p.id === preset && !p.broken);
2774
+ const valid = hit ? hit.id : this.presetCloneIds.includes(preset) ? preset : null;
2775
+ if (!valid) {
2776
+ this.emit({
2777
+ type: "notice",
2778
+ level: "warning",
2779
+ text: `未知预设「${preset}」,默认预设未改`,
2780
+ textEn: `Unknown preset "${preset}"; default preset unchanged`,
2781
+ });
2782
+ return;
2783
+ }
2784
+ this.settings.defaultAgentPreset = valid;
2785
+ this.stateStore.saveSettings(this.clientId, { defaultAgentPreset: valid });
2786
+ this.pushSettings();
2787
+ this.pushAgentPresets();
2788
+ this.flushSnapshot();
2789
+ }
2790
+ /** 空白会话切换预设(首轮后锁定,官方语义;失败按 code 出双语 notice)。 */
2791
+ async selectAgentPreset(preset) {
2792
+ const conv = this.conv;
2793
+ if (conv.presetLocked || conv.messages.length > 0) {
2794
+ conv.presetLocked = true;
2795
+ this.emit({
2796
+ type: "notice",
2797
+ level: "warning",
2798
+ text: `会话已开始,预设锁定为「${this.presetName(conv.agentPreset)}」(空白会话可切换)`,
2799
+ textEn: `Session already started; preset locked to "${this.presetName(conv.agentPreset)}" (only blank sessions can switch)`,
2800
+ });
2801
+ this.flushSnapshot();
2802
+ return;
2803
+ }
2804
+ const target = this.resolvePreset(preset);
2805
+ if (target === conv.agentPreset) {
2806
+ this.flushSnapshot();
2807
+ return;
2808
+ }
2809
+ try {
2810
+ const res = await this.runtime.selectPreset(conv.sessionId, target);
2811
+ if (!res.ok) {
2812
+ if (res.code === "agent-preset/locked")
2813
+ conv.presetLocked = true;
2814
+ this.emit({
2815
+ type: "notice",
2816
+ level: "warning",
2817
+ text: `预设切换失败:${res.code === "agent-preset/locked" ? "会话已开始,预设已锁定" : (res.message ?? res.code ?? "未知错误")}`,
2818
+ textEn: `Preset switch failed: ${res.code === "agent-preset/locked" ? "session already started; preset is locked" : (res.message ?? res.code ?? "unknown error")}`,
2819
+ });
2820
+ this.flushSnapshot();
2821
+ return;
2822
+ }
2823
+ conv.agentPreset = res.preset ?? target;
2824
+ this.emitConversations();
2825
+ this.flushSnapshot();
2826
+ }
2827
+ catch (err) {
2828
+ this.emit({
2829
+ type: "notice",
2830
+ level: "error",
2831
+ text: `预设切换失败:${err.message}`,
2832
+ textEn: `Preset switch failed: ${err.message}`,
2833
+ });
2834
+ }
2835
+ }
2836
+ /** 预设显示名(名录 name > id;供 notice/前端回退)。 */
2837
+ presetName(id) {
2838
+ return this.agentPresets.find((p) => p.id === id)?.name ?? id;
2839
+ }
2840
+ // -----------------------------------------------------------------------
2841
+ // 权限预设(三档;官方 /permission 弹窗同源,Node 侧只做值守 + 转发)
2842
+ // -----------------------------------------------------------------------
2843
+ /** 校验三档值(非法 → 默认;组合里没有也照默认,前端按 options 取交集展示)。 */
2844
+ resolvePermissionPreset(requested) {
2845
+ if (requested && PERMISSION_OFFERED.includes(requested))
2846
+ return requested;
2847
+ const def = this.settings.defaultPermissionPreset;
2848
+ if (def && PERMISSION_OFFERED.includes(def))
2849
+ return def;
2850
+ return PERMISSION_DEFAULT_PRESET;
2851
+ }
2852
+ /** 推选项表 + 默认(组合静态,options 为空 = 运行时未就绪/legacy,前端隐藏)。 */
2853
+ pushPermission() {
2854
+ this.emit({
2855
+ type: "dsh_permission",
2856
+ options: this.permissionOptions.map((o) => ({ ...o })),
2857
+ defaultPreset: this.settings.defaultPermissionPreset,
2858
+ });
2859
+ }
2860
+ /** 拉取权限选项表(运行时启动/重建后跑一次;失败 → 空,前端隐藏)。 */
2861
+ async refreshPermissionOptions() {
2862
+ try {
2863
+ const res = await this.runtime.getPermission(this.conv.sessionId);
2864
+ this.permissionOptions = Array.isArray(res.options) ? res.options : [];
2865
+ }
2866
+ catch {
2867
+ this.permissionOptions = [];
2868
+ }
2869
+ this.pushPermission();
2870
+ }
2871
+ /** 拉取当前会话的权限预设值(激活/创建/切换/重启后跑;失败保持 null)。 */
2872
+ async refreshActivePermission() {
2873
+ const conv = this.conv;
2874
+ try {
2875
+ const res = await this.runtime.getPermission(conv.sessionId);
2876
+ const cur = typeof res.currentValue === "string" ? res.currentValue : null;
2877
+ if (cur && cur !== conv.permissionPreset) {
2878
+ conv.permissionPreset = cur;
2879
+ this.flushSnapshot();
2880
+ return;
2881
+ }
2882
+ if (cur)
2883
+ conv.permissionPreset = cur;
2884
+ }
2885
+ catch {
2886
+ /* 运行时未就绪:保持 null/旧值 */
2887
+ }
2888
+ this.flushSnapshot();
2889
+ }
2890
+ /** 新会话默认权限预设(设置面板;非法值拒绝并提示)。 */
2891
+ async setDefaultPermissionPreset(preset) {
2892
+ if (!PERMISSION_OFFERED.includes(preset)) {
2893
+ this.emit({
2894
+ type: "notice",
2895
+ level: "warning",
2896
+ text: `未知权限预设「${preset}」,默认未改`,
2897
+ textEn: `Unknown permission preset "${preset}"; default unchanged`,
2898
+ });
2899
+ return;
2900
+ }
2901
+ this.settings.defaultPermissionPreset = preset;
2902
+ this.stateStore.saveSettings(this.clientId, { defaultPermissionPreset: preset });
2903
+ this.pushPermission();
2904
+ this.flushSnapshot();
2905
+ }
2906
+ /** 首轮 prompt 前:新会话应用 per-client 默认(与当前一致时 apply 无事件零噪音)。 */
2907
+ async ensurePermissionDefault(conv) {
2908
+ const target = this.resolvePermissionPreset();
2909
+ try {
2910
+ if (!conv.permissionPreset) {
2911
+ const res = await this.runtime.getPermission(conv.sessionId);
2912
+ if (typeof res.currentValue === "string" && res.currentValue)
2913
+ conv.permissionPreset = res.currentValue;
2914
+ }
2915
+ if (conv.permissionPreset && conv.permissionPreset !== target) {
2916
+ const res = await this.runtime.setPermission(conv.sessionId, target);
2917
+ if (res.ok)
2918
+ conv.permissionPreset = res.currentValue || target;
2919
+ }
2920
+ }
2921
+ catch {
2922
+ /* legacy/运行时未就绪:best effort */
2923
+ }
2924
+ }
2925
+ /** 当前会话切换权限预设(热切换,无需重启;失败按 code 出双语 notice)。 */
2926
+ async setPermissionPreset(preset) {
2927
+ const conv = this.conv;
2928
+ const target = this.resolvePermissionPreset(preset);
2929
+ if (preset !== target) {
2930
+ this.emit({
2931
+ type: "notice",
2932
+ level: "warning",
2933
+ text: `未知权限预设「${preset}」,已回落「${target}」`,
2934
+ textEn: `Unknown permission preset "${preset}"; fell back to "${target}"`,
2935
+ });
2936
+ }
2937
+ try {
2938
+ const res = await this.runtime.setPermission(conv.sessionId, target);
2939
+ if (!res.ok) {
2940
+ this.emit({
2941
+ type: "notice",
2942
+ level: "warning",
2943
+ text: `权限切换失败:${res.message ?? res.code ?? "未知错误"}`,
2944
+ textEn: `Permission switch failed: ${res.message ?? res.code ?? "unknown error"}`,
2945
+ });
2946
+ this.flushSnapshot();
2947
+ return;
2948
+ }
2949
+ conv.permissionPreset = res.currentValue || target;
2950
+ if (Array.isArray(res.options) && res.options.length > 0) {
2951
+ this.permissionOptions = res.options;
2952
+ this.pushPermission();
2953
+ }
2954
+ this.emitConversations();
2955
+ this.flushSnapshot();
2956
+ }
2957
+ catch (err) {
2958
+ this.emit({
2959
+ type: "notice",
2960
+ level: "error",
2961
+ text: `权限切换失败:${err.message}`,
2962
+ textEn: `Permission switch failed: ${err.message}`,
2963
+ });
2964
+ }
2965
+ }
2522
2966
  async savePreset(name) {
2523
2967
  const presets = this.stateStore.getPresets(this.clientId);
2524
2968
  const existing = presets.find((p) => p.name === name);
@@ -3187,6 +3631,15 @@ export class DshClientSession {
3187
3631
  detail: pick(this.getLang(), "DSH 引擎不需要 pi CLI", "The DSH engine does not need the pi CLI", "dsh.engine.no.cli"),
3188
3632
  });
3189
3633
  }
3634
+ /** DSH 是否就绪:auth.json 的 deepseek key 或环境变量 DEEPSEEK_API_KEY 任一即可。 */
3635
+ isDshConfigured() {
3636
+ return !!loadDeepSeekKey(this.agentDir) || !!process.env.DEEPSEEK_API_KEY;
3637
+ }
3638
+ /** 下拉框里的 deepseek-official 归一到 auth.json 的 deepseek 槽位(loadDeepSeekKey 只读它)。 */
3639
+ normalizeDshProvider(provider) {
3640
+ const pid = provider.trim();
3641
+ return pid === "deepseek-official" ? "deepseek" : pid;
3642
+ }
3190
3643
  async setProviderApiKey(provider, apiKey) {
3191
3644
  const key = apiKey.trim();
3192
3645
  if (!key) {
@@ -3203,7 +3656,7 @@ export class DshClientSession {
3203
3656
  catch {
3204
3657
  /* new file */
3205
3658
  }
3206
- auth[provider.trim()] = { type: "api_key", key };
3659
+ auth[this.normalizeDshProvider(provider)] = { type: "api_key", key };
3207
3660
  mkdirSync(dirname(authPath), { recursive: true });
3208
3661
  writeFileSync(authPath, JSON.stringify(auth, null, 2) + "\n");
3209
3662
  this.emit({
@@ -3226,7 +3679,7 @@ export class DshClientSession {
3226
3679
  }
3227
3680
  }
3228
3681
  async clearProviderApiKey(provider) {
3229
- const pid = provider.trim();
3682
+ const pid = this.normalizeDshProvider(provider);
3230
3683
  try {
3231
3684
  const authPath = join(this.agentDir, "auth.json");
3232
3685
  const auth = JSON.parse(readFileSync(authPath, "utf8"));
@@ -3255,6 +3708,27 @@ export class DshClientSession {
3255
3708
  });
3256
3709
  }
3257
3710
  }
3711
+ startProviderOAuth(_provider) {
3712
+ this.emit({
3713
+ type: "notice",
3714
+ level: "warning",
3715
+ text: "当前引擎不支持 OAuth 登录",
3716
+ textEn: "The current engine does not support OAuth login",
3717
+ });
3718
+ }
3719
+ replyProviderOAuth(_flowId, _promptId, _value) { }
3720
+ cancelProviderOAuth(_flowId) { }
3721
+ listProviderOAuthFlows() {
3722
+ this.emit({ type: "provider_oauth_flows", flows: [] });
3723
+ }
3724
+ async logoutProviderOAuth(provider) {
3725
+ this.emit({
3726
+ type: "provider_oauth_logout_result",
3727
+ provider,
3728
+ ok: false,
3729
+ error: "当前引擎不支持 OAuth 登录",
3730
+ });
3731
+ }
3258
3732
  async listModelsConfig() {
3259
3733
  this.emit({ type: "models_config", providers: [] });
3260
3734
  }
@@ -3289,8 +3763,11 @@ export class DshClientSession {
3289
3763
  {
3290
3764
  id: "deepseek-official",
3291
3765
  name: pick(this.getLang(), "DeepSeek 官方", "DeepSeek Official", "dsh.provider.deepseek.official"),
3292
- configured: !!loadDeepSeekKey(),
3293
- source: loadDeepSeekKey() ? "stored" : undefined,
3766
+ configured: this.isDshConfigured(),
3767
+ source: loadDeepSeekKey(this.agentDir) ? "stored" : process.env.DEEPSEEK_API_KEY ? "environment" : undefined,
3768
+ supportsApiKey: true,
3769
+ supportsOAuth: false,
3770
+ usingOAuth: false,
3294
3771
  },
3295
3772
  ],
3296
3773
  });