pi-web-ui 0.44.2 → 0.45.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/README.md +458 -388
- package/README.zh-CN.md +380 -359
- package/bin/pi-web-ui.mjs +1818 -1818
- package/deploy/com.xingshuyin.pi-web-ui.plist +48 -48
- package/deploy/nginx-subpath.conf +88 -88
- package/deploy/pi-web-ui-task.xml +71 -71
- package/deploy/pi-web-ui.service +31 -31
- package/dist/server/agent-service.js +47 -9
- package/dist/server/attachments.js +3 -3
- package/dist/server/client-state.js +2 -0
- package/dist/server/index.js +6 -0
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/settings-service.js +105 -11
- package/dist/server/terminals.js +5 -5
- package/electron/main.mjs +427 -0
- package/extensions/webui.ts +192 -192
- package/package.json +19 -5
- package/themes/md-preview.css +7328 -7253
- package/themes/white.css +7389 -7314
- package/web/dist/assets/{TerminalPanel-BuG4RDBE.js → TerminalPanel-C2R8nfig.js} +1 -1
- package/web/dist/assets/{index-BByVm30o.css → index-CODfCAWK.css} +1 -1
- package/web/dist/assets/index-CeePtC8A.js +19 -0
- package/web/dist/favicon.svg +8 -8
- package/web/dist/index.html +15 -15
- package/web/public/favicon.svg +8 -8
- package/web/dist/assets/index-B0NAJgJX.js +0 -19
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* 预设存取 + 何时需要 reload」,真正动 runtime 的 session.reload() 走宿主回调
|
|
7
7
|
* (reloadSession 里还会刷新斜杠命令目录)。
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
9
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
10
|
+
import { basename, dirname, join } from "node:path";
|
|
10
11
|
import { extensionKey } from "./client-state.js";
|
|
11
12
|
import { findVisionModels, SYSTEM_PROMPT } from "./vision-bridge.js";
|
|
12
13
|
export class SettingsService {
|
|
@@ -39,20 +40,88 @@ export class SettingsService {
|
|
|
39
40
|
this.pendingReload = false;
|
|
40
41
|
return v;
|
|
41
42
|
}
|
|
43
|
+
/** 判断某个 skill 名是否仍存在于磁盘任何来源(agent 区 / 项目 .pi / 祖先
|
|
44
|
+
* .agents/skills / npm 包内 skills)。被禁用且文件已删除的名字不应再
|
|
45
|
+
* 出现在设置面板,也不应留在持久化记录里。 */
|
|
46
|
+
skillStillOnDisk(name) {
|
|
47
|
+
const cwd = this.host.cwd();
|
|
48
|
+
const agentDir = this.host.agentDir();
|
|
49
|
+
const check = (base) => existsSync(join(base, name)) || existsSync(join(base, `${name}.md`));
|
|
50
|
+
// ① 用户区 <agentDir>/skills ② 项目 .pi/skills
|
|
51
|
+
if (check(join(agentDir, "skills")))
|
|
52
|
+
return true;
|
|
53
|
+
if (check(join(cwd, ".pi", "skills")))
|
|
54
|
+
return true;
|
|
55
|
+
// ③ 祖先链 .agents/skills(SDK collectAncestorAgentsSkillDirs 语义,最多上溯 6 层)
|
|
56
|
+
let dir = cwd;
|
|
57
|
+
for (let i = 0; i < 6 && dir !== dirname(dir); i++, dir = dirname(dir)) {
|
|
58
|
+
if (check(join(dir, ".agents", "skills")))
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
// ④ npm 包内 skills(agent 级 + 项目级,含 @scope 两级子包)
|
|
62
|
+
for (const npmRoot of [
|
|
63
|
+
join(agentDir, "npm", "node_modules"),
|
|
64
|
+
join(cwd, ".pi", "npm", "node_modules"),
|
|
65
|
+
]) {
|
|
66
|
+
try {
|
|
67
|
+
for (const entry of readdirSync(npmRoot, { withFileTypes: true })) {
|
|
68
|
+
if (!entry.isDirectory())
|
|
69
|
+
continue;
|
|
70
|
+
if (!entry.name.startsWith("@")) {
|
|
71
|
+
if (check(join(npmRoot, entry.name, "skills")))
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
for (const sub of readdirSync(join(npmRoot, entry.name), { withFileTypes: true })) {
|
|
76
|
+
if (sub.isDirectory() && check(join(npmRoot, entry.name, sub.name, "skills"))) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// npm 目录不存在/不可读 → 不是来源
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
42
89
|
push() {
|
|
43
90
|
const disabledSkills = new Set(this.settings.disabledSkills);
|
|
44
91
|
const reviewDisabledSkills = new Set(this.settings.reviewDisabledSkills);
|
|
45
92
|
const disabledExts = new Set(this.settings.disabledExtensions);
|
|
93
|
+
let loadedSkillNames = null;
|
|
46
94
|
try {
|
|
47
|
-
|
|
48
|
-
|
|
95
|
+
const loadedSkills = this.host.getSession().resourceLoader.getSkills().skills;
|
|
96
|
+
const loadedExts = this.host.getSession().resourceLoader.getExtensions().extensions;
|
|
97
|
+
loadedSkillNames = new Set(loadedSkills.map((s) => s.name));
|
|
98
|
+
// Prune entries that no longer exist on disk AND aren't disabled
|
|
99
|
+
// (e.g. a skill/extension file was deleted). Disabled entries are
|
|
100
|
+
// kept so they can be re-enabled even when filtered out of the loader.
|
|
101
|
+
const keepSkills = new Set([
|
|
102
|
+
...loadedSkills.map((s) => s.name),
|
|
103
|
+
...this.settings.disabledSkills,
|
|
104
|
+
]);
|
|
105
|
+
const keepExts = new Set([
|
|
106
|
+
...loadedExts.map((e) => extensionKey(e)),
|
|
107
|
+
...this.settings.disabledExtensions,
|
|
108
|
+
]);
|
|
109
|
+
for (const name of [...this.knownSkills.keys()]) {
|
|
110
|
+
if (!keepSkills.has(name))
|
|
111
|
+
this.knownSkills.delete(name);
|
|
112
|
+
}
|
|
113
|
+
for (const id of [...this.knownExtensions.keys()]) {
|
|
114
|
+
if (!keepExts.has(id))
|
|
115
|
+
this.knownExtensions.delete(id);
|
|
116
|
+
}
|
|
117
|
+
for (const s of loadedSkills) {
|
|
49
118
|
this.knownSkills.set(s.name, {
|
|
50
119
|
name: s.name,
|
|
51
120
|
description: s.description,
|
|
52
121
|
enabled: true,
|
|
53
122
|
});
|
|
54
123
|
}
|
|
55
|
-
for (const e of
|
|
124
|
+
for (const e of loadedExts) {
|
|
56
125
|
const id = extensionKey(e);
|
|
57
126
|
const p = e.sourceInfo?.path ?? e.path;
|
|
58
127
|
this.knownExtensions.set(id, {
|
|
@@ -68,13 +137,33 @@ export class SettingsService {
|
|
|
68
137
|
catch {
|
|
69
138
|
// Session not ready yet — keep whatever we already know.
|
|
70
139
|
}
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
140
|
+
// 清理“源文件已删除”的禁用残留记录:磁盘上已不存在的技能名从
|
|
141
|
+
// disabledSkills / reviewDisabledSkills 持久化记录中移除——否则每次
|
|
142
|
+
// 推送都会把已删除的 skill 以灰条形式永恒地补回面板(“关闭过的
|
|
143
|
+
// skill 被一直记录”)。session 未就绪时保守跳过。
|
|
144
|
+
if (loadedSkillNames !== null) {
|
|
145
|
+
const stale = [
|
|
146
|
+
...new Set([...this.settings.disabledSkills, ...this.settings.reviewDisabledSkills]),
|
|
147
|
+
].filter((name) => !loadedSkillNames.has(name) && !this.skillStillOnDisk(name));
|
|
148
|
+
if (stale.length > 0) {
|
|
149
|
+
this.settings.disabledSkills = this.settings.disabledSkills.filter((n) => !stale.includes(n));
|
|
150
|
+
this.settings.reviewDisabledSkills = this.settings.reviewDisabledSkills.filter((n) => !stale.includes(n));
|
|
151
|
+
this.host.stateStore.saveSettings(this.host.clientId, {
|
|
152
|
+
disabledSkills: this.settings.disabledSkills,
|
|
153
|
+
reviewDisabledSkills: this.settings.reviewDisabledSkills,
|
|
154
|
+
});
|
|
76
155
|
}
|
|
77
156
|
}
|
|
157
|
+
// Disabled entries that still exist on disk are re-added (with the
|
|
158
|
+
// last-known description) so they can be re-enabled; entries whose
|
|
159
|
+
// source file was deleted are dropped instead of being resurrected.
|
|
160
|
+
for (const name of this.settings.disabledSkills) {
|
|
161
|
+
if (this.knownSkills.has(name))
|
|
162
|
+
continue;
|
|
163
|
+
if (!this.skillStillOnDisk(name))
|
|
164
|
+
continue;
|
|
165
|
+
this.knownSkills.set(name, { name, description: "", enabled: false });
|
|
166
|
+
}
|
|
78
167
|
for (const id of this.settings.disabledExtensions) {
|
|
79
168
|
if (!this.knownExtensions.has(id)) {
|
|
80
169
|
this.knownExtensions.set(id, {
|
|
@@ -103,6 +192,7 @@ export class SettingsService {
|
|
|
103
192
|
terminalBash: this.settings.terminalBash,
|
|
104
193
|
terminalBashIdleMs: this.settings.terminalBashIdleMs,
|
|
105
194
|
thinkingWrap: this.settings.thinkingWrap,
|
|
195
|
+
toolsWrap: this.settings.toolsWrap,
|
|
106
196
|
visionBridgeEnabled: this.settings.visionBridgeEnabled,
|
|
107
197
|
visionBridgeModel: this.settings.visionBridgeModel,
|
|
108
198
|
visionBridgePromptMode: this.settings.visionBridgePromptMode,
|
|
@@ -174,6 +264,9 @@ export class SettingsService {
|
|
|
174
264
|
if (partial.thinkingWrap !== undefined) {
|
|
175
265
|
this.settings.thinkingWrap = partial.thinkingWrap;
|
|
176
266
|
}
|
|
267
|
+
if (partial.toolsWrap !== undefined) {
|
|
268
|
+
this.settings.toolsWrap = partial.toolsWrap;
|
|
269
|
+
}
|
|
177
270
|
if (partial.visionBridgeEnabled !== undefined) {
|
|
178
271
|
this.settings.visionBridgeEnabled = partial.visionBridgeEnabled;
|
|
179
272
|
}
|
|
@@ -245,10 +338,11 @@ export class SettingsService {
|
|
|
245
338
|
reviewDisabledSkills: [
|
|
246
339
|
...(p.reviewDisabledSkills ?? this.settings.reviewDisabledSkills),
|
|
247
340
|
],
|
|
248
|
-
// Presets don't capture vision-bridge prefs — keep the current ones.
|
|
249
|
-
visionBridgeEnabled: this.settings.visionBridgeEnabled,
|
|
250
341
|
// 纯 UI 偏好不进预设——保留当前值。
|
|
251
342
|
thinkingWrap: this.settings.thinkingWrap,
|
|
343
|
+
toolsWrap: this.settings.toolsWrap,
|
|
344
|
+
// Presets don't capture vision-bridge prefs — keep the current ones.
|
|
345
|
+
visionBridgeEnabled: this.settings.visionBridgeEnabled,
|
|
252
346
|
visionBridgeModel: this.settings.visionBridgeModel,
|
|
253
347
|
visionBridgePromptMode: this.settings.visionBridgePromptMode,
|
|
254
348
|
visionBridgePrompt: this.settings.visionBridgePrompt,
|
package/dist/server/terminals.js
CHANGED
|
@@ -1230,11 +1230,11 @@ export const TERMINAL_TOOL_NAMES = [
|
|
|
1230
1230
|
/** System-prompt guidance teaching the model WHEN to prefer the terminal tools
|
|
1231
1231
|
* over one-shot bash. Without it models almost never pick them — bash returns
|
|
1232
1232
|
* complete output in a single call, so it always wins on convenience. */
|
|
1233
|
-
export const TERMINAL_TOOLS_GUIDANCE = `Persistent interactive terminal tools are available (terminal_create / terminal_list / terminal_close / terminal_input / terminal_key / terminal_read / terminal_wait). The one-shot bash tool stays the DEFAULT for ordinary commands - it runs once and returns the full output. Switch to the terminal tools only when:
|
|
1234
|
-
- The program is interactive or TUI-based (REPLs like python/node, vim/htop, installers asking y/n, anything waiting on stdin).
|
|
1235
|
-
- You start a long-running server or watcher and want to keep watching its output (terminal_read with waitMs), send keys to it later (e.g. interrupt via terminal_key with Ctrl+c), or block until a backgrounded command finishes without polling (terminal_wait).
|
|
1236
|
-
- The user explicitly asks you to work in the visible terminal panel.
|
|
1237
|
-
Liveness watchdog: terminals you touched (create/input/key) are monitored - if one goes silent with no new output while you are working (default 15s), an automatic system reminder is injected into the conversation. Treat it as a prompt to check that terminal (terminal_read), respond to an input prompt (terminal_input / terminal_key), or close it (terminal_close) if it is no longer needed.
|
|
1233
|
+
export const TERMINAL_TOOLS_GUIDANCE = `Persistent interactive terminal tools are available (terminal_create / terminal_list / terminal_close / terminal_input / terminal_key / terminal_read / terminal_wait). The one-shot bash tool stays the DEFAULT for ordinary commands - it runs once and returns the full output. Switch to the terminal tools only when:
|
|
1234
|
+
- The program is interactive or TUI-based (REPLs like python/node, vim/htop, installers asking y/n, anything waiting on stdin).
|
|
1235
|
+
- You start a long-running server or watcher and want to keep watching its output (terminal_read with waitMs), send keys to it later (e.g. interrupt via terminal_key with Ctrl+c), or block until a backgrounded command finishes without polling (terminal_wait).
|
|
1236
|
+
- The user explicitly asks you to work in the visible terminal panel.
|
|
1237
|
+
Liveness watchdog: terminals you touched (create/input/key) are monitored - if one goes silent with no new output while you are working (default 15s), an automatic system reminder is injected into the conversation. Treat it as a prompt to check that terminal (terminal_read), respond to an input prompt (terminal_input / terminal_key), or close it (terminal_close) if it is no longer needed.
|
|
1238
1238
|
Do NOT use them for simple one-shot commands; bash remains cheaper and simpler there.`;
|
|
1239
1239
|
/** Build the agent-facing persistent terminal tools for one conversation. */
|
|
1240
1240
|
export function makePersistentTerminalTools(terminals, cwd) {
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-web-ui Electron 桌面版主进程。
|
|
3
|
+
*
|
|
4
|
+
* 架构:
|
|
5
|
+
* - 主进程 fork 一个隐藏子进程跑 server(ELECTRON_RUN_AS_NODE=1),
|
|
6
|
+
* server 使用 Electron 内置的 Node 运行时,node-pty 等原生模块
|
|
7
|
+
* 在 electron-builder 打包时自动 rebuild 为 Electron ABI。
|
|
8
|
+
* - BrowserWindow 加载 http://127.0.0.1:{PORT}(server 就绪后)。
|
|
9
|
+
* - 托盘:关闭窗口 → 最小化到托盘;Quit → 真正退出(杀 server 子进程)。
|
|
10
|
+
* - 自动更新:electron-updater + GitHub Releases。
|
|
11
|
+
*
|
|
12
|
+
* 开发模式(npm run dev:electron):
|
|
13
|
+
* 先构建 web + server(npm run build),然后 electron . 即可。
|
|
14
|
+
* 如有 Vite dev server (:5173),优先加载它获取 HMR。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { app, BrowserWindow, Tray, Menu, nativeImage, dialog, Notification } from "electron";
|
|
18
|
+
import { fork } from "node:child_process";
|
|
19
|
+
import { createServer } from "node:net";
|
|
20
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
21
|
+
import { join, dirname, resolve } from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { autoUpdater } from "electron-updater";
|
|
24
|
+
|
|
25
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const ROOT = resolve(__dirname, "..");
|
|
27
|
+
const isDev = !app.isPackaged || !!process.env.DEV;
|
|
28
|
+
|
|
29
|
+
// ── 状态 ──
|
|
30
|
+
/** @type {BrowserWindow | null} */
|
|
31
|
+
let mainWindow = null;
|
|
32
|
+
/** @type {Tray | null} */
|
|
33
|
+
let tray = null;
|
|
34
|
+
/** @type {import("node:child_process").ChildProcess | null} */
|
|
35
|
+
let serverProcess = null;
|
|
36
|
+
let isQuitting = false;
|
|
37
|
+
let serverPort = 0;
|
|
38
|
+
|
|
39
|
+
// ── 路径 ──
|
|
40
|
+
|
|
41
|
+
/** 打包后 server 在 resources/dist/server/index.js */
|
|
42
|
+
function getServerPath() {
|
|
43
|
+
if (isDev) return join(ROOT, "dist", "server", "index.js");
|
|
44
|
+
return join(process.resourcesPath, "dist", "server", "index.js");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 打包后 themes 在 resources/themes/ */
|
|
48
|
+
function getPkgRoot() {
|
|
49
|
+
if (isDev) return ROOT;
|
|
50
|
+
return process.resourcesPath;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── 端口 ──
|
|
54
|
+
|
|
55
|
+
/** 找一个随机空闲端口(防止端口冲突) */
|
|
56
|
+
async function findFreePort() {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const srv = createServer();
|
|
59
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
60
|
+
const port = /** @type {import("node:net").AddressInfo} */ (srv.address()).port;
|
|
61
|
+
srv.close(() => resolve(port));
|
|
62
|
+
});
|
|
63
|
+
srv.on("error", reject);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── 启动 Server 子进程 ──
|
|
68
|
+
|
|
69
|
+
async function startServer() {
|
|
70
|
+
serverPort = await findFreePort();
|
|
71
|
+
const serverPath = getServerPath();
|
|
72
|
+
|
|
73
|
+
if (!existsSync(serverPath)) {
|
|
74
|
+
dialog.showErrorBox(
|
|
75
|
+
"pi-web-ui 启动失败",
|
|
76
|
+
`找不到 server 入口:${serverPath}\n\n请先执行 npm run build,然后重试。`,
|
|
77
|
+
);
|
|
78
|
+
app.quit();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 确保 ~/.pi-web 目录存在(server 需要)
|
|
83
|
+
const dataDir = join(process.env.HOME || process.env.USERPROFILE || "~", ".pi-web");
|
|
84
|
+
mkdirSync(dataDir, { recursive: true });
|
|
85
|
+
|
|
86
|
+
serverProcess = fork(serverPath, [], {
|
|
87
|
+
env: {
|
|
88
|
+
...process.env,
|
|
89
|
+
PORT: String(serverPort),
|
|
90
|
+
PI_WEB_HOST: "127.0.0.1",
|
|
91
|
+
PI_WEB_NO_BROWSER: "1", // 不要自动打开浏览器
|
|
92
|
+
PI_WEB_PKG_ROOT: getPkgRoot(), // 告诉 server 去哪找 web/dist / themes
|
|
93
|
+
ELECTRON_RUN_AS_NODE: "1", // 以 Node.js 模式运行(非 Electron)
|
|
94
|
+
},
|
|
95
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
96
|
+
serialization: "json",
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// 收集 stdout/stderr 用于调试
|
|
100
|
+
let serverOut = "";
|
|
101
|
+
serverProcess.stdout?.on("data", (chunk) => {
|
|
102
|
+
const text = chunk.toString();
|
|
103
|
+
serverOut += text;
|
|
104
|
+
process.stdout.write(`[server] ${text}`);
|
|
105
|
+
});
|
|
106
|
+
serverProcess.stderr?.on("data", (chunk) => {
|
|
107
|
+
const text = chunk.toString();
|
|
108
|
+
serverOut += text;
|
|
109
|
+
process.stderr.write(`[server:err] ${text}`);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// 等待 server 就绪:监听 stdout 中的 "⚡ pi-web-ui" 标记
|
|
113
|
+
await new Promise((resolve, reject) => {
|
|
114
|
+
const timeout = setTimeout(() => {
|
|
115
|
+
reject(
|
|
116
|
+
new Error(
|
|
117
|
+
`Server 启动超时(30s)。最后输出:\n${serverOut.slice(-500)}`,
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
}, 30_000);
|
|
121
|
+
|
|
122
|
+
const checkOutput = (chunk) => {
|
|
123
|
+
const text = chunk.toString();
|
|
124
|
+
if (text.includes("⚡ pi-web-ui") || text.includes("http://localhost")) {
|
|
125
|
+
clearTimeout(timeout);
|
|
126
|
+
resolve();
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
serverProcess.stdout?.on("data", checkOutput);
|
|
131
|
+
serverProcess.on("error", (err) => {
|
|
132
|
+
clearTimeout(timeout);
|
|
133
|
+
reject(err);
|
|
134
|
+
});
|
|
135
|
+
serverProcess.on("exit", (code) => {
|
|
136
|
+
clearTimeout(timeout);
|
|
137
|
+
if (code !== 0) {
|
|
138
|
+
reject(new Error(`Server 退出了 (exit code=${code})`));
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
console.log(`[electron] server 就绪,端口 ${serverPort}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── 窗口 ──
|
|
147
|
+
|
|
148
|
+
function createWindow() {
|
|
149
|
+
mainWindow = new BrowserWindow({
|
|
150
|
+
width: 1200,
|
|
151
|
+
height: 800,
|
|
152
|
+
minWidth: 800,
|
|
153
|
+
minHeight: 600,
|
|
154
|
+
title: "pi-web-ui",
|
|
155
|
+
show: false,
|
|
156
|
+
webPreferences: {
|
|
157
|
+
preload: join(__dirname, "preload.mjs"),
|
|
158
|
+
nodeIntegration: false,
|
|
159
|
+
contextIsolation: true,
|
|
160
|
+
},
|
|
161
|
+
icon: join(__dirname, "icon.png"),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// 加载 localhost 上的 server
|
|
165
|
+
const url = `http://127.0.0.1:${serverPort}`;
|
|
166
|
+
mainWindow.loadURL(url);
|
|
167
|
+
|
|
168
|
+
mainWindow.once("ready-to-show", () => {
|
|
169
|
+
mainWindow.show();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// 关闭窗口 → 隐藏到托盘(不退出)
|
|
173
|
+
mainWindow.on("close", (event) => {
|
|
174
|
+
if (!isQuitting) {
|
|
175
|
+
event.preventDefault();
|
|
176
|
+
mainWindow.hide();
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
mainWindow.on("closed", () => {
|
|
182
|
+
mainWindow = null;
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// 开发模式打开 DevTools
|
|
186
|
+
if (isDev) {
|
|
187
|
+
mainWindow.webContents.openDevTools({ mode: "detach" });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── 托盘 ──
|
|
192
|
+
|
|
193
|
+
function createTray() {
|
|
194
|
+
// 用 16x16 的图标,从 nativeImage 创建
|
|
195
|
+
// 如果没有图标,用 nativeImage.createEmpty()
|
|
196
|
+
const icon = nativeImage.createEmpty();
|
|
197
|
+
tray = new Tray(icon);
|
|
198
|
+
tray.setToolTip("pi-web-ui");
|
|
199
|
+
|
|
200
|
+
const contextMenu = Menu.buildFromTemplate([
|
|
201
|
+
{
|
|
202
|
+
label: "显示窗口",
|
|
203
|
+
click: () => {
|
|
204
|
+
if (mainWindow) {
|
|
205
|
+
mainWindow.show();
|
|
206
|
+
mainWindow.focus();
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
{ type: "separator" },
|
|
211
|
+
{
|
|
212
|
+
label: "关于 pi-web-ui",
|
|
213
|
+
click: () => {
|
|
214
|
+
dialog.showMessageBox({
|
|
215
|
+
type: "info",
|
|
216
|
+
title: "关于 pi-web-ui",
|
|
217
|
+
message: `pi-web-ui v${app.getVersion()}`,
|
|
218
|
+
detail: "Web chat interface for the pi coding agent.",
|
|
219
|
+
});
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
{ type: "separator" },
|
|
223
|
+
{
|
|
224
|
+
label: "退出",
|
|
225
|
+
click: () => {
|
|
226
|
+
isQuitting = true;
|
|
227
|
+
app.quit();
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
]);
|
|
231
|
+
|
|
232
|
+
tray.setContextMenu(contextMenu);
|
|
233
|
+
|
|
234
|
+
// 点击托盘图标 → 显示/隐藏窗口
|
|
235
|
+
tray.on("click", () => {
|
|
236
|
+
if (mainWindow) {
|
|
237
|
+
if (mainWindow.isVisible()) {
|
|
238
|
+
mainWindow.hide();
|
|
239
|
+
} else {
|
|
240
|
+
mainWindow.show();
|
|
241
|
+
mainWindow.focus();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── 菜单 ──
|
|
248
|
+
|
|
249
|
+
function createAppMenu() {
|
|
250
|
+
const template = [
|
|
251
|
+
{
|
|
252
|
+
label: "pi-web-ui",
|
|
253
|
+
submenu: [
|
|
254
|
+
{
|
|
255
|
+
label: "关于 pi-web-ui",
|
|
256
|
+
click: () => {
|
|
257
|
+
dialog.showMessageBox({
|
|
258
|
+
type: "info",
|
|
259
|
+
title: "关于 pi-web-ui",
|
|
260
|
+
message: `pi-web-ui v${app.getVersion()}`,
|
|
261
|
+
detail: "Web chat interface for the pi coding agent.",
|
|
262
|
+
});
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
{ type: "separator" },
|
|
266
|
+
{ role: "hide" },
|
|
267
|
+
{ role: "hideOthers" },
|
|
268
|
+
{ role: "unhide" },
|
|
269
|
+
{ type: "separator" },
|
|
270
|
+
{
|
|
271
|
+
label: "退出",
|
|
272
|
+
accelerator: "CmdOrCtrl+Q",
|
|
273
|
+
click: () => {
|
|
274
|
+
isQuitting = true;
|
|
275
|
+
app.quit();
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
label: "编辑",
|
|
282
|
+
submenu: [
|
|
283
|
+
{ role: "undo" },
|
|
284
|
+
{ role: "redo" },
|
|
285
|
+
{ type: "separator" },
|
|
286
|
+
{ role: "cut" },
|
|
287
|
+
{ role: "copy" },
|
|
288
|
+
{ role: "paste" },
|
|
289
|
+
{ role: "selectAll" },
|
|
290
|
+
],
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
label: "视图",
|
|
294
|
+
submenu: [
|
|
295
|
+
{ role: "reload" },
|
|
296
|
+
{ role: "forceReload" },
|
|
297
|
+
{ role: "toggleDevTools" },
|
|
298
|
+
{ type: "separator" },
|
|
299
|
+
{ role: "resetZoom" },
|
|
300
|
+
{ role: "zoomIn" },
|
|
301
|
+
{ role: "zoomOut" },
|
|
302
|
+
{ type: "separator" },
|
|
303
|
+
{ role: "togglefullscreen" },
|
|
304
|
+
],
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
label: "窗口",
|
|
308
|
+
submenu: [
|
|
309
|
+
{ role: "minimize" },
|
|
310
|
+
{ role: "close" },
|
|
311
|
+
],
|
|
312
|
+
},
|
|
313
|
+
];
|
|
314
|
+
|
|
315
|
+
// macOS 需要第一个菜单项是应用名
|
|
316
|
+
const menu = Menu.buildFromTemplate(template);
|
|
317
|
+
Menu.setApplicationMenu(menu);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ── 自动更新 ──
|
|
321
|
+
|
|
322
|
+
function setupAutoUpdater() {
|
|
323
|
+
if (isDev) return; // 开发模式不检查更新
|
|
324
|
+
|
|
325
|
+
autoUpdater.autoDownload = false;
|
|
326
|
+
autoUpdater.autoInstallOnAppQuit = true;
|
|
327
|
+
|
|
328
|
+
// 检查更新(启动后延迟 5s)
|
|
329
|
+
setTimeout(() => {
|
|
330
|
+
autoUpdater.checkForUpdates().catch(() => {
|
|
331
|
+
// 静默失败(无网络 / 超时等)
|
|
332
|
+
});
|
|
333
|
+
}, 5000);
|
|
334
|
+
|
|
335
|
+
autoUpdater.on("update-available", (info) => {
|
|
336
|
+
const notification = new Notification({
|
|
337
|
+
title: "pi-web-ui 更新可用",
|
|
338
|
+
body: `版本 ${info.version} 可下载(当前 ${app.getVersion()})`,
|
|
339
|
+
});
|
|
340
|
+
notification.on("click", () => {
|
|
341
|
+
autoUpdater.downloadUpdate();
|
|
342
|
+
});
|
|
343
|
+
notification.show();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
autoUpdater.on("update-downloaded", () => {
|
|
347
|
+
const result = dialog.showMessageBoxSync({
|
|
348
|
+
type: "info",
|
|
349
|
+
title: "更新已下载",
|
|
350
|
+
message: "新版本已下载完成,是否立即重启以安装更新?",
|
|
351
|
+
buttons: ["立即重启", "稍后"],
|
|
352
|
+
});
|
|
353
|
+
if (result === 0) {
|
|
354
|
+
autoUpdater.quitAndInstall();
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── 应用生命周期 ──
|
|
360
|
+
|
|
361
|
+
app.setAppUserModelId("com.xingshuyin.pi-web-ui");
|
|
362
|
+
|
|
363
|
+
// 确保只有一个实例
|
|
364
|
+
const gotLock = app.requestSingleInstanceLock();
|
|
365
|
+
if (!gotLock) {
|
|
366
|
+
app.quit();
|
|
367
|
+
} else {
|
|
368
|
+
app.on("second-instance", () => {
|
|
369
|
+
if (mainWindow) {
|
|
370
|
+
if (mainWindow.isMinimized()) mainWindow.restore();
|
|
371
|
+
mainWindow.show();
|
|
372
|
+
mainWindow.focus();
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
app.whenReady().then(async () => {
|
|
378
|
+
try {
|
|
379
|
+
await startServer();
|
|
380
|
+
} catch (err) {
|
|
381
|
+
dialog.showErrorBox("pi-web-ui 启动失败", err.message);
|
|
382
|
+
app.quit();
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
createAppMenu();
|
|
387
|
+
createWindow();
|
|
388
|
+
createTray();
|
|
389
|
+
setupAutoUpdater();
|
|
390
|
+
|
|
391
|
+
app.on("activate", () => {
|
|
392
|
+
if (!mainWindow) createWindow();
|
|
393
|
+
if (mainWindow) {
|
|
394
|
+
mainWindow.show();
|
|
395
|
+
mainWindow.focus();
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
app.on("window-all-closed", () => {
|
|
401
|
+
// macOS 不退出(dock 图标还在)
|
|
402
|
+
// 其他平台退出
|
|
403
|
+
if (process.platform !== "darwin") {
|
|
404
|
+
isQuitting = true;
|
|
405
|
+
app.quit();
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
app.on("before-quit", () => {
|
|
410
|
+
isQuitting = true;
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
app.on("will-quit", () => {
|
|
414
|
+
// 杀 server 子进程
|
|
415
|
+
if (serverProcess) {
|
|
416
|
+
try {
|
|
417
|
+
serverProcess.kill("SIGTERM");
|
|
418
|
+
} catch {
|
|
419
|
+
// 可能已经退出
|
|
420
|
+
}
|
|
421
|
+
serverProcess = null;
|
|
422
|
+
}
|
|
423
|
+
if (tray) {
|
|
424
|
+
tray.destroy();
|
|
425
|
+
tray = null;
|
|
426
|
+
}
|
|
427
|
+
});
|