pi-web-ui 0.16.2 → 0.17.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/LICENSE +21 -21
- package/bin/pi-web-ui.mjs +0 -0
- package/deploy/com.xingshuyin.pi-web-ui.plist +48 -48
- package/deploy/nginx-subpath.conf +78 -78
- package/deploy/pi-web-ui-task.xml +71 -71
- package/deploy/pi-web-ui.service +31 -31
- package/dist/server/agent-service.js +220 -0
- package/dist/server/index.js +3 -0
- package/extensions/webui.ts +184 -184
- package/package.json +1 -1
- package/web/dist/assets/{index-CyPZoWiz.css → index-CPHCWdQg.css} +1 -1
- package/web/dist/assets/{index-BslXQ1Pt.js → index-z5ToqWmQ.js} +40 -39
- package/web/dist/favicon.svg +8 -8
- package/web/dist/index.html +15 -15
- package/web/public/favicon.svg +8 -8
|
@@ -832,6 +832,9 @@ export class ClientSession {
|
|
|
832
832
|
// left panel shows every background chat (a fresh socket never got the
|
|
833
833
|
// newChat/switch pushes).
|
|
834
834
|
this.emitConversations();
|
|
835
|
+
// Reconnect: same for the slash-command catalog (the picker needs it even
|
|
836
|
+
// before the client asks).
|
|
837
|
+
void this.pushSlashCommands();
|
|
835
838
|
}
|
|
836
839
|
detachSink(send) {
|
|
837
840
|
this.sinks.delete(send);
|
|
@@ -1652,11 +1655,226 @@ export class ClientSession {
|
|
|
1652
1655
|
}, SNAPSHOT_INTERVAL_MS);
|
|
1653
1656
|
}
|
|
1654
1657
|
// ---------------------------------------------------------------------------
|
|
1658
|
+
// Slash commands
|
|
1659
|
+
// ---------------------------------------------------------------------------
|
|
1660
|
+
/**
|
|
1661
|
+
* Slash commands implemented natively by the web server (the pi CLI's built-in
|
|
1662
|
+
* interactive commands like /model and /new are NOT handled by the SDK's
|
|
1663
|
+
* prompt() — without this they'd be sent to the model as plain text). Keep in
|
|
1664
|
+
* sync with execNativeCommand(). /help and /copy are client-side UI actions
|
|
1665
|
+
* (they never reach the server) but stay listed so the picker shows them.
|
|
1666
|
+
*/
|
|
1667
|
+
static NATIVE_COMMANDS = [
|
|
1668
|
+
{ name: "new", description: "新建对话" },
|
|
1669
|
+
{ name: "model", description: "切换模型", argumentHint: "[名称]" },
|
|
1670
|
+
{ name: "compact", description: "压缩上下文", argumentHint: "[说明]" },
|
|
1671
|
+
{ name: "cwd", description: "切换工作目录", argumentHint: "<路径>" },
|
|
1672
|
+
{
|
|
1673
|
+
name: "thinking",
|
|
1674
|
+
description: "设置思考强度",
|
|
1675
|
+
argumentHint: "<off|low|medium|high>",
|
|
1676
|
+
},
|
|
1677
|
+
{ name: "resume", description: "刷新会话列表" },
|
|
1678
|
+
{ name: "help", description: "显示全部命令" },
|
|
1679
|
+
{ name: "copy", description: "复制上一条助手回复" },
|
|
1680
|
+
];
|
|
1681
|
+
/** Parse a prompt into "/command args" — returns null when it isn't one. */
|
|
1682
|
+
parseSlash(text) {
|
|
1683
|
+
const trimmed = text.trim();
|
|
1684
|
+
if (!trimmed.startsWith("/"))
|
|
1685
|
+
return null;
|
|
1686
|
+
const m = trimmed.match(/^\/([^\s]+)\s*([\s\S]*)$/);
|
|
1687
|
+
if (!m || !m[1])
|
|
1688
|
+
return null;
|
|
1689
|
+
return { name: m[1], args: m[2].trim() };
|
|
1690
|
+
}
|
|
1691
|
+
/** Run a native slash command (see NATIVE_COMMANDS). Returns false when the
|
|
1692
|
+
* name is not a native command (the prompt falls through to the SDK). */
|
|
1693
|
+
async execNativeCommand(name, args) {
|
|
1694
|
+
switch (name) {
|
|
1695
|
+
case "new":
|
|
1696
|
+
await this.newChat();
|
|
1697
|
+
return true;
|
|
1698
|
+
case "model": {
|
|
1699
|
+
if (!args) {
|
|
1700
|
+
const current = this.session.model;
|
|
1701
|
+
this.emit({
|
|
1702
|
+
type: "notice",
|
|
1703
|
+
level: "info",
|
|
1704
|
+
text: current
|
|
1705
|
+
? `当前模型:${current.name}(${current.provider}/${current.id})。用法:/model <名称>`
|
|
1706
|
+
: `用法:/model <名称>`,
|
|
1707
|
+
});
|
|
1708
|
+
return true;
|
|
1709
|
+
}
|
|
1710
|
+
const query = args.toLowerCase();
|
|
1711
|
+
const available = await this.session.modelRuntime.getAvailable();
|
|
1712
|
+
// Prefer an exact "provider/id" match, else id/name substring.
|
|
1713
|
+
const exact = available.find((m) => m.provider + "/" + m.id === args.trim());
|
|
1714
|
+
const matches = exact
|
|
1715
|
+
? [exact]
|
|
1716
|
+
: available.filter((m) => m.id.toLowerCase().includes(query) ||
|
|
1717
|
+
m.name.toLowerCase().includes(query) ||
|
|
1718
|
+
m.provider.toLowerCase().includes(query));
|
|
1719
|
+
if (matches.length === 0) {
|
|
1720
|
+
this.emit({
|
|
1721
|
+
type: "notice",
|
|
1722
|
+
level: "error",
|
|
1723
|
+
text: `没有匹配到模型:${args}(可用模型见顶栏模型列表)`,
|
|
1724
|
+
});
|
|
1725
|
+
return true;
|
|
1726
|
+
}
|
|
1727
|
+
const pick = matches[0];
|
|
1728
|
+
if (matches.length > 1) {
|
|
1729
|
+
this.emit({
|
|
1730
|
+
type: "notice",
|
|
1731
|
+
level: "warning",
|
|
1732
|
+
text: `找到 ${matches.length} 个匹配模型,已选用:${pick.name}(精确匹配请用 provider/id)`,
|
|
1733
|
+
});
|
|
1734
|
+
}
|
|
1735
|
+
await this.setModel(`${pick.provider}/${pick.id}`);
|
|
1736
|
+
return true;
|
|
1737
|
+
}
|
|
1738
|
+
case "compact":
|
|
1739
|
+
try {
|
|
1740
|
+
await this.session.compact(args || undefined);
|
|
1741
|
+
}
|
|
1742
|
+
catch (err) {
|
|
1743
|
+
this.emit({
|
|
1744
|
+
type: "notice",
|
|
1745
|
+
level: "error",
|
|
1746
|
+
text: `压缩上下文失败:${err.message}`,
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
return true;
|
|
1750
|
+
case "cwd":
|
|
1751
|
+
if (!args) {
|
|
1752
|
+
this.emit({
|
|
1753
|
+
type: "notice",
|
|
1754
|
+
level: "info",
|
|
1755
|
+
text: `当前工作目录:${this.cwd}。用法:/cwd <路径>`,
|
|
1756
|
+
});
|
|
1757
|
+
}
|
|
1758
|
+
else {
|
|
1759
|
+
await this.setCwd(args);
|
|
1760
|
+
}
|
|
1761
|
+
return true;
|
|
1762
|
+
case "thinking": {
|
|
1763
|
+
const ALIAS = {
|
|
1764
|
+
off: "off",
|
|
1765
|
+
minimal: "minimal",
|
|
1766
|
+
low: "low",
|
|
1767
|
+
medium: "medium",
|
|
1768
|
+
high: "high",
|
|
1769
|
+
xhigh: "xhigh",
|
|
1770
|
+
max: "max",
|
|
1771
|
+
关闭: "off",
|
|
1772
|
+
极简: "minimal",
|
|
1773
|
+
低: "low",
|
|
1774
|
+
中: "medium",
|
|
1775
|
+
高: "high",
|
|
1776
|
+
极高: "xhigh",
|
|
1777
|
+
最大: "max",
|
|
1778
|
+
};
|
|
1779
|
+
const level = ALIAS[args.trim().toLowerCase()];
|
|
1780
|
+
if (!level) {
|
|
1781
|
+
this.emit({
|
|
1782
|
+
type: "notice",
|
|
1783
|
+
level: "error",
|
|
1784
|
+
text: `无效的思考强度:${args || "(空)"}。可用:off / minimal / low / medium / high / xhigh / max`,
|
|
1785
|
+
});
|
|
1786
|
+
return true;
|
|
1787
|
+
}
|
|
1788
|
+
this.setThinking(level);
|
|
1789
|
+
return true;
|
|
1790
|
+
}
|
|
1791
|
+
case "resume":
|
|
1792
|
+
await this.refreshSessions();
|
|
1793
|
+
this.emit({
|
|
1794
|
+
type: "notice",
|
|
1795
|
+
level: "info",
|
|
1796
|
+
text: "会话列表已刷新,请在左侧「历史对话」中选择",
|
|
1797
|
+
});
|
|
1798
|
+
return true;
|
|
1799
|
+
case "help":
|
|
1800
|
+
case "copy":
|
|
1801
|
+
// Client-side UI actions — the client handles them before sending;
|
|
1802
|
+
// swallow here so the SDK never sees them as plain prompt text.
|
|
1803
|
+
return true;
|
|
1804
|
+
default:
|
|
1805
|
+
return false;
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
/**
|
|
1809
|
+
* Catalog of slash commands for the chat input: web-native builtins first,
|
|
1810
|
+
* then the SDK's invokable commands for the ACTIVE conversation (extension
|
|
1811
|
+
* commands, prompt templates, skills) — the same set the SDK expands when a
|
|
1812
|
+
* prompt text starts with "/" (see AgentSession.prompt).
|
|
1813
|
+
*/
|
|
1814
|
+
async pushSlashCommands() {
|
|
1815
|
+
const commands = [];
|
|
1816
|
+
const seen = new Set();
|
|
1817
|
+
for (const c of ClientSession.NATIVE_COMMANDS) {
|
|
1818
|
+
commands.push({ ...c, source: "builtin" });
|
|
1819
|
+
seen.add(c.name);
|
|
1820
|
+
}
|
|
1821
|
+
try {
|
|
1822
|
+
const s = this.session;
|
|
1823
|
+
// Extension commands — the SDK already suffixes collisions with builtin
|
|
1824
|
+
// names ("new:2"), and those still reach the SDK since execNativeCommand
|
|
1825
|
+
// only intercepts the exact native names.
|
|
1826
|
+
for (const cmd of s.extensionRunner.getRegisteredCommands()) {
|
|
1827
|
+
if (seen.has(cmd.invocationName))
|
|
1828
|
+
continue;
|
|
1829
|
+
commands.push({
|
|
1830
|
+
name: cmd.invocationName,
|
|
1831
|
+
description: cmd.description,
|
|
1832
|
+
source: "extension",
|
|
1833
|
+
});
|
|
1834
|
+
seen.add(cmd.invocationName);
|
|
1835
|
+
}
|
|
1836
|
+
// Prompt templates: /templatename args
|
|
1837
|
+
for (const t of s.promptTemplates) {
|
|
1838
|
+
if (seen.has(t.name))
|
|
1839
|
+
continue;
|
|
1840
|
+
commands.push({
|
|
1841
|
+
name: t.name,
|
|
1842
|
+
description: t.description,
|
|
1843
|
+
source: "prompt",
|
|
1844
|
+
});
|
|
1845
|
+
seen.add(t.name);
|
|
1846
|
+
}
|
|
1847
|
+
// Skills: /skill:name args
|
|
1848
|
+
for (const skill of s.resourceLoader.getSkills().skills) {
|
|
1849
|
+
const name = `skill:${skill.name}`;
|
|
1850
|
+
if (seen.has(name))
|
|
1851
|
+
continue;
|
|
1852
|
+
commands.push({
|
|
1853
|
+
name,
|
|
1854
|
+
description: skill.description,
|
|
1855
|
+
source: "skill",
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
catch {
|
|
1860
|
+
// Session not ready yet — native-only catalog still serves the picker.
|
|
1861
|
+
}
|
|
1862
|
+
this.emit({ type: "slash_commands", commands });
|
|
1863
|
+
}
|
|
1864
|
+
// ---------------------------------------------------------------------------
|
|
1655
1865
|
// Commands
|
|
1656
1866
|
// ---------------------------------------------------------------------------
|
|
1657
1867
|
async prompt(text, attachments) {
|
|
1658
1868
|
try {
|
|
1659
1869
|
const s = this.session;
|
|
1870
|
+
// Native slash commands (see NATIVE_COMMANDS) are executed here and
|
|
1871
|
+
// never reach the SDK. Extension / skill / template commands fall
|
|
1872
|
+
// through — AgentSession.prompt() handles those itself.
|
|
1873
|
+
const slash = this.parseSlash(text);
|
|
1874
|
+
if (slash && (await this.execNativeCommand(slash.name, slash.args))) {
|
|
1875
|
+
this.flushSnapshot();
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1660
1878
|
// Attach files as independent nextTurn context messages (asides) so the
|
|
1661
1879
|
// user message stays clean; they render as separate attachment cards.
|
|
1662
1880
|
const asides = await this.buildAttachmentMessages(attachments);
|
|
@@ -2766,6 +2984,8 @@ export class ClientSession {
|
|
|
2766
2984
|
void this.pushProjects();
|
|
2767
2985
|
this.webUi.refresh();
|
|
2768
2986
|
this.emitConversations();
|
|
2987
|
+
// Skills / prompt templates are project-bound — refresh the catalog.
|
|
2988
|
+
void this.pushSlashCommands();
|
|
2769
2989
|
this.emit({
|
|
2770
2990
|
type: "notice",
|
|
2771
2991
|
level: "info",
|
package/dist/server/index.js
CHANGED
package/extensions/webui.ts
CHANGED
|
@@ -1,184 +1,184 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* pi-web-ui 的 pi 扩展 —— 提供命令行集成。
|
|
3
|
-
*
|
|
4
|
-
* 能力:
|
|
5
|
-
* /webui 启动本机 pi-web-ui 服务器,打开浏览器访问
|
|
6
|
-
* /webui --port 9000 指定端口启动
|
|
7
|
-
* /webui --no-browser 启动但不开浏览器
|
|
8
|
-
* /webui stop 停止已启动的服务器
|
|
9
|
-
* /webui status 查看运行状态 / URL
|
|
10
|
-
*
|
|
11
|
-
* 实现说明:
|
|
12
|
-
* - 不依赖全局 bin(pi install 后 pi-web-ui 命令不一定在 PATH),直接用
|
|
13
|
-
* node 调包内 dist/server/index.js,通过环境变量 PORT / PI_WEB_CWD /
|
|
14
|
-
* PI_WEB_DATA_DIR 控制。
|
|
15
|
-
* - 工作目录默认用当前 pi 会话的 ctx.cwd;可用 --cwd / path 覆盖。
|
|
16
|
-
* - 服务器作为子进程后台运行,/webui 不阻塞 pi。
|
|
17
|
-
* - 每个 pi 会话管理一个子进程;session_shutdown 时清理,避免孤儿进程。
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import { spawn } from "node:child_process";
|
|
21
|
-
import { existsSync } from "node:fs";
|
|
22
|
-
import { dirname, join, resolve } from "node:path";
|
|
23
|
-
import { fileURLToPath } from "node:url";
|
|
24
|
-
import net from "node:net";
|
|
25
|
-
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
26
|
-
|
|
27
|
-
// 本文件位于 <pkg>/extensions/webui.ts → 包根在上一级
|
|
28
|
-
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
29
|
-
const SERVER_ENTRY = join(PKG_ROOT, "dist", "server", "index.js");
|
|
30
|
-
const NODE = process.execPath;
|
|
31
|
-
|
|
32
|
-
/** 每个会话的服务器子进程 + 元数据 */
|
|
33
|
-
interface RunningServer {
|
|
34
|
-
proc: ReturnType<typeof spawn>;
|
|
35
|
-
port: number;
|
|
36
|
-
cwd: string;
|
|
37
|
-
url: string;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// 会话 → 运行实例(模块级 Map;每会话一个会话对象,无需清理全局)
|
|
41
|
-
const running = new Map<string, RunningServer>();
|
|
42
|
-
|
|
43
|
-
/** 找一个空闲端口 */
|
|
44
|
-
function findFreePort(from = 8787): Promise<number> {
|
|
45
|
-
return new Promise((resolve_, reject) => {
|
|
46
|
-
const srv = net.createServer();
|
|
47
|
-
srv.listen(from, () => {
|
|
48
|
-
const port = (srv.address() as net.AddressInfo).port;
|
|
49
|
-
srv.close(() => resolve_(port));
|
|
50
|
-
});
|
|
51
|
-
srv.on("error", () => {
|
|
52
|
-
// 端口被占则顺延
|
|
53
|
-
findFreePort(from + 1).then(resolve_, reject);
|
|
54
|
-
});
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** 解析 --key value / --flag 参数 */
|
|
59
|
-
function parseArgs(args: string): { port?: number; cwd?: string; noBrowser: boolean } {
|
|
60
|
-
const out: { port?: number; cwd?: string; noBrowser: boolean } = { noBrowser: false };
|
|
61
|
-
const toks = args.split(/\s+/).filter(Boolean);
|
|
62
|
-
for (let i = 0; i < toks.length; i++) {
|
|
63
|
-
const t = toks[i];
|
|
64
|
-
if ((t === "--port" || t === "-p") && toks[i + 1]) {
|
|
65
|
-
const n = Number(toks[++i]);
|
|
66
|
-
if (Number.isInteger(n) && n > 0 && n < 65536) out.port = n;
|
|
67
|
-
} else if ((t === "--cwd") && toks[i + 1]) {
|
|
68
|
-
out.cwd = resolve(toks[++i]);
|
|
69
|
-
} else if (t === "--no-browser") {
|
|
70
|
-
out.noBrowser = true;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
return out;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** 打开浏览器 */
|
|
77
|
-
async function openBrowser(url: string): Promise<void> {
|
|
78
|
-
const { platform } = process;
|
|
79
|
-
const cmd =
|
|
80
|
-
platform === "darwin"
|
|
81
|
-
? ["open", url]
|
|
82
|
-
: platform === "win32"
|
|
83
|
-
? ["cmd", "/c", "start", "", url]
|
|
84
|
-
: ["xdg-open", url];
|
|
85
|
-
try {
|
|
86
|
-
spawn(cmd[0], cmd.slice(1), { stdio: "ignore", detached: true }).unref();
|
|
87
|
-
} catch {
|
|
88
|
-
/* 忽略打开失败(headless 等场景) */
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export default function (pi: ExtensionAPI): void {
|
|
93
|
-
pi.registerCommand("webui", {
|
|
94
|
-
description: "启动本机 pi-web-ui Web 界面(/webui [--port N] [--cwd PATH] [--no-browser] | stop | status)",
|
|
95
|
-
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
96
|
-
const sid = ctx.sessionManager.getSessionId();
|
|
97
|
-
const opts = parseArgs(args);
|
|
98
|
-
const action = (args.split(/\s+/)[0] || "start").toLowerCase();
|
|
99
|
-
|
|
100
|
-
// 停止
|
|
101
|
-
if (action === "stop" || action === "kill") {
|
|
102
|
-
const inst = running.get(sid);
|
|
103
|
-
if (!inst) {
|
|
104
|
-
ctx.ui.notify("没有正在运行的本机 pi-web-ui 服务器", "info");
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
inst.proc.kill("SIGTERM");
|
|
108
|
-
running.delete(sid);
|
|
109
|
-
ctx.ui.notify(`已停止 pi-web-ui (${inst.url})`, "info");
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// 状态
|
|
114
|
-
if (action === "status") {
|
|
115
|
-
const inst = running.get(sid);
|
|
116
|
-
if (!inst) {
|
|
117
|
-
ctx.ui.notify("本机 pi-web-ui 未运行", "info");
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
const alive = inst.proc.exitCode === null;
|
|
121
|
-
ctx.ui.notify(
|
|
122
|
-
alive ? `pi-web-ui 运行中 → ${inst.url}\n端口 ${inst.port} · cwd ${inst.cwd}` : `已退出(exit=${inst.proc.exitCode})`,
|
|
123
|
-
alive ? "info" : "warning",
|
|
124
|
-
);
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// 默认 start
|
|
129
|
-
if (action !== "start" && action !== "run") {
|
|
130
|
-
ctx.ui.notify(`未知动作 ${action}(可用 start|stop|status)`, "warning");
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// 已运行则提示
|
|
135
|
-
const existing = running.get(sid);
|
|
136
|
-
if (existing && existing.proc.exitCode === null) {
|
|
137
|
-
ctx.ui.notify(`pi-web-ui 已在运行 → ${existing.url}`, "info");
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// 检查是否已构建
|
|
142
|
-
if (!existsSync(SERVER_ENTRY)) {
|
|
143
|
-
ctx.ui.notify(
|
|
144
|
-
"缺少 dist/ 产物(当前安装未包含已构建前端)。请运行 `npm run build` 后重试,或用 pi-web-ui 官方 npm 包。",
|
|
145
|
-
"warning",
|
|
146
|
-
);
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const port = opts.port ?? (await findFreePort());
|
|
151
|
-
const cwd = opts.cwd ?? ctx.cwd;
|
|
152
|
-
const url = `http://localhost:${port}`;
|
|
153
|
-
|
|
154
|
-
const env = {
|
|
155
|
-
...process.env,
|
|
156
|
-
PORT: String(port),
|
|
157
|
-
PI_WEB_CWD: cwd,
|
|
158
|
-
...(process.env.PI_WEB_DATA_DIR ? {} : { PI_WEB_DATA_DIR: join(cwd, ".pi-web") }),
|
|
159
|
-
};
|
|
160
|
-
const proc = spawn(NODE, [SERVER_ENTRY], { cwd, env, stdio: "ignore", detached: true });
|
|
161
|
-
proc.unref();
|
|
162
|
-
running.set(sid, { proc, port, cwd, url });
|
|
163
|
-
|
|
164
|
-
ctx.ui.notify(`pi-web-ui 启动中 → ${url}\n端口 ${port} · cwd ${cwd}\n(几秒后可用,/webui status 查看)`);
|
|
165
|
-
|
|
166
|
-
if (!opts.noBrowser) await openBrowser(url);
|
|
167
|
-
|
|
168
|
-
// 进程退出时清理
|
|
169
|
-
proc.on("exit", () => {
|
|
170
|
-
if (running.get(sid)?.proc === proc) running.delete(sid);
|
|
171
|
-
});
|
|
172
|
-
},
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
// 会话结束清理子进程,避免孤儿
|
|
176
|
-
pi.on("session_shutdown", async (_event, ctx) => {
|
|
177
|
-
const sid = ctx.sessionManager.getSessionId();
|
|
178
|
-
const inst = running.get(sid);
|
|
179
|
-
if (inst && inst.proc.exitCode === null) {
|
|
180
|
-
inst.proc.kill("SIGTERM");
|
|
181
|
-
running.delete(sid);
|
|
182
|
-
}
|
|
183
|
-
});
|
|
184
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* pi-web-ui 的 pi 扩展 —— 提供命令行集成。
|
|
3
|
+
*
|
|
4
|
+
* 能力:
|
|
5
|
+
* /webui 启动本机 pi-web-ui 服务器,打开浏览器访问
|
|
6
|
+
* /webui --port 9000 指定端口启动
|
|
7
|
+
* /webui --no-browser 启动但不开浏览器
|
|
8
|
+
* /webui stop 停止已启动的服务器
|
|
9
|
+
* /webui status 查看运行状态 / URL
|
|
10
|
+
*
|
|
11
|
+
* 实现说明:
|
|
12
|
+
* - 不依赖全局 bin(pi install 后 pi-web-ui 命令不一定在 PATH),直接用
|
|
13
|
+
* node 调包内 dist/server/index.js,通过环境变量 PORT / PI_WEB_CWD /
|
|
14
|
+
* PI_WEB_DATA_DIR 控制。
|
|
15
|
+
* - 工作目录默认用当前 pi 会话的 ctx.cwd;可用 --cwd / path 覆盖。
|
|
16
|
+
* - 服务器作为子进程后台运行,/webui 不阻塞 pi。
|
|
17
|
+
* - 每个 pi 会话管理一个子进程;session_shutdown 时清理,避免孤儿进程。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawn } from "node:child_process";
|
|
21
|
+
import { existsSync } from "node:fs";
|
|
22
|
+
import { dirname, join, resolve } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import net from "node:net";
|
|
25
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
26
|
+
|
|
27
|
+
// 本文件位于 <pkg>/extensions/webui.ts → 包根在上一级
|
|
28
|
+
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
29
|
+
const SERVER_ENTRY = join(PKG_ROOT, "dist", "server", "index.js");
|
|
30
|
+
const NODE = process.execPath;
|
|
31
|
+
|
|
32
|
+
/** 每个会话的服务器子进程 + 元数据 */
|
|
33
|
+
interface RunningServer {
|
|
34
|
+
proc: ReturnType<typeof spawn>;
|
|
35
|
+
port: number;
|
|
36
|
+
cwd: string;
|
|
37
|
+
url: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 会话 → 运行实例(模块级 Map;每会话一个会话对象,无需清理全局)
|
|
41
|
+
const running = new Map<string, RunningServer>();
|
|
42
|
+
|
|
43
|
+
/** 找一个空闲端口 */
|
|
44
|
+
function findFreePort(from = 8787): Promise<number> {
|
|
45
|
+
return new Promise((resolve_, reject) => {
|
|
46
|
+
const srv = net.createServer();
|
|
47
|
+
srv.listen(from, () => {
|
|
48
|
+
const port = (srv.address() as net.AddressInfo).port;
|
|
49
|
+
srv.close(() => resolve_(port));
|
|
50
|
+
});
|
|
51
|
+
srv.on("error", () => {
|
|
52
|
+
// 端口被占则顺延
|
|
53
|
+
findFreePort(from + 1).then(resolve_, reject);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 解析 --key value / --flag 参数 */
|
|
59
|
+
function parseArgs(args: string): { port?: number; cwd?: string; noBrowser: boolean } {
|
|
60
|
+
const out: { port?: number; cwd?: string; noBrowser: boolean } = { noBrowser: false };
|
|
61
|
+
const toks = args.split(/\s+/).filter(Boolean);
|
|
62
|
+
for (let i = 0; i < toks.length; i++) {
|
|
63
|
+
const t = toks[i];
|
|
64
|
+
if ((t === "--port" || t === "-p") && toks[i + 1]) {
|
|
65
|
+
const n = Number(toks[++i]);
|
|
66
|
+
if (Number.isInteger(n) && n > 0 && n < 65536) out.port = n;
|
|
67
|
+
} else if ((t === "--cwd") && toks[i + 1]) {
|
|
68
|
+
out.cwd = resolve(toks[++i]);
|
|
69
|
+
} else if (t === "--no-browser") {
|
|
70
|
+
out.noBrowser = true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 打开浏览器 */
|
|
77
|
+
async function openBrowser(url: string): Promise<void> {
|
|
78
|
+
const { platform } = process;
|
|
79
|
+
const cmd =
|
|
80
|
+
platform === "darwin"
|
|
81
|
+
? ["open", url]
|
|
82
|
+
: platform === "win32"
|
|
83
|
+
? ["cmd", "/c", "start", "", url]
|
|
84
|
+
: ["xdg-open", url];
|
|
85
|
+
try {
|
|
86
|
+
spawn(cmd[0], cmd.slice(1), { stdio: "ignore", detached: true }).unref();
|
|
87
|
+
} catch {
|
|
88
|
+
/* 忽略打开失败(headless 等场景) */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export default function (pi: ExtensionAPI): void {
|
|
93
|
+
pi.registerCommand("webui", {
|
|
94
|
+
description: "启动本机 pi-web-ui Web 界面(/webui [--port N] [--cwd PATH] [--no-browser] | stop | status)",
|
|
95
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
96
|
+
const sid = ctx.sessionManager.getSessionId();
|
|
97
|
+
const opts = parseArgs(args);
|
|
98
|
+
const action = (args.split(/\s+/)[0] || "start").toLowerCase();
|
|
99
|
+
|
|
100
|
+
// 停止
|
|
101
|
+
if (action === "stop" || action === "kill") {
|
|
102
|
+
const inst = running.get(sid);
|
|
103
|
+
if (!inst) {
|
|
104
|
+
ctx.ui.notify("没有正在运行的本机 pi-web-ui 服务器", "info");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
inst.proc.kill("SIGTERM");
|
|
108
|
+
running.delete(sid);
|
|
109
|
+
ctx.ui.notify(`已停止 pi-web-ui (${inst.url})`, "info");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 状态
|
|
114
|
+
if (action === "status") {
|
|
115
|
+
const inst = running.get(sid);
|
|
116
|
+
if (!inst) {
|
|
117
|
+
ctx.ui.notify("本机 pi-web-ui 未运行", "info");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const alive = inst.proc.exitCode === null;
|
|
121
|
+
ctx.ui.notify(
|
|
122
|
+
alive ? `pi-web-ui 运行中 → ${inst.url}\n端口 ${inst.port} · cwd ${inst.cwd}` : `已退出(exit=${inst.proc.exitCode})`,
|
|
123
|
+
alive ? "info" : "warning",
|
|
124
|
+
);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 默认 start
|
|
129
|
+
if (action !== "start" && action !== "run") {
|
|
130
|
+
ctx.ui.notify(`未知动作 ${action}(可用 start|stop|status)`, "warning");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 已运行则提示
|
|
135
|
+
const existing = running.get(sid);
|
|
136
|
+
if (existing && existing.proc.exitCode === null) {
|
|
137
|
+
ctx.ui.notify(`pi-web-ui 已在运行 → ${existing.url}`, "info");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 检查是否已构建
|
|
142
|
+
if (!existsSync(SERVER_ENTRY)) {
|
|
143
|
+
ctx.ui.notify(
|
|
144
|
+
"缺少 dist/ 产物(当前安装未包含已构建前端)。请运行 `npm run build` 后重试,或用 pi-web-ui 官方 npm 包。",
|
|
145
|
+
"warning",
|
|
146
|
+
);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const port = opts.port ?? (await findFreePort());
|
|
151
|
+
const cwd = opts.cwd ?? ctx.cwd;
|
|
152
|
+
const url = `http://localhost:${port}`;
|
|
153
|
+
|
|
154
|
+
const env = {
|
|
155
|
+
...process.env,
|
|
156
|
+
PORT: String(port),
|
|
157
|
+
PI_WEB_CWD: cwd,
|
|
158
|
+
...(process.env.PI_WEB_DATA_DIR ? {} : { PI_WEB_DATA_DIR: join(cwd, ".pi-web") }),
|
|
159
|
+
};
|
|
160
|
+
const proc = spawn(NODE, [SERVER_ENTRY], { cwd, env, stdio: "ignore", detached: true });
|
|
161
|
+
proc.unref();
|
|
162
|
+
running.set(sid, { proc, port, cwd, url });
|
|
163
|
+
|
|
164
|
+
ctx.ui.notify(`pi-web-ui 启动中 → ${url}\n端口 ${port} · cwd ${cwd}\n(几秒后可用,/webui status 查看)`);
|
|
165
|
+
|
|
166
|
+
if (!opts.noBrowser) await openBrowser(url);
|
|
167
|
+
|
|
168
|
+
// 进程退出时清理
|
|
169
|
+
proc.on("exit", () => {
|
|
170
|
+
if (running.get(sid)?.proc === proc) running.delete(sid);
|
|
171
|
+
});
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// 会话结束清理子进程,避免孤儿
|
|
176
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
177
|
+
const sid = ctx.sessionManager.getSessionId();
|
|
178
|
+
const inst = running.get(sid);
|
|
179
|
+
if (inst && inst.proc.exitCode === null) {
|
|
180
|
+
inst.proc.kill("SIGTERM");
|
|
181
|
+
running.delete(sid);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-web-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Web chat interface for the pi coding agent, powered by the pi SDK (@earendil-works/pi-coding-agent) — one-command run, Docker/systemd/launchd deployable",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|