pi-web-ui 0.71.0 → 0.73.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -1
- package/README.md +17 -9
- package/README.zh-CN.md +22 -11
- package/dist/server/agent-service.js +526 -71
- package/dist/server/client-state.js +56 -0
- package/dist/server/dsh/dsh-agent-service.js +99 -3
- package/dist/server/index.js +11 -1
- package/dist/server/model-admin.js +135 -23
- package/dist/server/patch-remote-catalog.js +59 -0
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/subagents.js +51 -4
- package/package.json +4 -1
- package/themes/cyberpunk.css +1 -1
- package/themes/dazzle.css +1 -1
- package/themes/md-preview.css +1 -1
- package/themes/translucent.css +2 -2
- package/themes/white.css +1 -1
- package/web/dist/assets/{TerminalPanel-DXNpevIG.js → TerminalPanel-Bz-58xBx.js} +1 -1
- package/web/dist/assets/index-DB1SxjbK.js +335 -0
- package/web/dist/assets/index-Dqbw78CU.css +10 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-4ge_ioPK.css +0 -10
- package/web/dist/assets/index-Cue012aF.js +0 -335
|
@@ -331,6 +331,62 @@ export class ClientStateStore {
|
|
|
331
331
|
}
|
|
332
332
|
this.save();
|
|
333
333
|
}
|
|
334
|
+
/** Remove one provider from EVERY project's saved keys (all clients, all
|
|
335
|
+
* cwds) — e.g. the provider was cleared and returned to unconfigured.
|
|
336
|
+
* Returns the number of entries removed. */
|
|
337
|
+
deleteProviderEverywhere(provider) {
|
|
338
|
+
const all = this.load();
|
|
339
|
+
let removed = 0;
|
|
340
|
+
for (const state of Object.values(all)) {
|
|
341
|
+
const map = state.projectProviderKeys;
|
|
342
|
+
if (!map)
|
|
343
|
+
continue;
|
|
344
|
+
for (const [cwd, inner] of Object.entries(map)) {
|
|
345
|
+
if (inner && provider in inner) {
|
|
346
|
+
delete inner[provider];
|
|
347
|
+
removed++;
|
|
348
|
+
if (Object.keys(inner).length === 0)
|
|
349
|
+
delete map[cwd];
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (map && Object.keys(map).length === 0)
|
|
353
|
+
delete state.projectProviderKeys;
|
|
354
|
+
}
|
|
355
|
+
if (removed > 0)
|
|
356
|
+
this.save();
|
|
357
|
+
return removed;
|
|
358
|
+
}
|
|
359
|
+
/** Fix every project that still references a deleted key: point it at the
|
|
360
|
+
* key that took over (`newActive`), or drop the reference when the
|
|
361
|
+
* provider has no keys left (`newActive` null). A key deletion made in
|
|
362
|
+
* one project must not keep haunting every other project that once used
|
|
363
|
+
* the same key on every project switch. Returns entries touched. */
|
|
364
|
+
repointDeletedKeyEverywhere(provider, deletedKeyName, newActive) {
|
|
365
|
+
const all = this.load();
|
|
366
|
+
let touched = 0;
|
|
367
|
+
for (const state of Object.values(all)) {
|
|
368
|
+
const map = state.projectProviderKeys;
|
|
369
|
+
if (!map)
|
|
370
|
+
continue;
|
|
371
|
+
for (const [cwd, inner] of Object.entries(map)) {
|
|
372
|
+
if (inner?.[provider] !== deletedKeyName)
|
|
373
|
+
continue;
|
|
374
|
+
if (newActive)
|
|
375
|
+
inner[provider] = newActive;
|
|
376
|
+
else {
|
|
377
|
+
delete inner[provider];
|
|
378
|
+
if (Object.keys(inner).length === 0)
|
|
379
|
+
delete map[cwd];
|
|
380
|
+
}
|
|
381
|
+
touched++;
|
|
382
|
+
}
|
|
383
|
+
if (map && Object.keys(map).length === 0)
|
|
384
|
+
delete state.projectProviderKeys;
|
|
385
|
+
}
|
|
386
|
+
if (touched > 0)
|
|
387
|
+
this.save();
|
|
388
|
+
return touched;
|
|
389
|
+
}
|
|
334
390
|
/** Get the model the user last selected in a project, or undefined. */
|
|
335
391
|
getProjectModel(clientId, cwd) {
|
|
336
392
|
return this.load()[clientId]?.projectModels?.[cwd];
|
|
@@ -1560,6 +1560,65 @@ export class DshClientSession {
|
|
|
1560
1560
|
textEn: "The DSH engine cannot stop bash alone (stop the whole conversation instead)",
|
|
1561
1561
|
});
|
|
1562
1562
|
}
|
|
1563
|
+
/** 手动重试上次失败的模型调用:找到最后一条用户提问并重发一次
|
|
1564
|
+
* (DSH 运行时以 prompt 驱动回合,无 triggerTurn 语义)。流式中 /
|
|
1565
|
+
* 无可重试失败时只发 notice 拒绝。 */
|
|
1566
|
+
async retryLast() {
|
|
1567
|
+
const conv = this.conv;
|
|
1568
|
+
try {
|
|
1569
|
+
if (this.quiesceBlocked())
|
|
1570
|
+
return;
|
|
1571
|
+
if (conv.isStreaming) {
|
|
1572
|
+
this.emit({
|
|
1573
|
+
type: "notice",
|
|
1574
|
+
level: "info",
|
|
1575
|
+
text: "对话正在生成中,无需重试",
|
|
1576
|
+
textEn: "The conversation is still generating — no need to retry",
|
|
1577
|
+
});
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
let failed = false;
|
|
1581
|
+
for (let i = conv.messages.length - 1; i >= 0; i--) {
|
|
1582
|
+
const m = conv.messages[i];
|
|
1583
|
+
if (m.role !== "assistant")
|
|
1584
|
+
continue;
|
|
1585
|
+
failed = !!m.errorMessage;
|
|
1586
|
+
break;
|
|
1587
|
+
}
|
|
1588
|
+
if (!failed) {
|
|
1589
|
+
this.emit({
|
|
1590
|
+
type: "notice",
|
|
1591
|
+
level: "info",
|
|
1592
|
+
text: "没有可重试的失败:上一轮没有报错结束",
|
|
1593
|
+
textEn: "Nothing to retry: the last turn did not end with an error",
|
|
1594
|
+
});
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
let lastUser = "";
|
|
1598
|
+
for (let i = conv.messages.length - 1; i >= 0; i--) {
|
|
1599
|
+
const m = conv.messages[i];
|
|
1600
|
+
if (m.role !== "user")
|
|
1601
|
+
continue;
|
|
1602
|
+
lastUser = m.content
|
|
1603
|
+
.map((c) => ("text" in c ? c.text : ""))
|
|
1604
|
+
.join("")
|
|
1605
|
+
.trim();
|
|
1606
|
+
if (lastUser)
|
|
1607
|
+
break;
|
|
1608
|
+
}
|
|
1609
|
+
const lang = this.getLang();
|
|
1610
|
+
await this.prompt(lastUser || pick(lang, "请继续", "Please continue", "dsh.prompt.continue"));
|
|
1611
|
+
}
|
|
1612
|
+
catch (err) {
|
|
1613
|
+
this.emit({
|
|
1614
|
+
type: "notice",
|
|
1615
|
+
level: "error",
|
|
1616
|
+
text: `手动重试失败:${err.message}`,
|
|
1617
|
+
textEn: `Manual retry failed: ${err.message}`,
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
this.flushSnapshot();
|
|
1621
|
+
}
|
|
1563
1622
|
// -----------------------------------------------------------------------
|
|
1564
1623
|
// 后台任务
|
|
1565
1624
|
// -----------------------------------------------------------------------
|
|
@@ -1683,7 +1742,9 @@ export class DshClientSession {
|
|
|
1683
1742
|
});
|
|
1684
1743
|
}
|
|
1685
1744
|
}
|
|
1686
|
-
async dismissConversation(id) {
|
|
1745
|
+
async dismissConversation(id, _withFinishedSubagents, force) {
|
|
1746
|
+
// DSH 引擎无第一方子代理:withFinishedSubagents 恒为 no-op(签名与标准引擎对齐)。
|
|
1747
|
+
// force 放行 active/终端限制;运行中仍拒绝(DSH 单 runtime 无法单独 abort 一个对话,请先点停止)。
|
|
1687
1748
|
const conv = this.convs.get(id);
|
|
1688
1749
|
if (!conv) {
|
|
1689
1750
|
this.emit({
|
|
@@ -1694,7 +1755,7 @@ export class DshClientSession {
|
|
|
1694
1755
|
});
|
|
1695
1756
|
return;
|
|
1696
1757
|
}
|
|
1697
|
-
if (id === this.activeId) {
|
|
1758
|
+
if (id === this.activeId && !force) {
|
|
1698
1759
|
this.emit({
|
|
1699
1760
|
type: "notice",
|
|
1700
1761
|
level: "warning",
|
|
@@ -1716,7 +1777,7 @@ export class DshClientSession {
|
|
|
1716
1777
|
});
|
|
1717
1778
|
return;
|
|
1718
1779
|
}
|
|
1719
|
-
if (conv.terminals.list().length > 0) {
|
|
1780
|
+
if (conv.terminals.list().length > 0 && !force) {
|
|
1720
1781
|
this.emit({
|
|
1721
1782
|
type: "notice",
|
|
1722
1783
|
level: "warning",
|
|
@@ -1725,10 +1786,37 @@ export class DshClientSession {
|
|
|
1725
1786
|
});
|
|
1726
1787
|
return;
|
|
1727
1788
|
}
|
|
1789
|
+
// force + active:先让出 active(切到其他对话或新建),再移除。
|
|
1790
|
+
if (id === this.activeId) {
|
|
1791
|
+
const other = [...this.convs.values()].find((c) => c.id !== id);
|
|
1792
|
+
if (other)
|
|
1793
|
+
await this.switchConversation(other.id);
|
|
1794
|
+
else
|
|
1795
|
+
await this.newChat();
|
|
1796
|
+
if (id === this.activeId) {
|
|
1797
|
+
this.emit({
|
|
1798
|
+
type: "notice",
|
|
1799
|
+
level: "warning",
|
|
1800
|
+
text: `当前对话「${conv.title}」暂时无法移出(无法创建接替对话)`,
|
|
1801
|
+
textEn: `Cannot dismiss the active conversation "${conv.title}" right now (no replacement chat available)`,
|
|
1802
|
+
});
|
|
1803
|
+
return;
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1728
1806
|
this.removeConversation(id);
|
|
1729
1807
|
this.emitConversations();
|
|
1730
1808
|
this.flushSnapshot();
|
|
1731
1809
|
}
|
|
1810
|
+
/** DSH 引擎无第一方子代理(emitConversations 恒 isSubagent:false):批量
|
|
1811
|
+
* 关闭退化为空操作提示,保持与 pi 引擎同一 wire 行为。 */
|
|
1812
|
+
async dismissFinishedSubagents(_parentId) {
|
|
1813
|
+
this.emit({
|
|
1814
|
+
type: "notice",
|
|
1815
|
+
level: "info",
|
|
1816
|
+
text: "没有可关闭的已结束子代理",
|
|
1817
|
+
textEn: "No finished subagents to dismiss",
|
|
1818
|
+
});
|
|
1819
|
+
}
|
|
1732
1820
|
/** 切换会话:读 JSONL 回放 → 新建 conversation(同一 sessionId 续聊)。 */
|
|
1733
1821
|
async switchSession(path) {
|
|
1734
1822
|
try {
|
|
@@ -2970,6 +3058,14 @@ export class DshClientSession {
|
|
|
2970
3058
|
async listModelsConfig() {
|
|
2971
3059
|
this.emit({ type: "models_config", providers: [] });
|
|
2972
3060
|
}
|
|
3061
|
+
async reloadModelsConfig() {
|
|
3062
|
+
this.emit({
|
|
3063
|
+
type: "notice",
|
|
3064
|
+
level: "warning",
|
|
3065
|
+
text: "DSH 引擎不支持自定义 provider",
|
|
3066
|
+
textEn: "The DSH engine does not support custom providers",
|
|
3067
|
+
});
|
|
3068
|
+
}
|
|
2973
3069
|
async saveModelConfig(_providerId, _config) {
|
|
2974
3070
|
this.emit({
|
|
2975
3071
|
type: "notice",
|
package/dist/server/index.js
CHANGED
|
@@ -705,6 +705,9 @@ wss.on("connection", (ws) => {
|
|
|
705
705
|
case "abort_bash":
|
|
706
706
|
void cs.abortBash();
|
|
707
707
|
break;
|
|
708
|
+
case "retry_last":
|
|
709
|
+
void cs.retryLast();
|
|
710
|
+
break;
|
|
708
711
|
case "kill_background_server":
|
|
709
712
|
void cs.killBackgroundServer(msg.port, msg.taskId);
|
|
710
713
|
break;
|
|
@@ -753,7 +756,10 @@ wss.on("connection", (ws) => {
|
|
|
753
756
|
void cs.renameConversation(msg.id, msg.name);
|
|
754
757
|
break;
|
|
755
758
|
case "dismiss_conversation":
|
|
756
|
-
void cs.dismissConversation(msg.id);
|
|
759
|
+
void cs.dismissConversation(msg.id, msg.withFinishedSubagents, msg.force);
|
|
760
|
+
break;
|
|
761
|
+
case "dismiss_finished_subagents":
|
|
762
|
+
void cs.dismissFinishedSubagents(msg.parentId);
|
|
757
763
|
break;
|
|
758
764
|
case "switch_session":
|
|
759
765
|
void cs.switchSession(msg.path);
|
|
@@ -835,6 +841,9 @@ wss.on("connection", (ws) => {
|
|
|
835
841
|
case "list_models_config":
|
|
836
842
|
void cs.listModelsConfig();
|
|
837
843
|
break;
|
|
844
|
+
case "reload_models_config":
|
|
845
|
+
void cs.reloadModelsConfig();
|
|
846
|
+
break;
|
|
838
847
|
case "save_model_config":
|
|
839
848
|
void cs.saveModelConfig(msg.providerId, msg.config);
|
|
840
849
|
break;
|
|
@@ -941,6 +950,7 @@ wss.on("connection", (ws) => {
|
|
|
941
950
|
visionBridgePromptMode: msg.visionBridgePromptMode,
|
|
942
951
|
visionBridgePrompt: msg.visionBridgePrompt,
|
|
943
952
|
subagentDefaultModel: msg.subagentDefaultModel,
|
|
953
|
+
retryMaxAttempts: msg.retryMaxAttempts,
|
|
944
954
|
reviewPrompt: msg.reviewPrompt,
|
|
945
955
|
reviewDisabledSkills: msg.reviewDisabledSkills,
|
|
946
956
|
markersEnabled: msg.markersEnabled,
|
|
@@ -57,6 +57,70 @@ function stripJsonComments(src) {
|
|
|
57
57
|
}
|
|
58
58
|
return out;
|
|
59
59
|
}
|
|
60
|
+
/** Merge a UI-submitted provider config into the existing models.json entry.
|
|
61
|
+
*
|
|
62
|
+
* 表单(`UiProviderConfig`)只承载 UI 认识的字段:provider 级 name/api/baseUrl/
|
|
63
|
+
* apiKey/authHeader,模型级 id/name/reasoning/input/contextWindow/maxTokens。
|
|
64
|
+
* models.json 里还可能有 UI 不认识的字段——provider 级 headers(浏览器拿不到,
|
|
65
|
+
* 见 listModelsConfig)、模型级 api/baseUrl/cost/compat/thinkingLevelMap(手写或
|
|
66
|
+
* 脚本写入,pi-ai 靠它们决定请求地址与推理格式)。按表单整体重建条目会把这些字段
|
|
67
|
+
* 静默抹掉,可能把可用的配置改坏:模型级 baseUrl 丢失后会回退到对该 api 适配器
|
|
68
|
+
* 无效的地址(例如 opencode-go 的 anthropic baseUrl),请求直接打到不存在的路径。
|
|
69
|
+
*
|
|
70
|
+
* 所以这里以已有条目为底、表单字段覆盖:表单没提到的字段原样保留,表单清空的可选
|
|
71
|
+
* 字段才真正删除;`models` 仍是「表单即全集」——表单里删掉的 id 会被移除。
|
|
72
|
+
*/
|
|
73
|
+
export function mergeProviderConfigEntry(prevEntry, config, models) {
|
|
74
|
+
const prevModels = new Map();
|
|
75
|
+
if (Array.isArray(prevEntry?.models)) {
|
|
76
|
+
for (const entry of prevEntry.models) {
|
|
77
|
+
if (!entry || typeof entry !== "object")
|
|
78
|
+
continue;
|
|
79
|
+
const id = entry.id;
|
|
80
|
+
if (typeof id === "string" && id.trim())
|
|
81
|
+
prevModels.set(id.trim(), entry);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const mergedModels = models.map((model) => {
|
|
85
|
+
// 旧条目同 id 的字段(api/baseUrl/cost/compat/…)先铺底,表单字段覆盖。
|
|
86
|
+
const merged = { ...(prevModels.get(model.id) ?? {}), id: model.id };
|
|
87
|
+
if (model.name?.trim())
|
|
88
|
+
merged.name = model.name.trim();
|
|
89
|
+
else
|
|
90
|
+
delete merged.name;
|
|
91
|
+
if (model.reasoning)
|
|
92
|
+
merged.reasoning = true;
|
|
93
|
+
else
|
|
94
|
+
delete merged.reasoning;
|
|
95
|
+
if (model.input?.length)
|
|
96
|
+
merged.input = model.input;
|
|
97
|
+
else
|
|
98
|
+
delete merged.input;
|
|
99
|
+
if (model.contextWindow)
|
|
100
|
+
merged.contextWindow = Number(model.contextWindow);
|
|
101
|
+
else
|
|
102
|
+
delete merged.contextWindow;
|
|
103
|
+
if (model.maxTokens)
|
|
104
|
+
merged.maxTokens = Number(model.maxTokens);
|
|
105
|
+
else
|
|
106
|
+
delete merged.maxTokens;
|
|
107
|
+
return merged;
|
|
108
|
+
});
|
|
109
|
+
const mergedEntry = { ...prevEntry };
|
|
110
|
+
const apply = (key, value) => {
|
|
111
|
+
if (value === undefined)
|
|
112
|
+
delete mergedEntry[key];
|
|
113
|
+
else
|
|
114
|
+
mergedEntry[key] = value;
|
|
115
|
+
};
|
|
116
|
+
apply("name", config.name?.trim() || undefined);
|
|
117
|
+
apply("api", config.api?.trim() || undefined);
|
|
118
|
+
apply("baseUrl", config.baseUrl?.trim() || undefined);
|
|
119
|
+
apply("apiKey", config.apiKey?.trim() || undefined);
|
|
120
|
+
apply("authHeader", config.authHeader ? true : undefined);
|
|
121
|
+
mergedEntry.models = mergedModels;
|
|
122
|
+
return mergedEntry;
|
|
123
|
+
}
|
|
60
124
|
/** Numeric metadata value (NaN/string "unknown" → undefined). */
|
|
61
125
|
function numMeta(v) {
|
|
62
126
|
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
@@ -177,6 +241,21 @@ export class ModelAdminService {
|
|
|
177
241
|
const data = this.readProviderKeys();
|
|
178
242
|
return data[provider]?.activeKeyName ?? null;
|
|
179
243
|
}
|
|
244
|
+
/** Whether a named key still exists for a provider (no side effects, no
|
|
245
|
+
* notices). Restore paths use this to drop stale per-project references
|
|
246
|
+
* silently instead of going through activate (which notifies). */
|
|
247
|
+
hasProviderKey(provider, keyName) {
|
|
248
|
+
const pid = provider.trim();
|
|
249
|
+
const targetName = keyName.trim();
|
|
250
|
+
if (!pid || !targetName)
|
|
251
|
+
return false;
|
|
252
|
+
try {
|
|
253
|
+
return this.readProviderKeys()[pid]?.keys.some((k) => k.name === targetName) ?? false;
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
180
259
|
/** Seed a provider's key list from an EXISTING auth.json credential (legacy
|
|
181
260
|
* configs written before the multi-key store existed) so the store stays
|
|
182
261
|
* authoritative and the UI shows the current active key immediately even
|
|
@@ -373,36 +452,44 @@ export class ModelAdminService {
|
|
|
373
452
|
this.host.flushSnapshot();
|
|
374
453
|
}
|
|
375
454
|
/** Make a stored API key the ACTIVE one for a built-in provider by NAME (the
|
|
376
|
-
* server resolves the stored value from the name).
|
|
377
|
-
|
|
455
|
+
* server resolves the stored value from the name). Returns true when the
|
|
456
|
+
* key is (now) active, false when it doesn't exist or the switch failed.
|
|
457
|
+
* `silent` suppresses all notices — for automatic project restores, which
|
|
458
|
+
* must self-heal stale references without spamming the user. */
|
|
459
|
+
async activateProviderKey(provider, keyName, opts) {
|
|
378
460
|
const pid = provider.trim();
|
|
379
461
|
const targetName = keyName.trim();
|
|
462
|
+
const silent = opts?.silent === true;
|
|
463
|
+
const notice = (msg) => {
|
|
464
|
+
if (!silent)
|
|
465
|
+
this.host.emit(msg);
|
|
466
|
+
};
|
|
380
467
|
try {
|
|
381
468
|
const data = this.readProviderKeys();
|
|
382
469
|
const entry = data[pid];
|
|
383
470
|
const target = entry?.keys.find((k) => k.name === targetName);
|
|
384
471
|
if (!target) {
|
|
385
|
-
|
|
472
|
+
notice({
|
|
386
473
|
type: "notice",
|
|
387
474
|
level: "error",
|
|
388
475
|
text: `${pid} 的密钥「${targetName}」不存在`,
|
|
389
476
|
textEn: `Key "${targetName}" for ${pid} does not exist`,
|
|
390
477
|
});
|
|
391
|
-
return;
|
|
478
|
+
return false;
|
|
392
479
|
}
|
|
393
480
|
if (entry.activeKeyName === targetName) {
|
|
394
|
-
|
|
481
|
+
notice({
|
|
395
482
|
type: "notice",
|
|
396
483
|
level: "info",
|
|
397
484
|
text: `「${targetName}」已是当前密钥`,
|
|
398
485
|
textEn: `"${targetName}" is already the active key`,
|
|
399
486
|
});
|
|
400
|
-
return;
|
|
487
|
+
return true;
|
|
401
488
|
}
|
|
402
489
|
entry.activeKeyName = targetName;
|
|
403
490
|
this.writeProviderKeys(data);
|
|
404
491
|
await this.applyActiveKey(pid, target.apiKey);
|
|
405
|
-
|
|
492
|
+
notice({
|
|
406
493
|
type: "notice",
|
|
407
494
|
level: "info",
|
|
408
495
|
text: `⚡ 已切换到 ${pid} 的「${targetName}」`,
|
|
@@ -411,16 +498,21 @@ export class ModelAdminService {
|
|
|
411
498
|
await this.host.pushModels();
|
|
412
499
|
await this.listProviders();
|
|
413
500
|
this.listProviderKeys();
|
|
501
|
+
return true;
|
|
414
502
|
}
|
|
415
503
|
catch (err) {
|
|
416
|
-
|
|
504
|
+
notice({
|
|
417
505
|
type: "notice",
|
|
418
506
|
level: "error",
|
|
419
507
|
text: `切换密钥失败:${err.message}`,
|
|
420
508
|
textEn: `Failed to switch key: ${err.message}`,
|
|
421
509
|
});
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
finally {
|
|
513
|
+
if (!silent)
|
|
514
|
+
this.host.flushSnapshot();
|
|
422
515
|
}
|
|
423
|
-
this.host.flushSnapshot();
|
|
424
516
|
}
|
|
425
517
|
/** Remove a stored API key by NAME. If it was active, the first remaining key
|
|
426
518
|
* becomes active (or the provider returns to unconfigured when no key is left). */
|
|
@@ -793,6 +885,31 @@ export class ModelAdminService {
|
|
|
793
885
|
});
|
|
794
886
|
this.host.emit({ type: "models_config", providers: list });
|
|
795
887
|
}
|
|
888
|
+
/** Re-read models.json from disk (hand/script edits outside the UI) and
|
|
889
|
+
* repush — same refresh tail that save_model_config runs. */
|
|
890
|
+
async reloadModelsConfig() {
|
|
891
|
+
try {
|
|
892
|
+
await this.host.modelRuntime().refresh();
|
|
893
|
+
this.host.invalidatePiConfig();
|
|
894
|
+
await this.listModelsConfig();
|
|
895
|
+
await this.host.pushModels();
|
|
896
|
+
this.host.emit({
|
|
897
|
+
type: "notice",
|
|
898
|
+
level: "info",
|
|
899
|
+
text: "🔄 已从磁盘重新加载模型配置",
|
|
900
|
+
textEn: "🔄 Reloaded model config from disk",
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
catch (err) {
|
|
904
|
+
this.host.emit({
|
|
905
|
+
type: "notice",
|
|
906
|
+
level: "error",
|
|
907
|
+
text: `重新加载模型配置失败:${err.message}`,
|
|
908
|
+
textEn: `Failed to reload model config: ${err.message}`,
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
this.host.flushSnapshot();
|
|
912
|
+
}
|
|
796
913
|
/** Numeric metadata value (NaN/string "unknown" → undefined). */
|
|
797
914
|
static numMeta(v) {
|
|
798
915
|
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
@@ -988,7 +1105,11 @@ export class ModelAdminService {
|
|
|
988
1105
|
const { providers } = this.readModelsConfig();
|
|
989
1106
|
// models.json 原始形状是 Record<string, unknown>——按已保存条目的结构断言
|
|
990
1107
|
const saved = providers[pid];
|
|
991
|
-
|
|
1108
|
+
// 纯覆盖条目(只改 models,没有 provider 级 baseUrl)回退到运行时
|
|
1109
|
+
// 已知的地址——内置 provider 的 baseUrl 本来就不在 models.json 里。
|
|
1110
|
+
// 注意只拿来探测用,不写回磁盘:保持条目仍是纯覆盖。
|
|
1111
|
+
const baseUrl = saved?.baseUrl?.trim() || (this.host.modelRuntime().getProvider(pid)?.baseUrl ?? "").trim() || undefined;
|
|
1112
|
+
if (!saved || !baseUrl) {
|
|
992
1113
|
this.host.emit({
|
|
993
1114
|
type: "notice",
|
|
994
1115
|
level: "warning",
|
|
@@ -997,7 +1118,7 @@ export class ModelAdminService {
|
|
|
997
1118
|
});
|
|
998
1119
|
return done(false, { error: "provider missing or no baseUrl" });
|
|
999
1120
|
}
|
|
1000
|
-
const fetched = await ModelAdminService.probeModelsEndpoint(
|
|
1121
|
+
const fetched = await ModelAdminService.probeModelsEndpoint(baseUrl, saved.apiKey, saved.authHeader === true ? true : undefined, saved.api, saved.headers, lang);
|
|
1001
1122
|
// Merge: manual values win; fetched fills blanks and appends new ids.
|
|
1002
1123
|
const prev = new Map((saved.models ?? []).map((m) => [m.id, m]));
|
|
1003
1124
|
let added = 0;
|
|
@@ -1078,18 +1199,9 @@ export class ModelAdminService {
|
|
|
1078
1199
|
}
|
|
1079
1200
|
try {
|
|
1080
1201
|
const { providers } = this.readModelsConfig();
|
|
1081
|
-
//
|
|
1082
|
-
//
|
|
1083
|
-
|
|
1084
|
-
providers[pid] = {
|
|
1085
|
-
...(config.name?.trim() ? { name: config.name.trim() } : {}),
|
|
1086
|
-
...(config.api?.trim() ? { api: config.api.trim() } : {}),
|
|
1087
|
-
...(config.baseUrl?.trim() ? { baseUrl: config.baseUrl.trim() } : {}),
|
|
1088
|
-
...(config.apiKey?.trim() ? { apiKey: config.apiKey.trim() } : {}),
|
|
1089
|
-
...(config.authHeader ? { authHeader: true } : {}),
|
|
1090
|
-
...(prevHeaders && Object.keys(prevHeaders).length > 0 ? { headers: prevHeaders } : {}),
|
|
1091
|
-
models,
|
|
1092
|
-
};
|
|
1202
|
+
// 合并而不是重建:UI 认识之外的字段(provider 级 headers、模型级 api/
|
|
1203
|
+
// baseUrl/cost/compat/thinkingLevelMap)必须原样保留,见 mergeProviderConfigEntry。
|
|
1204
|
+
providers[pid] = mergeProviderConfigEntry(providers[pid], config, models);
|
|
1093
1205
|
mkdirSync(this.host.agentDir, { recursive: true });
|
|
1094
1206
|
writeFileSync(this.modelsConfigPath(), JSON.stringify({ providers }, null, 2) + "\n");
|
|
1095
1207
|
// Allow a custom models.json entry to reuse the provider credential
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote-catalog merge patch for @earendil-works/pi-coding-agent.
|
|
3
|
+
*
|
|
4
|
+
* The SDK composes each built-in provider's model list as
|
|
5
|
+
* mergeModels(builtinStaticCatalog, pi.devRemoteCatalog)
|
|
6
|
+
* which is a UNION: same-id entries get replaced by the newer pi.dev row, new
|
|
7
|
+
* ids are appended, but stale built-in models are kept forever (and the UI
|
|
8
|
+
* reports "新增 N 个模型" when the overlay grows).
|
|
9
|
+
*
|
|
10
|
+
* pi-web-ui wants the picker to mirror the OFFICIAL remote catalog exactly —
|
|
11
|
+
* an "ensure latest, no merge, no 'new N' noise" behavior. This module rewrites
|
|
12
|
+
* the installed copy of remote-catalog-provider.js so the remote overlay
|
|
13
|
+
* REPLACES the built-in catalog entirely whenever pi.dev data is present:
|
|
14
|
+
* getModels: () => (dynamicModels.length > 0 ? dynamicModels : provider.getModels())
|
|
15
|
+
* (the built-in catalog only serves as fallback while no remote data has been
|
|
16
|
+
* received yet, so the list can never be empty).
|
|
17
|
+
*
|
|
18
|
+
* Same pattern as patch-node-pty.ts: idempotent (checks before rewriting),
|
|
19
|
+
* best-effort (a read-only node_modules or an SDK version whose source no
|
|
20
|
+
* longer matches the search strings just skips the patch and keeps the SDK's
|
|
21
|
+
* default union semantics — never a crash). MUST be imported before the SDK
|
|
22
|
+
* modules are first loaded (agent-service.ts imports this first).
|
|
23
|
+
*/
|
|
24
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
import { dirname, join } from "node:path";
|
|
27
|
+
/** Old fragment that performs the union merge (single occurrence in the file). */
|
|
28
|
+
const OLD = "getModels: () => mergeModels(provider.getModels(), dynamicModels),";
|
|
29
|
+
/** Replacement: whole-replace with the remote catalog when available. */
|
|
30
|
+
const NEW = "getModels: () => (dynamicModels.length > 0 ? dynamicModels : provider.getModels()),";
|
|
31
|
+
/** Absolute path of the installed remote-catalog-provider.js, or null. */
|
|
32
|
+
function remoteCatalogFile() {
|
|
33
|
+
try {
|
|
34
|
+
// import.meta.resolve honors the package "exports" (import condition) → dist/index.js;
|
|
35
|
+
// remote-catalog-provider.js lives next to it in dist/core/.
|
|
36
|
+
const entry = fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"));
|
|
37
|
+
return join(dirname(entry), "core", "remote-catalog-provider.js");
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function applyPatch() {
|
|
44
|
+
try {
|
|
45
|
+
const file = remoteCatalogFile();
|
|
46
|
+
if (!file || !existsSync(file))
|
|
47
|
+
return;
|
|
48
|
+
const src = readFileSync(file, "utf8");
|
|
49
|
+
if (src.includes(NEW))
|
|
50
|
+
return; // already patched
|
|
51
|
+
if (!src.includes(OLD))
|
|
52
|
+
return; // unexpected SDK content — leave alone
|
|
53
|
+
writeFileSync(file, src.replace(OLD, NEW), "utf8");
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// best-effort — without the patch the SDK keeps its default union merge
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
applyPatch();
|
package/dist/server/subagents.js
CHANGED
|
@@ -68,8 +68,14 @@ export function withSubagentOwner(host, ownerId) {
|
|
|
68
68
|
* 子代理工具集(注册进每个会话的 customTools,供主 agent 驱动子代理)。
|
|
69
69
|
* 用 `subagent_*` 前缀命名,避免与第三方 pi-subagents 的
|
|
70
70
|
* `Agent`/`get_subagent_result`/`steer_subagent` 冲突。
|
|
71
|
+
*
|
|
72
|
+
* `selfConvId`(可选):这套工具所注册进的会话 convId。`subagent_wait_all`
|
|
73
|
+
* 永远排除调用者自身——子代理会话上同样注册了全套工具,不传 runIds 时
|
|
74
|
+
* 「全部」会含它自己,而它正在执行本工具(streaming=true),不排除就是
|
|
75
|
+
* 自己等自己、永远到超时(self-wait deadlock)。主会话调用时传它自己的
|
|
76
|
+
* 普通对话 id 即可(不在子代理列表里,delete 是 no-op)。
|
|
71
77
|
*/
|
|
72
|
-
export function makeSubagentTools(host, lang) {
|
|
78
|
+
export function makeSubagentTools(host, lang, selfConvId) {
|
|
73
79
|
const getLang = lang ?? host.lang ?? (() => "en");
|
|
74
80
|
const text = (t, details = {}) => ({
|
|
75
81
|
content: [{ type: "text", text: t }],
|
|
@@ -232,11 +238,13 @@ export function makeSubagentTools(host, lang) {
|
|
|
232
238
|
label: "Wait for subagents",
|
|
233
239
|
description: bilingual("Wait for multiple subagents to finish at once (blocks this round until all reach a terminal state or time out), " +
|
|
234
240
|
"then summarize each result/error — no need to poll subagent_get_result. Pass runIds for specific subagents " +
|
|
235
|
-
"(convIds returned by subagent_spawn); omit = wait for all currently running ones.
|
|
236
|
-
"
|
|
241
|
+
"(convIds returned by subagent_spawn); omit = wait for all currently running ones. The calling session itself " +
|
|
242
|
+
"is never waited on (a subagent calling this without runIds won't deadlock on itself). " +
|
|
243
|
+
"On timeout or abort of this round, returns the remaining unfinished list; call again to continue waiting. " +
|
|
237
244
|
"Good for: collecting parallel subagents.", "一次性等待多个子代理全部完成(阻塞本回合直到它们都到达终态或超时),然后汇总返回每个的结果/错误——" +
|
|
238
245
|
"不用反复调 subagent_get_result 轮询。传 runIds 指定要等的子代理(subagent_spawn 返回的 convId);" +
|
|
239
|
-
"不传 =
|
|
246
|
+
"不传 = 等当前全部运行中的子代理。调用者自身永不计入等待(子代理不传 runIds 时不会等自己)。" +
|
|
247
|
+
"超时或本轮被中止时返回剩余未完成名单,可再次调用继续等。" +
|
|
240
248
|
"适合:并行派发多个子代理后收口。"),
|
|
241
249
|
promptSnippet: "wait for multiple subagents to finish (no polling) and get all results",
|
|
242
250
|
parameters: Type.Object({
|
|
@@ -251,6 +259,15 @@ export function makeSubagentTools(host, lang) {
|
|
|
251
259
|
}),
|
|
252
260
|
execute: async (_id, p, signal) => {
|
|
253
261
|
const wanted = new Set(p.runIds && p.runIds.length > 0 ? p.runIds : host.listSubagents().map((r) => r.convId));
|
|
262
|
+
// 调用者自身永不等待:子代理调本工具时它自己正在 streaming,不排除
|
|
263
|
+
// 就是自己等自己、永远到超时(self-wait deadlock)。主会话的普通
|
|
264
|
+
// 对话 id 不在子代理列表里,delete 是 no-op。
|
|
265
|
+
if (selfConvId)
|
|
266
|
+
wanted.delete(selfConvId);
|
|
267
|
+
if (wanted.size === 0) {
|
|
268
|
+
const emptyLang = getLang();
|
|
269
|
+
return text(pick(emptyLang, "没有需要等待的子代理(调用者自身不计入等待)。", "No subagents to wait for (the calling session itself is never waited on).", "subagents.wait.empty"));
|
|
270
|
+
}
|
|
254
271
|
const timeoutMs = Math.min(Math.max(p.timeoutSeconds ?? 600, 1), Math.floor(WAIT_CAP_MS / 1000)) * 1000;
|
|
255
272
|
const waitStart = Date.now();
|
|
256
273
|
const deadline = waitStart + timeoutMs;
|
|
@@ -327,6 +344,36 @@ export function makeSubagentTools(host, lang) {
|
|
|
327
344
|
function shortId(id) {
|
|
328
345
|
return id.slice(0, 8);
|
|
329
346
|
}
|
|
347
|
+
/**
|
|
348
|
+
* 收集 root 的传递子代理后代 id(parentId 链向上能走到 root 的;含嵌套的嵌套)。
|
|
349
|
+
* root 自身不含;非子代理对话不含(普通对话不参与子代理清理口径)。
|
|
350
|
+
* parentId 环(理论上不应出现)按 visited 截断,不会死循环。
|
|
351
|
+
* 纯函数:后端 dismiss 流程与单测共用(前端左栏按同样口径镜像实现,见
|
|
352
|
+
* LeftPanel finishedSubagentCount)。
|
|
353
|
+
*/
|
|
354
|
+
export function collectSubagentDescendantIds(items, rootId) {
|
|
355
|
+
const byId = new Map(items.map((c) => [c.id, c]));
|
|
356
|
+
const out = [];
|
|
357
|
+
for (const c of items) {
|
|
358
|
+
if (!c.isSubagent || c.id === rootId)
|
|
359
|
+
continue;
|
|
360
|
+
let cur = c;
|
|
361
|
+
const seen = new Set();
|
|
362
|
+
while (cur?.parentId) {
|
|
363
|
+
if (cur.parentId === rootId) {
|
|
364
|
+
out.push(c.id);
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
if (seen.has(cur.parentId))
|
|
368
|
+
break;
|
|
369
|
+
seen.add(cur.parentId);
|
|
370
|
+
cur = byId.get(cur.parentId);
|
|
371
|
+
if (!cur)
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return out;
|
|
376
|
+
}
|
|
330
377
|
/** 人类可读的终态判定:报错 > 中止 > done > running。 */
|
|
331
378
|
function subagentVerdict(r, lang = "en") {
|
|
332
379
|
if (r.error)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-web-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.73.0",
|
|
4
4
|
"description": "Web chat interface for the pi coding agent, powered by the pi SDK (@earendil-works/pi-coding-agent) — one-command run, Docker/systemd/launchd deployable",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -63,6 +63,9 @@
|
|
|
63
63
|
"test:unit": "vitest run",
|
|
64
64
|
"test:smoke": "node tests/run-smoke.mjs",
|
|
65
65
|
"check:protocol": "node scripts/check-protocol-sync.mjs",
|
|
66
|
+
"i18n:diff": "node scripts/i18n-diff.mjs",
|
|
67
|
+
"release:notes": "node scripts/release-notes.mjs",
|
|
68
|
+
"changelog:i18n": "node scripts/release-notes.mjs --unreleased",
|
|
66
69
|
"test:freeze": "node tests/freeze-test.mjs",
|
|
67
70
|
"start": "node dist/server/index.js",
|
|
68
71
|
"format": "prettier --write server/ web/src/ extensions/ scripts/ bin/ tests/*.mjs tests/unit/ *.mjs",
|
package/themes/cyberpunk.css
CHANGED
|
@@ -93,5 +93,5 @@
|
|
|
93
93
|
--chip-bg: color-mix(in srgb, var(--bg-elev2) var(--wallpaper-panel-alpha, 62%), transparent);
|
|
94
94
|
--msgs-bg: color-mix(in srgb, var(--bg-elev) var(--wallpaper-panel-alpha, 62%), transparent);
|
|
95
95
|
--inputbox-bg: color-mix(in srgb, var(--bg-elev2) var(--wallpaper-panel-alpha, 62%), transparent);
|
|
96
|
-
--menu-bg: var(--
|
|
96
|
+
--menu-bg: var(--bg-elev2);
|
|
97
97
|
}
|