@rexvane/piweb 0.3.18 → 0.3.20
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/.next/BUILD_ID +1 -1
- package/.next/app-path-routes-manifest.json +8 -6
- package/.next/build-manifest.json +3 -3
- package/.next/prerender-manifest.json +4 -4
- package/.next/react-loadable-manifest.json +23 -9
- package/.next/required-server-files.js +2 -1
- package/.next/required-server-files.json +2 -1
- package/.next/routes-manifest.json +12 -0
- package/.next/server/app/_global-error/page.js +2 -2
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page.js +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +2 -2
- package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/api/agent/[id]/events/route.js +3 -3
- package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/[id]/route.js +1 -1
- package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/[id]/tree/route.js +1 -1
- package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/new/route.js +1 -1
- package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
- package/.next/server/app/api/config/route.js +1 -1
- package/.next/server/app/api/config/route.js.nft.json +1 -1
- package/.next/server/app/api/files/route.js +1 -1
- package/.next/server/app/api/files/route.js.nft.json +1 -1
- package/.next/server/app/api/fs/route.js +1 -0
- package/.next/server/app/api/fs/route.js.nft.json +1 -0
- package/.next/server/app/api/fs/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/git/route.js +2 -2
- package/.next/server/app/api/git/route.js.nft.json +1 -1
- package/.next/server/app/api/growth/route.js +1 -1
- package/.next/server/app/api/growth/route.js.nft.json +1 -1
- package/.next/server/app/api/health/route.js +1 -1
- package/.next/server/app/api/models/route.js +1 -1
- package/.next/server/app/api/models/route.js.nft.json +1 -1
- package/.next/server/app/api/pi-settings/route.js +1 -1
- package/.next/server/app/api/pi-settings/route.js.nft.json +1 -1
- package/.next/server/app/api/plugins/route.js +1 -1
- package/.next/server/app/api/plugins/route.js.nft.json +1 -1
- package/.next/server/app/api/prompts/route.js +1 -1
- package/.next/server/app/api/prompts/route.js.nft.json +1 -1
- package/.next/server/app/api/remote/route.js +8 -0
- package/.next/server/app/api/remote/route.js.nft.json +1 -0
- package/.next/server/app/api/remote/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/security/route.js +1 -1
- package/.next/server/app/api/security/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/import/route.js +2 -2
- package/.next/server/app/api/sessions/import/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/route.js +1 -1
- package/.next/server/app/api/sessions/route.js.nft.json +1 -1
- package/.next/server/app/api/skills/route.js +1 -1
- package/.next/server/app/api/skills/route.js.nft.json +1 -1
- package/.next/server/app/api/update/route.js +2 -2
- package/.next/server/app/api/update/route.js.nft.json +1 -1
- package/.next/server/app/api/version/route.js +1 -1
- package/.next/server/app/api/version/route.js.nft.json +1 -1
- package/.next/server/app/api/web-auth/route.js +3 -1
- package/.next/server/app/api/workspaces/route.js +1 -1
- package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
- package/.next/server/app/icon.svg/route.js +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +4 -4
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/index.segments/_full.segment.rsc +4 -4
- package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/login/page.js +1 -1
- package/.next/server/app/login/page_client-reference-manifest.js +1 -1
- package/.next/server/app/login.html +1 -1
- package/.next/server/app/login.rsc +3 -3
- package/.next/server/app/login.segments/_full.segment.rsc +3 -3
- package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +3 -3
- package/.next/server/app/page.js +1 -1
- package/.next/server/app/page.js.nft.json +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +8 -6
- package/.next/server/chunks/229.js +1 -1
- package/.next/server/chunks/243.js +1 -0
- package/.next/server/chunks/393.js +1 -0
- package/.next/server/chunks/496.js +1 -1
- package/.next/server/chunks/538.js +1 -0
- package/.next/server/chunks/686.js +1 -4
- package/.next/server/chunks/802.js +2 -0
- package/.next/server/chunks/807.js +1 -0
- package/.next/server/chunks/920.js +4 -0
- package/.next/server/chunks/924.js +6 -0
- package/.next/server/instrumentation.js +1 -0
- package/.next/server/instrumentation.js.nft.json +1 -0
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/.next/server/middleware.js +4 -3
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/_Y3Lq0lI5RJ1tFSOnDi0L/_buildManifest.js +1 -0
- package/.next/static/chunks/2716.7ad073038e94a802.js +1 -0
- package/.next/static/chunks/316-5ea443ee6b512aa9.js +1 -0
- package/.next/static/chunks/4673.8a5a9e6855350e34.js +1 -0
- package/.next/static/chunks/4686-b1e9fa876d0ea671.js +1 -0
- package/.next/static/chunks/4778.e4c9ed093a413bc4.js +1 -0
- package/.next/static/chunks/5173.91ddb803785009f8.js +1 -0
- package/.next/static/chunks/839.d3fe337a9117c325.js +1 -0
- package/.next/static/chunks/8552.6bfe3e101250b20f.js +1 -0
- package/.next/static/chunks/app/_global-error/page-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/events/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/tree/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/agent/new/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/config/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/files/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/fs/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/git/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/growth/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/health/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/models/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/pi-settings/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/plugins/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/prompts/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/remote/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/security/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/export/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/sessions/import/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/sessions/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/skills/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/update/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/version/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/web-auth/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/workspaces/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/login/page-5e276a1eeb8867df.js +1 -0
- package/.next/static/chunks/app/page-2e425ef83fba0782.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/app-error-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/forbidden-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/not-found-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/webpack-3614a8337843456b.js +1 -0
- package/.next/static/css/7391c42e1ae617f9.css +1 -0
- package/.next/types/app/api/fs/route.ts +353 -0
- package/.next/types/app/api/remote/route.ts +353 -0
- package/.next/types/routes.d.ts +3 -1
- package/.next/types/validator.ts +18 -0
- package/README.md +56 -10
- package/next.config.ts +3 -2
- package/package.json +3 -3
- package/scripts/launcher.mjs +194 -13
- package/scripts/release.mjs +0 -2
- package/src/app/api/agent/[id]/events/route.ts +11 -6
- package/src/app/api/fs/route.ts +119 -0
- package/src/app/api/growth/route.ts +11 -6
- package/src/app/api/models/route.ts +21 -9
- package/src/app/api/remote/route.ts +77 -0
- package/src/app/api/sessions/[id]/export/route.ts +11 -1
- package/src/app/api/sessions/route.ts +3 -1
- package/src/app/api/web-auth/route.ts +45 -49
- package/src/app/api/workspaces/route.ts +16 -0
- package/src/app/globals.css +6 -0
- package/src/app/login/page.tsx +8 -60
- package/src/components/AppShell.tsx +202 -155
- package/src/components/ChatInput.tsx +61 -6
- package/src/components/ChatWindow.tsx +274 -132
- package/src/components/DirectoryPicker.tsx +189 -0
- package/src/components/DrawerPanel.tsx +164 -0
- package/src/components/FileViewer.tsx +2 -2
- package/src/components/GrowthTree.tsx +6 -5
- package/src/components/MarkdownBlock.tsx +134 -0
- package/src/components/OutlineRail.tsx +22 -1
- package/src/components/ProjectPanel.tsx +197 -14
- package/src/components/RemoteSection.tsx +265 -0
- package/src/components/SessionImportSection.tsx +60 -38
- package/src/components/SettingsPanel.tsx +104 -57
- package/src/components/icons.tsx +23 -0
- package/src/hooks/useGrowth.ts +47 -25
- package/src/hooks/usePiWeb.ts +100 -14
- package/src/i18n.tsx +117 -28
- package/src/instrumentation-node.ts +35 -0
- package/src/instrumentation.ts +18 -0
- package/src/lib/agent-config.ts +134 -0
- package/src/lib/agent-manager.ts +243 -61
- package/src/lib/auth-throttle.ts +102 -0
- package/src/lib/auth.ts +35 -27
- package/src/lib/files-service.ts +61 -1
- package/src/lib/font.ts +84 -0
- package/src/lib/git-service.ts +5 -3
- package/src/lib/growth-service.ts +78 -30
- package/src/lib/growth-tracker.ts +91 -9
- package/src/lib/models-service.ts +36 -11
- package/src/lib/pi-settings.ts +10 -12
- package/src/lib/pi.ts +145 -18
- package/src/lib/plugins-service.ts +67 -35
- package/src/lib/remote/assets.json +4 -0
- package/src/lib/remote/mobile-proxy.ts +155 -0
- package/src/lib/remote/supervisor.ts +492 -0
- package/src/lib/security-service.ts +6 -11
- package/src/lib/session-import/claude.ts +45 -21
- package/src/lib/session-reader.ts +106 -3
- package/src/lib/skills-service.ts +34 -12
- package/src/lib/text-sanitize.ts +12 -0
- package/src/lib/theme.ts +3 -0
- package/src/lib/types.ts +16 -2
- package/src/lib/update-service.ts +0 -1
- package/src/lib/web-auth-shared.ts +5 -1
- package/src/lib/web-auth.ts +139 -22
- package/src/lib/web-launch-token.ts +55 -0
- package/src/lib/web-secret.ts +182 -0
- package/src/lib/workspace-store.ts +91 -7
- package/src/proxy.ts +46 -12
- package/.next/server/chunks/532.js +0 -1
- package/.next/static/chunks/2155.2f27fe7098987699.js +0 -1
- package/.next/static/chunks/2608.ec8ff1f5767c9056.js +0 -1
- package/.next/static/chunks/2716.93be8ad5c970c516.js +0 -1
- package/.next/static/chunks/4632.112d2e893d48d188.js +0 -1
- package/.next/static/chunks/4673.c1fb1580efc6ace7.js +0 -1
- package/.next/static/chunks/4686-f4a037d609cea3f2.js +0 -1
- package/.next/static/chunks/692-7992db0ae9cc3021.js +0 -1
- package/.next/static/chunks/app/_global-error/page-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/agent/[id]/events/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/agent/[id]/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/agent/[id]/tree/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/agent/new/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/config/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/files/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/git/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/growth/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/health/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/models/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/pi-settings/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/plugins/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/prompts/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/security/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/sessions/[id]/export/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/sessions/[id]/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/sessions/import/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/sessions/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/skills/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/update/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/version/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/web-auth/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/workspaces/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/login/page-52ef4841cbd199a8.js +0 -1
- package/.next/static/chunks/app/page-b51087831582ee37.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/app-error-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/forbidden-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/not-found-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/webpack-c31263fd83d75ce6.js +0 -1
- package/.next/static/css/0cd968b9b62035b5.css +0 -1
- package/.next/static/en03hnPiuro0rnf8QkFEe/_buildManifest.js +0 -1
- /package/.next/static/{en03hnPiuro0rnf8QkFEe → _Y3Lq0lI5RJ1tFSOnDi0L}/_ssgManifest.js +0 -0
package/scripts/launcher.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import net from "node:net";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
5
6
|
import { spawn } from "node:child_process";
|
|
6
7
|
import { fileURLToPath } from "node:url";
|
|
7
8
|
import { assertInstallation, selectProductionBuild } from "./build-output.mjs";
|
|
@@ -11,7 +12,7 @@ export { isMainModule } from "./entrypoint.mjs";
|
|
|
11
12
|
const DEFAULT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
12
13
|
|
|
13
14
|
/** @typedef {Record<string, string | undefined>} Environment */
|
|
14
|
-
/** @typedef {{port: number, hostname: string, noOpen: boolean, isDev: boolean, help: boolean}} ParsedOptions */
|
|
15
|
+
/** @typedef {{port: number, hostname: string, publicUrl: string | null, noOpen: boolean, isDev: boolean, help: boolean}} ParsedOptions */
|
|
15
16
|
/** @typedef {import('node:events').EventEmitter & {kill(signal?: NodeJS.Signals | number): boolean}} LauncherChild */
|
|
16
17
|
/**
|
|
17
18
|
* @typedef {Object} LauncherOptions
|
|
@@ -20,6 +21,7 @@ const DEFAULT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)),
|
|
|
20
21
|
* @property {string} [root]
|
|
21
22
|
* @property {{log?: (message: string) => void, probePort?: typeof probePort,
|
|
22
23
|
* isPiWebRunning?: typeof isPiWebRunning, openBrowser?: (url: string) => Promise<void>,
|
|
24
|
+
* warmFirstPaint?: typeof warmFirstPaint, readLaunchToken?: typeof readLaunchToken,
|
|
23
25
|
* spawn?: (command: string, args: string[], options: import('node:child_process').SpawnOptions) => LauncherChild}} [dependencies]
|
|
24
26
|
*/
|
|
25
27
|
|
|
@@ -40,10 +42,78 @@ function normalizeHostname(value) {
|
|
|
40
42
|
return host;
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
/**
|
|
46
|
+
* 对外地址(反代/隧道给用户看的那个 URL)。只用于拼登录链接和补 Host 白名单。
|
|
47
|
+
* 去掉查询串与结尾斜杠,拼接时统一补。
|
|
48
|
+
* @param {string | null | undefined} value
|
|
49
|
+
* @returns {string | null}
|
|
50
|
+
*/
|
|
51
|
+
export function normalizePublicUrl(value) {
|
|
52
|
+
if (value === null || value === undefined) return null;
|
|
53
|
+
const trimmed = String(value).trim();
|
|
54
|
+
if (!trimmed) return null;
|
|
55
|
+
let url;
|
|
56
|
+
try {
|
|
57
|
+
url = new URL(trimmed);
|
|
58
|
+
} catch {
|
|
59
|
+
throw new Error(`无效的对外地址:${value}`);
|
|
60
|
+
}
|
|
61
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`对外地址必须是 http/https:${value}`);
|
|
62
|
+
if (url.username || url.password) throw new Error(`对外地址不能带凭据:${value}`);
|
|
63
|
+
url.search = "";
|
|
64
|
+
url.hash = "";
|
|
65
|
+
return url.href.replace(/\/+$/, "");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 把令牌拼成可直接打开的登录链接 */
|
|
69
|
+
export function tokenLoginUrl(base, token) {
|
|
70
|
+
return `${String(base).replace(/\/+$/, "")}/?token=${encodeURIComponent(token)}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 读某个端口上正在运行的实例留下的启动令牌(服务端 publishLaunchToken 写的,
|
|
75
|
+
* 位置是 `<agentDir>/web-launch/<端口>.token`,agentDir 规则与 src/lib/web-secret.ts 一致)。
|
|
76
|
+
* 没有、读不了、内容不像令牌都返回 null —— 调用方退回打开裸地址。
|
|
77
|
+
* @param {number} port
|
|
78
|
+
* @param {Environment} [env]
|
|
79
|
+
* @returns {string | null}
|
|
80
|
+
*/
|
|
81
|
+
export function readLaunchToken(port, env = process.env) {
|
|
82
|
+
const raw = env.PI_CODING_AGENT_DIR;
|
|
83
|
+
const home = os.homedir();
|
|
84
|
+
const dir = !raw ? path.join(home, ".pi", "agent")
|
|
85
|
+
: raw === "~" ? home
|
|
86
|
+
: raw.startsWith("~/") || raw.startsWith("~\\") ? path.join(home, raw.slice(2))
|
|
87
|
+
: raw;
|
|
88
|
+
try {
|
|
89
|
+
const token = fs.readFileSync(path.join(dir, "web-launch", `${port}.token`), "utf8").trim();
|
|
90
|
+
return /^[A-Za-z0-9_-]{16,}$/.test(token) ? token : null;
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function mergeHostnames(current, hostname) {
|
|
97
|
+
const list = (current ?? "").split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
|
|
98
|
+
if (hostname && !list.includes(hostname)) list.push(hostname);
|
|
99
|
+
return list.join(",");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isTruthyFlag(value) {
|
|
103
|
+
const flag = (value ?? "").trim().toLowerCase();
|
|
104
|
+
return flag === "1" || flag === "true" || flag === "on" || flag === "yes";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isOffFlag(value) {
|
|
108
|
+
const flag = (value ?? "").trim().toLowerCase();
|
|
109
|
+
return flag === "0" || flag === "false" || flag === "off" || flag === "no";
|
|
110
|
+
}
|
|
111
|
+
|
|
43
112
|
/** @param {string[]} [args] @param {Environment} [env] @returns {ParsedOptions} */
|
|
44
113
|
export function parseLauncherArgs(args = [], env = process.env) {
|
|
45
114
|
let port = Number(env.PORT || 30141);
|
|
46
115
|
let hostname = env.PI_WEB_HOSTNAME || "127.0.0.1";
|
|
116
|
+
let publicUrl = env.PI_WEB_PUBLIC_URL ?? null;
|
|
47
117
|
let noOpen = env.PI_WEB_NO_OPEN === "1";
|
|
48
118
|
let isDev = env.PI_WEB_DEV === "1";
|
|
49
119
|
let help = false;
|
|
@@ -53,13 +123,15 @@ export function parseLauncherArgs(args = [], env = process.env) {
|
|
|
53
123
|
else if (arg.startsWith("--port=")) port = Number(arg.slice(7));
|
|
54
124
|
else if (arg === "-H" || arg === "--hostname") hostname = args[++i];
|
|
55
125
|
else if (arg.startsWith("--hostname=")) hostname = arg.slice(11);
|
|
126
|
+
else if (arg === "--public-url") publicUrl = args[++i];
|
|
127
|
+
else if (arg.startsWith("--public-url=")) publicUrl = arg.slice(13);
|
|
56
128
|
else if (arg === "--no-open") noOpen = true;
|
|
57
129
|
else if (arg === "--dev") isDev = true;
|
|
58
130
|
else if (arg === "-h" || arg === "--help") help = true;
|
|
59
131
|
else throw new Error(`未知参数:${arg}`);
|
|
60
132
|
}
|
|
61
133
|
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`无效端口:${port}`);
|
|
62
|
-
return { port, hostname: normalizeHostname(hostname), noOpen, isDev, help };
|
|
134
|
+
return { port, hostname: normalizeHostname(hostname), publicUrl: normalizePublicUrl(publicUrl), noOpen, isDev, help };
|
|
63
135
|
}
|
|
64
136
|
|
|
65
137
|
/**
|
|
@@ -69,23 +141,40 @@ export function parseLauncherArgs(args = [], env = process.env) {
|
|
|
69
141
|
* @returns {{root: string, command: 'dev' | 'start', env: Environment}}
|
|
70
142
|
*/
|
|
71
143
|
export function createLaunchPlan(root, options, env = process.env) {
|
|
144
|
+
// 拒绝启动而不是静默忽略:设了 PI_WEB_PASSWORD 的部署本来是有认证的,
|
|
145
|
+
// 悄悄放过就会让它在没有任何登录要求的情况下继续对外服务。
|
|
146
|
+
if (env.PI_WEB_PASSWORD) {
|
|
147
|
+
throw new Error("PI_WEB_PASSWORD 已被移除:密码登录不再存在,认证现在只用启动令牌(PI_WEB_AUTH=1,启动器默认已开)。请删掉这个环境变量再重试。");
|
|
148
|
+
}
|
|
72
149
|
const loopback = isLoopback(options.hostname);
|
|
73
150
|
if (!loopback && options.isDev) {
|
|
74
|
-
throw new Error("
|
|
151
|
+
throw new Error("拒绝非本机开发模式:启动令牌认证保护不了 Next 开发工具端点。请绑定 127.0.0.1,或先运行 npm run build:release 后用生产模式启动。");
|
|
75
152
|
}
|
|
76
|
-
|
|
153
|
+
const publicUrl = options.publicUrl ?? null;
|
|
77
154
|
const installation = assertInstallation(root);
|
|
78
155
|
const build = options.isDev ? null : selectProductionBuild(installation, env.PIWEB_BUILD_DIR);
|
|
79
156
|
if (!build && !loopback) {
|
|
80
157
|
throw new Error("找不到可用生产构建,拒绝对外自动回退到开发模式。请先运行 npm run build:release,再以相同启动参数重试。");
|
|
81
158
|
}
|
|
82
159
|
const command = options.isDev || !build ? "dev" : "start";
|
|
160
|
+
// 局域网入口(「远程连接」内置插件)默认开着,等于这台机器同网段可达;
|
|
161
|
+
// 非本机监听、配了对外地址同样算暴露面。三种情况都必须有认证。
|
|
162
|
+
// 开发模式下局域网入口根本不会起(instrumentation 会跳过),不算暴露面。
|
|
163
|
+
const lanEntry = command !== "dev" && !isOffFlag(env.PIWEB_REMOTE_LAN);
|
|
164
|
+
const exposed = !loopback || Boolean(publicUrl) || lanEntry;
|
|
83
165
|
const childEnv = {
|
|
84
166
|
...env,
|
|
85
167
|
PI_WEB_ROOT: installation,
|
|
86
168
|
PYTHONUTF8: env.PYTHONUTF8 ?? "1",
|
|
87
169
|
PYTHONIOENCODING: env.PYTHONIOENCODING ?? "utf-8",
|
|
88
170
|
};
|
|
171
|
+
// 每次启动换一个启动令牌;调用方已经给出就沿用(便于测试与外部编排)。
|
|
172
|
+
if (!childEnv.PI_WEB_LAUNCH_TOKEN) childEnv.PI_WEB_LAUNCH_TOKEN = randomBytes(32).toString("base64url");
|
|
173
|
+
// 有暴露面 + 用户没表态 → 自动打开启动令牌认证,而不是拒绝启动。
|
|
174
|
+
// 只在用户没表态时才替它打开:显式 PI_WEB_AUTH=0 会被尊重(此时局域网入口也会自行跳过)。
|
|
175
|
+
if (exposed && !String(childEnv.PI_WEB_AUTH ?? "").trim()) childEnv.PI_WEB_AUTH = "1";
|
|
176
|
+
// 反代会把外部主机名原样转给后端,不进白名单会被判 untrusted host。
|
|
177
|
+
if (publicUrl) childEnv.PI_WEB_TRUSTED_HOSTS = mergeHostnames(childEnv.PI_WEB_TRUSTED_HOSTS, new URL(publicUrl).hostname.toLowerCase());
|
|
89
178
|
if (command === "start") childEnv.PIWEB_BUILD_DIR = build.buildDir;
|
|
90
179
|
else {
|
|
91
180
|
delete childEnv.PIWEB_BUILD_DIR;
|
|
@@ -142,6 +231,70 @@ export async function isPiWebRunning(base, { fetchImpl = fetch, timeoutMs = 1500
|
|
|
142
231
|
}
|
|
143
232
|
}
|
|
144
233
|
|
|
234
|
+
/** 预热要打的四个首屏路由(会话列表 / 工作区 / 模型目录 / pi 设置) */
|
|
235
|
+
export const FIRST_PAINT_ROUTES = ["/api/sessions", "/api/workspaces", "/api/models", "/api/pi-settings"];
|
|
236
|
+
|
|
237
|
+
/** @param {number} ms @param {AbortSignal} [signal] */
|
|
238
|
+
function delay(ms, signal) {
|
|
239
|
+
return new Promise((resolve) => {
|
|
240
|
+
const timer = setTimeout(resolve, ms);
|
|
241
|
+
timer.unref?.();
|
|
242
|
+
signal?.addEventListener("abort", () => { clearTimeout(timer); resolve(); }, { once: true });
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 首屏预热:生产模式下,Next 服务器对**第一个动态 API 请求**要一次性付出 2.4~3.3 秒的
|
|
248
|
+
* 模块加载成本(本机实测:`/api/version` 首次 2559~3566ms、第二次 4~10ms;页面路由与
|
|
249
|
+
* `/api/health` 不吃这笔账)。浏览器首屏正好撞在这笔成本上,于是"页面出来了、列表还在转圈"。
|
|
250
|
+
*
|
|
251
|
+
* 这里在子进程刚起来时就后台打一次最便宜的路由吃掉它,再预热首屏真正要用的四个路由。
|
|
252
|
+
* 只等端口起来 + 身份确认,绝不阻塞启动;失败一律静默(预热属于优化,不是启动的前置条件)。
|
|
253
|
+
*
|
|
254
|
+
* @param {string} base
|
|
255
|
+
* @param {{fetchImpl?: typeof fetch, token?: string, timeoutMs?: number, waitMs?: number,
|
|
256
|
+
* pollMs?: number, check?: typeof isPiWebRunning, signal?: AbortSignal}} [options]
|
|
257
|
+
* @returns {Promise<boolean>} 是否完成了管线预热
|
|
258
|
+
*/
|
|
259
|
+
export async function warmFirstPaint(base, { fetchImpl = fetch, token, timeoutMs = 20_000, waitMs = 60_000, pollMs = 100, check = isPiWebRunning, signal } = {}) {
|
|
260
|
+
const timeoutFor = () => (signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs));
|
|
261
|
+
// 身份确认不带凭据(与 isPiWebRunning 的既有约定一致):端口可能被别的进程抢走。
|
|
262
|
+
const deadline = Date.now() + waitMs;
|
|
263
|
+
for (;;) {
|
|
264
|
+
if (signal?.aborted) return false;
|
|
265
|
+
if (await check(base, { fetchImpl, timeoutMs: Math.min(1500, timeoutMs), signal })) break;
|
|
266
|
+
if (Date.now() >= deadline) return false;
|
|
267
|
+
await delay(pollMs, signal);
|
|
268
|
+
}
|
|
269
|
+
// 令牌只认根路径的 GET:先换到 Cookie,后面几条路由才有资格预热。
|
|
270
|
+
let cookie;
|
|
271
|
+
if (token) {
|
|
272
|
+
try {
|
|
273
|
+
const response = await fetchImpl(new URL(tokenLoginUrl("/", token), base), { credentials: "omit", redirect: "manual", signal: timeoutFor() });
|
|
274
|
+
const match = /^([^;]+)/.exec(response.headers.get("set-cookie") ?? "");
|
|
275
|
+
if (match) cookie = match[1];
|
|
276
|
+
await response.arrayBuffer().catch(() => undefined);
|
|
277
|
+
} catch {
|
|
278
|
+
/* 换不到就退化成只预热公开路由 */
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const headers = {};
|
|
282
|
+
if (cookie) headers.cookie = cookie;
|
|
283
|
+
const headerInit = Object.keys(headers).length > 0 ? headers : undefined;
|
|
284
|
+
const get = async (route) => {
|
|
285
|
+
try {
|
|
286
|
+
const response = await fetchImpl(new URL(route, base), { credentials: "omit", redirect: "error", headers: headerInit, signal: timeoutFor() });
|
|
287
|
+
// 只需让服务端把这条路由跑完;响应体要么丢弃,要么读干净以释放连接。
|
|
288
|
+
await response.arrayBuffer().catch(() => undefined);
|
|
289
|
+
} catch {
|
|
290
|
+
/* 预热失败不影响启动 */
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
await get("/api/version");
|
|
294
|
+
for (const route of FIRST_PAINT_ROUTES) void get(route);
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
|
|
145
298
|
/** @param {number} port @param {string} hostname @returns {Promise<'busy' | 'free' | NodeJS.ErrnoException>} */
|
|
146
299
|
export function probePort(port, hostname) {
|
|
147
300
|
return new Promise((resolve) => {
|
|
@@ -169,9 +322,10 @@ export async function runLauncher({ args = process.argv.slice(2), env = process.
|
|
|
169
322
|
const options = parseLauncherArgs(args, env);
|
|
170
323
|
const log = dependencies.log ?? console.log;
|
|
171
324
|
if (options.help) {
|
|
172
|
-
log("Usage: pi web [-p, --port <port>] [-H, --hostname <host>] [--dev] [--no-open]");
|
|
325
|
+
log("Usage: pi web [-p, --port <port>] [-H, --hostname <host>] [--public-url <url>] [--dev] [--no-open]");
|
|
173
326
|
log("默认绑定 127.0.0.1:30141;端口被占自动 +1。开发模式仅允许本机监听。");
|
|
174
|
-
log("
|
|
327
|
+
log("非本机监听、或配了 --public-url,都会自动启用启动令牌认证(PI_WEB_AUTH=1)。");
|
|
328
|
+
log("对外监听必须使用生产构建。构建/恢复发布:npm run build:release。");
|
|
175
329
|
return 0;
|
|
176
330
|
}
|
|
177
331
|
// Validate before probing/reusing: an existing server must not hide an unsafe request.
|
|
@@ -183,7 +337,7 @@ export async function runLauncher({ args = process.argv.slice(2), env = process.
|
|
|
183
337
|
// 常见根因:包目录属主不是当前用户(sudo 安装残留),构建产物写不进去
|
|
184
338
|
let owner = "";
|
|
185
339
|
try {
|
|
186
|
-
const stat =
|
|
340
|
+
const stat = fs.statSync(root);
|
|
187
341
|
if (typeof stat.uid === "number" && stat.uid !== process.getuid?.()) {
|
|
188
342
|
owner = `\n此安装目录属主不是当前用户(uid ${stat.uid})——之前可能用 sudo 安装过。修复:sudo chown -R $(whoami) "${root}",然后重试 piweb。`;
|
|
189
343
|
}
|
|
@@ -204,7 +358,12 @@ export async function runLauncher({ args = process.argv.slice(2), env = process.
|
|
|
204
358
|
const url = localUrl(options.hostname, port);
|
|
205
359
|
if (await checkHealth(url)) {
|
|
206
360
|
log(`[piweb] 检测到已有 PiWeb:${url}(未发送凭据)`);
|
|
207
|
-
if (!options.noOpen)
|
|
361
|
+
if (!options.noOpen) {
|
|
362
|
+
// 那个实例开了认证的话,裸地址只会落到登录页,而这里手里没有它的令牌;
|
|
363
|
+
// 它启动时留下过令牌就直接开登录链接(浏览器里已有 Cookie 时令牌也无害)
|
|
364
|
+
const token = (dependencies.readLaunchToken ?? readLaunchToken)(port, env);
|
|
365
|
+
await browse(token ? tokenLoginUrl(url, token) : url);
|
|
366
|
+
}
|
|
208
367
|
return 0;
|
|
209
368
|
}
|
|
210
369
|
if (attempts >= 50 || port >= 65535) throw new Error(`连续 ${attempts + 1} 个端口都被占用,请用 -p 指定端口`);
|
|
@@ -212,31 +371,53 @@ export async function runLauncher({ args = process.argv.slice(2), env = process.
|
|
|
212
371
|
port += 1;
|
|
213
372
|
}
|
|
214
373
|
const url = localUrl(options.hostname, port);
|
|
374
|
+
// 内置插件要转发回 PiWeb 自己的端口;端口可能被占而 +1,所以在这里给最终值。
|
|
375
|
+
plan.env.PIWEB_PORT = String(port);
|
|
215
376
|
log(`[piweb] 启动 ${url} (${plan.command} 模式)`);
|
|
216
|
-
|
|
377
|
+
const authOn = isTruthyFlag(plan.env.PI_WEB_AUTH);
|
|
378
|
+
if (authOn) log("[piweb] 已启用启动令牌认证");
|
|
217
379
|
if (!isLoopback(options.hostname)) {
|
|
218
380
|
for (const addresses of Object.values(os.networkInterfaces())) {
|
|
219
381
|
for (const address of addresses ?? []) {
|
|
220
382
|
if (address.family === "IPv4" && !address.internal && !address.address.startsWith("169.254.")) {
|
|
221
|
-
log(`[piweb]
|
|
383
|
+
log(`[piweb] 局域网/手机访问地址(需同一网络):http://${address.address}:${port}`);
|
|
222
384
|
}
|
|
223
385
|
}
|
|
224
386
|
}
|
|
225
387
|
}
|
|
388
|
+
// 令牌即凭据:只往终端打印,不写日志文件,也不要贴到聊天或截图里。
|
|
389
|
+
const loginBase = options.publicUrl ?? url;
|
|
390
|
+
const loginUrl = tokenLoginUrl(loginBase, plan.env.PI_WEB_LAUNCH_TOKEN);
|
|
391
|
+
if (authOn) {
|
|
392
|
+
if (options.publicUrl) log(`[piweb] 对外地址:${options.publicUrl}`);
|
|
393
|
+
log(`[piweb] 登录链接(令牌即凭据,别外传;它会进浏览器历史):${loginUrl}`);
|
|
394
|
+
}
|
|
226
395
|
const next = path.join(plan.root, "node_modules", "next", "dist", "bin", "next");
|
|
227
396
|
const child = start(process.execPath, [next, plan.command, ...(plan.command === "dev" ? ["--webpack"] : []), "-p", String(port), "-H", options.hostname], {
|
|
228
397
|
cwd: plan.root, stdio: "inherit", env: plan.env,
|
|
229
398
|
});
|
|
230
399
|
const controller = new AbortController();
|
|
231
400
|
let timer;
|
|
401
|
+
// 首屏预热:开发模式每次请求都要现场编译,预热只会白占 CPU,因此只在生产模式下做。
|
|
402
|
+
let warmPromise = null;
|
|
403
|
+
if (plan.command !== "dev" && env.PIWEB_SKIP_WARMUP !== "1") {
|
|
404
|
+
const warm = dependencies.warmFirstPaint ?? warmFirstPaint;
|
|
405
|
+
warmPromise = Promise.resolve(warm(url, { token: authOn ? plan.env.PI_WEB_LAUNCH_TOKEN : undefined, signal: controller.signal })).then(() => true, () => false);
|
|
406
|
+
}
|
|
232
407
|
const ready = async (attempt = 0) => {
|
|
233
|
-
if (controller.signal.aborted || attempt >
|
|
408
|
+
if (controller.signal.aborted || attempt > 240) return;
|
|
234
409
|
if (await checkHealth(url, { signal: controller.signal })) {
|
|
235
410
|
if (controller.signal.aborted) return;
|
|
411
|
+
if (warmPromise) {
|
|
412
|
+
log("[piweb] 预热首屏…");
|
|
413
|
+
await warmPromise;
|
|
414
|
+
if (controller.signal.aborted) return;
|
|
415
|
+
}
|
|
236
416
|
log(`[piweb] 就绪:${url}`);
|
|
237
|
-
|
|
417
|
+
// 开了认证就直接开登录链接:否则用户打开的是登录页,而他手里还没有会话 Cookie。
|
|
418
|
+
if (!options.noOpen) await browse(authOn ? loginUrl : url);
|
|
238
419
|
} else if (!controller.signal.aborted) {
|
|
239
|
-
timer = setTimeout(() => void ready(attempt + 1),
|
|
420
|
+
timer = setTimeout(() => void ready(attempt + 1), 250);
|
|
240
421
|
timer.unref();
|
|
241
422
|
}
|
|
242
423
|
};
|
package/scripts/release.mjs
CHANGED
|
@@ -141,8 +141,6 @@ export async function prepareRelease({ root = ROOT, env = process.env, run = run
|
|
|
141
141
|
...env, PI_WEB_ROOT: installation, PIWEB_BUILD_DIR: buildDir,
|
|
142
142
|
PYTHONUTF8: env.PYTHONUTF8 ?? "1", PYTHONIOENCODING: env.PYTHONIOENCODING ?? "utf-8",
|
|
143
143
|
};
|
|
144
|
-
// Do not pass the live web password to Git/npm/package build scripts.
|
|
145
|
-
delete childEnv.PI_WEB_PASSWORD;
|
|
146
144
|
delete childEnv.PI_WEB_DEV;
|
|
147
145
|
delete childEnv.NODE_ENV;
|
|
148
146
|
const context = { root: installation, buildDir, env: childEnv, run, signal };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { buildSnapshot, getManaged, subscribe, unsubscribe } from "@/lib/agent-manager";
|
|
1
|
+
import { buildSnapshot, getManaged, parseLastEventId, sseEventId, subscribe, unsubscribe } from "@/lib/agent-manager";
|
|
2
2
|
import { BoundaryError, resolveSessionPath } from "@/lib/path-security";
|
|
3
3
|
|
|
4
4
|
export const dynamic = "force-dynamic";
|
|
@@ -14,10 +14,15 @@ export async function GET(req: Request, { params }: { params: Promise<{ id: stri
|
|
|
14
14
|
} catch (error) {
|
|
15
15
|
return new Response(error instanceof BoundaryError ? error.message : "invalid session", { status: 400 });
|
|
16
16
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
let m: ReturnType<typeof getManaged>;
|
|
18
|
+
try {
|
|
19
|
+
m = getManaged(sessionPath);
|
|
20
|
+
} catch (error) {
|
|
21
|
+
// 待落盘的会话在服务重启后就不存在了:给明确的 404,而不是未捕获异常的 500
|
|
22
|
+
return new Response(error instanceof BoundaryError ? error.message : "session unavailable", { status: error instanceof BoundaryError ? 404 : 500 });
|
|
23
|
+
}
|
|
24
|
+
// id 带着这份会话的 epoch:服务重启或会话被回收重开后,旧的 Last-Event-ID 不会被误续到新一轮事件上
|
|
25
|
+
const lastEventId = parseLastEventId(m, req.headers.get("last-event-id"));
|
|
21
26
|
|
|
22
27
|
const encoder = new TextEncoder();
|
|
23
28
|
let closed = false;
|
|
@@ -41,7 +46,7 @@ export async function GET(req: Request, { params }: { params: Promise<{ id: stri
|
|
|
41
46
|
}
|
|
42
47
|
};
|
|
43
48
|
subscriber = (seq: number, json: string) => {
|
|
44
|
-
write(`id: ${seq}\ndata: ${json}\n\n`);
|
|
49
|
+
write(`id: ${sseEventId(m, seq)}\ndata: ${json}\n\n`);
|
|
45
50
|
if (json.includes('"state":"disposed"')) close();
|
|
46
51
|
};
|
|
47
52
|
const send = subscriber;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { NextResponse } from "next/server";
|
|
5
|
+
import { BoundaryError } from "@/lib/path-security";
|
|
6
|
+
|
|
7
|
+
export const dynamic = "force-dynamic";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 只读的目录枚举,供应用内的工作区选择器用(手机等远程设备)。
|
|
11
|
+
*
|
|
12
|
+
* 只列目录、不读内容:选择器的产出是一个路径,随后仍由 resolveWorkspacePath 校验
|
|
13
|
+
* (必须绝对路径、必须存在、必须是目录)才写进工作区注册表。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** 单个目录最多返回多少条,避免在手机上拉一个几万项的目录 */
|
|
17
|
+
const MAX_ENTRIES = 2000;
|
|
18
|
+
|
|
19
|
+
interface FsEntry {
|
|
20
|
+
name: string;
|
|
21
|
+
path: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function isDirectory(target: string): Promise<boolean> {
|
|
25
|
+
try {
|
|
26
|
+
return (await fs.stat(target)).isDirectory();
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 设备的起点:Windows 逐个探测盘符,macOS 列 /Volumes,其余从主目录与根开始 */
|
|
33
|
+
async function listRoots(): Promise<FsEntry[]> {
|
|
34
|
+
const roots: FsEntry[] = [];
|
|
35
|
+
if (process.platform === "win32") {
|
|
36
|
+
// 26 个盘符一起探:断开的网络盘、没插卡的读卡器一个就能卡上几秒,逐个探会把等待叠加起来
|
|
37
|
+
const drives = Array.from({ length: 26 }, (_, i) => `${String.fromCharCode(65 + i)}:\\`);
|
|
38
|
+
const present = await Promise.all(drives.map(isDirectory));
|
|
39
|
+
return drives.filter((_, i) => present[i]).map((drive) => ({ name: drive, path: drive }));
|
|
40
|
+
}
|
|
41
|
+
if (process.platform === "darwin") {
|
|
42
|
+
const volumes = await fs.readdir("/Volumes", { withFileTypes: true }).catch(() => []);
|
|
43
|
+
for (const entry of volumes) {
|
|
44
|
+
if (entry.isDirectory() || entry.isSymbolicLink()) roots.push({ name: entry.name, path: path.join("/Volumes", entry.name) });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const home = os.homedir();
|
|
48
|
+
if (await isDirectory(home)) roots.push({ name: home, path: home });
|
|
49
|
+
if (await isDirectory("/")) roots.push({ name: "/", path: "/" });
|
|
50
|
+
return roots;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function directoryError(error: unknown): BoundaryError {
|
|
54
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
55
|
+
if (code === "ENOENT") return new BoundaryError("目录不存在");
|
|
56
|
+
if (code === "EACCES" || code === "EPERM") return new BoundaryError("没有权限读取此目录");
|
|
57
|
+
if (code === "ENOTDIR") return new BoundaryError("这不是一个目录");
|
|
58
|
+
return new BoundaryError(error instanceof Error ? error.message : String(error));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function listDirectory(target: unknown) {
|
|
62
|
+
if (target !== undefined && (typeof target !== "string" || target.length === 0 || target.length > 4096)) {
|
|
63
|
+
throw new BoundaryError("invalid path");
|
|
64
|
+
}
|
|
65
|
+
const requested = typeof target === "string" ? target : os.homedir();
|
|
66
|
+
if (!path.isAbsolute(requested)) throw new BoundaryError("path must be absolute");
|
|
67
|
+
// realpath 同时做两件事:解析链接,以及确认目录真的存在
|
|
68
|
+
const resolved = await fs.realpath(requested).catch((error: unknown) => {
|
|
69
|
+
throw directoryError(error);
|
|
70
|
+
});
|
|
71
|
+
let stat;
|
|
72
|
+
try {
|
|
73
|
+
stat = await fs.stat(resolved);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw directoryError(error);
|
|
76
|
+
}
|
|
77
|
+
if (!stat.isDirectory()) throw new BoundaryError("这不是一个目录");
|
|
78
|
+
|
|
79
|
+
const dirents = await fs.readdir(resolved, { withFileTypes: true }).catch((error: unknown) => {
|
|
80
|
+
throw directoryError(error);
|
|
81
|
+
});
|
|
82
|
+
const entries: FsEntry[] = [];
|
|
83
|
+
for (const entry of dirents) {
|
|
84
|
+
// 符号链接也列出来(macOS 的 /Volumes 全是链接);能否进入由下一次 list 决定
|
|
85
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
86
|
+
entries.push({ name: entry.name, path: path.join(resolved, entry.name) });
|
|
87
|
+
}
|
|
88
|
+
entries.sort((left, right) => left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" }));
|
|
89
|
+
const parent = path.dirname(resolved);
|
|
90
|
+
return {
|
|
91
|
+
path: resolved,
|
|
92
|
+
parent: parent === resolved ? null : parent,
|
|
93
|
+
entries: entries.slice(0, MAX_ENTRIES),
|
|
94
|
+
truncated: entries.length > MAX_ENTRIES,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function POST(req: Request) {
|
|
99
|
+
let body: unknown;
|
|
100
|
+
try {
|
|
101
|
+
body = await req.json();
|
|
102
|
+
} catch {
|
|
103
|
+
return NextResponse.json({ success: false, error: "invalid JSON body" }, { status: 400 });
|
|
104
|
+
}
|
|
105
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
106
|
+
return NextResponse.json({ success: false, error: "expected an object body" }, { status: 400 });
|
|
107
|
+
}
|
|
108
|
+
const { action, path: target } = body as { action?: unknown; path?: unknown };
|
|
109
|
+
try {
|
|
110
|
+
if (action === "roots") return NextResponse.json({ success: true, data: { roots: await listRoots() } });
|
|
111
|
+
if (action === "list") return NextResponse.json({ success: true, data: await listDirectory(target) });
|
|
112
|
+
return NextResponse.json({ success: false, error: "unknown action" }, { status: 400 });
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return NextResponse.json(
|
|
115
|
+
{ success: false, error: error instanceof Error ? error.message : String(error) },
|
|
116
|
+
{ status: error instanceof BoundaryError ? 400 : 500 },
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
|
-
import { getManaged, growthRecord } from "@/lib/agent-manager";
|
|
2
|
+
import { getManaged, growthConsent, growthRecord } from "@/lib/agent-manager";
|
|
3
3
|
import { GrowthError, changesBetween, fileContent, filePatch, isAvailable, listTree, readRounds } from "@/lib/growth-service";
|
|
4
4
|
import { BoundaryError, resolveSessionPath, resolveWorkspacePath } from "@/lib/path-security";
|
|
5
5
|
|
|
@@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
|
|
|
7
7
|
|
|
8
8
|
function status(err: unknown): number {
|
|
9
9
|
if (err instanceof BoundaryError) return 400;
|
|
10
|
-
if (err instanceof GrowthError) return err.code === "not-found" ? 404 : err.code === "unavailable" ? 503 : err.code === "too-large" ? 413 : 500;
|
|
10
|
+
if (err instanceof GrowthError) return err.code === "not-found" ? 404 : err.code === "unavailable" ? 503 : err.code === "too-large" ? 413 : err.code === "needs-consent" ? 409 : 500;
|
|
11
11
|
return 500;
|
|
12
12
|
}
|
|
13
13
|
|
|
@@ -36,13 +36,18 @@ export async function GET(req: Request) {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* POST { action: "record", session: <会话 id> }:立即记录,把当前改动提交为「你的修改」(并广播给会话订阅者)
|
|
41
|
+
* POST { action: "consent", session, allow: boolean }:回答「要不要在工作区建 git 仓库」;同意时立即拍基线
|
|
42
|
+
*/
|
|
40
43
|
export async function POST(req: Request) {
|
|
41
44
|
try {
|
|
42
|
-
const body = (await req.json()) as { action?: string; session?: unknown };
|
|
43
|
-
if (body.action !== "record") return NextResponse.json({ success: false, error: "unknown action" }, { status: 400 });
|
|
45
|
+
const body = (await req.json()) as { action?: string; session?: unknown; allow?: unknown };
|
|
46
|
+
if (body.action !== "record" && body.action !== "consent") return NextResponse.json({ success: false, error: "unknown action" }, { status: 400 });
|
|
47
|
+
if (body.action === "consent" && typeof body.allow !== "boolean") return NextResponse.json({ success: false, error: "allow must be a boolean" }, { status: 400 });
|
|
44
48
|
const sessionPath = await resolveSessionPath(String(body.session ?? ""), { allowPending: true });
|
|
45
|
-
const
|
|
49
|
+
const m = getManaged(sessionPath);
|
|
50
|
+
const round = body.action === "consent" ? await growthConsent(m, body.allow as boolean) : await growthRecord(m);
|
|
46
51
|
return NextResponse.json({ success: true, data: { round } });
|
|
47
52
|
} catch (err: any) {
|
|
48
53
|
return NextResponse.json({ success: false, error: String(err?.message ?? err) }, { status: err instanceof SyntaxError ? 400 : status(err) });
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
listModels,
|
|
10
10
|
listModelsForPiWeb,
|
|
11
11
|
loginState,
|
|
12
|
+
modelListStamp,
|
|
12
13
|
providerUsage,
|
|
13
14
|
readCustomProviders,
|
|
14
15
|
removeApiKey,
|
|
@@ -19,17 +20,28 @@ import {
|
|
|
19
20
|
|
|
20
21
|
export const dynamic = "force-dynamic";
|
|
21
22
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* 完整目录 482KB、精简目录 271KB,未压缩发送太浪费;这里自己 gzip。
|
|
25
|
+
* gzip 是主线程同步操作,设置面板开关一次会连着请求好几次,所以按目录版本戳复用压好的字节:
|
|
26
|
+
* 目录没变(5 秒 TTL 内、且没人改配置)就不再重新压一遍。
|
|
27
|
+
*/
|
|
28
|
+
const gzipCache = new Map<string, { at: number; body: Uint8Array }>();
|
|
29
|
+
const GZIP_CACHE_TTL_MS = 5_000;
|
|
30
|
+
|
|
31
|
+
function jsonMaybeGzip(req: Request, payload: unknown, stamp: string): Response {
|
|
24
32
|
const body = JSON.stringify(payload);
|
|
25
33
|
const accept = req.headers.get("accept-encoding") ?? "";
|
|
26
|
-
|
|
27
|
-
|
|
34
|
+
const headers = { "Content-Type": "application/json; charset=utf-8", Vary: "Accept-Encoding", "Cache-Control": "no-store" };
|
|
35
|
+
if (!/\bgzip\b/.test(accept) || body.length < 2048) return new Response(body, { headers });
|
|
36
|
+
const key = `${stamp}:${body.length}`;
|
|
37
|
+
const cached = gzipCache.get(key);
|
|
38
|
+
if (cached && Date.now() - cached.at < GZIP_CACHE_TTL_MS) {
|
|
39
|
+
return new Response(cached.body.slice(), { headers: { ...headers, "Content-Encoding": "gzip" } });
|
|
28
40
|
}
|
|
29
|
-
const gz = gzipSync(Buffer.from(body, "utf8"));
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
});
|
|
41
|
+
const gz = new Uint8Array(gzipSync(Buffer.from(body, "utf8")));
|
|
42
|
+
if (gzipCache.size > 8) gzipCache.clear();
|
|
43
|
+
gzipCache.set(key, { at: Date.now(), body: gz });
|
|
44
|
+
return new Response(gz.slice(), { headers: { ...headers, "Content-Encoding": "gzip" } });
|
|
33
45
|
}
|
|
34
46
|
|
|
35
47
|
export async function GET(req: Request) {
|
|
@@ -46,7 +58,7 @@ export async function GET(req: Request) {
|
|
|
46
58
|
const providers = full
|
|
47
59
|
? data.providers
|
|
48
60
|
: data.providers.map((p) => ({ id: p.id, name: p.name, authReady: p.authReady, builtIn: p.builtIn, modelCount: p.modelCount }));
|
|
49
|
-
return jsonMaybeGzip(req, { success: true, data: { providers, models, customProviders } });
|
|
61
|
+
return jsonMaybeGzip(req, { success: true, data: { providers, models, customProviders } }, `${modelListStamp()}:${full ? "full" : "piweb"}`);
|
|
50
62
|
} catch (err: any) {
|
|
51
63
|
return NextResponse.json({ success: false, error: String(err?.message ?? err) }, { status: 500 });
|
|
52
64
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「远程连接」内置插件的控制接口。
|
|
3
|
+
*
|
|
4
|
+
* GET 当前状态(局域网入口 + 公网入口 + 拼登录链接用的启动令牌)
|
|
5
|
+
* POST { action: "start" | "stop" | "start-lan" | "stop-lan" }
|
|
6
|
+
* DELETE 全停
|
|
7
|
+
*
|
|
8
|
+
* 「谁能调这个接口」由 proxy.ts 把关;但「现在能不能开入口」必须在这里再看一次:
|
|
9
|
+
* 认证没开时 proxy 一律放行,这时开出去的局域网 / 公网入口就是一个免密 shell。
|
|
10
|
+
*/
|
|
11
|
+
import { NextResponse } from "next/server";
|
|
12
|
+
import { remoteBlocked, remoteStatus, startLan, startPublic, stopAll, stopPublic } from "@/lib/remote/supervisor";
|
|
13
|
+
import { authEnabled } from "@/lib/web-auth";
|
|
14
|
+
|
|
15
|
+
export const dynamic = "force-dynamic";
|
|
16
|
+
|
|
17
|
+
const NO_STORE = { "Cache-Control": "no-store" };
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 本次启动的令牌:设置页拿它拼带令牌的入口链接,手机打开就直接登录,不用再去终端里抄。
|
|
21
|
+
* 能调到这个接口的已经是登录过的设备(proxy.ts 把关),它们本来就有完整权限,拿到令牌不多出任何能力;
|
|
22
|
+
* 认证没开时没有令牌这回事。
|
|
23
|
+
*/
|
|
24
|
+
function loginToken(): string | null {
|
|
25
|
+
return authEnabled() ? process.env.PI_WEB_LAUNCH_TOKEN || null : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function respond() {
|
|
29
|
+
return NextResponse.json({ success: true, data: { ...remoteStatus(), loginToken: loginToken() } }, { headers: NO_STORE });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function bad(error: string, status = 400) {
|
|
33
|
+
return NextResponse.json({ success: false, error }, { status, headers: NO_STORE });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function GET() {
|
|
37
|
+
return respond();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function POST(request: Request) {
|
|
41
|
+
let body: unknown;
|
|
42
|
+
try {
|
|
43
|
+
body = await request.json();
|
|
44
|
+
} catch {
|
|
45
|
+
return bad("invalid JSON body");
|
|
46
|
+
}
|
|
47
|
+
const action = typeof body === "object" && body !== null && !Array.isArray(body)
|
|
48
|
+
? (body as { action?: unknown }).action
|
|
49
|
+
: undefined;
|
|
50
|
+
if (action === "start" || action === "start-lan") {
|
|
51
|
+
const blocked = remoteBlocked();
|
|
52
|
+
if (blocked === "auth") return bad("remote access requires authentication (PI_WEB_AUTH=1)", 409);
|
|
53
|
+
if (blocked === "dev") return bad("remote access is disabled in development mode", 409);
|
|
54
|
+
}
|
|
55
|
+
switch (action) {
|
|
56
|
+
case "start":
|
|
57
|
+
await startLan();
|
|
58
|
+
await startPublic();
|
|
59
|
+
return respond();
|
|
60
|
+
case "stop":
|
|
61
|
+
await stopPublic();
|
|
62
|
+
return respond();
|
|
63
|
+
case "start-lan":
|
|
64
|
+
await startLan();
|
|
65
|
+
return respond();
|
|
66
|
+
case "stop-lan":
|
|
67
|
+
await stopAll();
|
|
68
|
+
return respond();
|
|
69
|
+
default:
|
|
70
|
+
return bad("unknown action");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function DELETE() {
|
|
75
|
+
await stopAll();
|
|
76
|
+
return respond();
|
|
77
|
+
}
|