pi-web-ui 0.60.0 → 0.61.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.
@@ -733,6 +733,8 @@ export class ClientSession {
733
733
  }
734
734
  }
735
735
  await cs.bindSession();
736
+ await cs.restoreProjectProviderKeysForCwd(cwd);
737
+ await cs.restoreProjectModelForCwd(cwd);
736
738
  return cs;
737
739
  }
738
740
  /**
@@ -928,6 +930,9 @@ export class ClientSession {
928
930
  // Reconnect: push the background-task list — it must survive reconnects
929
931
  // and outlive the conversation that started the tasks.
930
932
  this.bg.push();
933
+ // Reconnect: push the built-in provider key list (multi-key grouping in the
934
+ // model picker needs it even before the client asks).
935
+ this.modelAdmin.listProviderKeys();
931
936
  // PTYs are conversation-owned and survive a socket reconnect.
932
937
  this.pushTerminals();
933
938
  }
@@ -1813,6 +1818,120 @@ export class ClientSession {
1813
1818
  deleteModelConfig(providerId) {
1814
1819
  return this.modelAdmin.deleteModelConfig(providerId);
1815
1820
  }
1821
+ listProviderKeys() {
1822
+ return this.modelAdmin.listProviderKeys();
1823
+ }
1824
+ async addProviderKey(provider, apiKey, name) {
1825
+ await this.modelAdmin.addProviderKey(provider, apiKey, name);
1826
+ const active = this.modelAdmin.getActiveKeyName(provider);
1827
+ if (active)
1828
+ this.stateStore.saveProjectProviderKey(this.clientId, this.cwd, provider, active);
1829
+ }
1830
+ async activateProviderKey(provider, keyName) {
1831
+ await this.modelAdmin.activateProviderKey(provider, keyName);
1832
+ this.stateStore.saveProjectProviderKey(this.clientId, this.cwd, provider, keyName);
1833
+ }
1834
+ async removeProviderKey(provider, keyName) {
1835
+ await this.modelAdmin.removeProviderKey(provider, keyName);
1836
+ const saved = this.stateStore.getProjectProviderKey(this.clientId, this.cwd, provider);
1837
+ if (saved === keyName) {
1838
+ const active = this.modelAdmin.getActiveKeyName(provider);
1839
+ if (active)
1840
+ this.stateStore.saveProjectProviderKey(this.clientId, this.cwd, provider, active);
1841
+ else
1842
+ this.stateStore.deleteProjectProviderKey(this.clientId, this.cwd, provider);
1843
+ }
1844
+ }
1845
+ /** Restore per-project provider keys when entering a project. For each
1846
+ * provider that has a saved key for `cwd`, activate it if it differs from
1847
+ * the current global active. Silent — no notice spam on project switch. */
1848
+ async restoreProjectProviderKeysForCwd(cwd) {
1849
+ const saved = this.stateStore.getProjectProviderKeys(this.clientId, cwd);
1850
+ if (!saved)
1851
+ return;
1852
+ for (const [provider, keyName] of Object.entries(saved)) {
1853
+ const cur = this.modelAdmin.getActiveKeyName(provider);
1854
+ if (cur === keyName)
1855
+ continue;
1856
+ try {
1857
+ await this.modelAdmin.activateProviderKey(provider, keyName);
1858
+ }
1859
+ catch {
1860
+ // saved key may have been deleted — ignore
1861
+ }
1862
+ }
1863
+ }
1864
+ /** When a model is set, ensure its provider's per-project key is restored. */
1865
+ async restoreKeyForModel(modelId, cwd) {
1866
+ const slash = modelId.indexOf("/");
1867
+ if (slash <= 0)
1868
+ return;
1869
+ const provider = modelId.slice(0, slash);
1870
+ const saved = this.stateStore.getProjectProviderKey(this.clientId, cwd, provider);
1871
+ if (!saved)
1872
+ return;
1873
+ const cur = this.modelAdmin.getActiveKeyName(provider);
1874
+ if (cur === saved)
1875
+ return;
1876
+ try {
1877
+ await this.modelAdmin.activateProviderKey(provider, saved);
1878
+ }
1879
+ catch { }
1880
+ }
1881
+ /** Remember the just-selected model (and the key that was active for its
1882
+ * provider) for the current project. Called IMMEDIATELY on model selection —
1883
+ * not only after a turn — so switching back to the project restores the exact
1884
+ * {model, key} left behind, even for a fresh conversation with no assistant
1885
+ * message yet (the SDK only flushes a model_change to disk once one exists). */
1886
+ rememberProjectModel(modelId) {
1887
+ const cwd = this.cwd;
1888
+ this.stateStore.saveProjectModel(this.clientId, cwd, modelId);
1889
+ const slash = modelId.indexOf("/");
1890
+ if (slash <= 0)
1891
+ return;
1892
+ const provider = modelId.slice(0, slash);
1893
+ const active = this.modelAdmin.getActiveKeyName(provider);
1894
+ if (active)
1895
+ this.stateStore.saveProjectProviderKey(this.clientId, cwd, provider, active);
1896
+ }
1897
+ /** Restore the project's remembered model (and its provider's key) onto the
1898
+ * ACTIVE conversation — but ONLY for a conversation the user hasn't really
1899
+ * started (no messages yet). A conversation that already has content keeps its
1900
+ * own per-session model: switching back to a RUNNING / completed chat must not
1901
+ * silently overwrite its model with the project default. So a fresh chat in the
1902
+ * project gets the remembered model; an in-progress one keeps what it had and
1903
+ * the user switches via the picker. Silent on failure (model no longer in catalog). */
1904
+ async restoreProjectModelForCwd(cwd) {
1905
+ const savedModel = this.stateStore.getProjectModel(this.clientId, cwd);
1906
+ if (!savedModel)
1907
+ return;
1908
+ try {
1909
+ if (this.conv.session.getSessionStats().totalMessages > 0)
1910
+ return;
1911
+ }
1912
+ catch {
1913
+ return;
1914
+ }
1915
+ try {
1916
+ const mr = this.runtime.services.modelRuntime;
1917
+ const slash = savedModel.indexOf("/");
1918
+ if (slash <= 0 || slash === savedModel.length - 1)
1919
+ return;
1920
+ const model = mr.getModel(savedModel.slice(0, slash), savedModel.slice(slash + 1));
1921
+ if (!model)
1922
+ return;
1923
+ const cur = this.session.model;
1924
+ const curId = cur ? `${cur.provider}/${cur.id}` : null;
1925
+ // Restore the model's provider key first so setModel's auth check passes.
1926
+ await this.restoreKeyForModel(savedModel, cwd);
1927
+ if (curId === savedModel)
1928
+ return;
1929
+ await this.session.setModel(model);
1930
+ }
1931
+ catch {
1932
+ // model no longer resolvable / key gone — keep the conversation default
1933
+ }
1934
+ }
1816
1935
  // ---------------------------------------------------------------------------
1817
1936
  // Settings (system prompt / skills / extensions / presets)
1818
1937
  // ---------------------------------------------------------------------------
@@ -2266,6 +2385,9 @@ export class ClientSession {
2266
2385
  if (prevModel && this.sharedModelRuntime) {
2267
2386
  try {
2268
2387
  await this.session.setModel(prevModel);
2388
+ const p = prevModel.provider;
2389
+ const mid = `${p}/${prevModel.id}`;
2390
+ await this.restoreKeyForModel(mid, this.cwd);
2269
2391
  }
2270
2392
  catch {
2271
2393
  // model no longer resolvable — keep the default
@@ -2363,6 +2485,8 @@ export class ClientSession {
2363
2485
  void this.pushSlashCommands();
2364
2486
  if (cwdChanged) {
2365
2487
  this.cwd = newCwd;
2488
+ await this.restoreProjectProviderKeysForCwd(newCwd);
2489
+ await this.restoreProjectModelForCwd(newCwd);
2366
2490
  // Mirror set_cwd's project-switch side-effects so the whole UI follows
2367
2491
  // the new workspace, not just the chat pane.
2368
2492
  try {
@@ -2645,6 +2769,8 @@ export class ClientSession {
2645
2769
  this.removeConversation(displaced.id);
2646
2770
  await this.bindSession();
2647
2771
  this.cwd = targetCwd;
2772
+ await this.restoreProjectProviderKeysForCwd(targetCwd);
2773
+ await this.restoreProjectModelForCwd(targetCwd);
2648
2774
  this.conv.lastActiveAt = Date.now();
2649
2775
  this.webUi.refresh();
2650
2776
  this.emitConversations();
@@ -2746,6 +2872,8 @@ export class ClientSession {
2746
2872
  if (prevModel && this.sharedModelRuntime) {
2747
2873
  try {
2748
2874
  await this.session.setModel(prevModel);
2875
+ const pm = prevModel;
2876
+ await this.restoreKeyForModel(`${pm.provider}/${pm.id}`, this.cwd);
2749
2877
  }
2750
2878
  catch {
2751
2879
  // model no longer resolvable — keep the default
@@ -2861,7 +2989,13 @@ export class ClientSession {
2861
2989
  }
2862
2990
  async cycleModel() {
2863
2991
  try {
2864
- await this.session.cycleModel();
2992
+ const result = await this.session.cycleModel();
2993
+ if (result?.model) {
2994
+ const mid = `${result.model.provider}/${result.model.id}`;
2995
+ await this.restoreKeyForModel(mid, this.cwd);
2996
+ // Remember per-project like setModel — cycling is also a model switch.
2997
+ this.rememberProjectModel(mid);
2998
+ }
2865
2999
  }
2866
3000
  catch (err) {
2867
3001
  this.emit({
@@ -2942,6 +3076,8 @@ export class ClientSession {
2942
3076
  this.conv.promptedSinceActive = false;
2943
3077
  this.conv.lastActiveAt = Date.now();
2944
3078
  this.cwd = abs;
3079
+ await this.restoreProjectProviderKeysForCwd(abs);
3080
+ await this.restoreProjectModelForCwd(abs);
2945
3081
  // 工作区跟随型插件(编辑器文件树等)同步切根。
2946
3082
  try {
2947
3083
  this.onCwdChanged?.(abs);
@@ -3041,6 +3177,11 @@ export class ClientSession {
3041
3177
  if (!model)
3042
3178
  throw new Error(`模型不存在:${modelId}`);
3043
3179
  await this.session.setModel(model);
3180
+ await this.restoreKeyForModel(modelId, this.cwd);
3181
+ // Immediately remember the model + the key it uses for the current
3182
+ // project (not only after a turn). This is what makes project switching
3183
+ // restore both the model and the provider key.
3184
+ this.rememberProjectModel(modelId);
3044
3185
  }
3045
3186
  catch (err) {
3046
3187
  this.emit({
@@ -231,4 +231,56 @@ export class ClientStateStore {
231
231
  state.presets = presets;
232
232
  this.save();
233
233
  }
234
+ /** Get per-project provider keys for a cwd, or undefined. */
235
+ getProjectProviderKeys(clientId, cwd) {
236
+ return this.load()[clientId]?.projectProviderKeys?.[cwd];
237
+ }
238
+ /** Get a single provider's saved key for a project. */
239
+ getProjectProviderKey(clientId, cwd, provider) {
240
+ return this.load()[clientId]?.projectProviderKeys?.[cwd]?.[provider];
241
+ }
242
+ /** Remember which key was last used for a provider in a project. */
243
+ saveProjectProviderKey(clientId, cwd, provider, keyName) {
244
+ const all = this.load();
245
+ const state = (all[clientId] ??= { projects: [] });
246
+ const map = (state.projectProviderKeys ??= {});
247
+ const inner = (map[cwd] ??= {});
248
+ inner[provider] = keyName;
249
+ this.save();
250
+ }
251
+ /** Delete a per-project provider key (e.g. when the key is removed). */
252
+ deleteProjectProviderKey(clientId, cwd, provider) {
253
+ const all = this.load();
254
+ const inner = all[clientId]?.projectProviderKeys?.[cwd];
255
+ if (!inner || !(provider in inner))
256
+ return;
257
+ delete inner[provider];
258
+ if (Object.keys(inner).length === 0) {
259
+ delete all[clientId].projectProviderKeys[cwd];
260
+ }
261
+ this.save();
262
+ }
263
+ /** Get the model the user last selected in a project, or undefined. */
264
+ getProjectModel(clientId, cwd) {
265
+ return this.load()[clientId]?.projectModels?.[cwd];
266
+ }
267
+ /** Remember the model last selected in a project (immediate, not after a turn). */
268
+ saveProjectModel(clientId, cwd, modelId) {
269
+ const all = this.load();
270
+ const state = (all[clientId] ??= { projects: [] });
271
+ (state.projectModels ??= {})[cwd] = modelId;
272
+ this.save();
273
+ }
274
+ /** Drop the per-project model memory for a project (e.g. when the model is
275
+ * removed from the catalog). */
276
+ deleteProjectModel(clientId, cwd) {
277
+ const all = this.load();
278
+ const map = all[clientId]?.projectModels;
279
+ if (!map || !(cwd in map))
280
+ return;
281
+ delete map[cwd];
282
+ if (Object.keys(map).length === 0)
283
+ delete all[clientId].projectModels;
284
+ this.save();
285
+ }
234
286
  }
@@ -2526,6 +2526,18 @@ export class DshClientSession {
2526
2526
  ],
2527
2527
  });
2528
2528
  }
2529
+ listProviderKeys() {
2530
+ this.emit({ type: "provider_keys", keys: {} });
2531
+ }
2532
+ async addProviderKey(provider, apiKey, name) {
2533
+ this.emit({ type: "notice", level: "warning", text: "DSH 引擎不支持多密钥" });
2534
+ }
2535
+ async activateProviderKey(provider, keyName) {
2536
+ this.emit({ type: "notice", level: "warning", text: "DSH 引擎不支持多密钥" });
2537
+ }
2538
+ async removeProviderKey(provider, keyName) {
2539
+ this.emit({ type: "notice", level: "warning", text: "DSH 引擎不支持多密钥" });
2540
+ }
2529
2541
  async fetchModelsList(reqId, baseUrl, apiKey, authHeader, api) {
2530
2542
  this.emit({ type: "fetch_models_result", reqId, ok: false, error: "DSH 引擎不支持自定义 provider 探测" });
2531
2543
  }
@@ -2533,7 +2545,9 @@ export class DshClientSession {
2533
2545
  this.emit({ type: "refresh_provider_result", reqId, ok: false, error: "DSH 引擎不支持自定义 provider" });
2534
2546
  }
2535
2547
  async cloneProvider(provider, reqId) {
2536
- this.emit({ type: "clone_provider_result", reqId, ok: false, error: "DSH 引擎不支持自定义 provider" });
2548
+ const error = "DSH 引擎不支持自定义 provider";
2549
+ this.emit({ type: "notice", level: "error", text: error });
2550
+ this.emit({ type: "clone_provider_result", reqId, ok: false, error });
2537
2551
  }
2538
2552
  // -----------------------------------------------------------------------
2539
2553
  // 其他
@@ -694,6 +694,18 @@ wss.on("connection", (ws) => {
694
694
  case "clone_provider":
695
695
  void cs.cloneProvider(msg.provider, msg.reqId);
696
696
  break;
697
+ case "list_provider_keys":
698
+ cs.listProviderKeys();
699
+ break;
700
+ case "add_provider_key":
701
+ void cs.addProviderKey(msg.provider, msg.apiKey, msg.name);
702
+ break;
703
+ case "activate_provider_key":
704
+ void cs.activateProviderKey(msg.provider, msg.keyName);
705
+ break;
706
+ case "remove_provider_key":
707
+ void cs.removeProviderKey(msg.provider, msg.keyName);
708
+ break;
697
709
  case "terminal_create": {
698
710
  const tm = cs.getTerminalManager(msg.conversationId);
699
711
  if (tm)