pi-web-ui 0.59.1 → 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.
@@ -27,7 +27,7 @@ import { ModelAdminService } from "./model-admin.js";
27
27
  import { FilesService, workspacePath } from "./files-service.js";
28
28
  import { isExtensionDisabled, ClientStateStore, } from "./client-state.js";
29
29
  import { saveUpload } from "./uploads.js";
30
- import { makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, TERMINAL_TOOL_NAMES, } from "./terminals.js";
30
+ import { applyHeadTail, makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, TERMINAL_TOOL_NAMES, } from "./terminals.js";
31
31
  import { WebUIContext } from "./webui-context.js";
32
32
  import { buildAttachmentMessages, parseModelSpec, } from "./attachments.js";
33
33
  import { serializeMessage, serializeStreamingMessage, } from "./serialize.js";
@@ -82,14 +82,14 @@ export class QuiesceRejectedError extends Error {
82
82
  * programs wait for input that never comes. Legacy Chinese files are often
83
83
  * GBK/GB2312 — read them with the right encoding, never paste mojibake into
84
84
  * reasoning/answers. */
85
- const WINDOWS_PERSONA = `You are a coding agent running on Windows. The bash tool runs Git Bash (bash.exe), not PowerShell. Follow these rules to avoid hanging the session:
86
-
87
-
88
-
89
- - ALWAYS pass a timeout parameter to the bash tool (in seconds). There is NO default timeout — a command that never finishes (servers, watchers, infinite loops, slow downloads/installs) will hang the entire conversation indefinitely. Pick a generous timeout for long-running work, but never omit it.
90
- - NEVER run interactive or foreground long-running commands through the bash tool (vi, less, top, python -, node -, npm run dev, sleep 10000). For servers/daemons use background execution with output redirected to a log file, then poll the log; stop them when done.
91
- - In the interactive terminal (TTY) — which is Git Bash too, not PowerShell — NEVER use heredocs (<<'EOF' ... EOF) or here-strings, and NEVER start interactive programs (vi, less, python -, node -, npm init): they wait for keyboard input that never arrives and hang the terminal forever. Prefer writing a temp script file (e.g. .pi-tmp.sh) and running it non-interactively. ALWAYS pass a timeout to long-running commands (e.g. \`timeout 120 npm run dev\`).
92
-
85
+ const WINDOWS_PERSONA = `You are a coding agent running on Windows. The bash tool runs Git Bash (bash.exe), not PowerShell. Follow these rules to avoid hanging the session:
86
+
87
+
88
+
89
+ - ALWAYS pass a timeout parameter to the bash tool (in seconds). There is NO default timeout — a command that never finishes (servers, watchers, infinite loops, slow downloads/installs) will hang the entire conversation indefinitely. Pick a generous timeout for long-running work, but never omit it.
90
+ - NEVER run interactive or foreground long-running commands through the bash tool (vi, less, top, python -, node -, npm run dev, sleep 10000). For servers/daemons use background execution with output redirected to a log file, then poll the log; stop them when done.
91
+ - In the interactive terminal (TTY) — which is Git Bash too, not PowerShell — NEVER use heredocs (<<'EOF' ... EOF) or here-strings, and NEVER start interactive programs (vi, less, python -, node -, npm init): they wait for keyboard input that never arrives and hang the terminal forever. Prefer writing a temp script file (e.g. .pi-tmp.sh) and running it non-interactively. ALWAYS pass a timeout to long-running commands (e.g. \`timeout 120 npm run dev\`).
92
+
93
93
  Many legacy Chinese text files (.html/.txt/.md/.log, exported documents) are GBK/GB2312 encoded: the read tool decodes UTF-8 only and will show mojibake (乱码) for them. If a file's content looks garbled, read it through the terminal instead: in Git Bash use \`cat file | iconv -f GBK -t UTF-8\` (or \`iconv -f GBK -t UTF-8 file\`); in cmd use \`chcp 65001 && type file\`; in PowerShell use \`Get-Content -Encoding Default file\`. Never paste mojibake into your reasoning or answer — describe the decoded content instead.`;
94
94
  /** bash 工具输出限制/过滤管道引导:模型习惯套 `| tail/-n`、`| head`、`| grep`、`| less`
95
95
  * 等限输出。这些管道在持久终端里会①缓冲(可见终端全程哑火、看不到实时进度)②把退出码
@@ -97,13 +97,14 @@ Many legacy Chinese text files (.html/.txt/.md/.log, exported documents) are GBK
97
97
  * 让模型改用 bash 的 `tail` 参数限输出;长驻/交互任务改走持久终端工具。 */
98
98
  const PIPELESS_BASH_GUIDANCE = `Bash tool output-limiting/filtering: do NOT chain shell pipes to trim or filter output. Avoid \`| tail\`, \`| head\`, \`| grep\`, \`| less\`, \`| more\`, \`| cat\`, \`| sort\`, \`| awk\`, \`| sed\`. They buffer output (so the visible terminal shows nothing live), turn the real exit code into the last pipe command's (tail always 0, grep 1 when no match — hiding the actual failure), and can hang a long-running or failing command until timeout. Instead:\n- To limit returned output use the bash \`tail\` parameter (e.g. \`bash(command=..., tail=20)\`) — the underlying command still streams live to the visible terminal.\n- For a long-running server / watcher / interactive program, use the persistent terminal tools (terminal_create then terminal_read / terminal_input / terminal_key / terminal_wait) instead of piping through bash.\nThe bash tool auto-detects a trailing \`| tail\`/\`| grep\` etc. and runs the underlying command directly so it never hides a failure — but you should still prefer the \`tail\` parameter.`;
99
99
  /**
100
- * Killable bash tool: wraps the SDK bash tool with operations that register
101
- * their own AbortController into a client-level set. abortBash() aborts only
102
- * those controllers → the command's process tree is killed while the agent
103
- * run and the conversation continue (the tool returns an aborted error and
104
- * the model moves on). Injected as a customTool overriding the builtin bash.
100
+ * Killable bash tool: wraps the SDK bash tool (native process spawn, NO terminal).
101
+ * Used when the「默认 bash 覆盖」setting is OFF. Registers its own AbortController
102
+ * into a client-level set (kills) so abortBash() kills only these commands while the
103
+ * agent run and the conversation continue. Exposes persist (ignored — native has no
104
+ * terminal) plus head/tail (post-processed on the returned output) so the parameter
105
+ * schema stays consistent with the terminal-backed tool.
105
106
  */
106
- function makeKillableBashTool(cwd, kills) {
107
+ export function makeKillableBashTool(cwd, kills) {
107
108
  const base = createLocalBashOperations();
108
109
  const tool = createBashTool(cwd, {
109
110
  operations: {
@@ -127,20 +128,50 @@ function makeKillableBashTool(cwd, kills) {
127
128
  return {
128
129
  name: tool.name,
129
130
  label: tool.label,
130
- description: tool.description,
131
- parameters: tool.parameters,
131
+ description: "Run a shell command natively (process spawn, no terminal) and return its full output plus exit code — the SDK's plain bash tool. persist is ignored here (no terminal); use head/tail to trim the returned output.",
132
+ parameters: Type.Object({
133
+ command: Type.String({ description: "The shell command to run" }),
134
+ timeout: Type.Optional(Type.Number({ description: "Optional timeout in seconds" })),
135
+ persist: Type.Optional(Type.Boolean({
136
+ description: "Ignored in native mode (no terminal). Only meaningful when the terminal-backed bash is active.",
137
+ })),
138
+ head: Type.Optional(Type.Integer({
139
+ minimum: 1,
140
+ maximum: 5000,
141
+ description: "Only return the FIRST N lines of output (like `| head -N`).",
142
+ })),
143
+ tail: Type.Optional(Type.Integer({
144
+ minimum: 1,
145
+ maximum: 5000,
146
+ description: "Only return the LAST N lines of output (like `| tail -N`).",
147
+ })),
148
+ }),
132
149
  prepareArguments: tool.prepareArguments,
133
150
  executionMode: tool.executionMode,
134
- execute: (toolCallId, params, signal, onUpdate) => tool.execute(toolCallId, params, signal, onUpdate),
151
+ execute: async (toolCallId, params, signal, onUpdate) => {
152
+ const result = (await tool.execute(toolCallId, params, signal, onUpdate));
153
+ // head/tail 后处理(native 无终端,直接截返回行即可)。
154
+ const p = params;
155
+ if ((p?.head || p?.tail) && result?.content?.[0]?.text != null) {
156
+ result.content[0].text = applyHeadTail(result.content[0].text, p.head, p.tail);
157
+ }
158
+ return result;
159
+ },
135
160
  };
136
161
  }
137
162
  /**
138
- * 动态分流 bash:调用时按设置决定走哪套实现——「终端接管 bash」开关因此
139
- * 即时生效(customTools 在 runtime 创建时固定,不能在创建时二选一)。
163
+ * 动态分流 bash:按「默认 bash 覆盖」设置(terminalBash)在调用时决定走哪套——
164
+ * 关 = 原生 SDK bash(纯进程、不开终端);开 = 终端接管 bash(persist 决定一次性/
165
+ * 持久)。开关因此即时生效(customTools 固定于 runtime 创建,不能在创建时二选一)。
140
166
  */
141
- function makeAdaptiveBashTool(killable, terminalBacked, useTerminal) {
167
+ export function makeAdaptiveBashTool(killable, terminalBacked, useTerminal) {
142
168
  return {
143
169
  ...killable,
170
+ description: "Run a shell command and return its full output plus exit code. Behavior depends on the「default bash override」setting (terminalBash):\n" +
171
+ "Setting OFF → runs natively (process spawn, no terminal) — the SDK's plain bash tool. persist has no effect.\n" +
172
+ "Setting ON → runs in a visible terminal. persist=true keeps that terminal alive ('ai-bash': shell state such as cd/venv/ssh retained across calls, silent commands move to the background and notify when done); persist=false (default in terminal mode) creates a one-shot terminal that exits when the command finishes while its output stays for review.\n" +
173
+ "Run the bare command — do NOT pipe through head/tail/more/less (use the head/tail parameters to trim the returned output instead; piping also hides live progress in the visible terminal). For interactive commands (REPLs, prompts, installers asking y/n) set persist=true (terminal mode) and drive them with terminal_input / terminal_key.",
174
+ promptSnippet: "run shell commands",
144
175
  execute: (id, params, signal, onUpdate, ctx) => (useTerminal() ? terminalBacked : killable).execute(id, params, signal, onUpdate, ctx),
145
176
  };
146
177
  }
@@ -702,6 +733,8 @@ export class ClientSession {
702
733
  }
703
734
  }
704
735
  await cs.bindSession();
736
+ await cs.restoreProjectProviderKeysForCwd(cwd);
737
+ await cs.restoreProjectModelForCwd(cwd);
705
738
  return cs;
706
739
  }
707
740
  /**
@@ -772,21 +805,20 @@ export class ClientSession {
772
805
  const created = await createAgentSessionFromServices({
773
806
  services,
774
807
  sessionManager,
775
- // 可手动停止的 bash 工具:覆盖 SDK 内置 bash(customTools 按 name
776
- // 覆盖),执行时把自己的 AbortController 注册进客户端集合——
777
- // abortBash() 只杀这些命令,agent run 与对话继续。
808
+ // 覆盖 SDK 内置 bash(customTools 按 name 覆盖)。双实现分流:
809
+ // 「默认 bash 覆盖」开关(terminalBash)关 → 原生 SDK bash(纯进程、不开终端);
810
+ // 开 → 终端接管 bash(persist 决定一次性/持久,可静默自动转后台)。
778
811
  customTools: [
779
- // bash 双实现动态分流:「终端接管」开启时命令跑进持久可见终端
780
- // (保留 shell 状态、静默自动转后台),关闭时是原生 killable bash。
781
812
  makeAdaptiveBashTool(makeKillableBashTool(effectiveCwd, this.bashKills), makeTerminalBashTool(terminals, {
782
813
  cwd: effectiveCwd,
783
- idleMs: () => this.settingsSvc.current.terminalBash
784
- ? Math.max(0, Math.floor(this.settingsSvc.current.terminalBashIdleMs) ||
785
- 0)
786
- : 0,
814
+ // 设置开 = 用终端;此分支里 persist 未显式给时默认一次性(false)。
815
+ defaultPersist: () => false,
816
+ idleMs: () => Math.max(0, Math.floor(this.settingsSvc.current.terminalBashIdleMs) || 0),
787
817
  kills: this.bashKills,
788
818
  notifyBackgroundDone: (info) => this.notifyTerminalBashDone(terminals, info),
789
- }), () => this.settingsSvc.current.terminalBash),
819
+ }),
820
+ // 设置关 → 原生 bash;开 → 终端 bash。
821
+ () => this.settingsSvc.current.terminalBash),
790
822
  ...makePersistentTerminalTools(terminals, effectiveCwd),
791
823
  // 插件注册的 AI 工具(创建时刻的实时快照;后续注册经
792
824
  // refreshPluginTools 动态补入已有会话)。
@@ -898,6 +930,9 @@ export class ClientSession {
898
930
  // Reconnect: push the background-task list — it must survive reconnects
899
931
  // and outlive the conversation that started the tasks.
900
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();
901
936
  // PTYs are conversation-owned and survive a socket reconnect.
902
937
  this.pushTerminals();
903
938
  }
@@ -1783,6 +1818,120 @@ export class ClientSession {
1783
1818
  deleteModelConfig(providerId) {
1784
1819
  return this.modelAdmin.deleteModelConfig(providerId);
1785
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
+ }
1786
1935
  // ---------------------------------------------------------------------------
1787
1936
  // Settings (system prompt / skills / extensions / presets)
1788
1937
  // ---------------------------------------------------------------------------
@@ -2056,8 +2205,8 @@ export class ClientSession {
2056
2205
  }
2057
2206
  /** Kill only the running bash command(s) — the agent run itself continues
2058
2207
  * (the bash tool returns an aborted error and the model moves on). Uses
2059
- * the per-client AbortController set registered by
2060
- * makeKillableBashTool. */
2208
+ * the per-client AbortController set registered by the bash tool paths
2209
+ * ({@link makeKillableBashTool} / {@link makeTerminalBashTool}). */
2061
2210
  async abortBash() {
2062
2211
  if (this.bashKills.size === 0) {
2063
2212
  this.emit({
@@ -2236,6 +2385,9 @@ export class ClientSession {
2236
2385
  if (prevModel && this.sharedModelRuntime) {
2237
2386
  try {
2238
2387
  await this.session.setModel(prevModel);
2388
+ const p = prevModel.provider;
2389
+ const mid = `${p}/${prevModel.id}`;
2390
+ await this.restoreKeyForModel(mid, this.cwd);
2239
2391
  }
2240
2392
  catch {
2241
2393
  // model no longer resolvable — keep the default
@@ -2333,6 +2485,8 @@ export class ClientSession {
2333
2485
  void this.pushSlashCommands();
2334
2486
  if (cwdChanged) {
2335
2487
  this.cwd = newCwd;
2488
+ await this.restoreProjectProviderKeysForCwd(newCwd);
2489
+ await this.restoreProjectModelForCwd(newCwd);
2336
2490
  // Mirror set_cwd's project-switch side-effects so the whole UI follows
2337
2491
  // the new workspace, not just the chat pane.
2338
2492
  try {
@@ -2615,6 +2769,8 @@ export class ClientSession {
2615
2769
  this.removeConversation(displaced.id);
2616
2770
  await this.bindSession();
2617
2771
  this.cwd = targetCwd;
2772
+ await this.restoreProjectProviderKeysForCwd(targetCwd);
2773
+ await this.restoreProjectModelForCwd(targetCwd);
2618
2774
  this.conv.lastActiveAt = Date.now();
2619
2775
  this.webUi.refresh();
2620
2776
  this.emitConversations();
@@ -2716,6 +2872,8 @@ export class ClientSession {
2716
2872
  if (prevModel && this.sharedModelRuntime) {
2717
2873
  try {
2718
2874
  await this.session.setModel(prevModel);
2875
+ const pm = prevModel;
2876
+ await this.restoreKeyForModel(`${pm.provider}/${pm.id}`, this.cwd);
2719
2877
  }
2720
2878
  catch {
2721
2879
  // model no longer resolvable — keep the default
@@ -2831,7 +2989,13 @@ export class ClientSession {
2831
2989
  }
2832
2990
  async cycleModel() {
2833
2991
  try {
2834
- 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
+ }
2835
2999
  }
2836
3000
  catch (err) {
2837
3001
  this.emit({
@@ -2912,6 +3076,8 @@ export class ClientSession {
2912
3076
  this.conv.promptedSinceActive = false;
2913
3077
  this.conv.lastActiveAt = Date.now();
2914
3078
  this.cwd = abs;
3079
+ await this.restoreProjectProviderKeysForCwd(abs);
3080
+ await this.restoreProjectModelForCwd(abs);
2915
3081
  // 工作区跟随型插件(编辑器文件树等)同步切根。
2916
3082
  try {
2917
3083
  this.onCwdChanged?.(abs);
@@ -3011,6 +3177,11 @@ export class ClientSession {
3011
3177
  if (!model)
3012
3178
  throw new Error(`模型不存在:${modelId}`);
3013
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);
3014
3185
  }
3015
3186
  catch (err) {
3016
3187
  this.emit({
@@ -448,9 +448,9 @@ export async function buildAttachmentMessages(ctx, attachments) {
448
448
  content: [
449
449
  {
450
450
  type: "text",
451
- text: `
452
- <vision-bridge>
453
- ${transcript}
451
+ text: `
452
+ <vision-bridge>
453
+ ${transcript}
454
454
  </vision-bridge>`,
455
455
  },
456
456
  ...(pathImg
@@ -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
  // 其他