@liguoshuai/pi-web-chat 1.7.6 → 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 +23 -0
- package/package.json +1 -1
- package/public/app.js +481 -40
- package/public/index.html +24 -2
- package/public/style.css +288 -12
- package/server.js +98 -3
package/docs/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,29 @@ 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
|
+
|
|
10
33
|
## [1.7.6] - 2026-07-26
|
|
11
34
|
|
|
12
35
|
### Security
|
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() {
|
|
@@ -1149,16 +1189,36 @@ function handlePiMessage(obj) {
|
|
|
1149
1189
|
else if (obj.command === "get_available_models" && obj.success) updateModels(obj.data.models || []);
|
|
1150
1190
|
else if (obj.command === "set_model") {
|
|
1151
1191
|
if (obj.success) {
|
|
1192
|
+
const prev = state.currentModel;
|
|
1152
1193
|
if (obj.data) state.currentModel = obj.data;
|
|
1194
|
+
saveRecentModel(state.currentModel);
|
|
1153
1195
|
renderModelPill();
|
|
1154
1196
|
renderModelMenu();
|
|
1155
|
-
|
|
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
|
+
}
|
|
1156
1202
|
sendWs({ type: "get_state" });
|
|
1157
1203
|
} else {
|
|
1158
1204
|
showToast(`切换模型失败: ${obj.error || "未知错误"}`);
|
|
1159
1205
|
renderModelPill();
|
|
1160
1206
|
}
|
|
1161
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
|
+
}
|
|
1162
1222
|
else if (obj.command === "switch_session" && obj.success) {
|
|
1163
1223
|
// ask pi for current state so we can get session id, name
|
|
1164
1224
|
sendWs({ type: "get_state" });
|
|
@@ -1181,8 +1241,10 @@ function handlePiMessage(obj) {
|
|
|
1181
1241
|
case "model_select":
|
|
1182
1242
|
if (obj.model) {
|
|
1183
1243
|
state.currentModel = obj.model;
|
|
1244
|
+
saveRecentModel(obj.model);
|
|
1184
1245
|
renderModelPill();
|
|
1185
1246
|
renderModelMenu();
|
|
1247
|
+
updateEmptyStateModelInfo();
|
|
1186
1248
|
}
|
|
1187
1249
|
break;
|
|
1188
1250
|
case "remote_user_prompt":
|
|
@@ -1399,8 +1461,16 @@ function handleEntries(entries, leafId) { /* no-op: history rendered via REST */
|
|
|
1399
1461
|
function updateState(d) {
|
|
1400
1462
|
if (d?.sessionFile) state.currentSessionFile = d.sessionFile;
|
|
1401
1463
|
if (d?.sessionId) state.sessionId = d.sessionId;
|
|
1402
|
-
if (d?.model) {
|
|
1403
|
-
|
|
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();
|
|
1404
1474
|
$("#topSessionName").textContent = d?.sessionName || (d?.sessionFile ? baseName(d.sessionFile) : "新对话");
|
|
1405
1475
|
|
|
1406
1476
|
// Sync streaming state upon state updates (e.g. after reconnect)
|
|
@@ -1426,10 +1496,41 @@ function updateModels(models) {
|
|
|
1426
1496
|
renderModelPill();
|
|
1427
1497
|
}
|
|
1428
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
|
+
|
|
1429
1516
|
function renderModelPill() {
|
|
1430
1517
|
const m = state.currentModel;
|
|
1431
1518
|
const pill = $("#modelPill");
|
|
1432
|
-
|
|
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
|
+
|
|
1433
1534
|
const provider = m.provider || "?";
|
|
1434
1535
|
let name = m.name || m.id;
|
|
1435
1536
|
if (state.models && state.models.length > 0) {
|
|
@@ -1439,11 +1540,203 @@ function renderModelPill() {
|
|
|
1439
1540
|
name = found.name;
|
|
1440
1541
|
}
|
|
1441
1542
|
}
|
|
1442
|
-
|
|
1443
|
-
|
|
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
|
+
}
|
|
1444
1736
|
}
|
|
1445
1737
|
|
|
1446
1738
|
let modelSearchQuery = "";
|
|
1739
|
+
let focusedModelIndex = -1;
|
|
1447
1740
|
|
|
1448
1741
|
function renderModelMenu() {
|
|
1449
1742
|
const menu = $("#modelMenu");
|
|
@@ -1468,17 +1761,36 @@ function renderModelMenu() {
|
|
|
1468
1761
|
searchInput.addEventListener("click", (e) => e.stopPropagation());
|
|
1469
1762
|
searchInput.addEventListener("input", (e) => {
|
|
1470
1763
|
modelSearchQuery = e.target.value;
|
|
1764
|
+
focusedModelIndex = -1;
|
|
1471
1765
|
renderModelList(listContainer);
|
|
1472
1766
|
const clearBtn = $(".btn-clear-model-search", searchWrap);
|
|
1473
1767
|
if (clearBtn) clearBtn.style.display = modelSearchQuery ? "block" : "none";
|
|
1474
1768
|
});
|
|
1475
1769
|
searchInput.addEventListener("keydown", (e) => {
|
|
1770
|
+
const items = listContainer._items || [];
|
|
1476
1771
|
if (e.key === "Escape") {
|
|
1477
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
|
+
}
|
|
1478
1789
|
} else if (e.key === "Enter") {
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1790
|
+
if (focusedModelIndex >= 0 && items[focusedModelIndex]) {
|
|
1791
|
+
items[focusedModelIndex].element.click();
|
|
1792
|
+
} else if (items.length > 0) {
|
|
1793
|
+
items[0].element.click();
|
|
1482
1794
|
}
|
|
1483
1795
|
}
|
|
1484
1796
|
});
|
|
@@ -1494,6 +1806,7 @@ function renderModelMenu() {
|
|
|
1494
1806
|
modelSearchQuery = "";
|
|
1495
1807
|
searchInput.value = "";
|
|
1496
1808
|
clearBtn.style.display = "none";
|
|
1809
|
+
focusedModelIndex = -1;
|
|
1497
1810
|
searchInput.focus();
|
|
1498
1811
|
renderModelList(listContainer);
|
|
1499
1812
|
}
|
|
@@ -1525,9 +1838,88 @@ function renderModelList(listContainer) {
|
|
|
1525
1838
|
|
|
1526
1839
|
if (filteredModels.length === 0) {
|
|
1527
1840
|
listContainer.appendChild(el("div", { class: "model-empty", text: "未找到匹配的模型" }));
|
|
1841
|
+
listContainer._items = [];
|
|
1528
1842
|
return;
|
|
1529
1843
|
}
|
|
1530
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
|
|
1531
1923
|
const groups = {};
|
|
1532
1924
|
for (const m of filteredModels) {
|
|
1533
1925
|
const p = m.provider || "other";
|
|
@@ -1537,21 +1929,48 @@ function renderModelList(listContainer) {
|
|
|
1537
1929
|
for (const [provider, items] of Object.entries(groups).sort()) {
|
|
1538
1930
|
listContainer.appendChild(el("div", { class: "group-label", text: provider }));
|
|
1539
1931
|
for (const m of items) {
|
|
1540
|
-
|
|
1541
|
-
listContainer.appendChild(el("div", {
|
|
1542
|
-
class: "opt" + (active ? " active" : ""),
|
|
1543
|
-
onclick: () => {
|
|
1544
|
-
$("#modelPill").textContent = "切换中…";
|
|
1545
|
-
sendWs({ type: "set_model", provider: m.provider, modelId: m.id });
|
|
1546
|
-
$("#modelMenu").classList.remove("open");
|
|
1547
|
-
},
|
|
1548
|
-
}, [
|
|
1549
|
-
el("span", { class: "check", html: active ? "✓ " : "" }),
|
|
1550
|
-
el("span", { class: "model-name", text: `${m.name || m.id}` }),
|
|
1551
|
-
(m.name && m.id && m.name !== m.id) ? el("span", { class: "model-id-sub", text: m.id }) : null,
|
|
1552
|
-
]));
|
|
1932
|
+
listContainer.appendChild(renderModelOption(m));
|
|
1553
1933
|
}
|
|
1554
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");
|
|
1555
1974
|
}
|
|
1556
1975
|
|
|
1557
1976
|
function baseName(p) {
|
|
@@ -1598,6 +2017,7 @@ function updateComposerUI() {
|
|
|
1598
2017
|
|
|
1599
2018
|
function setComposerAborting(yes) {
|
|
1600
2019
|
updateComposerUI();
|
|
2020
|
+
renderModelPill();
|
|
1601
2021
|
}
|
|
1602
2022
|
|
|
1603
2023
|
function submitSteer() {
|
|
@@ -1734,26 +2154,47 @@ async function init() {
|
|
|
1734
2154
|
}
|
|
1735
2155
|
});
|
|
1736
2156
|
|
|
1737
|
-
// model pill / menu
|
|
1738
|
-
$("#modelPill")
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
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();
|
|
1752
2187
|
}
|
|
1753
2188
|
});
|
|
2189
|
+
|
|
1754
2190
|
document.addEventListener("click", (e) => {
|
|
1755
|
-
if (!e.target.closest("#modelMenu") && !e.target.closest("#modelPill")) {
|
|
1756
|
-
$("#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");
|
|
1757
2198
|
}
|
|
1758
2199
|
});
|
|
1759
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";
|
|
@@ -467,15 +467,104 @@ app.post("/api/log-error", (req, res) => {
|
|
|
467
467
|
res.json({ ok: true });
|
|
468
468
|
});
|
|
469
469
|
|
|
470
|
-
//
|
|
471
|
-
|
|
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);
|
|
472
513
|
res.json({
|
|
473
514
|
home: home(),
|
|
474
515
|
serverCwd: process.cwd(),
|
|
475
516
|
version: PKG_VERSION,
|
|
517
|
+
defaultModel: {
|
|
518
|
+
provider: settings.defaultProvider,
|
|
519
|
+
id: settings.defaultModel,
|
|
520
|
+
thinkingLevel: settings.defaultThinkingLevel,
|
|
521
|
+
source: settings.source,
|
|
522
|
+
},
|
|
476
523
|
});
|
|
477
524
|
});
|
|
478
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
|
+
|
|
479
568
|
// Endpoint to validate if a directory path exists on the server
|
|
480
569
|
app.get("/api/validate-dir", async (req, res) => {
|
|
481
570
|
const target = normalizeCwd(req.query.path || "");
|
|
@@ -803,6 +892,12 @@ wss.on("connection", (ws, req) => {
|
|
|
803
892
|
case "set_model":
|
|
804
893
|
agent.send({ type: "set_model", provider: msg.provider, modelId: msg.modelId });
|
|
805
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;
|
|
806
901
|
case "extension_ui_response":
|
|
807
902
|
agent.sendNoReply({ type: "extension_ui_response", ...msg });
|
|
808
903
|
break;
|