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.
- package/LICENSE +21 -21
- package/README.md +497 -497
- package/README.zh-CN.md +422 -422
- package/bin/pi-web-ui.mjs +1881 -1881
- package/deploy/com.xingshuyin.pi-web-ui.plist +48 -48
- package/deploy/nginx-subpath.conf +88 -88
- package/deploy/pi-web-ui-task.xml +54 -54
- package/deploy/pi-web-ui.service +31 -31
- package/dist/server/agent-service.js +205 -34
- package/dist/server/attachments.js +3 -3
- package/dist/server/client-state.js +52 -0
- package/dist/server/dsh/dsh-agent-service.js +15 -1
- package/dist/server/dsh/runtime/goal-rpc.mjs +662 -662
- package/dist/server/index.js +31 -5
- package/dist/server/model-admin.js +360 -35
- package/dist/server/terminals.js +135 -138
- package/extensions/webui.ts +192 -192
- package/package.json +1 -1
- package/web/dist/assets/TerminalPanel-CYq-pKeS.js +2 -0
- package/web/dist/assets/index-BVuOleif.js +321 -0
- package/web/dist/assets/index-CxB1f4vG.css +10 -0
- package/web/dist/favicon.svg +8 -8
- package/web/dist/index.html +15 -15
- package/web/public/favicon.svg +8 -8
- package/web/dist/assets/TerminalPanel-_VfntAyG.js +0 -2
- package/web/dist/assets/index-DRsP4BO2.css +0 -10
- package/web/dist/assets/index-MeifpZzi.js +0 -321
|
@@ -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
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* run and the conversation continue
|
|
104
|
-
*
|
|
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.
|
|
131
|
-
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) =>
|
|
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
|
|
139
|
-
*
|
|
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
|
-
//
|
|
776
|
-
//
|
|
777
|
-
//
|
|
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
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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
|
-
}),
|
|
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
|
-
|
|
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
|
// 其他
|