pi-web-ui 0.80.0 → 0.80.2
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/CHANGELOG.md +45 -3
- package/README.md +204 -36
- package/README.zh-CN.md +190 -25
- package/bin/pi-web-ui.mjs +21 -4
- package/deploy/nginx-subpath.conf +85 -88
- package/dist/server/agent-service.js +22 -7
- package/dist/server/dsh/dsh-agent-service.js +16 -6
- package/dist/server/index.js +38 -1
- package/dist/server/launch-origin.js +148 -0
- package/dist/server/slash-commands.js +19 -3
- package/dist/server/terminals.js +17 -9
- package/package.json +2 -2
- package/plugins/catalog.json +9 -0
- package/web/dist/assets/{TerminalPanel-BYigWYyx.js → TerminalPanel-CeruoZjh.js} +1 -1
- package/web/dist/assets/{index-GKv6Qo91.css → index-BduNm7_u.css} +1 -1
- package/web/dist/assets/index-DtBJSe33.js +347 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-D7BpHJ59.js +0 -347
|
@@ -45,6 +45,7 @@ import { collectSubagentDescendantIds, makeSubagentTools, subagentTitle, withSub
|
|
|
45
45
|
import { makeDelegateTaskTool } from "./delegate-task.js";
|
|
46
46
|
import { buildAttachmentMessages } from "./attachments.js";
|
|
47
47
|
import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, buildToolsSchemaText, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
|
|
48
|
+
import { launchOrigin, toServiceInfo } from "./launch-origin.js";
|
|
48
49
|
import { serializeMessage, serializeStreamingMessage, stripTransientRetryErrors, } from "./serialize.js";
|
|
49
50
|
import { loadCommands, saveCommandsFile, TerminalManager } from "./terminals.js";
|
|
50
51
|
const SNAPSHOT_INTERVAL_MS = 60;
|
|
@@ -127,7 +128,9 @@ Many legacy Chinese text files (.html/.txt/.md/.log, exported documents) are GBK
|
|
|
127
128
|
* terminal) plus head/tail (post-processed on the returned output) so the parameter
|
|
128
129
|
* schema stays consistent with the terminal-backed tool.
|
|
129
130
|
*/
|
|
130
|
-
export function makeKillableBashTool(cwd, kills
|
|
131
|
+
export function makeKillableBashTool(cwd, kills,
|
|
132
|
+
/** per-call 返回文本的服务端语言(默认英文);工具 definition 走 bilingual 内联双语。 */
|
|
133
|
+
lang = () => "en") {
|
|
131
134
|
const base = createLocalBashOperations();
|
|
132
135
|
const tool = createBashTool(cwd, {
|
|
133
136
|
operations: {
|
|
@@ -176,7 +179,7 @@ export function makeKillableBashTool(cwd, kills) {
|
|
|
176
179
|
// head/tail 后处理(native 无终端,直接截返回行即可)。
|
|
177
180
|
const p = params;
|
|
178
181
|
if ((p?.head || p?.tail) && result?.content?.[0]?.text != null) {
|
|
179
|
-
result.content[0].text = applyHeadTail(result.content[0].text, p.head, p.tail);
|
|
182
|
+
result.content[0].text = applyHeadTail(result.content[0].text, p.head, p.tail, lang());
|
|
180
183
|
}
|
|
181
184
|
return result;
|
|
182
185
|
},
|
|
@@ -1408,7 +1411,9 @@ export class ClientSession {
|
|
|
1408
1411
|
// 「默认 bash 覆盖」开关(terminalBash)关 → 原生 SDK bash(纯进程、不开终端);
|
|
1409
1412
|
// 开 → 终端接管 bash(persist 决定一次性/持久,可静默自动转后台)。
|
|
1410
1413
|
customTools: [
|
|
1411
|
-
makeAdaptiveBashTool(
|
|
1414
|
+
makeAdaptiveBashTool(
|
|
1415
|
+
// issue #91:bash 返回按客户端 UI 语言出中英(英文默认)。
|
|
1416
|
+
makeKillableBashTool(effectiveCwd, this.bashKills, () => this.getLang()), makeTerminalBashTool(terminals, {
|
|
1412
1417
|
cwd: effectiveCwd,
|
|
1413
1418
|
// 设置开 = 用终端;此分支里 persist 未显式给时默认一次性(false)。
|
|
1414
1419
|
defaultPersist: () => false,
|
|
@@ -2707,6 +2712,8 @@ export class ClientSession {
|
|
|
2707
2712
|
cwd: () => this.cwd,
|
|
2708
2713
|
getSession: () => this.session,
|
|
2709
2714
|
newChat: () => this.newChat(),
|
|
2715
|
+
// /new <prompt>: deliver the text as the new session's first prompt.
|
|
2716
|
+
prompt: (text) => this.prompt(text),
|
|
2710
2717
|
setModel: (id) => this.setModel(id),
|
|
2711
2718
|
setCwd: (path) => this.setCwd(path),
|
|
2712
2719
|
setThinking: (level) => this.setThinking(level),
|
|
@@ -3528,9 +3535,13 @@ export class ClientSession {
|
|
|
3528
3535
|
});
|
|
3529
3536
|
}
|
|
3530
3537
|
}
|
|
3538
|
+
/** 新建/切到一个空白对话。返回值 = 「当前活动对话就是一个可以接收首条的
|
|
3539
|
+
* 空白新对话」——/new <prompt> 只在 true 时投递首条提示;false 表示没能进入
|
|
3540
|
+
* 新对话(准入关闭 / 同项目对话数达上限 / runtime 创建失败),此时照发会把
|
|
3541
|
+
* 首条提示投进用户原本正在用的那个对话里。 */
|
|
3531
3542
|
async newChat() {
|
|
3532
3543
|
if (this.quiesceBlocked())
|
|
3533
|
-
return;
|
|
3544
|
+
return false;
|
|
3534
3545
|
// Reuse an already-open blank conversation instead of piling up new ones
|
|
3535
3546
|
// on every click: if the active chat has no messages it IS the new chat
|
|
3536
3547
|
// (focus already on it); otherwise switch to the first blank one (under
|
|
@@ -3548,7 +3559,7 @@ export class ClientSession {
|
|
|
3548
3559
|
const active = this.conv;
|
|
3549
3560
|
if (active && isBlank(active)) {
|
|
3550
3561
|
this.flushSnapshot();
|
|
3551
|
-
return;
|
|
3562
|
+
return true;
|
|
3552
3563
|
}
|
|
3553
3564
|
for (const conv of this.convs.values()) {
|
|
3554
3565
|
if (conv.id === this.activeId)
|
|
@@ -3556,7 +3567,7 @@ export class ClientSession {
|
|
|
3556
3567
|
if (isBlank(conv)) {
|
|
3557
3568
|
await this.switchConversation(conv.id);
|
|
3558
3569
|
this.flushSnapshot();
|
|
3559
|
-
return;
|
|
3570
|
+
return true;
|
|
3560
3571
|
}
|
|
3561
3572
|
}
|
|
3562
3573
|
// Cap is per project — conversations of other projects keep their own
|
|
@@ -3570,7 +3581,7 @@ export class ClientSession {
|
|
|
3570
3581
|
text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先打开某个对话并离开(不继续对话)以移出列表`,
|
|
3571
3582
|
textEn: `This project already has the max open conversations (${MAX_OPEN_CONVERSATIONS}). Open one and leave it (without continuing) to remove it from the list.`,
|
|
3572
3583
|
});
|
|
3573
|
-
return;
|
|
3584
|
+
return false;
|
|
3574
3585
|
}
|
|
3575
3586
|
// The outgoing conversation is left behind — apply the running-list
|
|
3576
3587
|
// lifecycle. Removal is deferred until the new chat exists so the active
|
|
@@ -3579,6 +3590,7 @@ export class ClientSession {
|
|
|
3579
3590
|
// Carry the model chosen in the active chat over to the new chat so it
|
|
3580
3591
|
// doesn't silently revert to the ModelRuntime default model.
|
|
3581
3592
|
const prevModel = this.conv.session.agent.state.model ?? null;
|
|
3593
|
+
let ready = false;
|
|
3582
3594
|
try {
|
|
3583
3595
|
const conversationId = this.nextConversationId();
|
|
3584
3596
|
const terminals = this.makeTerminalManager(conversationId, this.cwd);
|
|
@@ -3617,6 +3629,7 @@ export class ClientSession {
|
|
|
3617
3629
|
void this.pushSlashCommands();
|
|
3618
3630
|
// 新对话即当前打开 → 插件重拉(轨迹视图跟随)。
|
|
3619
3631
|
this.notifyConversationChanged();
|
|
3632
|
+
ready = true;
|
|
3620
3633
|
}
|
|
3621
3634
|
catch (err) {
|
|
3622
3635
|
this.emit({
|
|
@@ -3627,6 +3640,7 @@ export class ClientSession {
|
|
|
3627
3640
|
});
|
|
3628
3641
|
}
|
|
3629
3642
|
this.flushSnapshot();
|
|
3643
|
+
return ready;
|
|
3630
3644
|
}
|
|
3631
3645
|
/**
|
|
3632
3646
|
* The active conversation is being left (new_chat / switch_conversation /
|
|
@@ -5163,6 +5177,7 @@ export class AgentService {
|
|
|
5163
5177
|
connectedClients: this.socketCount,
|
|
5164
5178
|
activeConversations: this.activeConversations(),
|
|
5165
5179
|
pendingMessages: this.pendingMessages(),
|
|
5180
|
+
service: toServiceInfo(launchOrigin()),
|
|
5166
5181
|
};
|
|
5167
5182
|
}
|
|
5168
5183
|
/** Get or create the session for a client, racing attach calls safely. */
|
|
@@ -38,6 +38,7 @@ import { TerminalManager, loadCommands, saveCommandsFile } from "../terminals.js
|
|
|
38
38
|
import { saveUpload } from "../uploads.js";
|
|
39
39
|
import { checkAll as checkAllUpdates, collectTargets } from "../update-check.js";
|
|
40
40
|
import { previewKind } from "../text-sniff.js";
|
|
41
|
+
import { launchOrigin, toServiceInfo } from "../launch-origin.js";
|
|
41
42
|
import { DshRuntime, loadDeepSeekKey } from "./dsh-client.js";
|
|
42
43
|
import { DshStreamAccumulator, assistantMessageEventToUiMessage, toolResultEventToUiMessage, userMessageEventToUiMessage, } from "./dsh-serialize.js";
|
|
43
44
|
import { firstUserText, findSessionFilesForCwd, readSessionLog, replayEventsToMessages } from "./dsh-sessions.js";
|
|
@@ -1137,13 +1138,15 @@ export class DshClientSession {
|
|
|
1137
1138
|
}
|
|
1138
1139
|
this.emit({ type: "conversations", conversations: list, activeId: this.activeId });
|
|
1139
1140
|
}
|
|
1141
|
+
/** 语义同 pi 引擎的 newChat:true = 当前活动对话是可接收首条的空白新对话
|
|
1142
|
+
* (/new <prompt> 靠它决定要不要把首条提示发出去)。 */
|
|
1140
1143
|
async newChat() {
|
|
1141
1144
|
if (this.quiesceBlocked())
|
|
1142
|
-
return;
|
|
1145
|
+
return false;
|
|
1143
1146
|
const active = this.conv;
|
|
1144
1147
|
if (active.messages.length === 0 && active.terminals.list().length === 0) {
|
|
1145
1148
|
this.flushSnapshot();
|
|
1146
|
-
return;
|
|
1149
|
+
return true;
|
|
1147
1150
|
}
|
|
1148
1151
|
for (const conv of this.convs.values()) {
|
|
1149
1152
|
if (conv.id === this.activeId)
|
|
@@ -1151,7 +1154,7 @@ export class DshClientSession {
|
|
|
1151
1154
|
if (conv.messages.length === 0) {
|
|
1152
1155
|
this.switchConversation(conv.id);
|
|
1153
1156
|
this.flushSnapshot();
|
|
1154
|
-
return;
|
|
1157
|
+
return true;
|
|
1155
1158
|
}
|
|
1156
1159
|
}
|
|
1157
1160
|
const openInProject = [...this.convs.values()].filter((c) => c.cwd === this.cwd).length;
|
|
@@ -1162,7 +1165,7 @@ export class DshClientSession {
|
|
|
1162
1165
|
text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个)`,
|
|
1163
1166
|
textEn: `This project already has the max open conversations (${MAX_OPEN_CONVERSATIONS}).`,
|
|
1164
1167
|
});
|
|
1165
|
-
return;
|
|
1168
|
+
return false;
|
|
1166
1169
|
}
|
|
1167
1170
|
// 旧对话保留(listed 生命周期简化:不主动移除)。
|
|
1168
1171
|
const prevModel = this.model;
|
|
@@ -1174,6 +1177,7 @@ export class DshClientSession {
|
|
|
1174
1177
|
this.emitGoalStatus();
|
|
1175
1178
|
this.pushTerminals();
|
|
1176
1179
|
this.flushSnapshot();
|
|
1180
|
+
return true;
|
|
1177
1181
|
}
|
|
1178
1182
|
async switchConversation(id) {
|
|
1179
1183
|
if (!this.convs.has(id) || id === this.activeId)
|
|
@@ -2854,9 +2858,14 @@ export class DshClientSession {
|
|
|
2854
2858
|
/** 拦截执行斜杠命令;返回 true 表示已处理(不发给模型)。 */
|
|
2855
2859
|
async execSlash(name, args) {
|
|
2856
2860
|
switch (name) {
|
|
2857
|
-
case "new":
|
|
2858
|
-
|
|
2861
|
+
case "new": {
|
|
2862
|
+
const first = args.trim();
|
|
2863
|
+
// /new <prompt>:与 pi 引擎一致(共用 NATIVE_COMMANDS 的提示词),
|
|
2864
|
+
// 仅当真的落在空白新对话上才投递首条提示,否则会把提示误发进当前对话。
|
|
2865
|
+
if ((await this.newChat()) && first)
|
|
2866
|
+
await this.prompt(first);
|
|
2859
2867
|
return true;
|
|
2868
|
+
}
|
|
2860
2869
|
case "model": {
|
|
2861
2870
|
if (!args.trim()) {
|
|
2862
2871
|
this.emit({
|
|
@@ -3421,6 +3430,7 @@ export class DshAgentService {
|
|
|
3421
3430
|
connectedClients: this.socketCount,
|
|
3422
3431
|
activeConversations: this.activeConversations(),
|
|
3423
3432
|
pendingMessages: this.pendingMessages(),
|
|
3433
|
+
service: toServiceInfo(launchOrigin()),
|
|
3424
3434
|
};
|
|
3425
3435
|
}
|
|
3426
3436
|
async attach(clientId, send) {
|
package/dist/server/index.js
CHANGED
|
@@ -37,6 +37,7 @@ import { scheduleUploadCleanup } from "./uploads.js";
|
|
|
37
37
|
import { ensureWindowsBash, windowsBashDir } from "./ensure-bash.js";
|
|
38
38
|
import { listThemes, resolveThemeFile } from "./themes.js";
|
|
39
39
|
import { isManaged, managedRefusal } from "./managed.js";
|
|
40
|
+
import { launchOrigin, toServiceInfo } from "./launch-origin.js";
|
|
40
41
|
import { parseTabs, tabsRefusal } from "./tabs.js";
|
|
41
42
|
import { installPack, isKnownPack, listPacks, loadServerStrings, readPackFile, removePack, unloadServerStrings, } from "./locales.js";
|
|
42
43
|
import { PluginManager, resolvePluginClientFile, } from "./plugins.js";
|
|
@@ -194,6 +195,13 @@ if (AUTH_TOKEN) {
|
|
|
194
195
|
const ENGINE = process.env.PI_WEB_ENGINE === "dsh" ? "dsh" : "pi";
|
|
195
196
|
/** PI_WEB_MANAGED=1: this instance is updated by whoever deploys it. */
|
|
196
197
|
const MANAGED = isManaged();
|
|
198
|
+
/** Who started this process: a platform service manager (launchd / systemd /
|
|
199
|
+
* Windows watchdog — i.e. `pi-web-ui server start|install`) or nothing
|
|
200
|
+
* (foreground / dev / Docker). Decides whether the UPDATE panel offers
|
|
201
|
+
* "restart service" and what quitting means (see scheduleQuit). */
|
|
202
|
+
const ORIGIN = launchOrigin();
|
|
203
|
+
/** 下发给浏览器的服务信息(null = 没有 supervisor)。 */
|
|
204
|
+
const SERVICE_INFO = toServiceInfo(ORIGIN);
|
|
197
205
|
/** PI_WEB_TABS: the tabs this instance offers. null = all of them, as before. */
|
|
198
206
|
const TABS = parseTabs();
|
|
199
207
|
app.get("/api/health", (_req, res) => {
|
|
@@ -665,8 +673,12 @@ service.onClientCwdChanged = (cwd) => pluginMgr.notifyCwd(cwd);
|
|
|
665
673
|
function scheduleQuit() {
|
|
666
674
|
const isLaunchd = process.platform === "darwin" && process.ppid === 1;
|
|
667
675
|
const isSystemd = process.platform === "linux" && !!process.env.INVOCATION_ID;
|
|
676
|
+
// Windows `server install` runs the server under the powershell watchdog
|
|
677
|
+
// launcher (`while ($true) { node …; Start-Sleep 10 }`) — exiting brings it
|
|
678
|
+
// back within ~10s, same contract as launchd/systemd (see launch-origin.ts).
|
|
679
|
+
const isWinWatchdog = ORIGIN.supervisor === "windows-watchdog";
|
|
668
680
|
const inDocker = existsSync("/.dockerenv");
|
|
669
|
-
if (isLaunchd || isSystemd || inDocker) {
|
|
681
|
+
if (isLaunchd || isSystemd || isWinWatchdog || inDocker) {
|
|
670
682
|
setTimeout(() => {
|
|
671
683
|
console.log("pi-web-ui:quit — shutting down (supervisor will restart)…");
|
|
672
684
|
if (isSystemd)
|
|
@@ -922,6 +934,30 @@ wss.on("connection", (ws) => {
|
|
|
922
934
|
case "check_updates_all":
|
|
923
935
|
void cs.checkUpdatesAll(msg.force === true);
|
|
924
936
|
break;
|
|
937
|
+
case "restart_service": {
|
|
938
|
+
// Same effect as `pi-web-ui server restart`: this process exits and its
|
|
939
|
+
// supervisor brings it back (launchd/systemd immediately, the Windows
|
|
940
|
+
// watchdog within ~10s). Refused without a supervisor — exiting there
|
|
941
|
+
// would just stop the server the user is looking at.
|
|
942
|
+
if (!ORIGIN.supervisor) {
|
|
943
|
+
send({
|
|
944
|
+
type: "notice",
|
|
945
|
+
level: "error",
|
|
946
|
+
text: "当前实例不是由 pi-web-ui 服务启动的(前台运行),无法自动重启;请在终端里重启,或先用 pi-web-ui server install 安装服务。",
|
|
947
|
+
textEn: "This instance runs in the foreground, not as a pi-web-ui service — nothing would bring it back. Restart it in its terminal, or install the service with `pi-web-ui server install`.",
|
|
948
|
+
});
|
|
949
|
+
break;
|
|
950
|
+
}
|
|
951
|
+
send({
|
|
952
|
+
type: "notice",
|
|
953
|
+
level: "info",
|
|
954
|
+
text: "正在重启服务…页面会在服务恢复后自动重连。",
|
|
955
|
+
textEn: "Restarting the service… this page reconnects once it is back.",
|
|
956
|
+
});
|
|
957
|
+
// Let the notice (and this socket's backlog) flush before we go down.
|
|
958
|
+
setTimeout(() => void scheduleQuit(), 400);
|
|
959
|
+
break;
|
|
960
|
+
}
|
|
925
961
|
case "dialog_response":
|
|
926
962
|
cs.resolveDialog(msg.id, msg.value);
|
|
927
963
|
break;
|
|
@@ -1153,6 +1189,7 @@ wss.on("connection", (ws) => {
|
|
|
1153
1189
|
appVersion: appVersion(),
|
|
1154
1190
|
managed: MANAGED,
|
|
1155
1191
|
tabs: TABS ? [...TABS] : undefined,
|
|
1192
|
+
service: SERVICE_INFO ?? undefined,
|
|
1156
1193
|
});
|
|
1157
1194
|
// Plugin catalog: re-scan + activate new dirs on every attach so
|
|
1158
1195
|
// freshly dropped plugins show up without a server restart.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* launch-origin — 这个进程是被谁启动的?
|
|
3
|
+
*
|
|
4
|
+
* 界面里有一处需要知道答案:更新面板的「重启服务」按钮。它只有在**退出后会被
|
|
5
|
+
* 自动拉起**的实例上才有意义(`pi-web-ui server start` 起的是这种),前台
|
|
6
|
+
* `pi-web-ui` / `npm run dev` 起的实例点它 = 把服务关掉不回来了。
|
|
7
|
+
*
|
|
8
|
+
* 判定的是「有没有 supervisor 会在本进程退出后把它拉起来」,一共三种:
|
|
9
|
+
* - **launchd**(macOS,`server install` 写的 plist)——launchd 给每个 job 设
|
|
10
|
+
* `XPC_SERVICE_NAME`,据此认出我们生成的 label(com.xingshuyin.pi-web-ui /
|
|
11
|
+
* com.<name>.server);
|
|
12
|
+
* - **systemd**(Linux,`Restart=always`)——systemd 给每个 unit 设
|
|
13
|
+
* `INVOCATION_ID`;unit 名从 /proc/self/cgroup 里取(`…/pi-web-ui.service`);
|
|
14
|
+
* - **windows-watchdog**(Windows,`server install` 写的 ps1:
|
|
15
|
+
* `while ($true) { node …; Start-Sleep 10 }`)——没有环境变量可依,靠 PID
|
|
16
|
+
* 文件反查:ps1 把自己的 PID 写进 `%APPDATA%\pi-web-ui\<name>.pid`,而 node
|
|
17
|
+
* 是它的直接子进程,所以 `process.ppid` 等于文件里那个 PID。同名 ps1 里必须
|
|
18
|
+
* 真的有 watchdog 循环 —— 桌面快捷方式的 ps1 也写同一个 PID 文件,但它
|
|
19
|
+
* (未安装服务时)是前台跑,退出不会回来。
|
|
20
|
+
*
|
|
21
|
+
* 新版 `server install` 另外烘焙 `PI_WEB_LAUNCHED_BY=service` +
|
|
22
|
+
* `PI_WEB_SERVICE_NAME=<name>`(最高优先级、最明确的一路);已装好的老服务没有
|
|
23
|
+
* 这两个变量,所以上面的运行时判据必须保留。
|
|
24
|
+
*
|
|
25
|
+
* Docker **刻意不算**:容器由编排/`restart:` 策略管,重启容器不是这个按钮该干的事。
|
|
26
|
+
*
|
|
27
|
+
* 纯函数(env / ppid / fs 读取全部可注入)便于单测;`launchOrigin()` 是进程级
|
|
28
|
+
* 缓存的一次性探测,服务端多处共用同一份结论。
|
|
29
|
+
*/
|
|
30
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
31
|
+
import { homedir } from "node:os";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
/** 默认服务名(`server install` 没传 --name 时)。 */
|
|
34
|
+
export const DEFAULT_SERVICE_NAME = "pi-web-ui";
|
|
35
|
+
/** 默认 launchd label —— 与 bin/pi-web-ui.mjs 的 serviceLabel() 保持一致。 */
|
|
36
|
+
const LAUNCHD_LABEL_DEFAULT = "com.xingshuyin.pi-web-ui";
|
|
37
|
+
/** Windows 启动器目录(CLI 的 winServiceDir 同源)。 */
|
|
38
|
+
const WIN_SERVICE_DIR_NAME = "pi-web-ui";
|
|
39
|
+
function defaultListDir(dir) {
|
|
40
|
+
try {
|
|
41
|
+
return readdirSync(dir);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function defaultReadFile(path) {
|
|
48
|
+
try {
|
|
49
|
+
return readFileSync(path, "utf8");
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Windows 启动器目录:`%APPDATA%\pi-web-ui`(CLI 的 winServiceDir)。 */
|
|
56
|
+
export function winServiceDir(env = process.env) {
|
|
57
|
+
const appData = env.APPDATA?.trim() || join(homedir(), "AppData", "Roaming");
|
|
58
|
+
return join(appData, WIN_SERVICE_DIR_NAME);
|
|
59
|
+
}
|
|
60
|
+
/** launchd label → 服务名;不是我们生成的 label 返回 null。 */
|
|
61
|
+
function nameFromLaunchdLabel(label) {
|
|
62
|
+
if (label === LAUNCHD_LABEL_DEFAULT)
|
|
63
|
+
return DEFAULT_SERVICE_NAME;
|
|
64
|
+
// serviceLabel(`--name foo`) = com.foo.server
|
|
65
|
+
const m = /^com\.(.+)\.server$/.exec(label);
|
|
66
|
+
return m?.[1] ?? null;
|
|
67
|
+
}
|
|
68
|
+
/** systemd cgroup 内容 → unit 名(`0::/system.slice/pi-web-ui.service`)。 */
|
|
69
|
+
function nameFromCgroup(content) {
|
|
70
|
+
const m = /([A-Za-z0-9_.@-]+)\.service/.exec(content ?? "");
|
|
71
|
+
return m?.[1];
|
|
72
|
+
}
|
|
73
|
+
/** `PI_WEB_LAUNCHED_BY=service` 时的 supervisor(按平台推导)。 */
|
|
74
|
+
function supervisorForPlatform(platform) {
|
|
75
|
+
if (platform === "win32")
|
|
76
|
+
return "windows-watchdog";
|
|
77
|
+
if (platform === "darwin")
|
|
78
|
+
return "launchd";
|
|
79
|
+
return "systemd";
|
|
80
|
+
}
|
|
81
|
+
/** Windows:PID 文件里的 PID === 本进程的 ppid,且同名 ps1 里确实有 watchdog 循环。 */
|
|
82
|
+
function detectWindows(input, env) {
|
|
83
|
+
const dir = input.winServiceDir ?? winServiceDir(env);
|
|
84
|
+
const listDir = input.listDir ?? defaultListDir;
|
|
85
|
+
const readFile = input.readFile ?? defaultReadFile;
|
|
86
|
+
const ppid = input.ppid ?? process.ppid;
|
|
87
|
+
for (const file of listDir(dir)) {
|
|
88
|
+
if (!file.endsWith(".pid"))
|
|
89
|
+
continue;
|
|
90
|
+
const raw = readFile(join(dir, file));
|
|
91
|
+
if (raw === null)
|
|
92
|
+
continue;
|
|
93
|
+
if (Number(raw.trim()) !== ppid)
|
|
94
|
+
continue;
|
|
95
|
+
const name = file.slice(0, -".pid".length);
|
|
96
|
+
const ps1 = readFile(join(dir, `${name}.ps1`));
|
|
97
|
+
if (ps1 && ps1.includes("while ($true)"))
|
|
98
|
+
return { supervisor: "windows-watchdog", name };
|
|
99
|
+
}
|
|
100
|
+
return { supervisor: null };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* 探测启动来源。`PI_WEB_LAUNCHED_BY=service` 优先(新版 install 烘焙的),
|
|
104
|
+
* 否则按平台识别 supervisor 自己的运行时痕迹。
|
|
105
|
+
*/
|
|
106
|
+
export function detectLaunchOrigin(input = {}) {
|
|
107
|
+
const env = input.env ?? process.env;
|
|
108
|
+
const platform = input.platform ?? process.platform;
|
|
109
|
+
const serviceName = env.PI_WEB_SERVICE_NAME?.trim() || undefined;
|
|
110
|
+
if ((env.PI_WEB_LAUNCHED_BY ?? "").trim().toLowerCase() === "service") {
|
|
111
|
+
return { supervisor: supervisorForPlatform(platform), name: serviceName ?? DEFAULT_SERVICE_NAME };
|
|
112
|
+
}
|
|
113
|
+
if (platform === "darwin") {
|
|
114
|
+
const label = env.XPC_SERVICE_NAME?.trim();
|
|
115
|
+
if (!label)
|
|
116
|
+
return { supervisor: null };
|
|
117
|
+
const name = nameFromLaunchdLabel(label);
|
|
118
|
+
return name ? { supervisor: "launchd", name } : { supervisor: null };
|
|
119
|
+
}
|
|
120
|
+
if (platform === "linux") {
|
|
121
|
+
if (!env.INVOCATION_ID)
|
|
122
|
+
return { supervisor: null };
|
|
123
|
+
const readCgroup = input.readCgroup ?? defaultReadCgroup;
|
|
124
|
+
return { supervisor: "systemd", name: serviceName ?? nameFromCgroup(readCgroup()) ?? DEFAULT_SERVICE_NAME };
|
|
125
|
+
}
|
|
126
|
+
if (platform === "win32")
|
|
127
|
+
return detectWindows(input, env);
|
|
128
|
+
return { supervisor: null };
|
|
129
|
+
}
|
|
130
|
+
function defaultReadCgroup() {
|
|
131
|
+
return defaultReadFile("/proc/self/cgroup");
|
|
132
|
+
}
|
|
133
|
+
let cached = null;
|
|
134
|
+
/** 进程级缓存的一次性探测(服务端多处共用:ready 消息 / quit 语义 / 控制 socket)。 */
|
|
135
|
+
export function launchOrigin() {
|
|
136
|
+
cached ??= detectLaunchOrigin();
|
|
137
|
+
return cached;
|
|
138
|
+
}
|
|
139
|
+
/** 仅测试用:清掉缓存。 */
|
|
140
|
+
export function resetLaunchOriginCache() {
|
|
141
|
+
cached = null;
|
|
142
|
+
}
|
|
143
|
+
/** 下发给浏览器的服务信息(null = 没有 supervisor,界面不提供「重启服务」)。 */
|
|
144
|
+
export function toServiceInfo(origin) {
|
|
145
|
+
if (!origin.supervisor)
|
|
146
|
+
return null;
|
|
147
|
+
return { name: origin.name ?? DEFAULT_SERVICE_NAME, supervisor: origin.supervisor };
|
|
148
|
+
}
|
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
* prompt() — without this they'd be sent to the model as plain text). Keep in
|
|
4
4
|
* sync with exec(). */
|
|
5
5
|
export const NATIVE_COMMANDS = [
|
|
6
|
-
{
|
|
6
|
+
{
|
|
7
|
+
name: "new",
|
|
8
|
+
description: "新建对话(可带首条提示:/new <提示>)",
|
|
9
|
+
descriptionEn: "New chat (optional first prompt: /new <prompt>)",
|
|
10
|
+
argumentHint: "[提示]",
|
|
11
|
+
argumentHintEn: "[prompt]",
|
|
12
|
+
},
|
|
7
13
|
{
|
|
8
14
|
name: "name",
|
|
9
15
|
description: "重命名当前会话",
|
|
@@ -135,9 +141,19 @@ export class SlashCommandsService {
|
|
|
135
141
|
* name is not a native command (the prompt falls through to the SDK). */
|
|
136
142
|
async exec(name, args) {
|
|
137
143
|
switch (name) {
|
|
138
|
-
case "new":
|
|
139
|
-
|
|
144
|
+
case "new": {
|
|
145
|
+
const first = args.trim();
|
|
146
|
+
const ready = await this.host.newChat();
|
|
147
|
+
// /new <prompt>: deliver the text as the new session's first
|
|
148
|
+
// prompt, exactly as if typed after the switch. Empty = old
|
|
149
|
+
// behavior (blank chat, no send). Only when the switch actually
|
|
150
|
+
// landed on a blank chat — newChat() reports false when it bailed
|
|
151
|
+
// (cap reached / runtime creation failed) and sending anyway would
|
|
152
|
+
// drop the text into the conversation the user was already in.
|
|
153
|
+
if (ready !== false && first && this.host.prompt)
|
|
154
|
+
await this.host.prompt(first);
|
|
140
155
|
return true;
|
|
156
|
+
}
|
|
141
157
|
case "name": {
|
|
142
158
|
const trimmed = args.trim();
|
|
143
159
|
if (!trimmed) {
|
package/dist/server/terminals.js
CHANGED
|
@@ -1366,18 +1366,24 @@ let oneShotBashSeq = 0;
|
|
|
1366
1366
|
/** 应用 head / tail 参数到输出顶层行(替代 `| head` / `| tail` 管道——管道会
|
|
1367
1367
|
* 缓冲输出、让可见终端全程哑火,还容易白白触发静默解阻)。两者同时给时先
|
|
1368
1368
|
* 截头再截尾。 */
|
|
1369
|
-
export function applyHeadTail(text, head, tail) {
|
|
1369
|
+
export function applyHeadTail(text, head, tail, lang = "en") {
|
|
1370
1370
|
// 只对真实数据行切片;省略提示行单独存,最后再包回输出,避免提示行在
|
|
1371
1371
|
// head+tail 组合时被当成数据行参与第二次截取(导致尾部少截一行)。
|
|
1372
1372
|
let data = text.split("\n");
|
|
1373
1373
|
let headNote = null;
|
|
1374
1374
|
let tailNote = null;
|
|
1375
1375
|
if (head && head > 0 && data.length > head) {
|
|
1376
|
-
|
|
1376
|
+
const n = data.length - head;
|
|
1377
|
+
headNote = pick(lang, `…(后 ${n} 行已省略)`, `…[${n} lines omitted below]…`, "terminals.headtail.omitted.below", {
|
|
1378
|
+
n,
|
|
1379
|
+
});
|
|
1377
1380
|
data = data.slice(0, head);
|
|
1378
1381
|
}
|
|
1379
1382
|
if (tail && tail > 0 && data.length > tail) {
|
|
1380
|
-
|
|
1383
|
+
const n = data.length - tail;
|
|
1384
|
+
tailNote = pick(lang, `…(前 ${n} 行已省略)`, `…[${n} lines omitted above]…`, "terminals.headtail.omitted.above", {
|
|
1385
|
+
n,
|
|
1386
|
+
});
|
|
1381
1387
|
data = data.slice(-tail);
|
|
1382
1388
|
}
|
|
1383
1389
|
const parts = [];
|
|
@@ -1460,10 +1466,12 @@ export function makeTerminalBashTool(terminals, opts) {
|
|
|
1460
1466
|
const redirect = stripped && limiter.kind === "tail" ? detectStdoutRedirect(runCommand) : null;
|
|
1461
1467
|
const tailFile = redirect ? { file: redirect.file, lines: limiter.lines ?? 10 } : undefined;
|
|
1462
1468
|
// 复杂子表达式先 hoist 成干净 const(issue #91 v2:vars key 不写复杂表达式)。
|
|
1463
|
-
|
|
1464
|
-
const
|
|
1465
|
-
const
|
|
1466
|
-
const
|
|
1469
|
+
// 注意:limiter 对「没有尾部限输出管道」的命令是 null(issue #121)——
|
|
1470
|
+
// 这些 const 一律走可选链,只在 stripped=true(limiterNote 才被取用)时才有意义。
|
|
1471
|
+
const limiterSegment = limiter?.segment ?? "";
|
|
1472
|
+
const limiterTailLines = limiter?.lines ?? 10;
|
|
1473
|
+
const limiterTailZh = limiter?.kind === "tail" ? `本次返回末尾 ${limiterTailLines} 行。` : "本次返回全部输出。";
|
|
1474
|
+
const limiterTailEn = limiter?.kind === "tail"
|
|
1467
1475
|
? `Returning the last ${limiterTailLines} lines this time.`
|
|
1468
1476
|
: "Returning the full output this time.";
|
|
1469
1477
|
const limiterNote = stripped
|
|
@@ -1497,7 +1505,7 @@ export function makeTerminalBashTool(terminals, opts) {
|
|
|
1497
1505
|
if (m) {
|
|
1498
1506
|
terminals.setSentinelPending(termId, false);
|
|
1499
1507
|
closeOneShot();
|
|
1500
|
-
const text = applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail);
|
|
1508
|
+
const text = applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail, lang);
|
|
1501
1509
|
return {
|
|
1502
1510
|
content: [
|
|
1503
1511
|
{
|
|
@@ -1517,7 +1525,7 @@ export function makeTerminalBashTool(terminals, opts) {
|
|
|
1517
1525
|
}
|
|
1518
1526
|
// 静默解阻(仅持久终端):转后台 + 注册完成观察器,立即把控制权还给模型。
|
|
1519
1527
|
if (persist && idleMs > 0 && Date.now() - lastDataAt >= idleMs) {
|
|
1520
|
-
return backgroundResult(terminals, opts, runCommand, applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail), Math.round((Date.now() - lastDataAt) / 1000), lang);
|
|
1528
|
+
return backgroundResult(terminals, opts, runCommand, applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail, lang), Math.round((Date.now() - lastDataAt) / 1000), lang);
|
|
1521
1529
|
}
|
|
1522
1530
|
}
|
|
1523
1531
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-web-ui",
|
|
3
|
-
"version": "0.80.
|
|
3
|
+
"version": "0.80.2",
|
|
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
|
"author": {
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"dependencies": {
|
|
82
82
|
"@deepseek-ai/dsh-sdk-jsonrpc-server": "^0.1.1-rc.2",
|
|
83
83
|
"@deepseek-ai/dsh-sdk-protocol": "^0.1.1-rc.2",
|
|
84
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
84
|
+
"@earendil-works/pi-coding-agent": "^0.85.1",
|
|
85
85
|
"@xterm/addon-fit": "^0.11.0",
|
|
86
86
|
"@xterm/xterm": "^6.0.0",
|
|
87
87
|
"compression": "^1.8.1",
|
package/plugins/catalog.json
CHANGED
|
@@ -43,5 +43,14 @@
|
|
|
43
43
|
"descriptionEn": "Run trajectory: task → thinking → tools → file changes → result timeline with replay and node details.",
|
|
44
44
|
"source": "xing-shuyin/pi-web-ui/plugins/run-trace",
|
|
45
45
|
"homepage": "https://github.com/xing-shuyin/pi-web-ui/tree/main/plugins/run-trace"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"id": "legado-web",
|
|
49
|
+
"name": "legado-web",
|
|
50
|
+
"icon": "📖",
|
|
51
|
+
"description": "Legado 阅读(文本源):搜索 / 发现 / 详情 / 目录 / 正文,书源 JSON 与安卓版兼容,可导入、检测、删废源;内置跨域+GBK 代理与本地存储,并给 AI 配了修源接口(读规则、读书源文件、逐步诊断链路、试规则验证)。",
|
|
52
|
+
"descriptionEn": "Legado reader (text book sources): search / explore / info / TOC / content with Android-compatible book source JSON, plus source import and health checking. Ships its own CORS+GBK proxy, local store, and AI tools to diagnose and repair book sources.",
|
|
53
|
+
"source": "xing-shuyin/pi-web-ui/plugins/legado-web",
|
|
54
|
+
"homepage": "https://github.com/xing-shuyin/pi-web-ui/tree/main/plugins/legado-web"
|
|
46
55
|
}
|
|
47
56
|
]
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{a as l,j as n}from"./markdown-D3PKeHAZ.js";import{u as P,b as O,T as M,a as p,c as W,F as Y,d as z,e as Z,f as H,g as ee,h as ne,i as te,j as se,r as ae}from"./index-
|
|
1
|
+
import{a as l,j as n}from"./markdown-D3PKeHAZ.js";import{u as P,b as O,T as M,a as p,c as W,F as Y,d as z,e as Z,f as H,g as ee,h as ne,i as te,j as se,r as ae}from"./index-DtBJSe33.js";import{D as re,o as ie}from"./xterm-B96xOxS9.js";import"./react-w24rH0km.js";function le(t){let c=null;return{clean:t.replace(/\r?\n?\[pi-term-exit:(-?\d+)\]\r?\n?/g,(h,u)=>(c=Number(u),`\r
|
|
2
2
|
`)).replace(/\r?\n?\x1b\[90m\[(?:进程已退出,退出码 |Process exited with code )-?\d+\]\x1b\[0m\r?\n?/g,`\r
|
|
3
3
|
`),exitCode:c}}function ce({conversationId:t,terminalId:c,command:s,cwd:h,title:u,active:w,running:f,exitCode:k,register:g}){const C=l.useRef(null),j=l.useRef(null),{locale:o}=P(),y=l.useRef(o);y.current=o;const E=s?JSON.stringify(s):"";l.useEffect(()=>{const m=C.current;if(!m)return;const r=new re({theme:O(),fontFamily:'"SF Mono", "JetBrains Mono", ui-monospace, Menlo, Consolas, monospace',fontSize:13,cursorBlink:!0,scrollback:8e3}),v=new ie;r.loadAddon(v),r.open(m),j.current={term:r,fit:v},w&&r.focus();const b=()=>{r.options.theme=O()};window.addEventListener(M,b),r.attachCustomKeyEventHandler(d=>{if(d.type!=="keydown")return!0;const S=d.key?.toLowerCase();if((d.ctrlKey||d.metaKey)&&S==="v")return!1;if(d.ctrlKey&&!d.shiftKey&&!d.altKey&&S==="c"&&r.hasSelection()){const D=r.textarea;return D&&(D.value=r.getSelection(),D.select()),!1}return!0});const _=g(t,c,{write:d=>r.write(le(d).clean),dispose:()=>r.dispose()}),$=()=>{try{v.fit(),p({type:"terminal_resize",terminalId:c,conversationId:t,cols:r.cols,rows:r.rows})}catch{}},B=requestAnimationFrame(()=>{try{v.fit()}catch{}s?p({type:"run_command",terminalId:c,conversationId:t,command:s,cols:r.cols,rows:r.rows}):p({type:"terminal_create",terminalId:c,title:u,locale:y.current,conversationId:t,cwd:h,cols:r.cols,rows:r.rows})}),R=r.onData(d=>{p({type:"terminal_input",terminalId:c,conversationId:t,data:d})});let T=null;return typeof ResizeObserver<"u"&&(T=new ResizeObserver(()=>{m.offsetWidth>0&&m.offsetHeight>0&&$()}),T.observe(m)),()=>{cancelAnimationFrame(B),R.dispose(),window.removeEventListener(M,b),T?.disconnect(),_(),r.dispose(),j.current=null}},[t,c,E,g]),l.useEffect(()=>{if(!w)return;const m=requestAnimationFrame(()=>{const r=j.current;if(r){try{r.fit.fit(),p({type:"terminal_resize",terminalId:c,conversationId:t,cols:r.term.cols,rows:r.term.rows})}catch{}r.term.focus()}});return()=>cancelAnimationFrame(m)},[w]);const{t:F}=P(),x=l.useRef(void 0);return l.useEffect(()=>{if(f===x.current||(x.current=f,f!==!1))return;const m=j.current;m&&m.term.write(`\r
|
|
4
4
|
\x1B[90m${F("exitBanner",{code:k??""})}\x1B[0m\r
|