@liguoshuai/pi-web-chat 1.7.3 → 1.7.5
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/README.md +8 -0
- package/bin/pi-web-chat.js +34 -6
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CHANGELOG.md +24 -0
- package/docs/ISSUES.md +23 -0
- package/package.json +1 -1
- package/public/app.js +108 -15
- package/public/style.css +92 -10
- package/server.js +1 -1
package/README.md
CHANGED
|
@@ -154,6 +154,10 @@ pi-web-chat/
|
|
|
154
154
|
│ ├── index.html 单页 UI
|
|
155
155
|
│ ├── app.js 前端逻辑与状态机
|
|
156
156
|
│ └── style.css ChatGPT/Gemini 风格样式
|
|
157
|
+
├── scripts/ Systemd 服务安装与管理脚本
|
|
158
|
+
│ ├── pi-web-chat.service Systemd user unit 模板
|
|
159
|
+
│ ├── install-service.sh 一键安装与启动脚本
|
|
160
|
+
│ └── uninstall-service.sh 一键卸载脚本
|
|
157
161
|
└── docs/ 项目文档库
|
|
158
162
|
├── ARCHITECTURE.md 架构设计文档
|
|
159
163
|
├── DESIGN.md 详细设计与决策文档
|
|
@@ -210,6 +214,10 @@ journalctl --user -u pi-web-chat -f
|
|
|
210
214
|
|
|
211
215
|
卸载服务:
|
|
212
216
|
```bash
|
|
217
|
+
# 使用一键卸载脚本
|
|
218
|
+
./scripts/uninstall-service.sh
|
|
219
|
+
|
|
220
|
+
# 或手动清理:
|
|
213
221
|
systemctl --user disable --now pi-web-chat
|
|
214
222
|
rm ~/.config/systemd/user/pi-web-chat.service
|
|
215
223
|
systemctl --user daemon-reload
|
package/bin/pi-web-chat.js
CHANGED
|
@@ -37,15 +37,43 @@ Examples:
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
function parseArgs(argv) {
|
|
40
|
-
const opts = { port: process.env.PORT || 3000, cwd: process.env.HOME };
|
|
40
|
+
const opts = { port: process.env.PORT || 3000, cwd: process.env.HOME || os.homedir() };
|
|
41
41
|
for (let i = 0; i < argv.length; i++) {
|
|
42
42
|
const a = argv[i];
|
|
43
43
|
if (a === "-h" || a === "--help") { printHelp(); process.exit(0); }
|
|
44
|
-
if (a === "-p" || a === "--port")
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
if (a === "-p" || a === "--port") {
|
|
45
|
+
const val = argv[++i];
|
|
46
|
+
if (!val || isNaN(Number(val))) {
|
|
47
|
+
console.error(`Error: --port requires a valid number`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
opts.port = Number(val);
|
|
51
|
+
} else if (a.startsWith("--port=")) {
|
|
52
|
+
const val = a.split("=")[1];
|
|
53
|
+
if (!val || isNaN(Number(val))) {
|
|
54
|
+
console.error(`Error: --port requires a valid number`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
opts.port = Number(val);
|
|
58
|
+
} else if (a === "-c" || a === "--cwd") {
|
|
59
|
+
const val = argv[++i];
|
|
60
|
+
if (!val) {
|
|
61
|
+
console.error(`Error: --cwd requires a path`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
opts.cwd = val;
|
|
65
|
+
} else if (a.startsWith("--cwd=")) {
|
|
66
|
+
const val = a.split("=")[1];
|
|
67
|
+
if (!val) {
|
|
68
|
+
console.error(`Error: --cwd requires a path`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
opts.cwd = val;
|
|
72
|
+
} else {
|
|
73
|
+
console.error(`Unknown option: ${a}`);
|
|
74
|
+
printHelp();
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
49
77
|
}
|
|
50
78
|
return opts;
|
|
51
79
|
}
|
package/docs/ARCHITECTURE.md
CHANGED
package/docs/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [1.7.5] - 2026-07-26
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **初始化 CWD 配置加载与 WebSocket 建立竞态修复 (Async CWD Init Race Condition)**:
|
|
14
|
+
- 前端 `init()` 声明为异步函数,优先 `await loadServerConfig()` 完成服务器配置(`serverCwd` / `homeDir`)与本地缓存读取后,再发起 WebSocket 连接或加载会话,彻底消除首次加载时 agent 子进程工作目录与前端显示不一致的问题。
|
|
15
|
+
- 服务端 `normalizeCwd(dir)` 在 `dir` 为空时默认使用 `process.cwd()`(服务启动目录),保证前后端默认工作目录始终精确统一。
|
|
16
|
+
- **错误信息二次 HTML 转义修复 (Double Escape on Error Messages)**:移除 `message_end` 中冗余的 `escapeHtml()` 调用,避免错误信息包含特殊符号时被 Markdown 渲染器二次转义显示为实体编码字符。
|
|
17
|
+
- **CLI 参数解析健壮性增强 (CLI Arguments Validation)**:`bin/pi-web-chat.js` 增加对 `-p/--port` 与 `-c/--cwd` 选项值的合法性检验与防越界保护,避免 `NaN` 或 `undefined`。
|
|
18
|
+
- **输入法合成事件优化 (IME Keycode Handling)**:输入框 `keydown` 事件追加 `e.keyCode !== 229` 判断,进一步增强各平台中文输入法选词回车时的兼容性。
|
|
19
|
+
- **Systemd 安装脚本优化 (Systemd Absolute ExecStart Path)**:`scripts/install-service.sh` 生成 unit 文件时将 `ExecStart` 明确为绝对路径 `$NODE_BIN "$PROJECT_DIR/server.js"`。
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## [1.7.4] - 2026-07-26
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- **模型菜单实时搜索与过滤 (Model Search & Instant Filter)**:
|
|
27
|
+
- 顶栏模型下拉菜单增加置顶固定的搜索过滤输入框,支持根据 Provider 提供商名称、模型名称、模型 ID 进行多维度实时模糊过滤。
|
|
28
|
+
- 打开模型下拉框时自动聚焦并全选搜索框,支持一键清空(`×`)、按 `Escape` 快捷关闭、按 `Enter` 快捷选中。
|
|
29
|
+
- 优化模型列表项布局,同时展示友好显示名与底层 Model ID,解决模型列表过长时翻找困难的问题。
|
|
30
|
+
- 移动端与桌面端自适应滚动与粘性搜索栏布局。
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
10
34
|
## [1.7.3] - 2026-07-26
|
|
11
35
|
|
|
12
36
|
### Fixed
|
package/docs/ISSUES.md
CHANGED
|
@@ -99,4 +99,27 @@
|
|
|
99
99
|
- 给 `.session-list` 增加 `min-height: 0`,给 `.sidebar-bottom` 增加 `flex-shrink: 0`。
|
|
100
100
|
- 引入 `safe-area-inset-bottom` 底部安全区自适应内边距,并将 z-index 提升至 105,确保手机端完整可见。
|
|
101
101
|
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## 10. 页面初次加载时服务端配置加载与 WebSocket 连接的竞态
|
|
105
|
+
|
|
106
|
+
**症状**:在未设置 localStorage CWD 时打开页面,前端顶栏胶囊显示为当前服务启动目录(`serverCwd`),但后端生成的 agent 子进程的工作目录实际上是用户的 home 目录(`~`),导致执行文件操作或命令时位置不一致。
|
|
107
|
+
|
|
108
|
+
**根因**:前端 `init()` 中 `loadServerConfig()` 异步发起 `/api/config` 请求,但没有等待返回就同步调用了 `connectWs({})`。此时 `state.cwd` 尚为空字符串 `""`,WebSocket URL 为 `/ws?cwd=`,后端 fallback 成了 `home()`。等到 fetch 返回后 `state.cwd` 虽被赋予 `serverCwd`,但 WebSocket 连接和 `PiAgent` 已经按 `home()` 创建。
|
|
109
|
+
|
|
110
|
+
**修复**:
|
|
111
|
+
- 前端 `init()` 改为异步函数,显式 `await loadServerConfig()` 保证配置解析完毕后再建立 WebSocket 连接或加载会话。
|
|
112
|
+
- 后端 `normalizeCwd(dir)` 在 `dir` 为空时优先返回 `process.cwd()`,其次回退到 `home()`,确保前后端默认工作目录始终精确一致。
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## 11. 模型异常响应错误信息二次转义 (Double Escape)
|
|
117
|
+
|
|
118
|
+
**症状**:当模型返回错误(如 stopReason="error")且错误信息中含有 `<`、`&` 等字符时,聊天框中显示为 `<`、`&` 字面量。
|
|
119
|
+
|
|
120
|
+
**根因**:`public/app.js` 在 `message_end` 处理中对 `errMsg` 调用了一次 `escapeHtml()`,随后 `refreshStreamingContent()` 调用 `renderMarkdown()` 时又内部进行了一次 `escapeHtml()`。
|
|
121
|
+
|
|
122
|
+
**修复**:移除 `message_end` 中冗余的 `escapeHtml()`,统一由 Markdown 渲染器进行安全转义。
|
|
123
|
+
|
|
124
|
+
|
|
102
125
|
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -1226,7 +1226,7 @@ function handlePiMessage(obj) {
|
|
|
1226
1226
|
const parsed = JSON.parse(errMsg);
|
|
1227
1227
|
if (parsed.error?.message) errMsg = parsed.error.message;
|
|
1228
1228
|
} catch {}
|
|
1229
|
-
state.streamingItems.push({ type: "text", text: `⚠️ **${
|
|
1229
|
+
state.streamingItems.push({ type: "text", text: `⚠️ **${errMsg}**` });
|
|
1230
1230
|
refreshStreamingContent();
|
|
1231
1231
|
}
|
|
1232
1232
|
break;
|
|
@@ -1439,29 +1439,112 @@ function renderModelPill() {
|
|
|
1439
1439
|
pill.title = `当前模型: ${provider} / ${name} (${m.id})`;
|
|
1440
1440
|
}
|
|
1441
1441
|
|
|
1442
|
+
let modelSearchQuery = "";
|
|
1443
|
+
|
|
1442
1444
|
function renderModelMenu() {
|
|
1443
1445
|
const menu = $("#modelMenu");
|
|
1444
|
-
menu
|
|
1445
|
-
|
|
1446
|
+
if (!menu) return;
|
|
1447
|
+
|
|
1448
|
+
let searchInput = $("#modelSearchInput", menu);
|
|
1449
|
+
let listContainer = $(".model-menu-list", menu);
|
|
1450
|
+
|
|
1451
|
+
if (!searchInput || !listContainer) {
|
|
1452
|
+
menu.innerHTML = "";
|
|
1453
|
+
const searchWrap = el("div", { class: "model-search-wrap" });
|
|
1454
|
+
searchInput = el("input", {
|
|
1455
|
+
type: "text",
|
|
1456
|
+
id: "modelSearchInput",
|
|
1457
|
+
class: "model-search-input",
|
|
1458
|
+
placeholder: "搜索模型 (如 claude, deepseek, 4o)…",
|
|
1459
|
+
autocomplete: "off",
|
|
1460
|
+
spellcheck: "false",
|
|
1461
|
+
value: modelSearchQuery,
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
searchInput.addEventListener("click", (e) => e.stopPropagation());
|
|
1465
|
+
searchInput.addEventListener("input", (e) => {
|
|
1466
|
+
modelSearchQuery = e.target.value;
|
|
1467
|
+
renderModelList(listContainer);
|
|
1468
|
+
const clearBtn = $(".btn-clear-model-search", searchWrap);
|
|
1469
|
+
if (clearBtn) clearBtn.style.display = modelSearchQuery ? "block" : "none";
|
|
1470
|
+
});
|
|
1471
|
+
searchInput.addEventListener("keydown", (e) => {
|
|
1472
|
+
if (e.key === "Escape") {
|
|
1473
|
+
menu.classList.remove("open");
|
|
1474
|
+
} else if (e.key === "Enter") {
|
|
1475
|
+
const visibleOpts = listContainer.querySelectorAll(".opt");
|
|
1476
|
+
if (visibleOpts.length > 0) {
|
|
1477
|
+
visibleOpts[0].click();
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1482
|
+
const clearBtn = el("button", {
|
|
1483
|
+
class: "btn-clear-model-search",
|
|
1484
|
+
text: "×",
|
|
1485
|
+
type: "button",
|
|
1486
|
+
title: "清空搜索",
|
|
1487
|
+
style: `display: ${modelSearchQuery ? "block" : "none"}`,
|
|
1488
|
+
onclick: (e) => {
|
|
1489
|
+
e.stopPropagation();
|
|
1490
|
+
modelSearchQuery = "";
|
|
1491
|
+
searchInput.value = "";
|
|
1492
|
+
clearBtn.style.display = "none";
|
|
1493
|
+
searchInput.focus();
|
|
1494
|
+
renderModelList(listContainer);
|
|
1495
|
+
}
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
searchWrap.appendChild(searchInput);
|
|
1499
|
+
searchWrap.appendChild(clearBtn);
|
|
1500
|
+
menu.appendChild(searchWrap);
|
|
1501
|
+
|
|
1502
|
+
listContainer = el("div", { class: "model-menu-list" });
|
|
1503
|
+
menu.appendChild(listContainer);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
renderModelList(listContainer);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
function renderModelList(listContainer) {
|
|
1510
|
+
if (!listContainer) return;
|
|
1511
|
+
listContainer.innerHTML = "";
|
|
1512
|
+
const q = modelSearchQuery.trim().toLowerCase();
|
|
1513
|
+
|
|
1514
|
+
const filteredModels = state.models.filter(m => {
|
|
1515
|
+
if (!q) return true;
|
|
1516
|
+
const idMatch = (m.id || "").toLowerCase().includes(q);
|
|
1517
|
+
const nameMatch = (m.name || "").toLowerCase().includes(q);
|
|
1518
|
+
const providerMatch = (m.provider || "").toLowerCase().includes(q);
|
|
1519
|
+
return idMatch || nameMatch || providerMatch;
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
if (filteredModels.length === 0) {
|
|
1523
|
+
listContainer.appendChild(el("div", { class: "model-empty", text: "未找到匹配的模型" }));
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1446
1527
|
const groups = {};
|
|
1447
|
-
for (const m of
|
|
1528
|
+
for (const m of filteredModels) {
|
|
1448
1529
|
const p = m.provider || "other";
|
|
1449
1530
|
(groups[p] = groups[p] || []).push(m);
|
|
1450
1531
|
}
|
|
1532
|
+
|
|
1451
1533
|
for (const [provider, items] of Object.entries(groups).sort()) {
|
|
1452
|
-
|
|
1534
|
+
listContainer.appendChild(el("div", { class: "group-label", text: provider }));
|
|
1453
1535
|
for (const m of items) {
|
|
1454
1536
|
const active = state.currentModel && m.id === state.currentModel.id && m.provider === state.currentModel.provider;
|
|
1455
|
-
|
|
1537
|
+
listContainer.appendChild(el("div", {
|
|
1456
1538
|
class: "opt" + (active ? " active" : ""),
|
|
1457
1539
|
onclick: () => {
|
|
1458
1540
|
$("#modelPill").textContent = "切换中…";
|
|
1459
1541
|
sendWs({ type: "set_model", provider: m.provider, modelId: m.id });
|
|
1460
|
-
|
|
1542
|
+
$("#modelMenu").classList.remove("open");
|
|
1461
1543
|
},
|
|
1462
1544
|
}, [
|
|
1463
1545
|
el("span", { class: "check", html: active ? "✓ " : "" }),
|
|
1464
|
-
|
|
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,
|
|
1465
1548
|
]));
|
|
1466
1549
|
}
|
|
1467
1550
|
}
|
|
@@ -1585,11 +1668,13 @@ function autoResize() {
|
|
|
1585
1668
|
}
|
|
1586
1669
|
|
|
1587
1670
|
// ---- Init ----
|
|
1588
|
-
function init() {
|
|
1671
|
+
async function init() {
|
|
1589
1672
|
// Default cwd to home (server uses home default too).
|
|
1590
1673
|
state.cwd = document.body.dataset.cwd || "";
|
|
1591
1674
|
|
|
1592
|
-
//
|
|
1675
|
+
// Load server config & restore saved CWD before connecting WebSocket
|
|
1676
|
+
await loadServerConfig();
|
|
1677
|
+
|
|
1593
1678
|
// event listeners
|
|
1594
1679
|
$("#btnNew").addEventListener("click", () => {
|
|
1595
1680
|
if (state.streaming) {
|
|
@@ -1633,7 +1718,7 @@ function init() {
|
|
|
1633
1718
|
updateComposerUI();
|
|
1634
1719
|
});
|
|
1635
1720
|
ta.addEventListener("keydown", (e) => {
|
|
1636
|
-
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
|
|
1721
|
+
if (e.key === "Enter" && !e.shiftKey && !e.isComposing && e.keyCode !== 229) {
|
|
1637
1722
|
e.preventDefault();
|
|
1638
1723
|
if (state.streaming) {
|
|
1639
1724
|
if (ta.value.trim()) {
|
|
@@ -1649,7 +1734,18 @@ function init() {
|
|
|
1649
1734
|
$("#modelPill").addEventListener("click", (e) => {
|
|
1650
1735
|
e.stopPropagation();
|
|
1651
1736
|
sendWs({ type: "get_available_models" });
|
|
1652
|
-
$("#modelMenu")
|
|
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);
|
|
1748
|
+
}
|
|
1653
1749
|
});
|
|
1654
1750
|
document.addEventListener("click", (e) => {
|
|
1655
1751
|
if (!e.target.closest("#modelMenu") && !e.target.closest("#modelPill")) {
|
|
@@ -1732,9 +1828,6 @@ function init() {
|
|
|
1732
1828
|
});
|
|
1733
1829
|
}
|
|
1734
1830
|
|
|
1735
|
-
// Load server config & restore saved CWD
|
|
1736
|
-
loadServerConfig();
|
|
1737
|
-
|
|
1738
1831
|
// Status badge click to reconnect
|
|
1739
1832
|
const connStatusEl = $("#connStatus");
|
|
1740
1833
|
if (connStatusEl) {
|
package/public/style.css
CHANGED
|
@@ -798,18 +798,97 @@ body {
|
|
|
798
798
|
/* Model selector dropdown */
|
|
799
799
|
.model-menu {
|
|
800
800
|
position: absolute; top: 52px; right: 16px;
|
|
801
|
-
background: var(--bg-
|
|
802
|
-
border-radius:
|
|
803
|
-
box-shadow: 0
|
|
801
|
+
background: var(--bg-sidebar); border: 1px solid var(--border);
|
|
802
|
+
border-radius: 14px; padding: 0; width: 340px; max-height: 440px;
|
|
803
|
+
box-shadow: 0 12px 32px rgba(0,0,0,.5); z-index: 200; display: none;
|
|
804
|
+
flex-direction: column; overflow: hidden;
|
|
804
805
|
}
|
|
805
|
-
.model-menu.open { display:
|
|
806
|
-
|
|
807
|
-
.model-
|
|
808
|
-
padding: 8px 10px;
|
|
806
|
+
.model-menu.open { display: flex; }
|
|
807
|
+
|
|
808
|
+
.model-search-wrap {
|
|
809
|
+
padding: 8px 10px;
|
|
810
|
+
border-bottom: 1px solid var(--border);
|
|
811
|
+
background: var(--bg-sidebar);
|
|
812
|
+
position: relative;
|
|
813
|
+
display: flex;
|
|
814
|
+
align-items: center;
|
|
815
|
+
flex-shrink: 0;
|
|
816
|
+
}
|
|
817
|
+
.model-search-input {
|
|
818
|
+
width: 100%;
|
|
819
|
+
background: var(--bg-input);
|
|
820
|
+
border: 1px solid var(--border);
|
|
821
|
+
border-radius: 8px;
|
|
822
|
+
padding: 7px 12px;
|
|
823
|
+
padding-right: 28px;
|
|
824
|
+
color: var(--text);
|
|
825
|
+
font-size: 13px;
|
|
826
|
+
outline: none;
|
|
827
|
+
transition: border-color 0.15s ease;
|
|
828
|
+
}
|
|
829
|
+
.model-search-input:focus {
|
|
830
|
+
border-color: var(--accent);
|
|
831
|
+
}
|
|
832
|
+
.btn-clear-model-search {
|
|
833
|
+
position: absolute;
|
|
834
|
+
right: 18px;
|
|
835
|
+
background: transparent;
|
|
836
|
+
border: none;
|
|
837
|
+
color: var(--text-muted);
|
|
838
|
+
font-size: 16px;
|
|
839
|
+
cursor: pointer;
|
|
840
|
+
line-height: 1;
|
|
841
|
+
padding: 2px;
|
|
842
|
+
}
|
|
843
|
+
.btn-clear-model-search:hover {
|
|
844
|
+
color: var(--text);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
.model-menu-list {
|
|
848
|
+
flex: 1;
|
|
849
|
+
min-height: 0;
|
|
850
|
+
overflow-y: auto;
|
|
851
|
+
padding: 6px;
|
|
852
|
+
max-height: 360px;
|
|
853
|
+
}
|
|
854
|
+
.model-menu-list .group-label {
|
|
855
|
+
font-size: 11px;
|
|
856
|
+
color: var(--text-dim);
|
|
857
|
+
text-transform: uppercase;
|
|
858
|
+
padding: 6px 8px 3px;
|
|
859
|
+
font-weight: 600;
|
|
860
|
+
letter-spacing: 0.5px;
|
|
861
|
+
}
|
|
862
|
+
.model-menu-list .opt {
|
|
863
|
+
padding: 8px 10px;
|
|
864
|
+
border-radius: 8px;
|
|
865
|
+
cursor: pointer;
|
|
866
|
+
font-size: 13px;
|
|
867
|
+
color: var(--text);
|
|
868
|
+
display: flex;
|
|
869
|
+
align-items: center;
|
|
870
|
+
gap: 6px;
|
|
871
|
+
transition: background 0.12s;
|
|
872
|
+
}
|
|
873
|
+
.model-menu-list .opt:hover { background: var(--bg-hover); }
|
|
874
|
+
.model-menu-list .opt.active { background: var(--bg-hover); color: var(--accent); }
|
|
875
|
+
.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; }
|
|
877
|
+
.model-menu-list .opt .model-id-sub {
|
|
878
|
+
font-size: 11px;
|
|
879
|
+
color: var(--text-dim);
|
|
880
|
+
font-family: "SF Mono", Menlo, Consolas, monospace;
|
|
881
|
+
max-width: 120px;
|
|
882
|
+
overflow: hidden;
|
|
883
|
+
text-overflow: ellipsis;
|
|
884
|
+
white-space: nowrap;
|
|
885
|
+
}
|
|
886
|
+
.model-empty {
|
|
887
|
+
padding: 24px 12px;
|
|
888
|
+
text-align: center;
|
|
889
|
+
font-size: 13px;
|
|
890
|
+
color: var(--text-dim);
|
|
809
891
|
}
|
|
810
|
-
.model-menu .opt:hover { background: var(--bg-hover); }
|
|
811
|
-
.model-menu .opt.active { background: var(--bg-hover); }
|
|
812
|
-
.model-menu .opt .check { color: var(--accent); margin-right: 6px; }
|
|
813
892
|
|
|
814
893
|
|
|
815
894
|
/* ==========================================================================
|
|
@@ -942,6 +1021,9 @@ body {
|
|
|
942
1021
|
max-height: calc(100dvh - 70px);
|
|
943
1022
|
border-radius: 12px;
|
|
944
1023
|
}
|
|
1024
|
+
.model-menu-list {
|
|
1025
|
+
max-height: calc(100dvh - 130px);
|
|
1026
|
+
}
|
|
945
1027
|
|
|
946
1028
|
.mobile-toolbar-fab {
|
|
947
1029
|
display: flex;
|
package/server.js
CHANGED
|
@@ -37,7 +37,7 @@ const PI_BIN = resolvePiBin();
|
|
|
37
37
|
function home() { return os.homedir(); }
|
|
38
38
|
|
|
39
39
|
function normalizeCwd(dir) {
|
|
40
|
-
if (!dir) return home();
|
|
40
|
+
if (!dir) return process.cwd() || home();
|
|
41
41
|
let resolved = dir;
|
|
42
42
|
if (dir.startsWith("~")) {
|
|
43
43
|
resolved = path.join(home(), dir.slice(1));
|