@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 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` 严密性,将原有对 `&`、`<`、`>` 的处理,扩展至双引号 `"` (`&quot;`) 与单引号 `'` (`&#39;`),阻断由于模型生成恶意链接等引起的 `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()` 实现,额外对 `"` (`&quot;`) 与 `'` (`&#39;`) 进行了严格转义,阻止任何 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "1.7.5",
3
+ "version": "1.8.0",
4
4
  "description": "A ChatGPT/Gemini-style web UI for the pi coding agent, powered by pi's RPC mode.",
5
5
  "type": "module",
6
6
  "main": "server.js",
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 res = await fetch(`${API}/api/config`);
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
220
+ return s.replace(/&/g, "&amp;")
221
+ .replace(/</g, "&lt;")
222
+ .replace(/>/g, "&gt;")
223
+ .replace(/"/g, "&quot;")
224
+ .replace(/'/g, "&#39;");
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
- showToast(`已切换模型: ${state.currentModel?.name || state.currentModel?.id || ""}`);
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) { state.currentModel = d.model; renderModelPill(); }
1399
- if (d?.thinkingLevel) state.thinkingLevel = d.thinkingLevel;
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
- if (!m) { pill.textContent = "选择模型"; return; }
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
- pill.textContent = `${provider} / ${name}`;
1439
- pill.title = `当前模型: ${provider} / ${name} (${m.id})`;
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
- const visibleOpts = listContainer.querySelectorAll(".opt");
1476
- if (visibleOpts.length > 0) {
1477
- visibleOpts[0].click();
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
- const active = state.currentModel && m.id === state.currentModel.id && m.provider === state.currentModel.provider;
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").addEventListener("click", (e) => {
1735
- e.stopPropagation();
1736
- sendWs({ type: "get_available_models" });
1737
- const menu = $("#modelMenu");
1738
- const willOpen = !menu.classList.contains("open");
1739
- menu.classList.toggle("open");
1740
- if (willOpen) {
1741
- setTimeout(() => {
1742
- const input = $("#modelSearchInput");
1743
- if (input) {
1744
- input.focus();
1745
- input.select();
1746
- }
1747
- }, 50);
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").classList.remove("open");
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 style="position:relative;">
52
- <button class="model-pill" id="modelPill">选择模型</button>
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
- .model-pill:hover { color: var(--text); border-color: var(--text-dim); }
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: 340px; max-height: 440px;
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: 360px;
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: 6px 8px 3px;
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 { background: var(--bg-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-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
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 10px;
1263
+ padding: 5px 8px;
1006
1264
  font-size: 11px;
1007
- max-width: 100px;
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 — fixed so it never detaches from topbar */
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 so we only capture events that happen while nobody is connected
163
- this.eventBuffer = [];
164
- this.bufferHead = 0;
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, remember it so a reconnect can replay.
330
- if (!this.hasWs) this.bufferEvent(obj);
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
- // Clear buffer once consumed by the reconnecting client
357
- this.eventBuffer = [];
358
- this.bufferHead = 0;
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
- // Endpoint to get server environment config (home dir, server process cwd)
461
- app.get("/api/config", (req, res) => {
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;