@oversk7/pi-web-dsh-style 1.0.0 → 1.0.1
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 +39 -3
- package/frp-process.ts +135 -0
- package/index.ts +35 -3
- package/local-session.ts +10 -2
- package/network-access.ts +171 -0
- package/network-settings.ts +146 -0
- package/package.json +5 -2
- package/server.ts +204 -32
- package/web/app.css +79 -2
- package/web/app.js +437 -44
- package/web/index.html +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
采用 DeepSeek Harness(DSH)视觉风格、面向 Windows 的非官方 Pi Coding Agent 本地浏览器界面扩展。后端使用 Pi 的公开 RPC 模式,每个会话对应独立的 RPC 进程;历史会话可直接从 Pi 会话文件恢复和渲染。
|
|
4
4
|
|
|
5
|
-
>
|
|
5
|
+
> 仅承诺 Windows 10/11。首次启动只监听 `127.0.0.1`;电脑网页的“设置 → 手机访问”可配置仅本机、局域网和服务器中转。之后 `pi --web` 使用已保存的模式。
|
|
6
6
|
|
|
7
7
|
## 安装
|
|
8
8
|
|
|
@@ -34,9 +34,41 @@ pi --web
|
|
|
34
34
|
/web
|
|
35
35
|
/web --no-open
|
|
36
36
|
/web --port 18789
|
|
37
|
+
/web --lan
|
|
38
|
+
/web --relay
|
|
39
|
+
/web --local
|
|
37
40
|
/web --stop
|
|
38
41
|
```
|
|
39
42
|
|
|
43
|
+
### 手机通过局域网访问
|
|
44
|
+
|
|
45
|
+
电脑和手机连接同一 Wi-Fi,在电脑本机网页“设置 → 手机访问”中选择“局域网”并保存。也可以执行 `/web --lan`,或直接以 `pi --web-lan` 启动。首次使用时,在同一设置中设置固定密码;之后手机打开显示的地址,输入该密码即可登录。界面只显示一个手机访问地址,优先使用 Wi-Fi,并过滤 WSL、Hyper-V 等虚拟网卡。
|
|
46
|
+
|
|
47
|
+
手机登录状态保留 30 天。密码在服务重启后继续有效,可在电脑本机网页中修改;修改密码后,已登录的手机需要重新登录。
|
|
48
|
+
|
|
49
|
+
手机与电脑共用工作区和会话,可各自切换、发送消息、停止生成及回答扩展提问。手机上点击左上角按钮展开会话列表;输入框回车换行,点击发送按钮提交;图片按钮选择手机图片。手机切回前台或网络重连时会刷新会话内容。
|
|
50
|
+
|
|
51
|
+
设置面板及 `/web --lan`、`/web --relay`、`/web --local` 会保存模式并切换正在运行的服务,保留后台会话进程。切换时浏览器实时连接会重新连接。执行 `/reload` 会保留端口和访问模式;重载或重启服务会保留密码和未过期的登录状态。
|
|
52
|
+
|
|
53
|
+
首次开启时,如 Windows 弹出防火墙提示,请允许 Node.js 在当前可信网络上接收连接。若手机仍无法打开,检查防火墙是否允许该端口,以及 Wi-Fi 是否启用了客户端隔离。电脑需要保持运行且未休眠。
|
|
54
|
+
|
|
55
|
+
局域网直连使用 HTTP,请只在可信局域网内使用。
|
|
56
|
+
|
|
57
|
+
### 通过服务器中转
|
|
58
|
+
|
|
59
|
+
电脑运行扩展和原生 `frpc`,服务器运行 `frps`、作为 STCP 访问端的 `frpc` 以及 Nginx。手机通过服务器的 HTTPS 域名访问,适用于校园网客户端隔离等无法直连的网络。电脑上的 `frpc` 由扩展启动和关闭,中转请求直接进入扩展的 Web 服务。
|
|
60
|
+
|
|
61
|
+
1. 从 [frp 官方 Releases](https://github.com/fatedier/frp/releases) 下载电脑对应架构的 Windows 版本,将 `frpc.exe` 解压到固定位置。客户端和服务器建议使用相同版本(已验证 0.70.1)。
|
|
62
|
+
2. 在电脑网页“设置 → 手机访问”选择“服务器中转”,填写服务器域名或 IPv4、frps 端口(默认 `7000`)、frps 认证密钥、手机 HTTPS 入口(如 `https://web.example.com`),以及 `frpc.exe` 路径。路径留空时从 PATH 查找 `frpc`;相对路径以状态目录为基准。认证密钥留空会保留已保存的值。
|
|
63
|
+
3. 保存后,下载设置中生成的 `frps.toml`、`visitor.toml` 和 `nginx.conf`。隧道名称和私有密钥自动生成;隧道名称及服务器内部端口可在高级配置中调整。将域名 DNS 指向服务器,并为该域名准备有效的 HTTPS 证书。
|
|
64
|
+
4. 服务器安装原生 frp 和 Nginx。新服务器使用 `frps -c frps.toml`,访问端使用 `frpc -c visitor.toml`,并用 systemd 等服务管理器保持运行。已有 frps 时复用现有端口和认证密钥,保留其配置。把生成的 Nginx 配置中的证书路径替换成实际证书路径,再安装到 Nginx 的站点配置目录,执行 `nginx -t` 验证并重载。
|
|
65
|
+
|
|
66
|
+
手机打开显示的 HTTPS 地址,输入原有固定密码即可使用。设置会显示 frpc 的连接或错误状态;服务器暂时不可达时 frpc 自动重连,本机网页仍可使用。后续执行 `pi --web` 会自动恢复保存的中转模式;也可以用 `pi --web-relay` 或 `/web --relay` 明确启用已配置的中转。
|
|
67
|
+
|
|
68
|
+
服务器需开放 HTTPS 端口和 frps 控制端口;生成的 STCP 访问端仅监听服务器 `127.0.0.1`,无须开放其内部端口。Nginx 配置保留域名 Host、附加代理密钥、限制登录请求速率,并关闭缓冲以支持实时消息。公网访问由扩展直接验证来源和固定密码,登录 Cookie 带有 `Secure` 标记。中转模式下,电脑 Web 服务仅监听本机回环地址。
|
|
69
|
+
|
|
70
|
+
网络配置和服务器配置下载仅对电脑本机网页开放。更换服务器域名、隧道名称、认证密钥或服务器内部端口后,需要同步部署重新下载的服务器配置。电脑上的 Pi Web 需要保持运行且未休眠。
|
|
71
|
+
|
|
40
72
|
## 功能
|
|
41
73
|
|
|
42
74
|
- 多工作区、多会话和后台会话并行运行。
|
|
@@ -45,14 +77,14 @@ pi --web
|
|
|
45
77
|
- 模型、思考等级、中断、压缩和扩展交互对话框。
|
|
46
78
|
- 运行中逐条显示待处理消息,可单条撤回并重新编辑;`Alt+Q` 可将全部队列消息恢复到输入框。
|
|
47
79
|
- 会话历史保留完整分支与检查点,支持回溯后返回切换前的位置,以及 `pi-rewind` 文件恢复。
|
|
48
|
-
- Windows
|
|
80
|
+
- Windows 系统剪贴板图片和文本粘贴;手机支持选择或粘贴本机图片。
|
|
49
81
|
- 会话中的本地文件链接可在右侧分栏预览,支持语法高亮、行定位和拖拽调整宽度。
|
|
50
82
|
- 设置面板内更新扩展并热重载 Pi 运行时,无需退出 Web UI。
|
|
51
83
|
- 会话 HTML 导出。
|
|
52
84
|
|
|
53
85
|
## 进程生命周期
|
|
54
86
|
|
|
55
|
-
`/web --stop`、宿主 Pi
|
|
87
|
+
`/web --stop`、宿主 Pi 退出或运行时重载会关闭扩展托管的 frpc 和 Web 会话进程及其后代,包括 detached 后台进程;即使宿主被强制结束,Windows 托管进程也会检测到宿主退出并清理整棵进程树。不会扫描或结束工作区外独立启动的进程,也不会结束其他 Pi 实例。
|
|
56
88
|
|
|
57
89
|
单个 RPC 进程退出、异常终止或被空闲进程回收时,它启动的后台进程也会一起结束。需要独立于会话长期运行的服务,应从 Web 会话之外的终端启动。通过系统服务管理器、计划任务等外部机制启动的进程不属于此进程树。
|
|
58
90
|
|
|
@@ -66,6 +98,10 @@ pi --web
|
|
|
66
98
|
~/.pi/agent/pi-web/state.json
|
|
67
99
|
```
|
|
68
100
|
|
|
101
|
+
访问密码的加盐哈希和登录签名密钥保存在同一目录的 `auth.json` 中,不保存明文密码。
|
|
102
|
+
|
|
103
|
+
访问模式及中转配置保存在同一目录的 `network.json` 中。中转所需的 frps 认证密钥、STCP 私有密钥和代理密钥保存在此文件;普通设置响应不返回这些密钥,服务器配置下载会包含部署所需的密钥。配置文件不属于发布包。运行时的 frpc 配置写入状态目录下的 `runtime/`,正常停止时清理。
|
|
104
|
+
|
|
69
105
|
从旧版目录安装首次升级时,如果新位置尚无状态文件,扩展会从扩展目录中的旧 `state.json` 原样复制并继续使用。旧文件不会被删除或改写,可作为迁移备份。
|
|
70
106
|
|
|
71
107
|
可用环境变量:
|
package/frp-process.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
export type FrpStatus = { state: "stopped" | "connecting" | "connected" | "error"; message: string };
|
|
6
|
+
type StartOptions = { executable: string; args: string[]; cwd: string };
|
|
7
|
+
type Run = { child: ChildProcess; stopped: boolean; closed: Promise<void> };
|
|
8
|
+
|
|
9
|
+
export class FrpProcess {
|
|
10
|
+
status: FrpStatus = { state: "stopped", message: "隧道已停止" };
|
|
11
|
+
private current?: Run;
|
|
12
|
+
private pending: Promise<void> = Promise.resolve();
|
|
13
|
+
|
|
14
|
+
private onStatus?: (status: FrpStatus) => void;
|
|
15
|
+
|
|
16
|
+
constructor(onStatus?: (status: FrpStatus) => void) {
|
|
17
|
+
this.onStatus = onStatus;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private update(state: FrpStatus["state"], message: string): void {
|
|
21
|
+
if (this.status.state === state && this.status.message === message) return;
|
|
22
|
+
this.status = { state, message };
|
|
23
|
+
this.onStatus?.({ ...this.status });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private enqueue(action: () => Promise<void>): Promise<void> {
|
|
27
|
+
const result = this.pending.then(action);
|
|
28
|
+
this.pending = result.catch(() => {});
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
start(options: StartOptions): Promise<void> {
|
|
33
|
+
const launch = { ...options, args: [...options.args] };
|
|
34
|
+
return this.enqueue(async () => {
|
|
35
|
+
await this.stopCurrent();
|
|
36
|
+
this.update("connecting", "正在连接隧道服务器");
|
|
37
|
+
const windows = process.platform === "win32";
|
|
38
|
+
let child: ChildProcess;
|
|
39
|
+
try {
|
|
40
|
+
child = spawn(windows
|
|
41
|
+
? join(process.env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
|
42
|
+
: launch.executable, windows
|
|
43
|
+
? ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", fileURLToPath(new URL("./windows-rpc-job.ps1", import.meta.url))]
|
|
44
|
+
: launch.args, {
|
|
45
|
+
cwd: launch.cwd,
|
|
46
|
+
env: { ...process.env, NO_COLOR: "1", ...(windows ? {
|
|
47
|
+
PI_WEB_RPC_JOB: JSON.stringify({ ownerPid: process.pid, ...launch }),
|
|
48
|
+
} : {}) },
|
|
49
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
50
|
+
windowsHide: true,
|
|
51
|
+
shell: false,
|
|
52
|
+
detached: !windows,
|
|
53
|
+
});
|
|
54
|
+
} catch {
|
|
55
|
+
this.update("error", "隧道进程启动失败");
|
|
56
|
+
throw new Error("隧道进程启动失败");
|
|
57
|
+
}
|
|
58
|
+
let resolveClosed!: () => void;
|
|
59
|
+
const run: Run = { child, stopped: false, closed: new Promise<void>((resolve) => { resolveClosed = resolve; }) };
|
|
60
|
+
this.current = run;
|
|
61
|
+
const line = (text: string) => {
|
|
62
|
+
if (this.current !== run || run.stopped) return;
|
|
63
|
+
const clean = text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
64
|
+
if (/start proxy success/i.test(clean)) {
|
|
65
|
+
this.update("connected", "隧道已连接");
|
|
66
|
+
} else if (/login.*failed|connection.*(?:closed|lost)|connect.*(?:fail|refused|timeout)|reconnect|retry|re-?login|连接.*(?:丢失|断开|失败)|重试/i.test(clean)) {
|
|
67
|
+
this.update("connecting", "连接中断,正在重连");
|
|
68
|
+
} else if (/\b(?:error|fatal|failed)\b|\[E\]|错误/i.test(clean)) {
|
|
69
|
+
this.update("error", "隧道发生错误,请检查连接设置");
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
for (const stream of [child.stdout, child.stderr]) {
|
|
73
|
+
let buffer = "";
|
|
74
|
+
stream?.setEncoding("utf8");
|
|
75
|
+
stream?.on("data", (chunk: string) => {
|
|
76
|
+
buffer += chunk;
|
|
77
|
+
let end: number;
|
|
78
|
+
while ((end = buffer.indexOf("\n")) >= 0) {
|
|
79
|
+
line(buffer.slice(0, end));
|
|
80
|
+
buffer = buffer.slice(end + 1);
|
|
81
|
+
}
|
|
82
|
+
// Bound unfinished log lines from a long-lived process.
|
|
83
|
+
if (buffer.length > 16_384) buffer = buffer.slice(-16_384);
|
|
84
|
+
});
|
|
85
|
+
stream?.on("end", () => { if (buffer) line(buffer); });
|
|
86
|
+
}
|
|
87
|
+
child.on("error", () => {
|
|
88
|
+
if (this.current === run && !run.stopped) this.update("error", "隧道进程启动失败");
|
|
89
|
+
});
|
|
90
|
+
child.once("exit", () => { if (!windows) this.terminate(run); });
|
|
91
|
+
child.once("close", () => {
|
|
92
|
+
if (this.current === run) {
|
|
93
|
+
this.current = undefined;
|
|
94
|
+
if (run.stopped) this.update("stopped", "隧道已停止");
|
|
95
|
+
else this.update("error", "隧道进程意外退出");
|
|
96
|
+
}
|
|
97
|
+
resolveClosed();
|
|
98
|
+
});
|
|
99
|
+
// On Windows, spawn confirms the launcher; target creation failures arrive as an exit.
|
|
100
|
+
await new Promise<void>((resolve, reject) => {
|
|
101
|
+
child.once("spawn", resolve);
|
|
102
|
+
child.once("error", () => reject(new Error("隧道进程启动失败")));
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private terminate(run: Run): void {
|
|
108
|
+
if (process.platform !== "win32" && run.child.pid) {
|
|
109
|
+
try { process.kill(-run.child.pid, "SIGKILL"); } catch { /* The process group has already exited. */ }
|
|
110
|
+
}
|
|
111
|
+
run.child.kill("SIGKILL");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private async stopCurrent(): Promise<void> {
|
|
115
|
+
const run = this.current;
|
|
116
|
+
if (run) {
|
|
117
|
+
run.stopped = true;
|
|
118
|
+
this.terminate(run);
|
|
119
|
+
await run.closed;
|
|
120
|
+
}
|
|
121
|
+
this.update("stopped", "隧道已停止");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
stop(): Promise<void> {
|
|
125
|
+
return this.enqueue(() => this.stopCurrent());
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
kill(): void {
|
|
129
|
+
const run = this.current;
|
|
130
|
+
if (run) {
|
|
131
|
+
run.stopped = true;
|
|
132
|
+
this.terminate(run);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
package/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { getPackageDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai/compat";
|
|
5
|
+
import type { NetworkMode } from "./network-settings.ts";
|
|
5
6
|
|
|
6
7
|
interface MaintenanceCommandResult {
|
|
7
8
|
stdout: string;
|
|
@@ -15,6 +16,8 @@ interface WebServerModule {
|
|
|
15
16
|
defaultCwd?: string;
|
|
16
17
|
port?: number;
|
|
17
18
|
open?: boolean;
|
|
19
|
+
lan?: boolean;
|
|
20
|
+
mode?: NetworkMode;
|
|
18
21
|
initialModelInfo?: {
|
|
19
22
|
models: unknown[];
|
|
20
23
|
thinkingLevels: string[];
|
|
@@ -27,10 +30,13 @@ interface WebServerModule {
|
|
|
27
30
|
}): Promise<string>;
|
|
28
31
|
stopWebServer(): Promise<void>;
|
|
29
32
|
getWebUrl(): string | undefined;
|
|
33
|
+
getWebLanUrls(): string[];
|
|
34
|
+
getWebConnectionOptions(): { port: number; lan: boolean; mode: NetworkMode } | undefined;
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
interface ReloadState {
|
|
33
38
|
resumeWebAfterReload: boolean;
|
|
39
|
+
connection?: { port: number; lan: boolean; mode: NetworkMode };
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
const RELOAD_STATE_KEY = Symbol.for("@oversk7/pi-web-dsh-style/reload-state");
|
|
@@ -57,6 +63,9 @@ function parseArgs(args?: string): Record<string, string | boolean> {
|
|
|
57
63
|
const token = tokens[i];
|
|
58
64
|
if (token === "--stop") out.stop = true;
|
|
59
65
|
else if (token === "--no-open") out.open = "false";
|
|
66
|
+
else if (token === "--lan") out.mode = "lan";
|
|
67
|
+
else if (token === "--local") out.mode = "local";
|
|
68
|
+
else if (token === "--relay") out.mode = "relay";
|
|
60
69
|
else if (token === "--port" && tokens[i + 1]) {
|
|
61
70
|
out.port = tokens[++i];
|
|
62
71
|
}
|
|
@@ -78,6 +87,7 @@ async function startServer(
|
|
|
78
87
|
defaultCwd: ctx.cwd,
|
|
79
88
|
port: typeof options.port === "string" ? Number(options.port) : undefined,
|
|
80
89
|
open: options.open !== "false",
|
|
90
|
+
mode: options.mode as NetworkMode | undefined,
|
|
81
91
|
initialModelInfo: {
|
|
82
92
|
models: initialModels,
|
|
83
93
|
thinkingLevels: ctx.model ? getSupportedThinkingLevels(ctx.model) : [],
|
|
@@ -103,6 +113,7 @@ async function startServer(
|
|
|
103
113
|
},
|
|
104
114
|
});
|
|
105
115
|
ctx.ui.notify(`pi-web: ${url}`, "info");
|
|
116
|
+
for (const lanUrl of mod.getWebLanUrls()) ctx.ui.notify(`手机访问: ${lanUrl}`, "info");
|
|
106
117
|
} catch (error) {
|
|
107
118
|
serverPromise = undefined;
|
|
108
119
|
ctx.ui.notify(`pi-web failed: ${String(error)}`, "error");
|
|
@@ -141,17 +152,38 @@ export default function (pi: ExtensionAPI) {
|
|
|
141
152
|
default: false,
|
|
142
153
|
});
|
|
143
154
|
|
|
155
|
+
pi.registerFlag("web-lan", {
|
|
156
|
+
description: "Start the web UI with authenticated LAN access for phones",
|
|
157
|
+
type: "boolean",
|
|
158
|
+
default: false,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
pi.registerFlag("web-relay", {
|
|
162
|
+
description: "Start the web UI with the configured native frp relay",
|
|
163
|
+
type: "boolean",
|
|
164
|
+
default: false,
|
|
165
|
+
});
|
|
166
|
+
|
|
144
167
|
pi.on("session_start", async (_event, ctx) => {
|
|
145
168
|
const resumeAfterReload = reloadState.resumeWebAfterReload;
|
|
169
|
+
const connection = reloadState.connection;
|
|
146
170
|
reloadState.resumeWebAfterReload = false;
|
|
147
|
-
|
|
148
|
-
|
|
171
|
+
reloadState.connection = undefined;
|
|
172
|
+
if (pi.getFlag("web") === true || pi.getFlag("web-lan") === true || pi.getFlag("web-relay") === true || resumeAfterReload) {
|
|
173
|
+
await startServer(pi, ctx, resumeAfterReload && connection
|
|
174
|
+
? { open: "false", port: String(connection.port), mode: connection.mode }
|
|
175
|
+
: pi.getFlag("web-relay") === true ? { mode: "relay" }
|
|
176
|
+
: pi.getFlag("web-lan") === true ? { mode: "lan" } : {});
|
|
149
177
|
}
|
|
150
178
|
});
|
|
151
179
|
|
|
152
|
-
pi.on("session_shutdown", async () => {
|
|
180
|
+
pi.on("session_shutdown", async (event) => {
|
|
153
181
|
if (!serverPromise) return;
|
|
154
182
|
const mod = await serverPromise;
|
|
183
|
+
if (event.reason === "reload" && mod.getWebUrl()) {
|
|
184
|
+
reloadState.resumeWebAfterReload = true;
|
|
185
|
+
reloadState.connection = mod.getWebConnectionOptions();
|
|
186
|
+
}
|
|
155
187
|
await mod.stopWebServer();
|
|
156
188
|
serverPromise = undefined;
|
|
157
189
|
});
|
package/local-session.ts
CHANGED
|
@@ -18,6 +18,7 @@ interface SessionEntry {
|
|
|
18
18
|
parentId?: string | null;
|
|
19
19
|
timestamp?: string;
|
|
20
20
|
message?: Json;
|
|
21
|
+
usage?: Json;
|
|
21
22
|
provider?: string;
|
|
22
23
|
modelId?: string;
|
|
23
24
|
thinkingLevel?: string;
|
|
@@ -143,6 +144,14 @@ export function loadLocalSession(sessionFile: string, fallbackCwd?: string): Loc
|
|
|
143
144
|
let input = 0;
|
|
144
145
|
let output = 0;
|
|
145
146
|
let cost = 0;
|
|
147
|
+
for (const entry of entries) {
|
|
148
|
+
const message = entry.type === "message" ? entry.message : undefined;
|
|
149
|
+
const usage = message?.usage ?? ((entry.type === "compaction" || entry.type === "branch_summary") ? entry.usage : undefined);
|
|
150
|
+
const usageCost = usage && typeof usage === "object" ? (usage as Json).cost : undefined;
|
|
151
|
+
const total = usageCost && typeof usageCost === "object" ? (usageCost as Json).total : usageCost;
|
|
152
|
+
if (typeof total === "number" && Number.isFinite(total)) cost += total;
|
|
153
|
+
else if (typeof message?.cost === "number" && Number.isFinite(message.cost)) cost += message.cost;
|
|
154
|
+
}
|
|
146
155
|
for (const raw of rawMessages) {
|
|
147
156
|
const message = raw as Json;
|
|
148
157
|
const role = message.role;
|
|
@@ -158,7 +167,6 @@ export function loadLocalSession(sessionFile: string, fallbackCwd?: string): Loc
|
|
|
158
167
|
input += Number(usage.input ?? 0);
|
|
159
168
|
output += Number(usage.output ?? 0);
|
|
160
169
|
}
|
|
161
|
-
if (typeof message.cost === "number") cost += message.cost;
|
|
162
170
|
}
|
|
163
171
|
|
|
164
172
|
const state: Json = {
|
|
@@ -182,7 +190,7 @@ export function loadLocalSession(sessionFile: string, fallbackCwd?: string): Loc
|
|
|
182
190
|
toolCalls,
|
|
183
191
|
tokens: { input, output },
|
|
184
192
|
contextUsage: { tokens: contextTokens },
|
|
185
|
-
cost
|
|
193
|
+
cost,
|
|
186
194
|
},
|
|
187
195
|
title: sessionName || undefined,
|
|
188
196
|
sessionFile: manager.getSessionFile(),
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { createHmac, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import type { IncomingMessage } from "node:http";
|
|
3
|
+
import { networkInterfaces } from "node:os";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { loadJsonWithLegacyMigration, writeJsonAtomically } from "./state-store.ts";
|
|
6
|
+
|
|
7
|
+
const derivePassword = promisify(scrypt);
|
|
8
|
+
const LOGIN_SECONDS = 30 * 24 * 60 * 60;
|
|
9
|
+
|
|
10
|
+
export interface WebProxyConfig {
|
|
11
|
+
origin: string;
|
|
12
|
+
secret: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function equalSecret(value: unknown, expected: string): boolean {
|
|
16
|
+
if (typeof value !== "string") return false;
|
|
17
|
+
const candidate = Buffer.from(value);
|
|
18
|
+
const secret = Buffer.from(expected);
|
|
19
|
+
return candidate.length === secret.length && timingSafeEqual(candidate, secret);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface WebCredentials {
|
|
23
|
+
salt: string;
|
|
24
|
+
passwordHash: string;
|
|
25
|
+
sessionKey: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function validCredentials(value: unknown): value is WebCredentials {
|
|
29
|
+
const data = value as WebCredentials | null;
|
|
30
|
+
return Boolean(data && typeof data.salt === "string" && /^[a-f0-9]{32}$/.test(data.salt)
|
|
31
|
+
&& typeof data.passwordHash === "string" && /^[a-f0-9]{128}$/.test(data.passwordHash)
|
|
32
|
+
&& typeof data.sessionKey === "string" && /^[a-f0-9]{64}$/.test(data.sessionKey));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
|
|
36
|
+
|
|
37
|
+
export function lanAddresses(): string[] {
|
|
38
|
+
return [...new Set(Object.values(networkInterfaces()).flatMap((entries) => (
|
|
39
|
+
(entries ?? []).filter((entry) => entry.family === "IPv4" && !entry.internal).map((entry) => entry.address)
|
|
40
|
+
)))];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function phoneLanAddress(interfaces = networkInterfaces()): string | undefined {
|
|
44
|
+
const candidates = Object.entries(interfaces)
|
|
45
|
+
.filter(([name]) => !/vEthernet|WSL|Hyper-V|VirtualBox|VMware|Loopback/i.test(name))
|
|
46
|
+
.flatMap(([name, entries]) => (entries ?? [])
|
|
47
|
+
.filter((entry) => entry.family === "IPv4" && !entry.internal && !entry.address.startsWith("169.254."))
|
|
48
|
+
.map((entry) => ({ name, address: entry.address })));
|
|
49
|
+
return (candidates.find(({ name }) => /wi-?fi|wlan|无线/i.test(name)) ?? candidates[0])?.address;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function requestHostname(req: IncomingMessage): string {
|
|
53
|
+
try {
|
|
54
|
+
const authority = req.headers.host ?? "";
|
|
55
|
+
const url = new URL(`http://${authority}`);
|
|
56
|
+
if (url.host.toLowerCase() !== authority.toLowerCase() && `${url.hostname}:80` !== authority.toLowerCase()) return "";
|
|
57
|
+
return url.hostname;
|
|
58
|
+
} catch {
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function isLocalRequest(req: IncomingMessage): boolean {
|
|
64
|
+
const address = req.socket.remoteAddress;
|
|
65
|
+
return (address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1")
|
|
66
|
+
&& LOOPBACK_HOSTS.has(requestHostname(req))
|
|
67
|
+
&& !req.headers["x-pi-web-proxy-secret"];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class WebAccess {
|
|
71
|
+
lan: boolean;
|
|
72
|
+
private credentials?: WebCredentials;
|
|
73
|
+
private readonly proxy?: WebProxyConfig;
|
|
74
|
+
|
|
75
|
+
constructor(lan: boolean, credentials?: WebCredentials, proxy?: WebProxyConfig) {
|
|
76
|
+
this.lan = lan;
|
|
77
|
+
this.credentials = credentials;
|
|
78
|
+
this.proxy = proxy;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
static async load(lan: boolean, path: string, proxy?: WebProxyConfig): Promise<WebAccess> {
|
|
82
|
+
const credentials = await loadJsonWithLegacyMigration({ stateFile: path, validate: validCredentials });
|
|
83
|
+
return new WebAccess(lan, credentials, proxy);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get remoteEnabled(): boolean {
|
|
87
|
+
return this.lan || Boolean(this.proxy);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private isProxyRequest(req: IncomingMessage): boolean {
|
|
91
|
+
return Boolean(this.proxy
|
|
92
|
+
&& req.headers.host?.toLowerCase() === new URL(this.proxy.origin).host
|
|
93
|
+
&& equalSecret(req.headers["x-pi-web-proxy-secret"], this.proxy.secret));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
get passwordConfigured(): boolean {
|
|
97
|
+
return Boolean(this.credentials);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async setPassword(password: unknown, path: string): Promise<void> {
|
|
101
|
+
if (typeof password !== "string" || !password.trim() || password.length > 256) {
|
|
102
|
+
throw new Error("请输入 1–256 个字符的密码");
|
|
103
|
+
}
|
|
104
|
+
const salt = randomBytes(16).toString("hex");
|
|
105
|
+
const passwordHash = (await derivePassword(password, salt, 64) as Buffer).toString("hex");
|
|
106
|
+
const credentials = { salt, passwordHash, sessionKey: randomBytes(32).toString("hex") };
|
|
107
|
+
await writeJsonAtomically(path, credentials);
|
|
108
|
+
this.credentials = credentials;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
trusted(req: IncomingMessage): boolean {
|
|
112
|
+
const hostname = requestHostname(req);
|
|
113
|
+
const proxied = this.isProxyRequest(req);
|
|
114
|
+
if (req.headers["x-pi-web-proxy-secret"] && !proxied) return false;
|
|
115
|
+
if (!proxied && !LOOPBACK_HOSTS.has(hostname) && !(this.lan && lanAddresses().includes(hostname))) return false;
|
|
116
|
+
if (req.headers["sec-fetch-site"] === "cross-site") return false;
|
|
117
|
+
if (req.headers.origin) {
|
|
118
|
+
try {
|
|
119
|
+
const expected = proxied ? this.proxy!.origin : new URL(`http://${req.headers.host}`).origin;
|
|
120
|
+
if (new URL(req.headers.origin).origin !== expected) return false;
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async acceptsPassword(value: unknown): Promise<boolean> {
|
|
129
|
+
const credentials = this.credentials;
|
|
130
|
+
if (!credentials || typeof value !== "string" || value.length > 256) return false;
|
|
131
|
+
const candidate = await derivePassword(value, credentials.salt, 64) as Buffer;
|
|
132
|
+
return credentials === this.credentials && timingSafeEqual(candidate, Buffer.from(credentials.passwordHash, "hex"));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private signature(expires: string): string {
|
|
136
|
+
return createHmac("sha256", Buffer.from(this.credentials!.sessionKey, "hex")).update(expires).digest("hex");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private acceptsCookie(value: string): boolean {
|
|
140
|
+
if (!this.credentials) return false;
|
|
141
|
+
const match = /^(\d+)\.([a-f0-9]{64})$/.exec(value);
|
|
142
|
+
if (!match || Number(match[1]) <= Math.floor(Date.now() / 1000)) return false;
|
|
143
|
+
return timingSafeEqual(Buffer.from(match[2], "hex"), Buffer.from(this.signature(match[1]), "hex"));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
cookieName(req: IncomingMessage): string {
|
|
147
|
+
return `pi_web_access_${req.socket.localPort}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
authorized(req: IncomingMessage): boolean {
|
|
151
|
+
if (isLocalRequest(req)) return true;
|
|
152
|
+
if (!this.remoteEnabled) return false;
|
|
153
|
+
const prefix = `${this.cookieName(req)}=`;
|
|
154
|
+
return (req.headers.cookie ?? "").split(";").some((part) => {
|
|
155
|
+
const cookie = part.trim();
|
|
156
|
+
return cookie.startsWith(prefix) && this.acceptsCookie(cookie.slice(prefix.length));
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
cookie(req: IncomingMessage): string {
|
|
161
|
+
const expires = String(Math.floor(Date.now() / 1000) + LOGIN_SECONDS);
|
|
162
|
+
const secure = this.isProxyRequest(req) ? "; Secure" : "";
|
|
163
|
+
return `${this.cookieName(req)}=${expires}.${this.signature(expires)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${LOGIN_SECONDS}${secure}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
urls(port: number): string[] {
|
|
167
|
+
if (this.proxy) return [`${this.proxy.origin}/`];
|
|
168
|
+
const address = this.lan ? phoneLanAddress() : undefined;
|
|
169
|
+
return address ? [`http://${address}:${port}/`] : [];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { access, chmod, stat } from "node:fs/promises";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { delimiter, isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { loadJsonWithLegacyMigration, writeJsonAtomically } from "./state-store.ts";
|
|
6
|
+
import type { WebProxyConfig } from "./network-access.ts";
|
|
7
|
+
|
|
8
|
+
export type NetworkMode = "local" | "lan" | "relay";
|
|
9
|
+
|
|
10
|
+
export interface RelaySettings {
|
|
11
|
+
serverAddr: string;
|
|
12
|
+
serverPort: number;
|
|
13
|
+
token: string;
|
|
14
|
+
proxyName: string;
|
|
15
|
+
secretKey: string;
|
|
16
|
+
origin: string;
|
|
17
|
+
proxySecret: string;
|
|
18
|
+
frpcPath: string;
|
|
19
|
+
visitorPort: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface NetworkSettings {
|
|
23
|
+
mode: NetworkMode;
|
|
24
|
+
relay?: RelaySettings;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function text(value: unknown, label: string, max = 1024): string {
|
|
28
|
+
if (typeof value !== "string" || !value.trim() || value.length > max || /[\x00-\x1f\x7f]/.test(value)) {
|
|
29
|
+
throw new Error(`${label}不能为空,且不能包含换行或控制字符`);
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function port(value: unknown, label: string): number {
|
|
35
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 65535) throw new Error(`${label}必须是 1–65535 之间的整数`);
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validateRelay(value: RelaySettings): RelaySettings {
|
|
40
|
+
text(value.serverAddr, "服务器地址", 253);
|
|
41
|
+
if (!/^[a-zA-Z0-9.-]+$/.test(value.serverAddr)) throw new Error("服务器地址应填写域名或 IPv4,不包含协议和端口");
|
|
42
|
+
port(value.serverPort, "frps 端口");
|
|
43
|
+
port(value.visitorPort, "服务器内部端口");
|
|
44
|
+
text(value.token, "frps 认证密钥");
|
|
45
|
+
text(value.frpcPath, "frpc 路径");
|
|
46
|
+
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(value.proxyName)) throw new Error("隧道名称只能包含英文字母、数字、下划线和短横线");
|
|
47
|
+
for (const key of [value.secretKey, value.proxySecret]) {
|
|
48
|
+
if (typeof key !== "string" || !/^[a-zA-Z0-9_-]{32,256}$/.test(key)) throw new Error("中转密钥必须包含 32–256 个字母、数字、下划线或短横线");
|
|
49
|
+
}
|
|
50
|
+
let url: URL;
|
|
51
|
+
try { url = new URL(value.origin); } catch { throw new Error("手机访问地址应填写 HTTPS 域名,例如 https://web.example.com"); }
|
|
52
|
+
if (url.protocol !== "https:" || url.origin !== value.origin || !/^[a-zA-Z0-9.-]+$/.test(url.hostname)) {
|
|
53
|
+
throw new Error("手机访问地址必须是 HTTPS 根地址,不包含路径、查询参数或用户名");
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function updateNetworkSettings(input: unknown, current: NetworkSettings = { mode: "local" }): NetworkSettings {
|
|
59
|
+
if (!input || typeof input !== "object") throw new Error("网络配置格式不正确");
|
|
60
|
+
const data = input as Record<string, unknown>;
|
|
61
|
+
if (!["local", "lan", "relay"].includes(data.mode as string)) throw new Error("请选择仅本机、局域网或服务器中转");
|
|
62
|
+
const mode = data.mode as NetworkMode;
|
|
63
|
+
if (data.relay === undefined) {
|
|
64
|
+
if (mode === "relay" && !current.relay) throw new Error("请先在设置中填写服务器中转配置");
|
|
65
|
+
return { mode, ...(current.relay ? { relay: current.relay } : {}) };
|
|
66
|
+
}
|
|
67
|
+
if (!data.relay || typeof data.relay !== "object") throw new Error("服务器中转配置格式不正确");
|
|
68
|
+
const incoming = data.relay as Record<string, unknown>;
|
|
69
|
+
const previous = current.relay;
|
|
70
|
+
const relay = validateRelay({
|
|
71
|
+
serverAddr: incoming.serverAddr as string,
|
|
72
|
+
serverPort: incoming.serverPort as number,
|
|
73
|
+
token: (incoming.token || previous?.token) as string,
|
|
74
|
+
proxyName: (incoming.proxyName ?? previous?.proxyName ?? `pi-web-${randomBytes(4).toString("hex")}`) as string,
|
|
75
|
+
secretKey: (incoming.secretKey ?? previous?.secretKey ?? randomBytes(32).toString("hex")) as string,
|
|
76
|
+
origin: incoming.origin as string,
|
|
77
|
+
proxySecret: (incoming.proxySecret ?? previous?.proxySecret ?? randomBytes(32).toString("hex")) as string,
|
|
78
|
+
frpcPath: (incoming.frpcPath || "frpc") as string,
|
|
79
|
+
visitorPort: (incoming.visitorPort ?? 13000) as number,
|
|
80
|
+
});
|
|
81
|
+
return { mode, relay };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function loadNetworkSettings(path: string): Promise<NetworkSettings> {
|
|
85
|
+
return await loadJsonWithLegacyMigration({ stateFile: path, validate: (value): value is NetworkSettings => {
|
|
86
|
+
try {
|
|
87
|
+
const data = value as NetworkSettings;
|
|
88
|
+
if (!data || !["local", "lan", "relay"].includes(data.mode)) return false;
|
|
89
|
+
if (data.relay) validateRelay(data.relay);
|
|
90
|
+
return data.mode !== "relay" || Boolean(data.relay);
|
|
91
|
+
} catch { return false; }
|
|
92
|
+
} }) ?? { mode: "local" };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function saveNetworkSettings(path: string, settings: NetworkSettings): Promise<void> {
|
|
96
|
+
await writeJsonAtomically(path, settings);
|
|
97
|
+
await chmod(path, 0o600);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function publicNetworkSettings(settings: NetworkSettings) {
|
|
101
|
+
const relay = settings.relay;
|
|
102
|
+
return {
|
|
103
|
+
mode: settings.mode,
|
|
104
|
+
...(relay ? { relay: {
|
|
105
|
+
serverAddr: relay.serverAddr, serverPort: relay.serverPort, proxyName: relay.proxyName,
|
|
106
|
+
origin: relay.origin, frpcPath: relay.frpcPath, visitorPort: relay.visitorPort, tokenConfigured: Boolean(relay.token),
|
|
107
|
+
} } : {}),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function proxyForNetwork(settings: NetworkSettings): WebProxyConfig | undefined {
|
|
112
|
+
return settings.mode === "relay" && settings.relay ? { origin: settings.relay.origin, secret: settings.relay.proxySecret } : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function resolveFrpc(executable: string, configDir: string): Promise<string> {
|
|
116
|
+
const candidates = isAbsolute(executable) || /[\\/]/.test(executable)
|
|
117
|
+
? [resolve(configDir, executable)]
|
|
118
|
+
: (process.env.PATH ?? "").split(delimiter).filter(Boolean).map((directory) => join(directory.replace(/^"|"$/g, ""), process.platform === "win32" && !executable.toLowerCase().endsWith(".exe") ? `${executable}.exe` : executable));
|
|
119
|
+
for (const candidate of candidates) {
|
|
120
|
+
try {
|
|
121
|
+
if (!(await stat(candidate)).isFile()) continue;
|
|
122
|
+
await access(candidate, constants.X_OK);
|
|
123
|
+
return candidate;
|
|
124
|
+
} catch { /* Try the next PATH directory. */ }
|
|
125
|
+
}
|
|
126
|
+
throw new Error("未找到 frpc。请下载原生 frpc,并在服务器中转设置中填写 frpc.exe 路径,或将其加入 PATH。");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function frpCommon(relay: RelaySettings, serverAddr: string): string {
|
|
130
|
+
return `serverAddr = ${JSON.stringify(serverAddr)}\nserverPort = ${relay.serverPort}\nauth.method = "token"\nauth.token = ${JSON.stringify(relay.token)}\ntransport.tls.enable = true\nloginFailExit = false\nlog.to = "console"\nlog.disablePrintColor = true\n`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function frpcConfig(relay: RelaySettings, localPort: number): string {
|
|
134
|
+
return `${frpCommon(relay, relay.serverAddr)}\n[[proxies]]\nname = ${JSON.stringify(relay.proxyName)}\ntype = "stcp"\nsecretKey = ${JSON.stringify(relay.secretKey)}\nlocalIP = "127.0.0.1"\nlocalPort = ${port(localPort, "本机端口")}\n`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function relayServerFiles(relay: RelaySettings): Record<string, string> {
|
|
138
|
+
const url = new URL(relay.origin);
|
|
139
|
+
const zone = `pi_web_${relay.proxyName.replaceAll("-", "_")}`;
|
|
140
|
+
const proxyHeaders = ` proxy_http_version 1.1;\n proxy_set_header Host ${url.host};\n proxy_set_header X-Pi-Web-Proxy-Secret "${relay.proxySecret}";\n proxy_set_header X-Forwarded-Proto https;\n proxy_set_header Connection "";\n proxy_buffering off;\n proxy_request_buffering off;\n proxy_read_timeout 3600s;\n proxy_send_timeout 3600s;`;
|
|
141
|
+
return {
|
|
142
|
+
"frps.toml": `bindPort = ${relay.serverPort}\nauth.method = "token"\nauth.token = ${JSON.stringify(relay.token)}\ntransport.tls.force = true\n`,
|
|
143
|
+
"visitor.toml": `${frpCommon(relay, "127.0.0.1")}\n[[visitors]]\nname = ${JSON.stringify(`${relay.proxyName}-visitor`)}\ntype = "stcp"\nserverName = ${JSON.stringify(relay.proxyName)}\nsecretKey = ${JSON.stringify(relay.secretKey)}\nbindAddr = "127.0.0.1"\nbindPort = ${relay.visitorPort}\n`,
|
|
144
|
+
"nginx.conf": `limit_req_zone $binary_remote_addr zone=${zone}:10m rate=10r/m;\n\nserver {\n listen 80;\n server_name ${url.hostname};\n return 301 ${relay.origin}$request_uri;\n}\n\nserver {\n listen ${url.port || 443} ssl;\n server_name ${url.hostname};\n ssl_certificate /etc/nginx/ssl/pi-web.pem;\n ssl_certificate_key /etc/nginx/ssl/pi-web.key;\n client_max_body_size 21m;\n${proxyHeaders}\n\n location = /api/auth {\n limit_req zone=${zone} burst=10 nodelay;\n limit_req_status 429;\n proxy_pass http://127.0.0.1:${relay.visitorPort};\n }\n location / {\n proxy_pass http://127.0.0.1:${relay.visitorPort};\n }\n}\n`,
|
|
145
|
+
};
|
|
146
|
+
}
|