@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "1.7.6",
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() {
@@ -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
- 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
+ }
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) { state.currentModel = d.model; renderModelPill(); }
1403
- 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();
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
- 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
+
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
- pill.textContent = `${provider} / ${name}`;
1443
- 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
+ }
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
- const visibleOpts = listContainer.querySelectorAll(".opt");
1480
- if (visibleOpts.length > 0) {
1481
- 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();
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
- const active = state.currentModel && m.id === state.currentModel.id && m.provider === state.currentModel.provider;
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").addEventListener("click", (e) => {
1739
- e.stopPropagation();
1740
- sendWs({ type: "get_available_models" });
1741
- const menu = $("#modelMenu");
1742
- const willOpen = !menu.classList.contains("open");
1743
- menu.classList.toggle("open");
1744
- if (willOpen) {
1745
- setTimeout(() => {
1746
- const input = $("#modelSearchInput");
1747
- if (input) {
1748
- input.focus();
1749
- input.select();
1750
- }
1751
- }, 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();
1752
2187
  }
1753
2188
  });
2189
+
1754
2190
  document.addEventListener("click", (e) => {
1755
- if (!e.target.closest("#modelMenu") && !e.target.closest("#modelPill")) {
1756
- $("#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");
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 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";
@@ -467,15 +467,104 @@ app.post("/api/log-error", (req, res) => {
467
467
  res.json({ ok: true });
468
468
  });
469
469
 
470
- // Endpoint to get server environment config (home dir, server process cwd)
471
- 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);
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;