pi-web-ui 0.85.0 → 0.86.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +124 -1
- package/bin/pi-web-ui.mjs +98 -8
- package/dist/server/agent-service.js +542 -16
- package/dist/server/client-state.js +99 -1
- package/dist/server/dsh/dsh-agent-service.js +239 -7
- package/dist/server/index.js +205 -3
- package/dist/server/managed.js +4 -0
- package/dist/server/plugin-catalog-sync.js +117 -0
- package/dist/server/plugin-catalog.js +67 -0
- package/dist/server/plugin-grants.js +281 -0
- package/dist/server/plugin-installer.js +238 -0
- package/dist/server/plugin-project.js +406 -0
- package/dist/server/plugins.js +390 -4
- package/dist/server/settings-service.js +34 -1
- package/dist/server/terminals.js +7 -2
- package/dist/server/update-check.js +100 -6
- package/dist/server/wait-subscription-scan.js +1 -1
- package/package.json +1 -1
- package/themes/dark-teal.css +209 -0
- package/web/dist/assets/TerminalPanel-BytY8dx7.js +6 -0
- package/web/dist/assets/index-CKoVyDkP.css +10 -0
- package/web/dist/assets/index-Dmwji4Cr.js +361 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/TerminalPanel-CNz4vxHW.js +0 -6
- package/web/dist/assets/index-BjODtvwn.css +0 -10
- package/web/dist/assets/index-DM-A59lk.js +0 -348
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* 从 agent-service.ts 抽出,行为保持不变。
|
|
8
8
|
*/
|
|
9
9
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
10
|
-
import { dirname } from "node:path";
|
|
10
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
11
11
|
import { deriveLegacy, legacyToDisabled, normalizeDisabledAgentTools } from "./tool-manager.js";
|
|
12
12
|
/** 大模型 API 出错自动重试次数的默认值(SDK 默认 3)。 */
|
|
13
13
|
export const DEFAULT_RETRY_MAX_ATTEMPTS = 6;
|
|
@@ -18,6 +18,47 @@ export function normalizeRetryMaxAttempts(v) {
|
|
|
18
18
|
return DEFAULT_RETRY_MAX_ATTEMPTS;
|
|
19
19
|
return Math.min(100, Math.max(0, n));
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* 归一化 UI 布局偏好(UiLayoutPrefs):只收字符串数组 / 字符串字典,去重 + 长度上限。
|
|
23
|
+
* 脏数据(数字、对象、超长 key、嵌套)一律丢弃而不是整份回落 —— 用户手动调过的那部分
|
|
24
|
+
* 不该因为插件写坏了一个字段就全丢。
|
|
25
|
+
*/
|
|
26
|
+
export function normalizeUiLayout(v) {
|
|
27
|
+
if (!v || typeof v !== "object" || Array.isArray(v))
|
|
28
|
+
return {};
|
|
29
|
+
const o = v;
|
|
30
|
+
const arr = (x, max) => {
|
|
31
|
+
if (!Array.isArray(x))
|
|
32
|
+
return undefined;
|
|
33
|
+
const out = [
|
|
34
|
+
...new Set(x.filter((s) => typeof s === "string" && s.length > 0 && s.length <= 96)),
|
|
35
|
+
].slice(0, max);
|
|
36
|
+
return out.length ? out : undefined;
|
|
37
|
+
};
|
|
38
|
+
const dict = (x, max) => {
|
|
39
|
+
if (!x || typeof x !== "object" || Array.isArray(x))
|
|
40
|
+
return undefined;
|
|
41
|
+
const out = {};
|
|
42
|
+
for (const [k, val] of Object.entries(x).slice(0, max)) {
|
|
43
|
+
if (k.length > 0 && k.length <= 96 && typeof val === "string" && val.length > 0 && val.length <= 120) {
|
|
44
|
+
out[k] = val;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return Object.keys(out).length ? out : undefined;
|
|
48
|
+
};
|
|
49
|
+
const hidden = arr(o.hidden, 200);
|
|
50
|
+
const shown = arr(o.shown, 200);
|
|
51
|
+
const order = arr(o.order, 200);
|
|
52
|
+
const groups = dict(o.groups, 200);
|
|
53
|
+
const labels = dict(o.labels, 200);
|
|
54
|
+
return {
|
|
55
|
+
...(hidden ? { hidden } : {}),
|
|
56
|
+
...(shown ? { shown } : {}),
|
|
57
|
+
...(order ? { order } : {}),
|
|
58
|
+
...(groups ? { groups } : {}),
|
|
59
|
+
...(labels ? { labels } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
21
62
|
/** 归一化技能名单:字符串数组原样过滤;其他(含旧 bool 开关)回落空数组。 */
|
|
22
63
|
export function normalizeSkillList(v) {
|
|
23
64
|
return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
@@ -69,6 +110,36 @@ export function isExtensionEnabled(e, enabled) {
|
|
|
69
110
|
const keys = extensionKeyCandidates(e);
|
|
70
111
|
return enabled.some((d) => keys.includes(d));
|
|
71
112
|
}
|
|
113
|
+
/** 额外工作区根(宿主侧多根,issue #146)上限:右栏文件树可切换的根数量。 */
|
|
114
|
+
export const MAX_WORKSPACE_ROOTS = 8;
|
|
115
|
+
/**
|
|
116
|
+
* 归一化「额外工作区根」列表:只收**绝对路径**(相对路径在服务端没有任何可靠基准)、
|
|
117
|
+
* 去重(win32 折大小写)、上限 MAX_WORKSPACE_ROOTS,并 resolve 成规范形式以便与工作区
|
|
118
|
+
* 做前缀比较。脏数据(数字/空串/对象)逐个丢弃而不是整份回落 —— 用户加过的根不该因为
|
|
119
|
+
* 前端传坏了一个元素就全丢。
|
|
120
|
+
*/
|
|
121
|
+
export function normalizeWorkspaceRoots(v) {
|
|
122
|
+
if (!Array.isArray(v))
|
|
123
|
+
return [];
|
|
124
|
+
const out = [];
|
|
125
|
+
const seen = new Set();
|
|
126
|
+
for (const raw of v) {
|
|
127
|
+
if (typeof raw !== "string")
|
|
128
|
+
continue;
|
|
129
|
+
const p = raw.trim();
|
|
130
|
+
if (!p || !isAbsolute(p))
|
|
131
|
+
continue;
|
|
132
|
+
const abs = resolve(p);
|
|
133
|
+
const key = process.platform === "win32" ? abs.toLowerCase() : abs;
|
|
134
|
+
if (seen.has(key))
|
|
135
|
+
continue;
|
|
136
|
+
seen.add(key);
|
|
137
|
+
out.push(abs);
|
|
138
|
+
if (out.length >= MAX_WORKSPACE_ROOTS)
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
72
143
|
/**
|
|
73
144
|
* Persists which workspace each browser client last used + which workspaces it
|
|
74
145
|
* has opened, so a server restart / page reload restores the same project and
|
|
@@ -165,6 +236,27 @@ export class ClientStateStore {
|
|
|
165
236
|
locked: s.goalPrefs.locked ?? true,
|
|
166
237
|
};
|
|
167
238
|
}
|
|
239
|
+
/** 某项目当前的额外工作区根(空数组 = 单根)。 */
|
|
240
|
+
getWorkspaceRoots(clientId, cwd) {
|
|
241
|
+
return this.load()[clientId]?.workspaceRoots?.[cwd] ?? [];
|
|
242
|
+
}
|
|
243
|
+
/** 记下某项目的额外工作区根(空数组 = 清掉该项目的键,不留空壳)。 */
|
|
244
|
+
saveWorkspaceRoots(clientId, cwd, roots) {
|
|
245
|
+
const all = this.load();
|
|
246
|
+
const state = (all[clientId] ??= { projects: [] });
|
|
247
|
+
const next = normalizeWorkspaceRoots(roots);
|
|
248
|
+
if (next.length === 0) {
|
|
249
|
+
if (state.workspaceRoots) {
|
|
250
|
+
delete state.workspaceRoots[cwd];
|
|
251
|
+
if (Object.keys(state.workspaceRoots).length === 0)
|
|
252
|
+
delete state.workspaceRoots;
|
|
253
|
+
}
|
|
254
|
+
this.save();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
(state.workspaceRoots ??= {})[cwd] = next;
|
|
258
|
+
this.save();
|
|
259
|
+
}
|
|
168
260
|
/** Persist the client's UI locale code (hello/set_locale; best-effort). */
|
|
169
261
|
saveLocale(clientId, locale) {
|
|
170
262
|
const code = locale.trim().slice(0, 16);
|
|
@@ -249,6 +341,8 @@ export class ClientStateStore {
|
|
|
249
341
|
: (stored?.questionnaireEnabled ?? true),
|
|
250
342
|
goalModeEnabled: stored?.goalModeEnabled ?? true,
|
|
251
343
|
thinkingWrap: stored?.thinkingWrap ?? false,
|
|
344
|
+
devNoCache: stored?.devNoCache,
|
|
345
|
+
autoReload: stored?.autoReload,
|
|
252
346
|
toolsWrap: stored?.toolsWrap ?? true,
|
|
253
347
|
skillsFullText: normalizeSkillList(stored?.skillsFullText),
|
|
254
348
|
visionBridgeEnabled: stored?.visionBridgeEnabled ?? true,
|
|
@@ -262,6 +356,7 @@ export class ClientStateStore {
|
|
|
262
356
|
reviewPrompt: stored?.reviewPrompt ?? "",
|
|
263
357
|
reviewDisabledSkills: stored?.reviewDisabledSkills ?? [],
|
|
264
358
|
disabledPlugins: stored?.disabledPlugins ?? [],
|
|
359
|
+
uiLayout: normalizeUiLayout(stored?.uiLayout),
|
|
265
360
|
};
|
|
266
361
|
}
|
|
267
362
|
/** Persist the settings-panel state (partial merge) — global shared config. */
|
|
@@ -284,6 +379,8 @@ export class ClientStateStore {
|
|
|
284
379
|
questionnaireEnabled: settings.questionnaireEnabled ?? cur.questionnaireEnabled ?? true,
|
|
285
380
|
goalModeEnabled: settings.goalModeEnabled ?? cur.goalModeEnabled ?? true,
|
|
286
381
|
thinkingWrap: settings.thinkingWrap ?? cur.thinkingWrap ?? false,
|
|
382
|
+
devNoCache: settings.devNoCache ?? cur.devNoCache,
|
|
383
|
+
autoReload: settings.autoReload ?? cur.autoReload,
|
|
287
384
|
toolsWrap: settings.toolsWrap ?? cur.toolsWrap ?? true,
|
|
288
385
|
skillsFullText: normalizeSkillList(settings.skillsFullText ?? cur.skillsFullText),
|
|
289
386
|
visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
|
|
@@ -295,6 +392,7 @@ export class ClientStateStore {
|
|
|
295
392
|
reviewPrompt: settings.reviewPrompt ?? cur.reviewPrompt ?? "",
|
|
296
393
|
reviewDisabledSkills: settings.reviewDisabledSkills ?? cur.reviewDisabledSkills ?? [],
|
|
297
394
|
disabledPlugins: settings.disabledPlugins ?? cur.disabledPlugins ?? [],
|
|
395
|
+
uiLayout: normalizeUiLayout(settings.uiLayout ?? cur.uiLayout),
|
|
298
396
|
quickPhrases: settings.quickPhrases ?? cur.quickPhrases ?? [],
|
|
299
397
|
quickPhrasesEnabled: settings.quickPhrasesEnabled ?? cur.quickPhrasesEnabled ?? true,
|
|
300
398
|
};
|
|
@@ -30,13 +30,14 @@ import { randomUUID } from "node:crypto";
|
|
|
30
30
|
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
|
+
import { normalizeUiLayout } from "../client-state.js";
|
|
33
34
|
import { FilesService, workspacePath } from "../files-service.js";
|
|
34
35
|
import { QuiesceRejectedError } from "../agent-service.js";
|
|
35
36
|
import { NATIVE_COMMANDS, parseSlash } from "../slash-commands.js";
|
|
36
37
|
import { bilingual, pick, resolveServerLang } from "../i18n.js";
|
|
37
38
|
import { TerminalManager, loadCommands, saveCommandsFile } from "../terminals.js";
|
|
38
39
|
import { saveUpload } from "../uploads.js";
|
|
39
|
-
import { checkAll as checkAllUpdates, collectTargets } from "../update-check.js";
|
|
40
|
+
import { checkAll as checkAllUpdates, collectTargets, resolveNpmRegistry } from "../update-check.js";
|
|
40
41
|
import { previewKind } from "../text-sniff.js";
|
|
41
42
|
import { launchOrigin, toServiceInfo } from "../launch-origin.js";
|
|
42
43
|
import { DshRuntime, loadDeepSeekKey } from "./dsh-client.js";
|
|
@@ -116,6 +117,7 @@ const DEFAULT_SETTINGS = {
|
|
|
116
117
|
thinkingWrap: false,
|
|
117
118
|
toolsWrap: true,
|
|
118
119
|
disabledPlugins: [],
|
|
120
|
+
uiLayout: {},
|
|
119
121
|
reviewPrompt: "",
|
|
120
122
|
quickPhrases: [],
|
|
121
123
|
quickPhrasesEnabled: true,
|
|
@@ -126,6 +128,9 @@ const DEFAULT_SETTINGS = {
|
|
|
126
128
|
export class DshClientSession {
|
|
127
129
|
clientId;
|
|
128
130
|
cwd;
|
|
131
|
+
/** 当前项目的额外工作区根(宿主侧多根,见 protocol 的 set_workspace_roots)——
|
|
132
|
+
* 按 cwd 存在 client-state 里,这里只存一份内存缓存给快照热路径读。 */
|
|
133
|
+
roots = [];
|
|
129
134
|
stateStore;
|
|
130
135
|
sessionRoot;
|
|
131
136
|
dataDir;
|
|
@@ -179,10 +184,20 @@ export class DshClientSession {
|
|
|
179
184
|
onQuit;
|
|
180
185
|
isQuiesced;
|
|
181
186
|
onCwdChanged;
|
|
187
|
+
/** issue #145 跨客户端感知(DshAgentService.attach 接线,与 pi 引擎同语义;
|
|
188
|
+
* DSH 的会话身份是 sessionId = JSONL 目录名)。 */
|
|
189
|
+
findSessionOwnerById = undefined;
|
|
190
|
+
listProjectRunners = undefined;
|
|
191
|
+
listExternalRunning = undefined;
|
|
192
|
+
notifyExternalClients = undefined;
|
|
193
|
+
onRunningChanged = undefined;
|
|
194
|
+
/** issue #145:上次 emit 时流式对话签名(变化才 poke 其他客户端,防循环)。 */
|
|
195
|
+
lastRunningSig = "";
|
|
182
196
|
constructor(clientId, cwd, stateStore, dataDir, agentDir) {
|
|
183
197
|
this.clientId = clientId;
|
|
184
198
|
this.cwd = cwd;
|
|
185
199
|
this.stateStore = stateStore;
|
|
200
|
+
this.roots = stateStore.getWorkspaceRoots(clientId, cwd);
|
|
186
201
|
this.dataDir = dataDir;
|
|
187
202
|
this.agentDir = agentDir;
|
|
188
203
|
this.sessionRoot = dshSessionRoot(dataDir);
|
|
@@ -234,6 +249,7 @@ export class DshClientSession {
|
|
|
234
249
|
thinkingWrap: savedSettings.thinkingWrap,
|
|
235
250
|
toolsWrap: savedSettings.toolsWrap,
|
|
236
251
|
disabledPlugins: savedSettings.disabledPlugins ?? [],
|
|
252
|
+
uiLayout: normalizeUiLayout(savedSettings.uiLayout),
|
|
237
253
|
reviewPrompt: savedSettings.reviewPrompt,
|
|
238
254
|
quickPhrases: savedSettings.quickPhrases ?? [],
|
|
239
255
|
quickPhrasesEnabled: savedSettings.quickPhrasesEnabled ?? true,
|
|
@@ -1042,6 +1058,7 @@ export class DshClientSession {
|
|
|
1042
1058
|
return {
|
|
1043
1059
|
clientId: this.clientId,
|
|
1044
1060
|
cwd: this.cwd,
|
|
1061
|
+
workspaceRoots: this.roots,
|
|
1045
1062
|
sessionId: conv.sessionId,
|
|
1046
1063
|
conversationId: this.activeId,
|
|
1047
1064
|
rev,
|
|
@@ -1121,6 +1138,43 @@ export class DshClientSession {
|
|
|
1121
1138
|
pendingMessages() {
|
|
1122
1139
|
return 0;
|
|
1123
1140
|
}
|
|
1141
|
+
/** issue #145:本实例连接的 socket 数(0 = 标签页全关了,会话残留)。 */
|
|
1142
|
+
sinkCount() {
|
|
1143
|
+
return this.sinks.size;
|
|
1144
|
+
}
|
|
1145
|
+
/** issue #145:按 sessionId 找本实例持有的对话(跨客户端查重的本机一半)。 */
|
|
1146
|
+
findConversationBySessionId(sessionId) {
|
|
1147
|
+
for (const conv of this.convs.values())
|
|
1148
|
+
if (conv.sessionId === sessionId)
|
|
1149
|
+
return conv;
|
|
1150
|
+
return undefined;
|
|
1151
|
+
}
|
|
1152
|
+
/** issue #145:本实例在某 cwd 下正在跑的对话摘要(同项目并行感知用)。 */
|
|
1153
|
+
streamingInCwd(cwd) {
|
|
1154
|
+
const out = [];
|
|
1155
|
+
for (const conv of this.convs.values()) {
|
|
1156
|
+
if (conv.cwd !== cwd || !conv.isStreaming)
|
|
1157
|
+
continue;
|
|
1158
|
+
out.push({ convId: conv.id, title: conv.title, sessionId: conv.sessionId });
|
|
1159
|
+
}
|
|
1160
|
+
return out;
|
|
1161
|
+
}
|
|
1162
|
+
/** issue #145:本实例所有正在跑的对话摘要(elsewhere 列表的本机一半)。 */
|
|
1163
|
+
streamingSummariesAll() {
|
|
1164
|
+
const out = [];
|
|
1165
|
+
for (const conv of this.convs.values()) {
|
|
1166
|
+
if (!conv.isStreaming)
|
|
1167
|
+
continue;
|
|
1168
|
+
out.push({ title: conv.title, cwd: conv.cwd, isStreaming: true });
|
|
1169
|
+
}
|
|
1170
|
+
return out;
|
|
1171
|
+
}
|
|
1172
|
+
/** issue #145:其他客户端重推 conversations 用(elsewhere 刷新)。 */
|
|
1173
|
+
refreshExternalRunning() {
|
|
1174
|
+
if (this.disposed)
|
|
1175
|
+
return;
|
|
1176
|
+
this.emitConversations();
|
|
1177
|
+
}
|
|
1124
1178
|
/** 左栏展示口径(issue #140,与 pi 引擎同义):listed 之外,当前对话只要有
|
|
1125
1179
|
* 内容(DSH 的对话消息全在内存里,直接数 messages)也在列表里;空白新对话
|
|
1126
1180
|
* 不入列。只影响展示,不动 listed 的语义。 */
|
|
@@ -1143,7 +1197,23 @@ export class DshClientSession {
|
|
|
1143
1197
|
isSubagent: false,
|
|
1144
1198
|
});
|
|
1145
1199
|
}
|
|
1146
|
-
|
|
1200
|
+
// issue #145:流式集合签名变化 → 通知其他客户端重推(左栏「另一处正在运行」近实时)
|
|
1201
|
+
const sig = JSON.stringify([...this.convs.values()]
|
|
1202
|
+
.filter((c) => c.isStreaming)
|
|
1203
|
+
.map((c) => c.id)
|
|
1204
|
+
.sort());
|
|
1205
|
+
if (sig !== this.lastRunningSig) {
|
|
1206
|
+
this.lastRunningSig = sig;
|
|
1207
|
+
this.onRunningChanged?.();
|
|
1208
|
+
}
|
|
1209
|
+
const elsewhere = this.listExternalRunning?.() ?? [];
|
|
1210
|
+
this.emit({
|
|
1211
|
+
type: "conversations",
|
|
1212
|
+
conversations: list,
|
|
1213
|
+
activeId: this.activeId,
|
|
1214
|
+
// 为空时缺省(老快照字节一致)
|
|
1215
|
+
...(elsewhere.length > 0 ? { elsewhere } : {}),
|
|
1216
|
+
});
|
|
1147
1217
|
}
|
|
1148
1218
|
/** 语义同 pi 引擎的 newChat:true = 当前活动对话是可接收首条的空白新对话
|
|
1149
1219
|
* (/new <prompt> 靠它决定要不要把首条提示发出去)。 */
|
|
@@ -1239,6 +1309,51 @@ export class DshClientSession {
|
|
|
1239
1309
|
}
|
|
1240
1310
|
}
|
|
1241
1311
|
let conv = this.conv;
|
|
1312
|
+
// issue #145:同文件守卫 —— 别处正在跑同一 sessionId 时拒绝发送(不造第二个 writer)。
|
|
1313
|
+
const fileOwner = this.findSessionOwnerById?.(conv.sessionId);
|
|
1314
|
+
if (fileOwner && fileOwner.isStreaming) {
|
|
1315
|
+
this.emit({
|
|
1316
|
+
type: "notice",
|
|
1317
|
+
level: "warning",
|
|
1318
|
+
text: `发送已拦截:该对话正在另一处运行中(「${fileOwner.title}」)。请等它结束后再发,或回到原窗口继续 —— 否则两个 agent 会同时写同一份记录,其中一支事后不可见。`,
|
|
1319
|
+
textEn: `Prompt blocked: this conversation is running in another window ("${fileOwner.title}"). Wait for it to finish or continue there — two writers on one transcript would leave one run permanently invisible.`,
|
|
1320
|
+
});
|
|
1321
|
+
this.flushSnapshot();
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
// issue #145:同项目并行感知(与 pi 引擎同语义;DSH 无 display:false 的
|
|
1325
|
+
// custom 消息通道,提醒以前置系统文本随本轮发给运行时,用户气泡保持原文)。
|
|
1326
|
+
let sysPrefix = "";
|
|
1327
|
+
if (!conv.isStreaming) {
|
|
1328
|
+
const localTitles = [...this.convs.values()]
|
|
1329
|
+
.filter((c) => c.id !== conv.id && c.cwd === conv.cwd && c.isStreaming)
|
|
1330
|
+
.map((c) => `本窗口「${c.title}」`);
|
|
1331
|
+
const externalRunners = (this.listProjectRunners?.(conv.cwd) ?? []).filter((r) => r.sessionFile === undefined || basename(dirname(resolve(r.sessionFile))) !== conv.sessionId);
|
|
1332
|
+
const runnerTitles = [...localTitles, ...externalRunners.map((r) => `另一处「${r.title}」`)];
|
|
1333
|
+
if (runnerTitles.length > 0) {
|
|
1334
|
+
const shown = runnerTitles.slice(0, 3).join("、");
|
|
1335
|
+
const more = runnerTitles.length > 3 ? `等 ${runnerTitles.length} 处` : "";
|
|
1336
|
+
this.emit({
|
|
1337
|
+
type: "notice",
|
|
1338
|
+
level: "info",
|
|
1339
|
+
text: `同项目并行提醒:${shown}${more}正在同一项目运行。你可以继续(适合改不同文件),改动同一文件前请先确认;拿不准就等它跑完。`,
|
|
1340
|
+
textEn: `Parallel-work notice: ${shown}${more ? " and more" : ""} running in the same project. You may continue (fine for different files); confirm before touching the same files, or wait for it to finish when unsure.`,
|
|
1341
|
+
});
|
|
1342
|
+
sysPrefix =
|
|
1343
|
+
`(System reminder: ${runnerTitles.length} other run(s) [${runnerTitles.join("; ").slice(0, 600)}] are currently running in the same project directory. ` +
|
|
1344
|
+
`You may work in parallel on different files, but before reading/writing files or running commands, assess the conflict probability with the other run(s). ` +
|
|
1345
|
+
`If a conflict is likely or you are unsure, use ask_user_question to let the user choose: continue in parallel / wait / watch read-only.)\n` +
|
|
1346
|
+
`(系统提醒:同一项目另有 ${runnerTitles.length} 处运行(${shown}${more})。改不同文件可并行;读写文件或跑命令前先评估冲突概率,拿不准就用 ask_user_question 让用户选择:并行 / 等它跑完 / 只读围观。)\n\n`;
|
|
1347
|
+
if (externalRunners.length > 0) {
|
|
1348
|
+
this.notifyExternalClients?.({
|
|
1349
|
+
type: "notice",
|
|
1350
|
+
level: "info",
|
|
1351
|
+
text: `同项目并行提醒:另一处在「${conv.cwd}」开始了对话(「${conv.title}」),可能与你正在跑的任务并行改动同一项目。`,
|
|
1352
|
+
textEn: `Parallel-work notice: another window started a conversation ("${conv.title}") in "${conv.cwd}", possibly editing the same project in parallel with your running task.`,
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1242
1357
|
// 磁盘回放会话(switch_session)没有 live runtime session —— DSH 的
|
|
1243
1358
|
// JSON-RPC 面不支持恢复(id collision),自动 fork 新会话继续:把历史
|
|
1244
1359
|
// 作为上下文注入首条 prompt,前端提示。
|
|
@@ -1256,7 +1371,7 @@ export class DshClientSession {
|
|
|
1256
1371
|
conv.title = trimmed.length > 30 ? `${trimmed.slice(0, 30)}…` : trimmed;
|
|
1257
1372
|
this.emitConversations();
|
|
1258
1373
|
}
|
|
1259
|
-
await this.promptConv(conv, text, attachments, queue);
|
|
1374
|
+
await this.promptConv(conv, text, attachments, queue, sysPrefix);
|
|
1260
1375
|
}
|
|
1261
1376
|
/**
|
|
1262
1377
|
* Remove ONE queued prompt text (the ✕ on a pending bubble). DSH queues are
|
|
@@ -1315,13 +1430,15 @@ export class DshClientSession {
|
|
|
1315
1430
|
/* 补图失败保持占位 */
|
|
1316
1431
|
}
|
|
1317
1432
|
}
|
|
1318
|
-
async promptConv(conv, text, attachments, _queue = false
|
|
1433
|
+
async promptConv(conv, text, attachments, _queue = false,
|
|
1434
|
+
// issue #145 同项目并行提醒:只进运行时上下文,不进用户气泡。
|
|
1435
|
+
sysPrefix = "") {
|
|
1319
1436
|
try {
|
|
1320
1437
|
if (this.quiesceBlocked())
|
|
1321
1438
|
return;
|
|
1322
1439
|
conv.promptedSinceActive = true;
|
|
1323
1440
|
conv.lastEventAt = Date.now();
|
|
1324
|
-
const blocks = await this.buildContentBlocks(text, attachments);
|
|
1441
|
+
const blocks = await this.buildContentBlocks(sysPrefix ? `${sysPrefix}${text}` : text, attachments);
|
|
1325
1442
|
// 乐观落地用户消息(id 用暂定值;user/message 事件到达时按内容去重)。
|
|
1326
1443
|
const optimistic = {
|
|
1327
1444
|
id: `u-pending-${Date.now()}-${conv.deltaSeq++}`,
|
|
@@ -1869,6 +1986,29 @@ export class DshClientSession {
|
|
|
1869
1986
|
return;
|
|
1870
1987
|
}
|
|
1871
1988
|
}
|
|
1989
|
+
// issue #145:同 pi 引擎 —— 别处正在跑同一 sessionId 时不建第二个持有者。
|
|
1990
|
+
const owner = this.findSessionOwnerById?.(sessionId);
|
|
1991
|
+
if (owner && owner.isStreaming) {
|
|
1992
|
+
this.emit({
|
|
1993
|
+
type: "notice",
|
|
1994
|
+
level: "warning",
|
|
1995
|
+
text: `该对话正在另一处运行中(「${owner.title}」),为避免两个 agent 同时写同一份记录,已停止打开。请等它结束后再试,或回到原窗口继续。`,
|
|
1996
|
+
textEn: `This conversation is running in another window ("${owner.title}"). Opening it here would create a second writer for the same transcript, so it was blocked. Wait for it to finish, or continue in the original window.`,
|
|
1997
|
+
});
|
|
1998
|
+
this.flushSnapshot(true);
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
if (owner) {
|
|
2002
|
+
// 对端已断开(标签页关了)只剩残留会话 —— 不打扰,直接开。
|
|
2003
|
+
if (owner.connected) {
|
|
2004
|
+
this.emit({
|
|
2005
|
+
type: "notice",
|
|
2006
|
+
level: "info",
|
|
2007
|
+
text: `提醒:该对话在另一处也开着(「${owner.title}」,当前空闲)。请只留一处发送消息,否则两边轮流发送会让历史分叉、其中一支事后不可见。`,
|
|
2008
|
+
textEn: `Note: this conversation is also open in another window ("${owner.title}", currently idle). Send new messages from only one place — alternating between two writers forks the history and hides one branch.`,
|
|
2009
|
+
});
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
1872
2012
|
const prev = this.conv;
|
|
1873
2013
|
prev.listed = prev.isStreaming || prev.terminals.list().length > 0 || prev.promptedSinceActive;
|
|
1874
2014
|
const conv = this.addConversation(sessionId, this.cwd, true);
|
|
@@ -2252,6 +2392,7 @@ export class DshClientSession {
|
|
|
2252
2392
|
reviewPrompt: this.settings.reviewPrompt,
|
|
2253
2393
|
reviewDisabledSkills: [],
|
|
2254
2394
|
disabledPlugins: this.settings.disabledPlugins,
|
|
2395
|
+
uiLayout: normalizeUiLayout(this.settings.uiLayout),
|
|
2255
2396
|
promptTemplate: "",
|
|
2256
2397
|
promptOverrides: {},
|
|
2257
2398
|
effectiveSystemPrompt: this.settings.customSystemPrompt,
|
|
@@ -2306,6 +2447,8 @@ export class DshClientSession {
|
|
|
2306
2447
|
this.settings.toolsWrap = partial.toolsWrap;
|
|
2307
2448
|
if (partial.disabledPlugins !== undefined)
|
|
2308
2449
|
this.settings.disabledPlugins = partial.disabledPlugins;
|
|
2450
|
+
if (partial.uiLayout !== undefined)
|
|
2451
|
+
this.settings.uiLayout = normalizeUiLayout(partial.uiLayout);
|
|
2309
2452
|
if (partial.reviewPrompt !== undefined)
|
|
2310
2453
|
this.settings.reviewPrompt = partial.reviewPrompt;
|
|
2311
2454
|
if (partial.quickPhrases !== undefined) {
|
|
@@ -2334,6 +2477,7 @@ export class DshClientSession {
|
|
|
2334
2477
|
thinkingWrap: this.settings.thinkingWrap,
|
|
2335
2478
|
toolsWrap: this.settings.toolsWrap,
|
|
2336
2479
|
disabledPlugins: this.settings.disabledPlugins,
|
|
2480
|
+
uiLayout: normalizeUiLayout(this.settings.uiLayout),
|
|
2337
2481
|
reviewPrompt: this.settings.reviewPrompt,
|
|
2338
2482
|
quickPhrases: this.settings.quickPhrases,
|
|
2339
2483
|
quickPhrasesEnabled: this.settings.quickPhrasesEnabled,
|
|
@@ -2442,6 +2586,7 @@ export class DshClientSession {
|
|
|
2442
2586
|
thinkingWrap: this.settings.thinkingWrap,
|
|
2443
2587
|
toolsWrap: this.settings.toolsWrap,
|
|
2444
2588
|
disabledPlugins: this.settings.disabledPlugins,
|
|
2589
|
+
uiLayout: normalizeUiLayout(this.settings.uiLayout),
|
|
2445
2590
|
reviewPrompt: this.settings.reviewPrompt,
|
|
2446
2591
|
});
|
|
2447
2592
|
await this.applyPersona();
|
|
@@ -2983,7 +3128,9 @@ export class DshClientSession {
|
|
|
2983
3128
|
}
|
|
2984
3129
|
async checkUpdate() {
|
|
2985
3130
|
try {
|
|
2986
|
-
|
|
3131
|
+
// registry 遵从 ~/.pi/agent/npm/.npmrc(与 `pi update` 经 npm 的行为一致,issue #151)
|
|
3132
|
+
const npmRegistry = resolveNpmRegistry(join(homedir(), ".pi", "agent"));
|
|
3133
|
+
const latest = await checkAllUpdates([{ name: "pi-web-ui", version: DshClientSession.currentAppVersion(), kind: "webui" }], undefined, () => this.getLang(), npmRegistry);
|
|
2987
3134
|
const item = latest[0];
|
|
2988
3135
|
this.emit({
|
|
2989
3136
|
type: "update_status",
|
|
@@ -3008,7 +3155,7 @@ export class DshClientSession {
|
|
|
3008
3155
|
async checkUpdatesAll(force = false) {
|
|
3009
3156
|
try {
|
|
3010
3157
|
const targets = collectTargets(join(homedir(), ".pi", "agent"), DshClientSession.currentAppVersion());
|
|
3011
|
-
const items = await checkAllUpdates(targets, undefined, () => this.getLang());
|
|
3158
|
+
const items = await checkAllUpdates(targets, undefined, () => this.getLang(), resolveNpmRegistry(join(homedir(), ".pi", "agent")));
|
|
3012
3159
|
if (force) {
|
|
3013
3160
|
// 强制模式:忽略缓存(默认 Fetcher 带 TTL,直接再查一次即可)。
|
|
3014
3161
|
void items;
|
|
@@ -3263,6 +3410,19 @@ export class DshClientSession {
|
|
|
3263
3410
|
return;
|
|
3264
3411
|
this.stateStore.saveLocale(this.clientId, code);
|
|
3265
3412
|
}
|
|
3413
|
+
/** 设置当前项目的额外工作区根(宿主侧多根,宿主 API v2 的宿主侧能力)。
|
|
3414
|
+
* DSH 引擎没有插件宿主,多根只落到「宿主知道 / 快照下发」这一层:右栏文件树
|
|
3415
|
+
* (前端)据此多出可切换的根。
|
|
3416
|
+
* 与 pi 引擎同口径:归一化交给 ClientStateStore(只收绝对路径 / 去重 / 上限 8)。 */
|
|
3417
|
+
async setWorkspaceRoots(roots) {
|
|
3418
|
+
const before = this.stateStore.getWorkspaceRoots(this.clientId, this.cwd);
|
|
3419
|
+
this.stateStore.saveWorkspaceRoots(this.clientId, this.cwd, roots ?? []);
|
|
3420
|
+
const saved = this.stateStore.getWorkspaceRoots(this.clientId, this.cwd);
|
|
3421
|
+
if (saved.length === before.length && saved.every((p, i) => p === before[i]))
|
|
3422
|
+
return;
|
|
3423
|
+
this.roots = saved;
|
|
3424
|
+
this.flushSnapshot();
|
|
3425
|
+
}
|
|
3266
3426
|
async setCwd(newCwd) {
|
|
3267
3427
|
try {
|
|
3268
3428
|
const abs = resolve(newCwd);
|
|
@@ -3278,6 +3438,7 @@ export class DshClientSession {
|
|
|
3278
3438
|
if (abs === this.cwd)
|
|
3279
3439
|
return;
|
|
3280
3440
|
this.cwd = abs;
|
|
3441
|
+
this.roots = this.stateStore.getWorkspaceRoots(this.clientId, abs);
|
|
3281
3442
|
this.stateStore.remember(this.clientId, abs);
|
|
3282
3443
|
// 换项目 = 重启运行时(initialize 固定 cwd)。
|
|
3283
3444
|
try {
|
|
@@ -3440,6 +3601,71 @@ export class DshAgentService {
|
|
|
3440
3601
|
service: toServiceInfo(launchOrigin()),
|
|
3441
3602
|
};
|
|
3442
3603
|
}
|
|
3604
|
+
/** issue #145:跨客户端同会话查重(sessionId 口径,与 pi 引擎同语义)。 */
|
|
3605
|
+
findSessionOwner(sessionId, excludeClientId) {
|
|
3606
|
+
for (const [clientId, cs] of this.clients) {
|
|
3607
|
+
if (clientId === excludeClientId)
|
|
3608
|
+
continue;
|
|
3609
|
+
const conv = cs.findConversationBySessionId(sessionId);
|
|
3610
|
+
if (conv)
|
|
3611
|
+
return {
|
|
3612
|
+
clientId,
|
|
3613
|
+
title: conv.title,
|
|
3614
|
+
cwd: conv.cwd,
|
|
3615
|
+
isStreaming: conv.isStreaming,
|
|
3616
|
+
connected: cs.sinkCount() > 0,
|
|
3617
|
+
};
|
|
3618
|
+
}
|
|
3619
|
+
return null;
|
|
3620
|
+
}
|
|
3621
|
+
/** issue #145:别处在某 cwd 下正在跑的对话(同项目并行感知用)。 */
|
|
3622
|
+
listProjectRunners(cwd, excludeClientId) {
|
|
3623
|
+
const out = [];
|
|
3624
|
+
for (const [clientId, cs] of this.clients) {
|
|
3625
|
+
if (clientId === excludeClientId)
|
|
3626
|
+
continue;
|
|
3627
|
+
for (const r of cs.streamingInCwd(cwd))
|
|
3628
|
+
out.push({ clientId, title: r.title });
|
|
3629
|
+
}
|
|
3630
|
+
return out;
|
|
3631
|
+
}
|
|
3632
|
+
/** issue #145:别处所有正在跑的对话(左栏 elsewhere 只读感知用)。 */
|
|
3633
|
+
listExternalRunning(excludeClientId) {
|
|
3634
|
+
const out = [];
|
|
3635
|
+
for (const [clientId, cs] of this.clients) {
|
|
3636
|
+
if (clientId === excludeClientId)
|
|
3637
|
+
continue;
|
|
3638
|
+
for (const r of cs.streamingSummariesAll())
|
|
3639
|
+
out.push(r);
|
|
3640
|
+
}
|
|
3641
|
+
return out;
|
|
3642
|
+
}
|
|
3643
|
+
/** issue #145:某客户端流式集合变化 → 其他客户端重推 conversations。 */
|
|
3644
|
+
pokeExternalRunning(excludeClientId) {
|
|
3645
|
+
for (const [clientId, cs] of this.clients) {
|
|
3646
|
+
if (clientId === excludeClientId)
|
|
3647
|
+
continue;
|
|
3648
|
+
try {
|
|
3649
|
+
cs.refreshExternalRunning();
|
|
3650
|
+
}
|
|
3651
|
+
catch {
|
|
3652
|
+
// 单客户端坏了不影响其他
|
|
3653
|
+
}
|
|
3654
|
+
}
|
|
3655
|
+
}
|
|
3656
|
+
/** issue #145:向除请求方外的所有客户端发一条 notice(并行通告用)。 */
|
|
3657
|
+
notifyClientsExcept(excludeClientId, msg) {
|
|
3658
|
+
for (const [clientId, cs] of this.clients) {
|
|
3659
|
+
if (clientId === excludeClientId)
|
|
3660
|
+
continue;
|
|
3661
|
+
try {
|
|
3662
|
+
cs.emitNotice(msg.level, msg.text, msg.textEn);
|
|
3663
|
+
}
|
|
3664
|
+
catch {
|
|
3665
|
+
// 单客户端坏了不影响其他
|
|
3666
|
+
}
|
|
3667
|
+
}
|
|
3668
|
+
}
|
|
3443
3669
|
async attach(clientId, send) {
|
|
3444
3670
|
let cs = this.clients.get(clientId);
|
|
3445
3671
|
if (!cs) {
|
|
@@ -3478,6 +3704,12 @@ export class DshAgentService {
|
|
|
3478
3704
|
cs.pluginBgTasksProvider = this.pluginBgTasksProvider;
|
|
3479
3705
|
cs.pluginStopBgTask = this.pluginStopBgTask;
|
|
3480
3706
|
cs.isQuiesced = () => this.quiesced;
|
|
3707
|
+
// issue #145 跨客户端感知接线(同会话查重 / 同项目并行 / elsewhere 列表)。
|
|
3708
|
+
cs.findSessionOwnerById = (sessionId) => this.findSessionOwner(sessionId, clientId);
|
|
3709
|
+
cs.listProjectRunners = (cwd) => this.listProjectRunners(cwd, clientId);
|
|
3710
|
+
cs.listExternalRunning = () => this.listExternalRunning(clientId);
|
|
3711
|
+
cs.notifyExternalClients = (msg) => this.notifyClientsExcept(clientId, msg);
|
|
3712
|
+
cs.onRunningChanged = () => this.pokeExternalRunning(clientId);
|
|
3481
3713
|
cs.onCwdChanged = (abs) => this.onClientCwdChanged?.(abs);
|
|
3482
3714
|
this.onClientCwdChanged?.(cs.cwd);
|
|
3483
3715
|
return cs;
|