@liguoshuai/pi-web-chat 1.7.5 → 1.8.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/docs/CHANGELOG.md +37 -0
- package/docs/ISSUES.md +25 -0
- package/package.json +1 -1
- package/public/app.js +486 -41
- package/public/index.html +24 -2
- package/public/style.css +288 -12
- package/server.js +116 -11
package/docs/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [1.8.0] - 2026-07-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **默认模型确认与展示体系 (Default Model Identification & Visual Indicators)**:
|
|
14
|
+
- 后端 `/api/config` 自动读取并整合项目配置 `<cwd>/.pi/settings.json` 与全局配置 `~/.pi/agent/settings.json` 中的 `defaultProvider`、`defaultModel` 及 `defaultThinkingLevel`。
|
|
15
|
+
- 顶部模型胶囊 (Model Pill) 自动识别并展示 `★ 默认` 或 `★ 项目默认` 徽章,悬停提示详细配置来源。
|
|
16
|
+
- 新建对话欢迎面板 (Empty State) 增加当前会话模型展示卡片,直观呈现模型名称、默认状态及特性标签。
|
|
17
|
+
- **深度思考 (Thinking / Reasoning) 联动选择器**:
|
|
18
|
+
- 当模型支持推理思考(`reasoning: true`)时,顶栏自动浮现 `🧠 High` / `🧠 Medium` 等深度思考胶囊。
|
|
19
|
+
- 支持快捷弹出思考深度菜单(Off / Minimal / Low / Medium / High / Max),即时调节思考预算;非思考模型自动隐藏。
|
|
20
|
+
- **一键持久化设为默认模型 (Set as Default Model)**:
|
|
21
|
+
- 新增后端接口 `POST /api/set-default-model`,模型列表项右侧支持一键将当前选中的模型设为全局/项目默认模型。
|
|
22
|
+
- **模型快捷置顶与特性标签 (Pinned, Recents & Capabilities)**:
|
|
23
|
+
- 模型下拉面板置顶呈现 `🌟 默认与常用` 分组,自动基于 `localStorage` 缓存并置顶最近使用的模型。
|
|
24
|
+
- 模型项展示 `🧠 Thinking`(深度思考)、`👁️ Vision`(多模态识图)、`★ 默认` 等能力标签。
|
|
25
|
+
- **全键盘极客操作与快捷键 (Keyboard Navigation & Shortcut)**:
|
|
26
|
+
- 支持全局快捷键 `Ctrl + M` / `Cmd + M` 快速呼出/收起模型选择面板。
|
|
27
|
+
- 搜索框支持 `↑` / `↓` 移动高亮、`Enter` 快速切换、`Esc` 退出。
|
|
28
|
+
- **会话流模型切换历史标记**:
|
|
29
|
+
- 会话中途切换模型时,在消息流中自动插入系统分割通知(`── 已切换模型至 Provider / ModelName ──`)。
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## [1.7.6] - 2026-07-26
|
|
34
|
+
|
|
35
|
+
### Security
|
|
36
|
+
- **Markdown 渲染 HTML 属性逃逸注入安全漏洞修复 (HTML Attribute Breakout XSS Vulnerability)**:升级 `public/app.js` 中的 `escapeHtml` 严密性,将原有对 `&`、`<`、`>` 的处理,扩展至双引号 `"` (`"`) 与单引号 `'` (`'`),阻断由于模型生成恶意链接等引起的 `href` 属性逃逸与任意 HTML 属性/事件(XSS)注入风险。
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
- **多设备与多标签页流式事件同步及中途连入追平优化 (Multi-device/Multi-tab Live Event Streaming Sync)**:
|
|
40
|
+
- 优化 `PiAgent` 事件录制范围:只要处于流式生成(Busy/Streaming)状态,即便当前已有在线浏览器连接,也会向 `eventBuffer` 持续录制。
|
|
41
|
+
- 优化缓存释放策略:若 Agent 仍处于 busy 生成状态,新连入的客户端消费完 `eventBuffer` 后不再立即清空 Buffer。
|
|
42
|
+
- 优化重连/中途断开录制:若最后一个客户端在 streaming 途中断开连接,只要生成任务仍在运行中,即不销毁当前的缓存。
|
|
43
|
+
- 以上多项优化完美实现了多台设备(例如手机、电脑)、多个浏览器 Tab 随时刷新或中途连入正在运行中的流式会话时,能够无缝重播已生成的上半段内容并追平后续实时流,极大地增强了多端多 Tab 的协同可靠性。
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
10
47
|
## [1.7.5] - 2026-07-26
|
|
11
48
|
|
|
12
49
|
### Fixed
|
package/docs/ISSUES.md
CHANGED
|
@@ -121,5 +121,30 @@
|
|
|
121
121
|
|
|
122
122
|
**修复**:移除 `message_end` 中冗余的 `escapeHtml()`,统一由 Markdown 渲染器进行安全转义。
|
|
123
123
|
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## 12. Markdown 渲染中潜在的 XSS 注入安全漏洞 (HTML Attribute Breakout)
|
|
127
|
+
|
|
128
|
+
**症状**:若模型返回了特制格式的 Markdown 链接(例如 `[点击](http://abc.com" style="..." onmouseover="alert(1))`),该链接渲染为 HTML 时能逃逸 `href="..."` 属性,注入任意 HTML 属性与恶意 JavaScript。
|
|
129
|
+
|
|
130
|
+
**根因**:原先 `escapeHtml()` 仅转义了 `&`、`<`、`>`,未能转义双引号 `"` 与单引号 `'`,使得属性逃逸攻击成为可能。
|
|
131
|
+
|
|
132
|
+
**修复**:修改 `public/app.js` 中的 `escapeHtml()` 实现,额外对 `"` (`"`) 与 `'` (`'`) 进行了严格转义,阻止任何 HTML 属性级别的注入攻击。
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## 13. 多设备 / 多 Tab 流式对话时新连入客户端无法追平进度
|
|
137
|
+
|
|
138
|
+
**症状**:在某一会话处于流式文本生成(Streaming)状态时,若用户在另一台设备或新的浏览器标签页中打开相同会话,由于 `PiAgent` 检测到已连接客户端而不将流式事件写入 `eventBuffer`,新连入的客户端无法回放当前的流式消息,导致其显示为空白、缺页或卡在加载中。
|
|
139
|
+
|
|
140
|
+
**根因**:
|
|
141
|
+
- 原逻辑在 `onPiMessage` 中判断只有在无客户端连接时才缓存事件(`if (!this.hasWs) this.bufferEvent(obj)`)。
|
|
142
|
+
- 离线回放完毕后,会强制清空环形 Buffer,导致后续其他客户端连入时无内容可播。
|
|
143
|
+
|
|
144
|
+
**修复**:
|
|
145
|
+
- 在 `server.js` 的 `onPiMessage` 逻辑中,当 `PiAgent` 处于繁忙或流式对话过程中(`this.isBusy` 为真)即便有客户端在线也开启缓存。
|
|
146
|
+
- 修改 `detachWs`,当所有连接断开但 Agent 仍在生成中时,保留 `eventBuffer` 保证继续录制。
|
|
147
|
+
- 修改 `replayBuffered`,在 Agent 仍处于 busy 状态时消费后不清空缓存,使多个设备/多个 Tab 可多次或同时连入并安全回溯追平全部生成细节,并在流式终止时通过 `agent_settled` 进行最终的统一清理。
|
|
148
|
+
|
|
124
149
|
|
|
125
150
|
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -37,6 +37,8 @@ const state = {
|
|
|
37
37
|
streaming: false,
|
|
38
38
|
models: [],
|
|
39
39
|
currentModel: null,
|
|
40
|
+
defaultModel: null,
|
|
41
|
+
recentModels: [],
|
|
40
42
|
thinkingLevel: "medium",
|
|
41
43
|
sessionId: null,
|
|
42
44
|
isBackfilling: false,
|
|
@@ -81,9 +83,38 @@ function setCwd(newCwd) {
|
|
|
81
83
|
refreshSessions();
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
function loadRecentModels() {
|
|
87
|
+
try {
|
|
88
|
+
const raw = localStorage.getItem("pi_recent_models");
|
|
89
|
+
if (raw) {
|
|
90
|
+
state.recentModels = JSON.parse(raw);
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
state.recentModels = [];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function saveRecentModel(model) {
|
|
98
|
+
if (!model || !model.id) return;
|
|
99
|
+
loadRecentModels();
|
|
100
|
+
const filtered = state.recentModels.filter(m => !(m.id === model.id && m.provider === model.provider));
|
|
101
|
+
filtered.unshift({
|
|
102
|
+
id: model.id,
|
|
103
|
+
name: model.name || model.id,
|
|
104
|
+
provider: model.provider,
|
|
105
|
+
reasoning: model.reasoning,
|
|
106
|
+
input: model.input,
|
|
107
|
+
});
|
|
108
|
+
state.recentModels = filtered.slice(0, 4);
|
|
109
|
+
try {
|
|
110
|
+
localStorage.setItem("pi_recent_models", JSON.stringify(state.recentModels));
|
|
111
|
+
} catch {}
|
|
112
|
+
}
|
|
113
|
+
|
|
84
114
|
async function loadServerConfig() {
|
|
85
115
|
try {
|
|
86
|
-
const
|
|
116
|
+
const cwdParam = state.cwd ? `?cwd=${encodeURIComponent(state.cwd)}` : "";
|
|
117
|
+
const res = await fetch(`${API}/api/config${cwdParam}`);
|
|
87
118
|
const data = await res.json();
|
|
88
119
|
if (data.home) state.homeDir = data.home;
|
|
89
120
|
if (data.serverCwd) state.serverCwd = data.serverCwd;
|
|
@@ -92,11 +123,20 @@ async function loadServerConfig() {
|
|
|
92
123
|
const verEl = $("#appVersion");
|
|
93
124
|
if (verEl) verEl.textContent = `v${data.version}`;
|
|
94
125
|
}
|
|
126
|
+
if (data.defaultModel) {
|
|
127
|
+
state.defaultModel = data.defaultModel;
|
|
128
|
+
if (data.defaultModel.thinkingLevel && !state.thinkingLevel) {
|
|
129
|
+
state.thinkingLevel = data.defaultModel.thinkingLevel;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
95
132
|
} catch {}
|
|
96
133
|
if (!state.cwd) {
|
|
97
134
|
state.cwd = localStorage.getItem("pi_cwd") || state.serverCwd || state.homeDir || "";
|
|
98
135
|
}
|
|
136
|
+
loadRecentModels();
|
|
99
137
|
updateCwdDisplay();
|
|
138
|
+
renderModelPill();
|
|
139
|
+
updateEmptyStateModelInfo();
|
|
100
140
|
}
|
|
101
141
|
|
|
102
142
|
async function openCwdModal() {
|
|
@@ -177,7 +217,11 @@ async function confirmCwdChange() {
|
|
|
177
217
|
|
|
178
218
|
// ---- Markdown render (small, safe renderer) ----
|
|
179
219
|
function escapeHtml(s) {
|
|
180
|
-
return s.replace(/&/g, "&")
|
|
220
|
+
return s.replace(/&/g, "&")
|
|
221
|
+
.replace(/</g, "<")
|
|
222
|
+
.replace(/>/g, ">")
|
|
223
|
+
.replace(/"/g, """)
|
|
224
|
+
.replace(/'/g, "'");
|
|
181
225
|
}
|
|
182
226
|
|
|
183
227
|
async function copyToClipboard(text) {
|
|
@@ -1145,16 +1189,36 @@ function handlePiMessage(obj) {
|
|
|
1145
1189
|
else if (obj.command === "get_available_models" && obj.success) updateModels(obj.data.models || []);
|
|
1146
1190
|
else if (obj.command === "set_model") {
|
|
1147
1191
|
if (obj.success) {
|
|
1192
|
+
const prev = state.currentModel;
|
|
1148
1193
|
if (obj.data) state.currentModel = obj.data;
|
|
1194
|
+
saveRecentModel(state.currentModel);
|
|
1149
1195
|
renderModelPill();
|
|
1150
1196
|
renderModelMenu();
|
|
1151
|
-
|
|
1197
|
+
const modelLabel = state.currentModel?.name || state.currentModel?.id || "";
|
|
1198
|
+
showToast(`已切换模型: ${modelLabel}`);
|
|
1199
|
+
if (prev && (prev.id !== state.currentModel?.id || prev.provider !== state.currentModel?.provider)) {
|
|
1200
|
+
appendSystemNotice(`已切换模型至 ${state.currentModel?.provider ? state.currentModel.provider + " / " : ""}${modelLabel}`);
|
|
1201
|
+
}
|
|
1152
1202
|
sendWs({ type: "get_state" });
|
|
1153
1203
|
} else {
|
|
1154
1204
|
showToast(`切换模型失败: ${obj.error || "未知错误"}`);
|
|
1155
1205
|
renderModelPill();
|
|
1156
1206
|
}
|
|
1157
1207
|
}
|
|
1208
|
+
else if (obj.command === "set_thinking_level") {
|
|
1209
|
+
if (obj.success) {
|
|
1210
|
+
showToast(`已更新思考深度: ${formatThinkingLevel(state.thinkingLevel)}`);
|
|
1211
|
+
} else {
|
|
1212
|
+
showToast(`设置思考深度失败: ${obj.error || "未知错误"}`);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
else if (obj.command === "cycle_thinking_level" && obj.success) {
|
|
1216
|
+
if (obj.data?.thinkingLevel) {
|
|
1217
|
+
state.thinkingLevel = obj.data.thinkingLevel;
|
|
1218
|
+
renderThinkingPill();
|
|
1219
|
+
updateEmptyStateModelInfo();
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1158
1222
|
else if (obj.command === "switch_session" && obj.success) {
|
|
1159
1223
|
// ask pi for current state so we can get session id, name
|
|
1160
1224
|
sendWs({ type: "get_state" });
|
|
@@ -1177,8 +1241,10 @@ function handlePiMessage(obj) {
|
|
|
1177
1241
|
case "model_select":
|
|
1178
1242
|
if (obj.model) {
|
|
1179
1243
|
state.currentModel = obj.model;
|
|
1244
|
+
saveRecentModel(obj.model);
|
|
1180
1245
|
renderModelPill();
|
|
1181
1246
|
renderModelMenu();
|
|
1247
|
+
updateEmptyStateModelInfo();
|
|
1182
1248
|
}
|
|
1183
1249
|
break;
|
|
1184
1250
|
case "remote_user_prompt":
|
|
@@ -1395,8 +1461,16 @@ function handleEntries(entries, leafId) { /* no-op: history rendered via REST */
|
|
|
1395
1461
|
function updateState(d) {
|
|
1396
1462
|
if (d?.sessionFile) state.currentSessionFile = d.sessionFile;
|
|
1397
1463
|
if (d?.sessionId) state.sessionId = d.sessionId;
|
|
1398
|
-
if (d?.model) {
|
|
1399
|
-
|
|
1464
|
+
if (d?.model) {
|
|
1465
|
+
state.currentModel = d.model;
|
|
1466
|
+
saveRecentModel(d.model);
|
|
1467
|
+
renderModelPill();
|
|
1468
|
+
}
|
|
1469
|
+
if (d?.thinkingLevel) {
|
|
1470
|
+
state.thinkingLevel = d.thinkingLevel;
|
|
1471
|
+
renderThinkingPill();
|
|
1472
|
+
}
|
|
1473
|
+
updateEmptyStateModelInfo();
|
|
1400
1474
|
$("#topSessionName").textContent = d?.sessionName || (d?.sessionFile ? baseName(d.sessionFile) : "新对话");
|
|
1401
1475
|
|
|
1402
1476
|
// Sync streaming state upon state updates (e.g. after reconnect)
|
|
@@ -1422,10 +1496,41 @@ function updateModels(models) {
|
|
|
1422
1496
|
renderModelPill();
|
|
1423
1497
|
}
|
|
1424
1498
|
|
|
1499
|
+
function formatThinkingLevel(lvl) {
|
|
1500
|
+
if (!lvl) return "Medium";
|
|
1501
|
+
return lvl.charAt(0).toUpperCase() + lvl.slice(1);
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
function isCurrentModelDefault() {
|
|
1505
|
+
if (!state.defaultModel || !state.currentModel) return false;
|
|
1506
|
+
const defId = state.defaultModel.id;
|
|
1507
|
+
const curId = state.currentModel.id;
|
|
1508
|
+
if (!defId || !curId) return false;
|
|
1509
|
+
if (defId !== curId) return false;
|
|
1510
|
+
if (state.defaultModel.provider && state.currentModel.provider) {
|
|
1511
|
+
return state.defaultModel.provider === state.currentModel.provider;
|
|
1512
|
+
}
|
|
1513
|
+
return true;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1425
1516
|
function renderModelPill() {
|
|
1426
1517
|
const m = state.currentModel;
|
|
1427
1518
|
const pill = $("#modelPill");
|
|
1428
|
-
|
|
1519
|
+
const nameEl = $("#modelPillName");
|
|
1520
|
+
const badgeEl = $("#modelPillBadge");
|
|
1521
|
+
if (!pill) return;
|
|
1522
|
+
|
|
1523
|
+
pill.disabled = state.streaming;
|
|
1524
|
+
|
|
1525
|
+
if (!m) {
|
|
1526
|
+
if (nameEl) nameEl.textContent = "选择模型";
|
|
1527
|
+
if (badgeEl) badgeEl.style.display = "none";
|
|
1528
|
+
pill.title = "点击选择模型 (快捷键: Ctrl+M)";
|
|
1529
|
+
renderThinkingPill();
|
|
1530
|
+
updateEmptyStateModelInfo();
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1429
1534
|
const provider = m.provider || "?";
|
|
1430
1535
|
let name = m.name || m.id;
|
|
1431
1536
|
if (state.models && state.models.length > 0) {
|
|
@@ -1435,11 +1540,203 @@ function renderModelPill() {
|
|
|
1435
1540
|
name = found.name;
|
|
1436
1541
|
}
|
|
1437
1542
|
}
|
|
1438
|
-
|
|
1439
|
-
|
|
1543
|
+
|
|
1544
|
+
if (nameEl) nameEl.textContent = `${provider} / ${name}`;
|
|
1545
|
+
|
|
1546
|
+
const isDefault = isCurrentModelDefault();
|
|
1547
|
+
if (badgeEl) {
|
|
1548
|
+
if (isDefault) {
|
|
1549
|
+
badgeEl.textContent = state.defaultModel?.source === "project" ? "★ 项目默认" : "★ 默认";
|
|
1550
|
+
badgeEl.style.display = "inline-block";
|
|
1551
|
+
} else {
|
|
1552
|
+
badgeEl.style.display = "none";
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
if (state.streaming) {
|
|
1557
|
+
pill.title = "生成中暂不可切换模型";
|
|
1558
|
+
} else if (isDefault) {
|
|
1559
|
+
pill.title = `当前模型: ${provider} / ${name} (默认模型 · ${state.defaultModel?.source === 'project' ? '来自项目配置' : '来自全局配置'})`;
|
|
1560
|
+
} else {
|
|
1561
|
+
const defDesc = state.defaultModel?.id ? ` · 默认: ${state.defaultModel.provider || ''}/${state.defaultModel.id}` : "";
|
|
1562
|
+
pill.title = `当前模型: ${provider} / ${name} (${m.id}${defDesc})`;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
renderThinkingPill();
|
|
1566
|
+
updateEmptyStateModelInfo();
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
function renderThinkingPill() {
|
|
1570
|
+
const pill = $("#thinkingPill");
|
|
1571
|
+
const labelEl = $("#thinkingPillLabel");
|
|
1572
|
+
if (!pill) return;
|
|
1573
|
+
|
|
1574
|
+
const m = state.currentModel;
|
|
1575
|
+
let supportsThinking = false;
|
|
1576
|
+
if (m) {
|
|
1577
|
+
if (m.reasoning === true) supportsThinking = true;
|
|
1578
|
+
else if (state.models && state.models.length > 0) {
|
|
1579
|
+
const found = state.models.find(item => item.id === m.id && item.provider === m.provider) ||
|
|
1580
|
+
state.models.find(item => item.id === m.id);
|
|
1581
|
+
if (found && found.reasoning === true) supportsThinking = true;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
if (!supportsThinking) {
|
|
1586
|
+
pill.style.display = "none";
|
|
1587
|
+
const menu = $("#thinkingMenu");
|
|
1588
|
+
if (menu) menu.classList.remove("open");
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
pill.style.display = "inline-flex";
|
|
1593
|
+
pill.disabled = state.streaming;
|
|
1594
|
+
if (labelEl) {
|
|
1595
|
+
labelEl.textContent = formatThinkingLevel(state.thinkingLevel);
|
|
1596
|
+
}
|
|
1597
|
+
pill.title = `深度思考: ${formatThinkingLevel(state.thinkingLevel)} (点击调整)`;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
const THINKING_LEVELS = [
|
|
1601
|
+
{ level: "off", label: "Off", desc: "关闭思考" },
|
|
1602
|
+
{ level: "minimal", label: "Minimal", desc: "最小思考" },
|
|
1603
|
+
{ level: "low", label: "Low", desc: "轻度思考" },
|
|
1604
|
+
{ level: "medium", label: "Medium", desc: "中等思考 (推荐)" },
|
|
1605
|
+
{ level: "high", label: "High", desc: "深度思考" },
|
|
1606
|
+
{ level: "xhigh", label: "Extra High", desc: "超高思考" },
|
|
1607
|
+
{ level: "max", label: "Max", desc: "最大思考深度" },
|
|
1608
|
+
];
|
|
1609
|
+
|
|
1610
|
+
function renderThinkingMenu() {
|
|
1611
|
+
const menu = $("#thinkingMenu");
|
|
1612
|
+
if (!menu) return;
|
|
1613
|
+
menu.innerHTML = "";
|
|
1614
|
+
|
|
1615
|
+
const titleRow = el("div", { class: "thinking-menu-title" }, [
|
|
1616
|
+
el("span", { text: "🧠 深度思考设置" }),
|
|
1617
|
+
el("span", { text: "Reasoning", style: "font-size: 10px; font-weight: normal;" })
|
|
1618
|
+
]);
|
|
1619
|
+
menu.appendChild(titleRow);
|
|
1620
|
+
|
|
1621
|
+
const curLevel = (state.thinkingLevel || "medium").toLowerCase();
|
|
1622
|
+
for (const item of THINKING_LEVELS) {
|
|
1623
|
+
const active = item.level === curLevel;
|
|
1624
|
+
const opt = el("div", {
|
|
1625
|
+
class: "thinking-opt" + (active ? " active" : ""),
|
|
1626
|
+
onclick: () => {
|
|
1627
|
+
state.thinkingLevel = item.level;
|
|
1628
|
+
sendWs({ type: "set_thinking_level", level: item.level });
|
|
1629
|
+
renderThinkingPill();
|
|
1630
|
+
updateEmptyStateModelInfo();
|
|
1631
|
+
showToast(`已设置思考级别: ${item.label}`);
|
|
1632
|
+
menu.classList.remove("open");
|
|
1633
|
+
}
|
|
1634
|
+
}, [
|
|
1635
|
+
el("span", { text: (active ? "✓ " : "") + item.label }),
|
|
1636
|
+
el("span", { class: "level-desc", text: item.desc })
|
|
1637
|
+
]);
|
|
1638
|
+
menu.appendChild(opt);
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
function updateEmptyStateModelInfo() {
|
|
1643
|
+
const nameEl = $("#emptyModelName");
|
|
1644
|
+
const badgeEl = $("#emptyModelBadge");
|
|
1645
|
+
const featsEl = $("#emptyModelFeatures");
|
|
1646
|
+
if (!nameEl) return;
|
|
1647
|
+
|
|
1648
|
+
const m = state.currentModel;
|
|
1649
|
+
if (!m) {
|
|
1650
|
+
nameEl.textContent = "未选择模型";
|
|
1651
|
+
if (badgeEl) badgeEl.style.display = "none";
|
|
1652
|
+
if (featsEl) featsEl.innerHTML = "";
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
const provider = m.provider || "?";
|
|
1657
|
+
let name = m.name || m.id;
|
|
1658
|
+
let supportsThinking = m.reasoning === true;
|
|
1659
|
+
let supportsVision = Array.isArray(m.input) && m.input.includes("image");
|
|
1660
|
+
|
|
1661
|
+
if (state.models && state.models.length > 0) {
|
|
1662
|
+
const found = state.models.find(item => item.id === m.id && item.provider === m.provider) ||
|
|
1663
|
+
state.models.find(item => item.id === m.id);
|
|
1664
|
+
if (found) {
|
|
1665
|
+
if (found.name) name = found.name;
|
|
1666
|
+
if (found.reasoning === true) supportsThinking = true;
|
|
1667
|
+
if (Array.isArray(found.input) && found.input.includes("image")) supportsVision = true;
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
nameEl.textContent = `${provider} / ${name}`;
|
|
1672
|
+
|
|
1673
|
+
const isDefault = isCurrentModelDefault();
|
|
1674
|
+
if (badgeEl) {
|
|
1675
|
+
if (isDefault) {
|
|
1676
|
+
badgeEl.textContent = state.defaultModel?.source === "project" ? "★ 项目默认" : "★ 默认模型";
|
|
1677
|
+
badgeEl.style.display = "inline-block";
|
|
1678
|
+
} else {
|
|
1679
|
+
badgeEl.style.display = "none";
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
if (featsEl) {
|
|
1684
|
+
featsEl.innerHTML = "";
|
|
1685
|
+
if (supportsThinking) {
|
|
1686
|
+
featsEl.appendChild(el("span", {
|
|
1687
|
+
class: "badge-feature",
|
|
1688
|
+
text: `🧠 思考: ${formatThinkingLevel(state.thinkingLevel)}`
|
|
1689
|
+
}));
|
|
1690
|
+
}
|
|
1691
|
+
if (supportsVision) {
|
|
1692
|
+
featsEl.appendChild(el("span", {
|
|
1693
|
+
class: "badge-feature",
|
|
1694
|
+
text: "👁️ 支持多模态识图"
|
|
1695
|
+
}));
|
|
1696
|
+
}
|
|
1697
|
+
if (m.id && m.name && m.id !== m.name) {
|
|
1698
|
+
featsEl.appendChild(el("span", {
|
|
1699
|
+
style: "font-family: monospace; font-size: 11px;",
|
|
1700
|
+
text: m.id
|
|
1701
|
+
}));
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
async function setDefaultModel(m) {
|
|
1707
|
+
try {
|
|
1708
|
+
const res = await fetch(`${API}/api/set-default-model`, {
|
|
1709
|
+
method: "POST",
|
|
1710
|
+
headers: { "Content-Type": "application/json" },
|
|
1711
|
+
body: JSON.stringify({
|
|
1712
|
+
provider: m.provider,
|
|
1713
|
+
modelId: m.id,
|
|
1714
|
+
cwd: state.cwd,
|
|
1715
|
+
scope: "global",
|
|
1716
|
+
}),
|
|
1717
|
+
});
|
|
1718
|
+
const data = await res.json();
|
|
1719
|
+
if (data.ok) {
|
|
1720
|
+
state.defaultModel = {
|
|
1721
|
+
provider: m.provider,
|
|
1722
|
+
id: m.id,
|
|
1723
|
+
thinkingLevel: data.defaultThinkingLevel || state.thinkingLevel,
|
|
1724
|
+
source: data.scope || "global",
|
|
1725
|
+
};
|
|
1726
|
+
renderModelPill();
|
|
1727
|
+
renderModelMenu();
|
|
1728
|
+
updateEmptyStateModelInfo();
|
|
1729
|
+
showToast(`已将 ${m.name || m.id} 设为全局默认模型`);
|
|
1730
|
+
} else {
|
|
1731
|
+
showToast(`设为默认模型失败: ${data.error || "未知错误"}`);
|
|
1732
|
+
}
|
|
1733
|
+
} catch (e) {
|
|
1734
|
+
showToast(`设置失败: ${e.message}`);
|
|
1735
|
+
}
|
|
1440
1736
|
}
|
|
1441
1737
|
|
|
1442
1738
|
let modelSearchQuery = "";
|
|
1739
|
+
let focusedModelIndex = -1;
|
|
1443
1740
|
|
|
1444
1741
|
function renderModelMenu() {
|
|
1445
1742
|
const menu = $("#modelMenu");
|
|
@@ -1464,17 +1761,36 @@ function renderModelMenu() {
|
|
|
1464
1761
|
searchInput.addEventListener("click", (e) => e.stopPropagation());
|
|
1465
1762
|
searchInput.addEventListener("input", (e) => {
|
|
1466
1763
|
modelSearchQuery = e.target.value;
|
|
1764
|
+
focusedModelIndex = -1;
|
|
1467
1765
|
renderModelList(listContainer);
|
|
1468
1766
|
const clearBtn = $(".btn-clear-model-search", searchWrap);
|
|
1469
1767
|
if (clearBtn) clearBtn.style.display = modelSearchQuery ? "block" : "none";
|
|
1470
1768
|
});
|
|
1471
1769
|
searchInput.addEventListener("keydown", (e) => {
|
|
1770
|
+
const items = listContainer._items || [];
|
|
1472
1771
|
if (e.key === "Escape") {
|
|
1473
1772
|
menu.classList.remove("open");
|
|
1773
|
+
} else if (e.key === "ArrowDown") {
|
|
1774
|
+
e.preventDefault();
|
|
1775
|
+
if (items.length === 0) return;
|
|
1776
|
+
focusedModelIndex = (focusedModelIndex + 1) % items.length;
|
|
1777
|
+
items.forEach((it, idx) => it.element.classList.toggle("focused", idx === focusedModelIndex));
|
|
1778
|
+
if (items[focusedModelIndex]) {
|
|
1779
|
+
items[focusedModelIndex].element.scrollIntoView({ block: "nearest" });
|
|
1780
|
+
}
|
|
1781
|
+
} else if (e.key === "ArrowUp") {
|
|
1782
|
+
e.preventDefault();
|
|
1783
|
+
if (items.length === 0) return;
|
|
1784
|
+
focusedModelIndex = (focusedModelIndex - 1 + items.length) % items.length;
|
|
1785
|
+
items.forEach((it, idx) => it.element.classList.toggle("focused", idx === focusedModelIndex));
|
|
1786
|
+
if (items[focusedModelIndex]) {
|
|
1787
|
+
items[focusedModelIndex].element.scrollIntoView({ block: "nearest" });
|
|
1788
|
+
}
|
|
1474
1789
|
} else if (e.key === "Enter") {
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1790
|
+
if (focusedModelIndex >= 0 && items[focusedModelIndex]) {
|
|
1791
|
+
items[focusedModelIndex].element.click();
|
|
1792
|
+
} else if (items.length > 0) {
|
|
1793
|
+
items[0].element.click();
|
|
1478
1794
|
}
|
|
1479
1795
|
}
|
|
1480
1796
|
});
|
|
@@ -1490,6 +1806,7 @@ function renderModelMenu() {
|
|
|
1490
1806
|
modelSearchQuery = "";
|
|
1491
1807
|
searchInput.value = "";
|
|
1492
1808
|
clearBtn.style.display = "none";
|
|
1809
|
+
focusedModelIndex = -1;
|
|
1493
1810
|
searchInput.focus();
|
|
1494
1811
|
renderModelList(listContainer);
|
|
1495
1812
|
}
|
|
@@ -1521,9 +1838,88 @@ function renderModelList(listContainer) {
|
|
|
1521
1838
|
|
|
1522
1839
|
if (filteredModels.length === 0) {
|
|
1523
1840
|
listContainer.appendChild(el("div", { class: "model-empty", text: "未找到匹配的模型" }));
|
|
1841
|
+
listContainer._items = [];
|
|
1524
1842
|
return;
|
|
1525
1843
|
}
|
|
1526
1844
|
|
|
1845
|
+
const allRenderedItems = [];
|
|
1846
|
+
|
|
1847
|
+
function renderModelOption(m, isDefaultBadge = false) {
|
|
1848
|
+
const active = state.currentModel && m.id === state.currentModel.id && m.provider === state.currentModel.provider;
|
|
1849
|
+
const isDef = state.defaultModel && m.id === state.defaultModel.id && (!state.defaultModel.provider || m.provider === state.defaultModel.provider);
|
|
1850
|
+
|
|
1851
|
+
const featureBadges = [];
|
|
1852
|
+
if (m.reasoning) featureBadges.push(el("span", { class: "badge-feature", text: "🧠 Thinking" }));
|
|
1853
|
+
if (Array.isArray(m.input) && m.input.includes("image")) featureBadges.push(el("span", { class: "badge-feature", text: "👁️ Vision" }));
|
|
1854
|
+
if (isDef || isDefaultBadge) featureBadges.push(el("span", { class: "badge-default-tag", text: "★ 默认" }));
|
|
1855
|
+
|
|
1856
|
+
const setDefaultBtn = isDef ? null : el("button", {
|
|
1857
|
+
class: "btn-set-default",
|
|
1858
|
+
type: "button",
|
|
1859
|
+
title: "将此模型设为全局默认模型",
|
|
1860
|
+
text: "★ 设为默认",
|
|
1861
|
+
onclick: (e) => {
|
|
1862
|
+
e.stopPropagation();
|
|
1863
|
+
setDefaultModel(m);
|
|
1864
|
+
}
|
|
1865
|
+
});
|
|
1866
|
+
|
|
1867
|
+
const opt = el("div", {
|
|
1868
|
+
class: "opt" + (active ? " active" : ""),
|
|
1869
|
+
onclick: () => {
|
|
1870
|
+
const prev = state.currentModel;
|
|
1871
|
+
$("#modelPillName").textContent = "切换中…";
|
|
1872
|
+
sendWs({ type: "set_model", provider: m.provider, modelId: m.id });
|
|
1873
|
+
saveRecentModel(m);
|
|
1874
|
+
$("#modelMenu").classList.remove("open");
|
|
1875
|
+
if (prev && (prev.id !== m.id || prev.provider !== m.provider)) {
|
|
1876
|
+
appendSystemNotice(`已切换模型至 ${m.provider ? m.provider + " / " : ""}${m.name || m.id}`);
|
|
1877
|
+
}
|
|
1878
|
+
},
|
|
1879
|
+
}, [
|
|
1880
|
+
el("span", { class: "check", html: active ? "✓" : "" }),
|
|
1881
|
+
el("div", { class: "model-main" }, [
|
|
1882
|
+
el("div", { class: "model-name-row" }, [
|
|
1883
|
+
el("span", { class: "model-name", text: `${m.name || m.id}` }),
|
|
1884
|
+
...featureBadges,
|
|
1885
|
+
]),
|
|
1886
|
+
(m.name && m.id && m.name !== m.id) ? el("span", { class: "model-id-sub", text: `${m.provider ? m.provider + " · " : ""}${m.id}` }) : null,
|
|
1887
|
+
]),
|
|
1888
|
+
setDefaultBtn,
|
|
1889
|
+
]);
|
|
1890
|
+
|
|
1891
|
+
allRenderedItems.push({ element: opt, model: m });
|
|
1892
|
+
return opt;
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
// If no search query, show "Default & Recent" section first
|
|
1896
|
+
if (!q) {
|
|
1897
|
+
loadRecentModels();
|
|
1898
|
+
const pinGroup = [];
|
|
1899
|
+
|
|
1900
|
+
// Find default model if exists
|
|
1901
|
+
if (state.defaultModel?.id) {
|
|
1902
|
+
const defM = state.models.find(m => m.id === state.defaultModel.id && (!state.defaultModel.provider || m.provider === state.defaultModel.provider));
|
|
1903
|
+
if (defM) pinGroup.push({ model: defM, isDefault: true });
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
// Add recent models (excluding default)
|
|
1907
|
+
for (const rm of state.recentModels) {
|
|
1908
|
+
const full = state.models.find(m => m.id === rm.id && m.provider === rm.provider) || rm;
|
|
1909
|
+
if (!pinGroup.some(item => item.model.id === full.id && item.model.provider === full.provider)) {
|
|
1910
|
+
pinGroup.push({ model: full, isDefault: false });
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
if (pinGroup.length > 0) {
|
|
1915
|
+
listContainer.appendChild(el("div", { class: "group-label", text: "🌟 默认与常用" }));
|
|
1916
|
+
for (const item of pinGroup) {
|
|
1917
|
+
listContainer.appendChild(renderModelOption(item.model, item.isDefault));
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
// Group rest by provider
|
|
1527
1923
|
const groups = {};
|
|
1528
1924
|
for (const m of filteredModels) {
|
|
1529
1925
|
const p = m.provider || "other";
|
|
@@ -1533,21 +1929,48 @@ function renderModelList(listContainer) {
|
|
|
1533
1929
|
for (const [provider, items] of Object.entries(groups).sort()) {
|
|
1534
1930
|
listContainer.appendChild(el("div", { class: "group-label", text: provider }));
|
|
1535
1931
|
for (const m of items) {
|
|
1536
|
-
|
|
1537
|
-
listContainer.appendChild(el("div", {
|
|
1538
|
-
class: "opt" + (active ? " active" : ""),
|
|
1539
|
-
onclick: () => {
|
|
1540
|
-
$("#modelPill").textContent = "切换中…";
|
|
1541
|
-
sendWs({ type: "set_model", provider: m.provider, modelId: m.id });
|
|
1542
|
-
$("#modelMenu").classList.remove("open");
|
|
1543
|
-
},
|
|
1544
|
-
}, [
|
|
1545
|
-
el("span", { class: "check", html: active ? "✓ " : "" }),
|
|
1546
|
-
el("span", { class: "model-name", text: `${m.name || m.id}` }),
|
|
1547
|
-
(m.name && m.id && m.name !== m.id) ? el("span", { class: "model-id-sub", text: m.id }) : null,
|
|
1548
|
-
]));
|
|
1932
|
+
listContainer.appendChild(renderModelOption(m));
|
|
1549
1933
|
}
|
|
1550
1934
|
}
|
|
1935
|
+
|
|
1936
|
+
listContainer._items = allRenderedItems;
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
function toggleModelMenu() {
|
|
1940
|
+
if (state.streaming) {
|
|
1941
|
+
showToast("生成中暂不可切换模型");
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
sendWs({ type: "get_available_models" });
|
|
1945
|
+
const menu = $("#modelMenu");
|
|
1946
|
+
const thinkingMenu = $("#thinkingMenu");
|
|
1947
|
+
if (thinkingMenu) thinkingMenu.classList.remove("open");
|
|
1948
|
+
|
|
1949
|
+
const willOpen = !menu.classList.contains("open");
|
|
1950
|
+
menu.classList.toggle("open");
|
|
1951
|
+
if (willOpen) {
|
|
1952
|
+
focusedModelIndex = -1;
|
|
1953
|
+
setTimeout(() => {
|
|
1954
|
+
const input = $("#modelSearchInput");
|
|
1955
|
+
if (input) {
|
|
1956
|
+
input.focus();
|
|
1957
|
+
input.select();
|
|
1958
|
+
}
|
|
1959
|
+
}, 50);
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
function toggleThinkingMenu() {
|
|
1964
|
+
if (state.streaming) {
|
|
1965
|
+
showToast("生成中暂不可调整思考等级");
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
const modelMenu = $("#modelMenu");
|
|
1969
|
+
if (modelMenu) modelMenu.classList.remove("open");
|
|
1970
|
+
|
|
1971
|
+
const menu = $("#thinkingMenu");
|
|
1972
|
+
renderThinkingMenu();
|
|
1973
|
+
menu.classList.toggle("open");
|
|
1551
1974
|
}
|
|
1552
1975
|
|
|
1553
1976
|
function baseName(p) {
|
|
@@ -1594,6 +2017,7 @@ function updateComposerUI() {
|
|
|
1594
2017
|
|
|
1595
2018
|
function setComposerAborting(yes) {
|
|
1596
2019
|
updateComposerUI();
|
|
2020
|
+
renderModelPill();
|
|
1597
2021
|
}
|
|
1598
2022
|
|
|
1599
2023
|
function submitSteer() {
|
|
@@ -1730,26 +2154,47 @@ async function init() {
|
|
|
1730
2154
|
}
|
|
1731
2155
|
});
|
|
1732
2156
|
|
|
1733
|
-
// model pill / menu
|
|
1734
|
-
$("#modelPill")
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
2157
|
+
// model pill / thinking pill / menu interactions
|
|
2158
|
+
const modelPillEl = $("#modelPill");
|
|
2159
|
+
if (modelPillEl) {
|
|
2160
|
+
modelPillEl.addEventListener("click", (e) => {
|
|
2161
|
+
e.stopPropagation();
|
|
2162
|
+
toggleModelMenu();
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
const thinkingPillEl = $("#thinkingPill");
|
|
2167
|
+
if (thinkingPillEl) {
|
|
2168
|
+
thinkingPillEl.addEventListener("click", (e) => {
|
|
2169
|
+
e.stopPropagation();
|
|
2170
|
+
toggleThinkingMenu();
|
|
2171
|
+
});
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
const emptyChangeModelBtn = $("#emptyChangeModelBtn");
|
|
2175
|
+
if (emptyChangeModelBtn) {
|
|
2176
|
+
emptyChangeModelBtn.addEventListener("click", (e) => {
|
|
2177
|
+
e.stopPropagation();
|
|
2178
|
+
toggleModelMenu();
|
|
2179
|
+
});
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
// Keyboard shortcut: Ctrl+M / Cmd+M to toggle model selector
|
|
2183
|
+
window.addEventListener("keydown", (e) => {
|
|
2184
|
+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "m") {
|
|
2185
|
+
e.preventDefault();
|
|
2186
|
+
toggleModelMenu();
|
|
1748
2187
|
}
|
|
1749
2188
|
});
|
|
2189
|
+
|
|
1750
2190
|
document.addEventListener("click", (e) => {
|
|
1751
|
-
if (!e.target.closest("#modelMenu") && !e.target.closest("#modelPill")) {
|
|
1752
|
-
$("#modelMenu")
|
|
2191
|
+
if (!e.target.closest("#modelMenu") && !e.target.closest("#modelPill") && !e.target.closest("#emptyChangeModelBtn")) {
|
|
2192
|
+
const m = $("#modelMenu");
|
|
2193
|
+
if (m) m.classList.remove("open");
|
|
2194
|
+
}
|
|
2195
|
+
if (!e.target.closest("#thinkingMenu") && !e.target.closest("#thinkingPill")) {
|
|
2196
|
+
const tm = $("#thinkingMenu");
|
|
2197
|
+
if (tm) tm.classList.remove("open");
|
|
1753
2198
|
}
|
|
1754
2199
|
});
|
|
1755
2200
|
|
package/public/index.html
CHANGED
|
@@ -48,10 +48,18 @@
|
|
|
48
48
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px; vertical-align:-2px;"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
|
|
49
49
|
<span id="cwdPill">~</span>
|
|
50
50
|
</button>
|
|
51
|
-
<div
|
|
52
|
-
<button class="model-pill" id="modelPill"
|
|
51
|
+
<div class="model-pill-container" id="modelPillContainer">
|
|
52
|
+
<button class="model-pill" id="modelPill" title="点击切换模型 (快捷键: Ctrl+M)">
|
|
53
|
+
<span class="model-pill-name" id="modelPillName">选择模型</span>
|
|
54
|
+
<span class="model-pill-badge" id="modelPillBadge" style="display:none;">★ 默认</span>
|
|
55
|
+
</button>
|
|
56
|
+
<button class="thinking-pill" id="thinkingPill" style="display:none;" title="点击调整深度思考 / 推理级别">
|
|
57
|
+
<span class="thinking-icon">🧠</span>
|
|
58
|
+
<span class="thinking-label" id="thinkingPillLabel">High</span>
|
|
59
|
+
</button>
|
|
53
60
|
</div>
|
|
54
61
|
<div class="model-menu" id="modelMenu"></div>
|
|
62
|
+
<div class="thinking-menu" id="thinkingMenu"></div>
|
|
55
63
|
</div>
|
|
56
64
|
|
|
57
65
|
<div class="chat" id="chat">
|
|
@@ -59,6 +67,20 @@
|
|
|
59
67
|
<div class="logo">π</div>
|
|
60
68
|
<h1>有什么可以帮你?</h1>
|
|
61
69
|
<p>我是 <strong>pi</strong> 编程助手,可以读写文件、运行命令、改代码。在下面输入你的需求,或选择左侧的历史会话继续。</p>
|
|
70
|
+
<div class="empty-model-banner" id="emptyModelBanner">
|
|
71
|
+
<div class="empty-model-left">
|
|
72
|
+
<span class="empty-model-icon">🤖</span>
|
|
73
|
+
<div class="empty-model-info">
|
|
74
|
+
<div class="empty-model-title-row">
|
|
75
|
+
<span class="empty-model-label">当前模型:</span>
|
|
76
|
+
<span class="empty-model-name" id="emptyModelName">加载中…</span>
|
|
77
|
+
<span class="badge-default" id="emptyModelBadge" style="display:none;">★ 默认模型</span>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="empty-model-features" id="emptyModelFeatures"></div>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
<button class="btn-change-model" id="emptyChangeModelBtn">切换模型</button>
|
|
83
|
+
</div>
|
|
62
84
|
<div class="suggestions">
|
|
63
85
|
<div class="chip" data-prompt="列出当前目录下的所有文件,并告诉我这是什么项目">列出当前目录文件</div>
|
|
64
86
|
<div class="chip" data-prompt="阅读 README 或主要源文件,然后用一段话总结这个项目是做什么的">总结这个项目</div>
|
package/public/style.css
CHANGED
|
@@ -226,6 +226,14 @@ body {
|
|
|
226
226
|
color: var(--text);
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
.model-pill-container {
|
|
230
|
+
position: relative;
|
|
231
|
+
display: flex;
|
|
232
|
+
align-items: center;
|
|
233
|
+
gap: 6px;
|
|
234
|
+
flex-shrink: 0;
|
|
235
|
+
}
|
|
236
|
+
|
|
229
237
|
.model-pill {
|
|
230
238
|
font-size: 12px;
|
|
231
239
|
color: var(--text-muted);
|
|
@@ -235,10 +243,52 @@ body {
|
|
|
235
243
|
cursor: pointer;
|
|
236
244
|
background: var(--bg-input);
|
|
237
245
|
white-space: nowrap;
|
|
238
|
-
transition: color 0.15s, border-color 0.15s;
|
|
246
|
+
transition: color 0.15s, border-color 0.15s, background 0.15s, opacity 0.15s;
|
|
247
|
+
flex-shrink: 0;
|
|
248
|
+
display: inline-flex;
|
|
249
|
+
align-items: center;
|
|
250
|
+
gap: 6px;
|
|
251
|
+
}
|
|
252
|
+
.model-pill:hover:not(:disabled) { color: var(--text); border-color: var(--text-dim); }
|
|
253
|
+
.model-pill:disabled { opacity: 0.55; cursor: not-allowed; }
|
|
254
|
+
|
|
255
|
+
.model-pill-name {
|
|
256
|
+
max-width: 140px;
|
|
257
|
+
overflow: hidden;
|
|
258
|
+
text-overflow: ellipsis;
|
|
259
|
+
white-space: nowrap;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.model-pill-badge {
|
|
263
|
+
font-size: 10px;
|
|
264
|
+
padding: 1px 6px;
|
|
265
|
+
border-radius: 999px;
|
|
266
|
+
background: rgba(59, 130, 246, 0.18);
|
|
267
|
+
color: #60a5fa;
|
|
268
|
+
font-weight: 600;
|
|
269
|
+
line-height: 1.3;
|
|
270
|
+
flex-shrink: 0;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.thinking-pill {
|
|
274
|
+
font-size: 12px;
|
|
275
|
+
color: var(--text-muted);
|
|
276
|
+
border: 1px solid var(--border);
|
|
277
|
+
border-radius: 999px;
|
|
278
|
+
padding: 6px 10px;
|
|
279
|
+
cursor: pointer;
|
|
280
|
+
background: var(--bg-input);
|
|
281
|
+
white-space: nowrap;
|
|
282
|
+
transition: all 0.15s ease;
|
|
239
283
|
flex-shrink: 0;
|
|
284
|
+
display: inline-flex;
|
|
285
|
+
align-items: center;
|
|
286
|
+
gap: 4px;
|
|
240
287
|
}
|
|
241
|
-
.
|
|
288
|
+
.thinking-pill:hover:not(:disabled) { color: var(--text); border-color: var(--accent); background: var(--bg-hover); }
|
|
289
|
+
.thinking-pill:disabled { opacity: 0.55; cursor: not-allowed; }
|
|
290
|
+
.thinking-pill .thinking-icon { font-size: 13px; line-height: 1; }
|
|
291
|
+
.thinking-pill .thinking-label { font-weight: 500; }
|
|
242
292
|
|
|
243
293
|
.btn-ghost {
|
|
244
294
|
background: transparent;
|
|
@@ -530,6 +580,93 @@ body {
|
|
|
530
580
|
.empty-state .logo { font-size: 48px; font-weight: bold; color: var(--accent); }
|
|
531
581
|
.empty-state h1 { font-size: 32px; font-weight: 500; color: var(--text); }
|
|
532
582
|
.empty-state p { font-size: 15px; max-width: 480px; line-height: 1.6; }
|
|
583
|
+
|
|
584
|
+
/* Empty state model banner */
|
|
585
|
+
.empty-model-banner {
|
|
586
|
+
display: flex;
|
|
587
|
+
align-items: center;
|
|
588
|
+
justify-content: space-between;
|
|
589
|
+
background: var(--bg-sidebar);
|
|
590
|
+
border: 1px solid var(--border);
|
|
591
|
+
border-radius: 12px;
|
|
592
|
+
padding: 12px 16px;
|
|
593
|
+
margin: 10px 0 16px;
|
|
594
|
+
width: 100%;
|
|
595
|
+
max-width: 520px;
|
|
596
|
+
box-sizing: border-box;
|
|
597
|
+
gap: 12px;
|
|
598
|
+
text-align: left;
|
|
599
|
+
}
|
|
600
|
+
.empty-model-left {
|
|
601
|
+
display: flex;
|
|
602
|
+
align-items: center;
|
|
603
|
+
gap: 12px;
|
|
604
|
+
min-width: 0;
|
|
605
|
+
}
|
|
606
|
+
.empty-model-icon {
|
|
607
|
+
font-size: 24px;
|
|
608
|
+
flex-shrink: 0;
|
|
609
|
+
line-height: 1;
|
|
610
|
+
}
|
|
611
|
+
.empty-model-info {
|
|
612
|
+
display: flex;
|
|
613
|
+
flex-direction: column;
|
|
614
|
+
gap: 4px;
|
|
615
|
+
min-width: 0;
|
|
616
|
+
}
|
|
617
|
+
.empty-model-title-row {
|
|
618
|
+
display: flex;
|
|
619
|
+
align-items: center;
|
|
620
|
+
gap: 6px;
|
|
621
|
+
font-size: 13px;
|
|
622
|
+
color: var(--text);
|
|
623
|
+
}
|
|
624
|
+
.empty-model-label {
|
|
625
|
+
color: var(--text-muted);
|
|
626
|
+
flex-shrink: 0;
|
|
627
|
+
}
|
|
628
|
+
.empty-model-name {
|
|
629
|
+
font-weight: 600;
|
|
630
|
+
color: var(--text);
|
|
631
|
+
overflow: hidden;
|
|
632
|
+
text-overflow: ellipsis;
|
|
633
|
+
white-space: nowrap;
|
|
634
|
+
}
|
|
635
|
+
.badge-default {
|
|
636
|
+
font-size: 10px;
|
|
637
|
+
padding: 1px 6px;
|
|
638
|
+
border-radius: 999px;
|
|
639
|
+
background: rgba(59, 130, 246, 0.18);
|
|
640
|
+
color: #60a5fa;
|
|
641
|
+
font-weight: 600;
|
|
642
|
+
line-height: 1.3;
|
|
643
|
+
flex-shrink: 0;
|
|
644
|
+
}
|
|
645
|
+
.empty-model-features {
|
|
646
|
+
display: flex;
|
|
647
|
+
align-items: center;
|
|
648
|
+
gap: 6px;
|
|
649
|
+
font-size: 11px;
|
|
650
|
+
color: var(--text-dim);
|
|
651
|
+
flex-wrap: wrap;
|
|
652
|
+
}
|
|
653
|
+
.btn-change-model {
|
|
654
|
+
padding: 6px 12px;
|
|
655
|
+
font-size: 12px;
|
|
656
|
+
border-radius: 8px;
|
|
657
|
+
background: var(--bg-input);
|
|
658
|
+
border: 1px solid var(--border);
|
|
659
|
+
color: var(--text);
|
|
660
|
+
cursor: pointer;
|
|
661
|
+
transition: background 0.15s, border-color 0.15s;
|
|
662
|
+
white-space: nowrap;
|
|
663
|
+
flex-shrink: 0;
|
|
664
|
+
}
|
|
665
|
+
.btn-change-model:hover {
|
|
666
|
+
background: var(--bg-hover);
|
|
667
|
+
border-color: var(--accent);
|
|
668
|
+
}
|
|
669
|
+
|
|
533
670
|
.suggestions { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; margin-top: 12px; }
|
|
534
671
|
.suggestions .chip {
|
|
535
672
|
border: 1px solid var(--border); border-radius: 999px; padding: 10px 18px;
|
|
@@ -799,7 +936,7 @@ body {
|
|
|
799
936
|
.model-menu {
|
|
800
937
|
position: absolute; top: 52px; right: 16px;
|
|
801
938
|
background: var(--bg-sidebar); border: 1px solid var(--border);
|
|
802
|
-
border-radius: 14px; padding: 0; width:
|
|
939
|
+
border-radius: 14px; padding: 0; width: 380px; max-height: 480px;
|
|
803
940
|
box-shadow: 0 12px 32px rgba(0,0,0,.5); z-index: 200; display: none;
|
|
804
941
|
flex-direction: column; overflow: hidden;
|
|
805
942
|
}
|
|
@@ -849,15 +986,18 @@ body {
|
|
|
849
986
|
min-height: 0;
|
|
850
987
|
overflow-y: auto;
|
|
851
988
|
padding: 6px;
|
|
852
|
-
max-height:
|
|
989
|
+
max-height: 400px;
|
|
853
990
|
}
|
|
854
991
|
.model-menu-list .group-label {
|
|
855
992
|
font-size: 11px;
|
|
856
993
|
color: var(--text-dim);
|
|
857
994
|
text-transform: uppercase;
|
|
858
|
-
padding:
|
|
995
|
+
padding: 8px 8px 3px;
|
|
859
996
|
font-weight: 600;
|
|
860
997
|
letter-spacing: 0.5px;
|
|
998
|
+
display: flex;
|
|
999
|
+
align-items: center;
|
|
1000
|
+
gap: 4px;
|
|
861
1001
|
}
|
|
862
1002
|
.model-menu-list .opt {
|
|
863
1003
|
padding: 8px 10px;
|
|
@@ -869,20 +1009,80 @@ body {
|
|
|
869
1009
|
align-items: center;
|
|
870
1010
|
gap: 6px;
|
|
871
1011
|
transition: background 0.12s;
|
|
1012
|
+
position: relative;
|
|
872
1013
|
}
|
|
873
|
-
.model-menu-list .opt:hover
|
|
1014
|
+
.model-menu-list .opt:hover,
|
|
1015
|
+
.model-menu-list .opt.focused { background: var(--bg-hover); }
|
|
874
1016
|
.model-menu-list .opt.active { background: var(--bg-hover); color: var(--accent); }
|
|
875
1017
|
.model-menu-list .opt .check { color: var(--accent); width: 14px; flex-shrink: 0; font-size: 12px; }
|
|
876
|
-
.model-menu-list .opt .model-
|
|
1018
|
+
.model-menu-list .opt .model-main {
|
|
1019
|
+
flex: 1;
|
|
1020
|
+
min-width: 0;
|
|
1021
|
+
display: flex;
|
|
1022
|
+
flex-direction: column;
|
|
1023
|
+
gap: 2px;
|
|
1024
|
+
}
|
|
1025
|
+
.model-menu-list .opt .model-name-row {
|
|
1026
|
+
display: flex;
|
|
1027
|
+
align-items: center;
|
|
1028
|
+
gap: 6px;
|
|
1029
|
+
min-width: 0;
|
|
1030
|
+
}
|
|
1031
|
+
.model-menu-list .opt .model-name {
|
|
1032
|
+
font-weight: 500;
|
|
1033
|
+
overflow: hidden;
|
|
1034
|
+
text-overflow: ellipsis;
|
|
1035
|
+
white-space: nowrap;
|
|
1036
|
+
}
|
|
877
1037
|
.model-menu-list .opt .model-id-sub {
|
|
878
1038
|
font-size: 11px;
|
|
879
1039
|
color: var(--text-dim);
|
|
880
1040
|
font-family: "SF Mono", Menlo, Consolas, monospace;
|
|
881
|
-
max-width: 120px;
|
|
882
1041
|
overflow: hidden;
|
|
883
1042
|
text-overflow: ellipsis;
|
|
884
1043
|
white-space: nowrap;
|
|
885
1044
|
}
|
|
1045
|
+
.model-menu-list .opt .badge-feature {
|
|
1046
|
+
font-size: 10px;
|
|
1047
|
+
padding: 1px 5px;
|
|
1048
|
+
border-radius: 4px;
|
|
1049
|
+
background: var(--bg-input);
|
|
1050
|
+
border: 1px solid var(--border);
|
|
1051
|
+
color: var(--text-muted);
|
|
1052
|
+
white-space: nowrap;
|
|
1053
|
+
}
|
|
1054
|
+
.model-menu-list .opt .badge-default-tag {
|
|
1055
|
+
font-size: 10px;
|
|
1056
|
+
padding: 1px 5px;
|
|
1057
|
+
border-radius: 4px;
|
|
1058
|
+
background: rgba(59, 130, 246, 0.18);
|
|
1059
|
+
color: #60a5fa;
|
|
1060
|
+
font-weight: 600;
|
|
1061
|
+
white-space: nowrap;
|
|
1062
|
+
}
|
|
1063
|
+
.btn-set-default {
|
|
1064
|
+
opacity: 0;
|
|
1065
|
+
padding: 3px 8px;
|
|
1066
|
+
font-size: 11px;
|
|
1067
|
+
border-radius: 6px;
|
|
1068
|
+
background: var(--bg-input);
|
|
1069
|
+
border: 1px solid var(--border);
|
|
1070
|
+
color: var(--text-dim);
|
|
1071
|
+
cursor: pointer;
|
|
1072
|
+
transition: all 0.15s;
|
|
1073
|
+
white-space: nowrap;
|
|
1074
|
+
flex-shrink: 0;
|
|
1075
|
+
}
|
|
1076
|
+
.model-menu-list .opt:hover .btn-set-default,
|
|
1077
|
+
.model-menu-list .opt.focused .btn-set-default {
|
|
1078
|
+
opacity: 1;
|
|
1079
|
+
}
|
|
1080
|
+
.btn-set-default:hover {
|
|
1081
|
+
background: var(--bg-hover);
|
|
1082
|
+
color: #60a5fa;
|
|
1083
|
+
border-color: #60a5fa;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
886
1086
|
.model-empty {
|
|
887
1087
|
padding: 24px 12px;
|
|
888
1088
|
text-align: center;
|
|
@@ -890,6 +1090,60 @@ body {
|
|
|
890
1090
|
color: var(--text-dim);
|
|
891
1091
|
}
|
|
892
1092
|
|
|
1093
|
+
/* Thinking selector dropdown */
|
|
1094
|
+
.thinking-menu {
|
|
1095
|
+
position: absolute; top: 52px; right: 16px;
|
|
1096
|
+
background: var(--bg-sidebar); border: 1px solid var(--border);
|
|
1097
|
+
border-radius: 12px; padding: 6px; width: 220px;
|
|
1098
|
+
box-shadow: 0 12px 32px rgba(0,0,0,.5); z-index: 210; display: none;
|
|
1099
|
+
flex-direction: column; gap: 2px;
|
|
1100
|
+
}
|
|
1101
|
+
.thinking-menu.open { display: flex; }
|
|
1102
|
+
.thinking-menu-title {
|
|
1103
|
+
font-size: 11px;
|
|
1104
|
+
font-weight: 600;
|
|
1105
|
+
color: var(--text-dim);
|
|
1106
|
+
text-transform: uppercase;
|
|
1107
|
+
padding: 4px 8px 6px;
|
|
1108
|
+
border-bottom: 1px solid var(--border);
|
|
1109
|
+
margin-bottom: 4px;
|
|
1110
|
+
display: flex;
|
|
1111
|
+
align-items: center;
|
|
1112
|
+
justify-content: space-between;
|
|
1113
|
+
}
|
|
1114
|
+
.thinking-opt {
|
|
1115
|
+
display: flex;
|
|
1116
|
+
align-items: center;
|
|
1117
|
+
justify-content: space-between;
|
|
1118
|
+
padding: 7px 10px;
|
|
1119
|
+
border-radius: 6px;
|
|
1120
|
+
font-size: 13px;
|
|
1121
|
+
color: var(--text);
|
|
1122
|
+
cursor: pointer;
|
|
1123
|
+
transition: background 0.12s;
|
|
1124
|
+
}
|
|
1125
|
+
.thinking-opt:hover { background: var(--bg-hover); }
|
|
1126
|
+
.thinking-opt.active { background: var(--bg-hover); color: var(--accent); font-weight: 500; }
|
|
1127
|
+
.thinking-opt .level-desc { font-size: 11px; color: var(--text-dim); }
|
|
1128
|
+
|
|
1129
|
+
/* System notice divider */
|
|
1130
|
+
.system-notice-divider {
|
|
1131
|
+
display: flex;
|
|
1132
|
+
align-items: center;
|
|
1133
|
+
justify-content: center;
|
|
1134
|
+
margin: 12px 0;
|
|
1135
|
+
animation: fade .18s ease;
|
|
1136
|
+
}
|
|
1137
|
+
.system-notice-text {
|
|
1138
|
+
font-size: 12px;
|
|
1139
|
+
color: var(--text-dim);
|
|
1140
|
+
background: var(--bg-sidebar);
|
|
1141
|
+
border: 1px solid var(--border);
|
|
1142
|
+
padding: 3px 12px;
|
|
1143
|
+
border-radius: 999px;
|
|
1144
|
+
letter-spacing: 0.2px;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
893
1147
|
|
|
894
1148
|
/* ==========================================================================
|
|
895
1149
|
Responsive Mobile Web Design (< 768px)
|
|
@@ -1001,18 +1255,30 @@ body {
|
|
|
1001
1255
|
flex-shrink: 0;
|
|
1002
1256
|
}
|
|
1003
1257
|
|
|
1258
|
+
.model-pill-container {
|
|
1259
|
+
max-width: 130px;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1004
1262
|
.model-pill {
|
|
1005
|
-
padding: 5px
|
|
1263
|
+
padding: 5px 8px;
|
|
1006
1264
|
font-size: 11px;
|
|
1007
|
-
max-width:
|
|
1265
|
+
max-width: 90px;
|
|
1008
1266
|
overflow: hidden;
|
|
1009
1267
|
text-overflow: ellipsis;
|
|
1010
1268
|
white-space: nowrap;
|
|
1011
1269
|
flex-shrink: 0;
|
|
1012
1270
|
}
|
|
1271
|
+
.model-pill-name {
|
|
1272
|
+
max-width: 50px;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
.thinking-pill {
|
|
1276
|
+
padding: 5px 7px;
|
|
1277
|
+
font-size: 11px;
|
|
1278
|
+
}
|
|
1013
1279
|
|
|
1014
|
-
/* Model Dropdown Menu Mobile Adjustments
|
|
1015
|
-
.model-menu {
|
|
1280
|
+
/* Model & Thinking Dropdown Menu Mobile Adjustments */
|
|
1281
|
+
.model-menu, .thinking-menu {
|
|
1016
1282
|
position: fixed;
|
|
1017
1283
|
top: 54px;
|
|
1018
1284
|
left: 8px;
|
|
@@ -1025,6 +1291,16 @@ body {
|
|
|
1025
1291
|
max-height: calc(100dvh - 130px);
|
|
1026
1292
|
}
|
|
1027
1293
|
|
|
1294
|
+
.empty-model-banner {
|
|
1295
|
+
padding: 10px 12px;
|
|
1296
|
+
flex-direction: column;
|
|
1297
|
+
align-items: flex-start;
|
|
1298
|
+
gap: 8px;
|
|
1299
|
+
}
|
|
1300
|
+
.btn-change-model {
|
|
1301
|
+
align-self: flex-end;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1028
1304
|
.mobile-toolbar-fab {
|
|
1029
1305
|
display: flex;
|
|
1030
1306
|
}
|
package/server.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// for listing sessions and reading session history from the JSONL store.
|
|
4
4
|
import { spawn } from "child_process";
|
|
5
5
|
import { randomUUID } from "crypto";
|
|
6
|
-
import { readFile, readdir, stat } from "fs/promises";
|
|
6
|
+
import { readFile, readdir, stat, writeFile, mkdir } from "fs/promises";
|
|
7
7
|
import { readFileSync, existsSync } from "fs";
|
|
8
8
|
import { StringDecoder } from "string_decoder";
|
|
9
9
|
import express from "express";
|
|
@@ -159,9 +159,13 @@ class PiAgent {
|
|
|
159
159
|
detachWs(ws) {
|
|
160
160
|
this.sockets.delete(ws);
|
|
161
161
|
if (this.sockets.size === 0) {
|
|
162
|
-
// Reset event buffer
|
|
163
|
-
|
|
164
|
-
|
|
162
|
+
// Reset event buffer only if NOT busy.
|
|
163
|
+
// If we are currently streaming/busy, we must retain the buffer so a reconnecting client
|
|
164
|
+
// can replay the full stream from the beginning of the active generation.
|
|
165
|
+
if (!this.isBusy) {
|
|
166
|
+
this.eventBuffer = [];
|
|
167
|
+
this.bufferHead = 0;
|
|
168
|
+
}
|
|
165
169
|
// Browser closed. We do NOT kill the subprocess here: a background task
|
|
166
170
|
// keeps running. We only arm the idle-kill, which fires once the agent
|
|
167
171
|
// is truly idle (no streaming, no pending requests) for IDLE_TIMEOUT_MS.
|
|
@@ -326,8 +330,11 @@ class PiAgent {
|
|
|
326
330
|
}
|
|
327
331
|
// Forward every event / response to connected browsers as-is.
|
|
328
332
|
this.wsSend(obj);
|
|
329
|
-
// If nobody is listening,
|
|
330
|
-
|
|
333
|
+
// If nobody is listening, or if the agent is currently busy (streaming), remember it
|
|
334
|
+
// so any reconnecting or newly connecting clients can replay and catch up.
|
|
335
|
+
if (!this.hasWs || this.isBusy) {
|
|
336
|
+
this.bufferEvent(obj);
|
|
337
|
+
}
|
|
331
338
|
}
|
|
332
339
|
|
|
333
340
|
bufferEvent(obj) {
|
|
@@ -353,9 +360,12 @@ class PiAgent {
|
|
|
353
360
|
try { ws.send(JSON.stringify(ev)); } catch {}
|
|
354
361
|
}
|
|
355
362
|
try { ws.send(JSON.stringify({ type: "backfill_end", streaming: this.isBusy, state: this.state })); } catch {}
|
|
356
|
-
//
|
|
357
|
-
|
|
358
|
-
this.
|
|
363
|
+
// Only clear the buffer if the agent is not busy.
|
|
364
|
+
// If the agent is still busy, keep the buffer so that other clients or future reconnects can still catch up.
|
|
365
|
+
if (!this.isBusy) {
|
|
366
|
+
this.eventBuffer = [];
|
|
367
|
+
this.bufferHead = 0;
|
|
368
|
+
}
|
|
359
369
|
}
|
|
360
370
|
|
|
361
371
|
send(cmd) {
|
|
@@ -457,15 +467,104 @@ app.post("/api/log-error", (req, res) => {
|
|
|
457
467
|
res.json({ ok: true });
|
|
458
468
|
});
|
|
459
469
|
|
|
460
|
-
//
|
|
461
|
-
|
|
470
|
+
// Helper to resolve pi settings (global and project-level)
|
|
471
|
+
async function getPiSettings(targetCwd) {
|
|
472
|
+
const globalPath = path.join(home(), ".pi", "agent", "settings.json");
|
|
473
|
+
let globalSettings = {};
|
|
474
|
+
let globalExists = false;
|
|
475
|
+
try {
|
|
476
|
+
if (existsSync(globalPath)) {
|
|
477
|
+
globalSettings = JSON.parse(await readFile(globalPath, "utf8"));
|
|
478
|
+
globalExists = true;
|
|
479
|
+
}
|
|
480
|
+
} catch {}
|
|
481
|
+
|
|
482
|
+
let projectSettings = {};
|
|
483
|
+
let projectExists = false;
|
|
484
|
+
if (targetCwd) {
|
|
485
|
+
try {
|
|
486
|
+
const pPath = path.join(targetCwd, ".pi", "settings.json");
|
|
487
|
+
if (existsSync(pPath)) {
|
|
488
|
+
projectSettings = JSON.parse(await readFile(pPath, "utf8"));
|
|
489
|
+
projectExists = true;
|
|
490
|
+
}
|
|
491
|
+
} catch {}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const defaultProvider = projectSettings.defaultProvider || globalSettings.defaultProvider || null;
|
|
495
|
+
const defaultModel = projectSettings.defaultModel || globalSettings.defaultModel || null;
|
|
496
|
+
const defaultThinkingLevel = projectSettings.defaultThinkingLevel || globalSettings.defaultThinkingLevel || null;
|
|
497
|
+
const source = (projectExists && projectSettings.defaultModel) ? "project" : ((globalExists && globalSettings.defaultModel) ? "global" : "default");
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
defaultProvider,
|
|
501
|
+
defaultModel,
|
|
502
|
+
defaultThinkingLevel,
|
|
503
|
+
source,
|
|
504
|
+
globalSettings,
|
|
505
|
+
projectSettings,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Endpoint to get server environment config (home dir, server process cwd, default model settings)
|
|
510
|
+
app.get("/api/config", async (req, res) => {
|
|
511
|
+
const reqCwd = normalizeCwd(req.query.cwd || "");
|
|
512
|
+
const settings = await getPiSettings(reqCwd);
|
|
462
513
|
res.json({
|
|
463
514
|
home: home(),
|
|
464
515
|
serverCwd: process.cwd(),
|
|
465
516
|
version: PKG_VERSION,
|
|
517
|
+
defaultModel: {
|
|
518
|
+
provider: settings.defaultProvider,
|
|
519
|
+
id: settings.defaultModel,
|
|
520
|
+
thinkingLevel: settings.defaultThinkingLevel,
|
|
521
|
+
source: settings.source,
|
|
522
|
+
},
|
|
466
523
|
});
|
|
467
524
|
});
|
|
468
525
|
|
|
526
|
+
// Endpoint to set default model in global or project settings.json
|
|
527
|
+
app.post("/api/set-default-model", async (req, res) => {
|
|
528
|
+
try {
|
|
529
|
+
const { provider, modelId, thinkingLevel, scope, cwd: reqCwd } = req.body;
|
|
530
|
+
if (!provider || !modelId) {
|
|
531
|
+
return res.status(400).json({ ok: false, error: "缺少 provider 或 modelId 参数" });
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const isProject = scope === "project" && reqCwd;
|
|
535
|
+
const settingsPath = isProject
|
|
536
|
+
? path.join(normalizeCwd(reqCwd), ".pi", "settings.json")
|
|
537
|
+
: path.join(home(), ".pi", "agent", "settings.json");
|
|
538
|
+
|
|
539
|
+
await mkdir(path.dirname(settingsPath), { recursive: true });
|
|
540
|
+
|
|
541
|
+
let current = {};
|
|
542
|
+
try {
|
|
543
|
+
if (existsSync(settingsPath)) {
|
|
544
|
+
current = JSON.parse(await readFile(settingsPath, "utf8"));
|
|
545
|
+
}
|
|
546
|
+
} catch {}
|
|
547
|
+
|
|
548
|
+
current.defaultProvider = provider;
|
|
549
|
+
current.defaultModel = modelId;
|
|
550
|
+
if (thinkingLevel !== undefined) {
|
|
551
|
+
current.defaultThinkingLevel = thinkingLevel;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
await writeFile(settingsPath, JSON.stringify(current, null, 2), "utf8");
|
|
555
|
+
|
|
556
|
+
res.json({
|
|
557
|
+
ok: true,
|
|
558
|
+
scope: isProject ? "project" : "global",
|
|
559
|
+
defaultProvider: provider,
|
|
560
|
+
defaultModel: modelId,
|
|
561
|
+
defaultThinkingLevel: current.defaultThinkingLevel || null,
|
|
562
|
+
});
|
|
563
|
+
} catch (err) {
|
|
564
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
|
|
469
568
|
// Endpoint to validate if a directory path exists on the server
|
|
470
569
|
app.get("/api/validate-dir", async (req, res) => {
|
|
471
570
|
const target = normalizeCwd(req.query.path || "");
|
|
@@ -793,6 +892,12 @@ wss.on("connection", (ws, req) => {
|
|
|
793
892
|
case "set_model":
|
|
794
893
|
agent.send({ type: "set_model", provider: msg.provider, modelId: msg.modelId });
|
|
795
894
|
break;
|
|
895
|
+
case "set_thinking_level":
|
|
896
|
+
agent.send({ type: "set_thinking_level", level: msg.level });
|
|
897
|
+
break;
|
|
898
|
+
case "cycle_thinking_level":
|
|
899
|
+
agent.send({ type: "cycle_thinking_level" });
|
|
900
|
+
break;
|
|
796
901
|
case "extension_ui_response":
|
|
797
902
|
agent.sendNoReply({ type: "extension_ui_response", ...msg });
|
|
798
903
|
break;
|