@world-engines/project-host 0.1.0-alpha.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 +46 -0
- package/assets/gui/WELogo-title.svg +11 -0
- package/assets/gui/app.js +1326 -0
- package/assets/gui/index.html +118 -0
- package/assets/gui/local-preview-asset-urls.js +61 -0
- package/assets/gui/local-preview-bridge.js +393 -0
- package/assets/gui/spatial-template-panel.js +233 -0
- package/assets/gui/styles.css +115 -0
- package/assets/gui/terrain-render.js +120 -0
- package/assets/gui/workspace-status.css +17 -0
- package/assets/gui/workspace-status.js +255 -0
- package/assets/gui/world-map-globe.js +187 -0
- package/assets/gui/world-map-worker.js +62 -0
- package/assets/gui/world-map.css +30 -0
- package/assets/gui/world-map.js +2466 -0
- package/dist/account-model-proxy.d.ts +21 -0
- package/dist/account-model-proxy.js +470 -0
- package/dist/authoring-browser-runtime.d.ts +7 -0
- package/dist/authoring-browser-runtime.js +110 -0
- package/dist/authoring-ingestion.d.ts +38 -0
- package/dist/authoring-ingestion.js +410 -0
- package/dist/authoring-model-connection.d.ts +166 -0
- package/dist/authoring-model-connection.js +757 -0
- package/dist/chinese-lexical.d.ts +152 -0
- package/dist/chinese-lexical.js +555 -0
- package/dist/chromium-preview-browser-driver.d.ts +43 -0
- package/dist/chromium-preview-browser-driver.js +575 -0
- package/dist/cli/download.d.ts +12 -0
- package/dist/cli/download.js +50 -0
- package/dist/cli/dump.d.ts +32 -0
- package/dist/cli/dump.js +91 -0
- package/dist/cli/ingest.d.ts +49 -0
- package/dist/cli/ingest.js +225 -0
- package/dist/cli/review.d.ts +10 -0
- package/dist/cli/review.js +44 -0
- package/dist/cli/upload.d.ts +57 -0
- package/dist/cli/upload.js +112 -0
- package/dist/cli-command.d.ts +60 -0
- package/dist/cli-command.js +613 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +182 -0
- package/dist/desktop-authoring-runtime.d.ts +88 -0
- package/dist/desktop-authoring-runtime.js +1602 -0
- package/dist/desktop-sidecar.d.ts +11 -0
- package/dist/desktop-sidecar.js +143 -0
- package/dist/document-dump.d.ts +113 -0
- package/dist/document-dump.js +431 -0
- package/dist/document-graph-extractor.d.ts +125 -0
- package/dist/document-graph-extractor.js +588 -0
- package/dist/document-ingestion-journal.d.ts +68 -0
- package/dist/document-ingestion-journal.js +471 -0
- package/dist/document-ingestion.d.ts +183 -0
- package/dist/document-ingestion.js +976 -0
- package/dist/document-query-path-proof.d.ts +30 -0
- package/dist/document-query-path-proof.js +34 -0
- package/dist/document-query-path-renderer.d.ts +50 -0
- package/dist/document-query-path-renderer.js +75 -0
- package/dist/document-query-proof-paths.d.ts +22 -0
- package/dist/document-query-proof-paths.js +71 -0
- package/dist/document-query.d.ts +97 -0
- package/dist/document-query.js +1106 -0
- package/dist/document-relation-role.d.ts +14 -0
- package/dist/document-relation-role.js +86 -0
- package/dist/document-source.d.ts +107 -0
- package/dist/document-source.js +255 -0
- package/dist/embedding-cache.d.ts +35 -0
- package/dist/embedding-cache.js +317 -0
- package/dist/frozen-review-local-pipeline.d.ts +88 -0
- package/dist/frozen-review-local-pipeline.js +609 -0
- package/dist/frozen-review-source-capture.d.ts +15 -0
- package/dist/frozen-review-source-capture.js +551 -0
- package/dist/frozen-review-wasm-encryptor.d.ts +16 -0
- package/dist/frozen-review-wasm-encryptor.js +72 -0
- package/dist/frozen-review.d.ts +131 -0
- package/dist/frozen-review.js +802 -0
- package/dist/graph-traversal.d.ts +23 -0
- package/dist/graph-traversal.js +100 -0
- package/dist/gui/graph-layout-worker.js +1 -0
- package/dist/gui/graph-layout.wasm +0 -0
- package/dist/gui/graph.css +1 -0
- package/dist/gui/graph.js +76 -0
- package/dist/gui/plane-generation.js +112 -0
- package/dist/gui/plane-generator.js +310 -0
- package/dist/gui/plane-generator_bg.wasm +0 -0
- package/dist/gui/procedural-city.js +595 -0
- package/dist/gui/road-generation.js +129 -0
- package/dist/gui/road-parcels.js +1584 -0
- package/dist/gui/score.css +1 -0
- package/dist/gui/score.js +54 -0
- package/dist/gui/spatial-runtime.js +1231 -0
- package/dist/gui/spatial-templates.js +200 -0
- package/dist/gui/trigger.css +1 -0
- package/dist/gui/trigger.js +142 -0
- package/dist/harness-callback-transport.d.ts +17 -0
- package/dist/harness-callback-transport.js +128 -0
- package/dist/harness-document-ingestion.d.ts +77 -0
- package/dist/harness-document-ingestion.js +343 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +43 -0
- package/dist/ingestion-source-reconciliation.d.ts +88 -0
- package/dist/ingestion-source-reconciliation.js +253 -0
- package/dist/ladybug-runtime.d.ts +117 -0
- package/dist/ladybug-runtime.js +886 -0
- package/dist/local-account-embedding.d.ts +34 -0
- package/dist/local-account-embedding.js +294 -0
- package/dist/local-gallery.d.ts +35 -0
- package/dist/local-gallery.js +227 -0
- package/dist/local-gui-auth.d.ts +96 -0
- package/dist/local-gui-auth.js +703 -0
- package/dist/local-gui-contract.d.ts +53 -0
- package/dist/local-gui-contract.js +5 -0
- package/dist/local-gui-image-assets.d.ts +17 -0
- package/dist/local-gui-image-assets.js +96 -0
- package/dist/local-gui-preview-client.d.ts +3 -0
- package/dist/local-gui-preview-client.js +111 -0
- package/dist/local-gui-score.d.ts +72 -0
- package/dist/local-gui-score.js +184 -0
- package/dist/local-gui-server.d.ts +10 -0
- package/dist/local-gui-server.js +987 -0
- package/dist/local-gui-version.d.ts +17 -0
- package/dist/local-gui-version.js +147 -0
- package/dist/local-gui-workspace.d.ts +14 -0
- package/dist/local-gui-workspace.js +171 -0
- package/dist/local-model-settings.d.ts +28 -0
- package/dist/local-model-settings.js +124 -0
- package/dist/local-preview-assets.d.ts +28 -0
- package/dist/local-preview-assets.js +124 -0
- package/dist/local-preview-bound-resources.d.ts +12 -0
- package/dist/local-preview-bound-resources.js +80 -0
- package/dist/local-preview-message-store.d.ts +63 -0
- package/dist/local-preview-message-store.js +214 -0
- package/dist/local-preview-player-read-models.d.ts +47 -0
- package/dist/local-preview-player-read-models.js +90 -0
- package/dist/local-preview-projections.d.ts +61 -0
- package/dist/local-preview-projections.js +117 -0
- package/dist/local-preview-runtime.d.ts +105 -0
- package/dist/local-preview-runtime.js +553 -0
- package/dist/local-preview-session.d.ts +73 -0
- package/dist/local-preview-session.js +395 -0
- package/dist/local-preview-source.d.ts +40 -0
- package/dist/local-preview-source.js +192 -0
- package/dist/local-preview-view-events.d.ts +101 -0
- package/dist/local-preview-view-events.js +179 -0
- package/dist/local-preview-view-state-sqlite.d.ts +24 -0
- package/dist/local-preview-view-state-sqlite.js +263 -0
- package/dist/local-preview-view-state.d.ts +93 -0
- package/dist/local-preview-view-state.js +108 -0
- package/dist/local-preview-vite-config.d.ts +6 -0
- package/dist/local-preview-vite-config.js +122 -0
- package/dist/local-preview-world-effects.d.ts +9 -0
- package/dist/local-preview-world-effects.js +55 -0
- package/dist/local-preview-worldline-store.d.ts +47 -0
- package/dist/local-preview-worldline-store.js +282 -0
- package/dist/local-preview-worldlines.d.ts +95 -0
- package/dist/local-preview-worldlines.js +136 -0
- package/dist/mcp-async-dispatcher.d.ts +3 -0
- package/dist/mcp-async-dispatcher.js +129 -0
- package/dist/mcp-line-queue.d.ts +14 -0
- package/dist/mcp-line-queue.js +184 -0
- package/dist/mcp-pipe-internal.d.ts +6 -0
- package/dist/mcp-pipe-internal.js +151 -0
- package/dist/mcp-pipe-response.d.ts +14 -0
- package/dist/mcp-pipe-response.js +50 -0
- package/dist/mcp-stdio-response.d.ts +12 -0
- package/dist/mcp-stdio-response.js +27 -0
- package/dist/mcp.d.ts +27 -0
- package/dist/mcp.js +194 -0
- package/dist/prepared-embedding-cache.d.ts +38 -0
- package/dist/prepared-embedding-cache.js +238 -0
- package/dist/preview-browser-adapter.d.ts +53 -0
- package/dist/preview-browser-adapter.js +171 -0
- package/dist/preview-runtime.d.ts +67 -0
- package/dist/preview-runtime.js +460 -0
- package/dist/project-auth-transport.d.ts +27 -0
- package/dist/project-auth-transport.js +121 -0
- package/dist/project-auth.d.ts +51 -0
- package/dist/project-auth.js +108 -0
- package/dist/project-git-capture-recovery.d.ts +16 -0
- package/dist/project-git-capture-recovery.js +347 -0
- package/dist/project-git-cli.d.ts +5 -0
- package/dist/project-git-cli.js +40 -0
- package/dist/project-git-scenario-source.d.ts +10 -0
- package/dist/project-git-scenario-source.js +240 -0
- package/dist/project-git-view-source.d.ts +13 -0
- package/dist/project-git-view-source.js +134 -0
- package/dist/project-git.d.ts +97 -0
- package/dist/project-git.js +656 -0
- package/dist/project-host.d.ts +59 -0
- package/dist/project-host.js +471 -0
- package/dist/project-writer-lease.d.ts +18 -0
- package/dist/project-writer-lease.js +430 -0
- package/dist/public-error.d.ts +7 -0
- package/dist/public-error.js +61 -0
- package/dist/retrieval-benchmark.d.ts +46 -0
- package/dist/retrieval-benchmark.js +58 -0
- package/dist/safe-project-path.d.ts +8 -0
- package/dist/safe-project-path.js +93 -0
- package/dist/scenario-writer.d.ts +32 -0
- package/dist/scenario-writer.js +191 -0
- package/dist/transfer/download.d.ts +159 -0
- package/dist/transfer/download.js +598 -0
- package/dist/transfer/transport.d.ts +26 -0
- package/dist/transfer/transport.js +1 -0
- package/dist/transfer/upload.d.ts +162 -0
- package/dist/transfer/upload.js +354 -0
- package/dist/transfer-types.d.ts +95 -0
- package/dist/transfer-types.js +1 -0
- package/dist/transfer.d.ts +18 -0
- package/dist/transfer.js +273 -0
- package/dist/trigger-runtime.d.ts +121 -0
- package/dist/trigger-runtime.js +153 -0
- package/dist/vendor/wasm-core/asset-hashes.json +5 -0
- package/dist/vendor/wasm-core/wasm_core.d.ts +490 -0
- package/dist/vendor/wasm-core/wasm_core.js +1 -0
- package/dist/vendor/wasm-core/wasm_core_bg.wasm +0 -0
- package/dist/view-watcher.d.ts +9 -0
- package/dist/view-watcher.js +34 -0
- package/package.json +110 -0
|
@@ -0,0 +1,987 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { lstat, realpath, stat } from "node:fs/promises";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { AUTHORING_BRIDGE_TOOLS, authoringToolDescriptor, } from "@world-engines/authoring-bridge";
|
|
8
|
+
import { LOCAL_GUI_HOST, LOCAL_GUI_MAX_JSON_BODY_BYTES, LOCAL_GUI_PORT, LOCAL_GUI_SESSION_COOKIE, } from "./local-gui-contract.js";
|
|
9
|
+
import { listLocalGuiFiles, readLocalGuiFile, writeLocalGuiViewFile, } from "./local-gui-workspace.js";
|
|
10
|
+
import { importLocalGalleryImage, listLocalGalleryImages, readLocalGalleryImage } from "./local-gallery.js";
|
|
11
|
+
import { readLocalGuiOfficialScore, readLocalGuiScore, readLocalGuiTagBenchmark } from "./local-gui-score.js";
|
|
12
|
+
import { LocalPreviewRuntime } from "./preview-runtime.js";
|
|
13
|
+
import { LocalPreviewRuntime as LocalPreviewStateRuntime } from "./local-preview-runtime.js";
|
|
14
|
+
import { LocalPreviewReadOnlyProjection, localPreviewCoreFromRuntimeSnapshot } from "./local-preview-projections.js";
|
|
15
|
+
import { localPreviewAssetsFromRuntimeSnapshot } from "./local-preview-assets.js";
|
|
16
|
+
import { LOCAL_PREVIEW_VIEW_STATE_NAMESPACE, LocalPreviewViewStateStore } from "./local-preview-view-state.js";
|
|
17
|
+
import { LocalPreviewSession } from "./local-preview-session.js";
|
|
18
|
+
import { createLocalPreviewViewEventIngress, projectLocalPreviewAuthorViewEvents, readLocalPreviewViewInteractionConfig, } from "./local-preview-view-events.js";
|
|
19
|
+
import { LocalProjectHost } from "./project-host.js";
|
|
20
|
+
import { toPublicError } from "./public-error.js";
|
|
21
|
+
import { LOCAL_GUI_AUTHORIZATION_ORIGINS, createLocalGuiAuth, } from "./local-gui-auth.js";
|
|
22
|
+
import { createLocalGuiVersionChecker } from "./local-gui-version.js";
|
|
23
|
+
import { fetchLocalAccountChatCatalog, readLocalModelSettings, writeLocalModelSettings } from "./local-model-settings.js";
|
|
24
|
+
const SECURITY_HEADERS = Object.freeze({
|
|
25
|
+
"Content-Security-Policy": `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; img-src 'self' data:; connect-src 'self'; worker-src 'self'; frame-src http://localhost:*; object-src 'none'; base-uri 'none'; form-action 'self' ${LOCAL_GUI_AUTHORIZATION_ORIGINS.join(" ")}; frame-ancestors 'none'`,
|
|
26
|
+
"X-Frame-Options": "DENY",
|
|
27
|
+
"X-Content-Type-Options": "nosniff",
|
|
28
|
+
"Referrer-Policy": "no-referrer",
|
|
29
|
+
"Cache-Control": "no-store",
|
|
30
|
+
});
|
|
31
|
+
const LOCAL_GUI_MAX_JSON_RESPONSE_BYTES = 16 * 1024 * 1024;
|
|
32
|
+
const LOCAL_PREVIEW_ASSET_CAPABILITY_TOKEN = /^[A-Za-z0-9_-]{43}$/u;
|
|
33
|
+
const CONTENT_TYPES = {
|
|
34
|
+
".css": "text/css; charset=utf-8",
|
|
35
|
+
".html": "text/html; charset=utf-8",
|
|
36
|
+
".ico": "image/x-icon",
|
|
37
|
+
".js": "text/javascript; charset=utf-8",
|
|
38
|
+
".json": "application/json; charset=utf-8",
|
|
39
|
+
".map": "application/json; charset=utf-8",
|
|
40
|
+
".png": "image/png",
|
|
41
|
+
".svg": "image/svg+xml",
|
|
42
|
+
".wasm": "application/wasm",
|
|
43
|
+
".woff": "font/woff",
|
|
44
|
+
".woff2": "font/woff2",
|
|
45
|
+
};
|
|
46
|
+
class LocalGuiHttpError extends Error {
|
|
47
|
+
code;
|
|
48
|
+
status;
|
|
49
|
+
constructor(code, status, message) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.code = code;
|
|
52
|
+
this.status = status;
|
|
53
|
+
this.name = "LocalGuiHttpError";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function success(result) {
|
|
57
|
+
return { ok: true, result };
|
|
58
|
+
}
|
|
59
|
+
function failure(code, message) {
|
|
60
|
+
return { ok: false, error: { code, message } };
|
|
61
|
+
}
|
|
62
|
+
function sendHeaders(response, headers = {}) {
|
|
63
|
+
for (const [name, value] of Object.entries(SECURITY_HEADERS))
|
|
64
|
+
response.setHeader(name, value);
|
|
65
|
+
for (const [name, value] of Object.entries(headers))
|
|
66
|
+
response.setHeader(name, value);
|
|
67
|
+
}
|
|
68
|
+
function sendJson(response, status, body) {
|
|
69
|
+
sendHeaders(response, { "Content-Type": "application/json; charset=utf-8" });
|
|
70
|
+
let serialized = JSON.stringify(body);
|
|
71
|
+
if (Buffer.byteLength(serialized, "utf8") > LOCAL_GUI_MAX_JSON_RESPONSE_BYTES) {
|
|
72
|
+
response.statusCode = 413;
|
|
73
|
+
serialized = JSON.stringify(failure("E_BODY_TOO_LARGE", "响应体过大"));
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
response.statusCode = status;
|
|
77
|
+
}
|
|
78
|
+
response.end(`${serialized}\n`);
|
|
79
|
+
}
|
|
80
|
+
function acceptsJson(request) {
|
|
81
|
+
return String(request.headers.accept ?? "").split(",").some((item) => item.trim().split(";", 1)[0]?.toLowerCase() === "application/json");
|
|
82
|
+
}
|
|
83
|
+
function sendGalleryImage(response, image) {
|
|
84
|
+
sendHeaders(response, {
|
|
85
|
+
"Content-Type": image.mime,
|
|
86
|
+
"Content-Length": String(image.bytes.byteLength),
|
|
87
|
+
"Cache-Control": "private, max-age=0, must-revalidate",
|
|
88
|
+
ETag: `"${image.sha256}"`,
|
|
89
|
+
});
|
|
90
|
+
response.statusCode = 200;
|
|
91
|
+
response.end(image.bytes);
|
|
92
|
+
}
|
|
93
|
+
/** @internal 供真实 HTTP Range 回归复用;调用方仍负责 session/capability authorization。 */
|
|
94
|
+
export function sendPreviewAsset(request, response, asset) {
|
|
95
|
+
const baseHeaders = {
|
|
96
|
+
"Content-Type": asset.mime,
|
|
97
|
+
"Cache-Control": "private, max-age=0, must-revalidate",
|
|
98
|
+
ETag: `"${asset.sha256}"`,
|
|
99
|
+
"X-Local-Preview-Asset-Sha256": asset.sha256,
|
|
100
|
+
"Cross-Origin-Resource-Policy": "cross-origin",
|
|
101
|
+
"Accept-Ranges": "bytes",
|
|
102
|
+
};
|
|
103
|
+
const range = request.headers.range;
|
|
104
|
+
let body = asset.bytes;
|
|
105
|
+
if (range !== undefined) {
|
|
106
|
+
const match = typeof range === "string" && !range.includes(",") ? /^bytes=(\d*)-(\d*)$/u.exec(range) : null;
|
|
107
|
+
const startText = match?.[1] ?? "";
|
|
108
|
+
const endText = match?.[2] ?? "";
|
|
109
|
+
let start = Number.NaN;
|
|
110
|
+
let end = Number.NaN;
|
|
111
|
+
if (match !== null && (startText !== "" || endText !== "")) {
|
|
112
|
+
if (startText === "") {
|
|
113
|
+
const suffix = Number(endText);
|
|
114
|
+
if (Number.isSafeInteger(suffix) && suffix > 0 && suffix <= asset.bytes.byteLength) {
|
|
115
|
+
start = asset.bytes.byteLength - suffix;
|
|
116
|
+
end = asset.bytes.byteLength - 1;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
start = Number(startText);
|
|
121
|
+
end = endText === "" ? asset.bytes.byteLength - 1 : Number(endText);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || end >= asset.bytes.byteLength) {
|
|
125
|
+
sendHeaders(response, { ...baseHeaders, "Content-Range": `bytes */${asset.bytes.byteLength}`, "Content-Length": "0" });
|
|
126
|
+
response.statusCode = 416;
|
|
127
|
+
response.end();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
body = asset.bytes.subarray(start, end + 1);
|
|
131
|
+
baseHeaders["Content-Range"] = `bytes ${start}-${end}/${asset.bytes.byteLength}`;
|
|
132
|
+
response.statusCode = 206;
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
response.statusCode = 200;
|
|
136
|
+
}
|
|
137
|
+
sendHeaders(response, { ...baseHeaders, "Content-Length": String(body.byteLength) });
|
|
138
|
+
if (request.method === "HEAD")
|
|
139
|
+
response.end();
|
|
140
|
+
else
|
|
141
|
+
response.end(body);
|
|
142
|
+
}
|
|
143
|
+
function isRecord(value) {
|
|
144
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
145
|
+
}
|
|
146
|
+
function requiredString(value, name) {
|
|
147
|
+
if (typeof value !== "string" || value.length === 0)
|
|
148
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, `${name} 无效`);
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
function requiredText(value, name) {
|
|
152
|
+
if (typeof value !== "string")
|
|
153
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, `${name} 无效`);
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
156
|
+
function requiredRevision(value) {
|
|
157
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
158
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, "expected_project_revision 无效");
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
function exactKeys(body, keys) {
|
|
162
|
+
if (Object.keys(body).sort().join("\0") !== [...keys].sort().join("\0")) {
|
|
163
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, "请求字段无效");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function readJsonBody(request) {
|
|
167
|
+
if ((request.headers["content-type"] ?? "").split(";", 1)[0]?.trim().toLowerCase() !== "application/json") {
|
|
168
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 415, "请求必须使用 application/json");
|
|
169
|
+
}
|
|
170
|
+
const declared = Number(request.headers["content-length"] ?? 0);
|
|
171
|
+
if (Number.isFinite(declared) && declared > LOCAL_GUI_MAX_JSON_BODY_BYTES) {
|
|
172
|
+
throw new LocalGuiHttpError("E_BODY_TOO_LARGE", 413, "请求体过大");
|
|
173
|
+
}
|
|
174
|
+
const chunks = [];
|
|
175
|
+
let size = 0;
|
|
176
|
+
for await (const chunk of request) {
|
|
177
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
178
|
+
size += bytes.byteLength;
|
|
179
|
+
if (size > LOCAL_GUI_MAX_JSON_BODY_BYTES)
|
|
180
|
+
throw new LocalGuiHttpError("E_BODY_TOO_LARGE", 413, "请求体过大");
|
|
181
|
+
chunks.push(bytes);
|
|
182
|
+
}
|
|
183
|
+
let value;
|
|
184
|
+
try {
|
|
185
|
+
value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, "JSON 无效");
|
|
189
|
+
}
|
|
190
|
+
if (!isRecord(value))
|
|
191
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, "JSON body 必须是 object");
|
|
192
|
+
return value;
|
|
193
|
+
}
|
|
194
|
+
function requestCookie(request, name) {
|
|
195
|
+
const cookie = request.headers.cookie;
|
|
196
|
+
if (cookie === undefined)
|
|
197
|
+
return undefined;
|
|
198
|
+
for (const part of cookie.split(";")) {
|
|
199
|
+
const separator = part.indexOf("=");
|
|
200
|
+
if (separator < 0 || part.slice(0, separator).trim() !== name)
|
|
201
|
+
continue;
|
|
202
|
+
return part.slice(separator + 1).trim();
|
|
203
|
+
}
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
function tokensEqual(left, right) {
|
|
207
|
+
if (left === undefined)
|
|
208
|
+
return false;
|
|
209
|
+
const a = Buffer.from(left);
|
|
210
|
+
const b = Buffer.from(right);
|
|
211
|
+
return a.byteLength === b.byteLength && timingSafeEqual(a, b);
|
|
212
|
+
}
|
|
213
|
+
function isAllowedTopLevelNavigation(request, origin) {
|
|
214
|
+
if (request.method !== "GET")
|
|
215
|
+
return false;
|
|
216
|
+
const fetchSite = request.headers["sec-fetch-site"];
|
|
217
|
+
const fetchMode = request.headers["sec-fetch-mode"];
|
|
218
|
+
const fetchDest = request.headers["sec-fetch-dest"];
|
|
219
|
+
const requestOrigin = request.headers.origin;
|
|
220
|
+
return (fetchSite === "none" || fetchSite === "same-origin")
|
|
221
|
+
&& fetchMode === "navigate"
|
|
222
|
+
&& fetchDest === "document"
|
|
223
|
+
&& (requestOrigin === undefined || requestOrigin === origin);
|
|
224
|
+
}
|
|
225
|
+
function isNavigationDocument(request) {
|
|
226
|
+
return request.method === "GET"
|
|
227
|
+
&& request.headers["sec-fetch-mode"] === "navigate"
|
|
228
|
+
&& request.headers["sec-fetch-dest"] === "document"
|
|
229
|
+
&& request.headers.origin === undefined;
|
|
230
|
+
}
|
|
231
|
+
function assertApiRequest(request, origin, sessionToken) {
|
|
232
|
+
if (!tokensEqual(requestCookie(request, LOCAL_GUI_SESSION_COOKIE), sessionToken)) {
|
|
233
|
+
throw new LocalGuiHttpError("E_GUI_SESSION_REQUIRED", 401, "需要本地 GUI session");
|
|
234
|
+
}
|
|
235
|
+
const fetchSite = request.headers["sec-fetch-site"];
|
|
236
|
+
const requestOrigin = request.headers.origin;
|
|
237
|
+
if (fetchSite === "cross-site" || fetchSite === "same-site" || requestOrigin === "null") {
|
|
238
|
+
throw new LocalGuiHttpError("E_GUI_ORIGIN_FORBIDDEN", 403, "请求来源无效");
|
|
239
|
+
}
|
|
240
|
+
if (requestOrigin !== undefined && requestOrigin !== origin) {
|
|
241
|
+
throw new LocalGuiHttpError("E_GUI_ORIGIN_FORBIDDEN", 403, "请求来源无效");
|
|
242
|
+
}
|
|
243
|
+
if (request.method !== "GET" && requestOrigin !== origin) {
|
|
244
|
+
throw new LocalGuiHttpError("E_GUI_ORIGIN_FORBIDDEN", 403, "mutation 必须来自精确同源");
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function area(value) {
|
|
248
|
+
if (value === "view" || value === "docs")
|
|
249
|
+
return value;
|
|
250
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, "area 必须是 view 或 docs");
|
|
251
|
+
}
|
|
252
|
+
function validateJobArgs(value) {
|
|
253
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 32 || value.some((item) => typeof item !== "string" || item.length === 0 || item.length > 8_192)) {
|
|
254
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, "args 无效");
|
|
255
|
+
}
|
|
256
|
+
const args = value;
|
|
257
|
+
const [command] = args;
|
|
258
|
+
const allowed = (command === "doctor" && args.length === 1)
|
|
259
|
+
|| (command === "ingest" && args.length === 5)
|
|
260
|
+
|| (command === "recall" && (args.length === 2 || args.length === 3));
|
|
261
|
+
if (!allowed) {
|
|
262
|
+
throw new LocalGuiHttpError("E_COMMAND_NOT_ALLOWED", 400, "命令不在本地 GUI allowlist");
|
|
263
|
+
}
|
|
264
|
+
return Object.freeze([...args]);
|
|
265
|
+
}
|
|
266
|
+
function publicFailure(error) {
|
|
267
|
+
if (error instanceof LocalGuiHttpError)
|
|
268
|
+
return { status: error.status, body: failure(error.code, error.message) };
|
|
269
|
+
const candidate = error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : undefined;
|
|
270
|
+
if (candidate === "E_FILE_CHANGED")
|
|
271
|
+
return { status: 409, body: failure(candidate, "文件已被其他写入者修改") };
|
|
272
|
+
if (candidate === "E_PROJECT_MIGRATION_REQUIRED")
|
|
273
|
+
return { status: 409, body: failure(candidate, "项目需要先迁移到 gallery manifest v2") };
|
|
274
|
+
const publicError = toPublicError(error);
|
|
275
|
+
const status = publicError.code === "E_STALE_PROJECT_REVISION" || publicError.code === "E_PROJECT_BUSY" ? 409 : 400;
|
|
276
|
+
return { status, body: failure(publicError.code, "请求失败") };
|
|
277
|
+
}
|
|
278
|
+
function safeStaticRelativePath(pathname) {
|
|
279
|
+
let decoded;
|
|
280
|
+
try {
|
|
281
|
+
decoded = decodeURIComponent(pathname);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
throw new LocalGuiHttpError("E_PATH_OUTSIDE_PROJECT", 400, "静态资源路径无效");
|
|
285
|
+
}
|
|
286
|
+
if (!decoded.startsWith("/") || decoded.startsWith("//") || decoded.includes("\0") || decoded.includes("\\")) {
|
|
287
|
+
throw new LocalGuiHttpError("E_PATH_OUTSIDE_PROJECT", 400, "静态资源路径无效");
|
|
288
|
+
}
|
|
289
|
+
const parts = decoded.slice(1).split("/").filter(Boolean);
|
|
290
|
+
const device = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
|
|
291
|
+
if (parts.some((part) => part === "." || part === ".." || part.includes(":") || part.endsWith(".") || part.endsWith(" ") || device.test(part))) {
|
|
292
|
+
throw new LocalGuiHttpError("E_PATH_OUTSIDE_PROJECT", 400, "静态资源路径无效");
|
|
293
|
+
}
|
|
294
|
+
const selected = parts.length === 0 || extname(parts.at(-1) ?? "") === "" ? "index.html" : parts.join("/");
|
|
295
|
+
if (!new Set([".css", ".html", ".ico", ".js", ".json", ".map", ".png", ".svg", ".wasm", ".woff", ".woff2"]).has(extname(selected).toLowerCase())) {
|
|
296
|
+
throw new LocalGuiHttpError("E_PATH_OUTSIDE_PROJECT", 400, "静态资源类型无效");
|
|
297
|
+
}
|
|
298
|
+
return selected;
|
|
299
|
+
}
|
|
300
|
+
async function staticFile(assetsRoot, pathname) {
|
|
301
|
+
const relativePath = safeStaticRelativePath(pathname);
|
|
302
|
+
const candidate = resolve(assetsRoot, ...relativePath.split("/"));
|
|
303
|
+
const rel = relative(assetsRoot, candidate);
|
|
304
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
|
|
305
|
+
throw new LocalGuiHttpError("E_PATH_OUTSIDE_PROJECT", 400, "静态资源路径无效");
|
|
306
|
+
let current = assetsRoot;
|
|
307
|
+
for (const segment of relativePath.split("/")) {
|
|
308
|
+
current = join(current, segment);
|
|
309
|
+
const info = await lstat(current);
|
|
310
|
+
if (info.isSymbolicLink())
|
|
311
|
+
throw new LocalGuiHttpError("E_PATH_OUTSIDE_PROJECT", 400, "拒绝链接静态资源");
|
|
312
|
+
}
|
|
313
|
+
const canonical = await realpath(candidate);
|
|
314
|
+
const canonicalRel = relative(assetsRoot, canonical);
|
|
315
|
+
if (canonicalRel === ".." || canonicalRel.startsWith(`..${sep}`) || isAbsolute(canonicalRel))
|
|
316
|
+
throw new LocalGuiHttpError("E_PATH_OUTSIDE_PROJECT", 400, "静态资源路径无效");
|
|
317
|
+
if (!(await stat(canonical)).isFile())
|
|
318
|
+
throw new LocalGuiHttpError("E_NOT_FOUND", 404, "静态资源不存在");
|
|
319
|
+
return canonical;
|
|
320
|
+
}
|
|
321
|
+
async function serveStatic(response, assetsRoot, pathname) {
|
|
322
|
+
const path = await staticFile(assetsRoot, pathname);
|
|
323
|
+
sendHeaders(response, { "Content-Type": CONTENT_TYPES[extname(path).toLowerCase()] ?? "application/octet-stream" });
|
|
324
|
+
response.statusCode = 200;
|
|
325
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
326
|
+
const stream = createReadStream(path);
|
|
327
|
+
stream.once("error", rejectPromise);
|
|
328
|
+
response.once("finish", resolvePromise);
|
|
329
|
+
stream.pipe(response);
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
function jobView(job) {
|
|
333
|
+
return {
|
|
334
|
+
job_id: job.job_id,
|
|
335
|
+
args: job.args,
|
|
336
|
+
created_at_ms: job.created_at_ms,
|
|
337
|
+
state: job.state,
|
|
338
|
+
...(job.result === undefined ? {} : { result: job.result }),
|
|
339
|
+
...(job.error === undefined ? {} : { error: job.error }),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
function startJob(jobs, runCommand, args, auth) {
|
|
343
|
+
const job = { job_id: `job_${randomBytes(16).toString("hex")}`, args, created_at_ms: Date.now(), state: "running" };
|
|
344
|
+
jobs.set(job.job_id, job);
|
|
345
|
+
void runCommand(args, auth === undefined ? {} : { auth }).then((result) => {
|
|
346
|
+
job.result = result;
|
|
347
|
+
job.state = "completed";
|
|
348
|
+
}, (error) => {
|
|
349
|
+
job.error = { code: toPublicError(error).code, message: "命令执行失败" };
|
|
350
|
+
job.state = "failed";
|
|
351
|
+
});
|
|
352
|
+
return job;
|
|
353
|
+
}
|
|
354
|
+
/** 启动绑定一个 canonical project root 的 npm 包内本地 GUI;浏览器不能改选 root。 */
|
|
355
|
+
export async function startLocalGuiServer(root, options = {}) {
|
|
356
|
+
const host = await LocalProjectHost.open(root, options.hostOptions ?? {});
|
|
357
|
+
const previewAssetCapabilities = new Map();
|
|
358
|
+
let projectionRuntime;
|
|
359
|
+
let projectionRuntimeOpening;
|
|
360
|
+
const currentProjectionRuntime = async () => {
|
|
361
|
+
const status = await host.inspect();
|
|
362
|
+
if (projectionRuntime !== undefined && projectionRuntime.projectRevision === status.project_revision)
|
|
363
|
+
return projectionRuntime.runtime;
|
|
364
|
+
if (projectionRuntimeOpening?.projectRevision === status.project_revision)
|
|
365
|
+
return await projectionRuntimeOpening.promise;
|
|
366
|
+
const revision = status.project_revision;
|
|
367
|
+
const promise = LocalPreviewStateRuntime.open({ project_root: host.root }).then((runtime) => {
|
|
368
|
+
const previous = projectionRuntime;
|
|
369
|
+
projectionRuntime = Object.freeze({ projectRevision: revision, runtime });
|
|
370
|
+
if (previous !== undefined && previous.runtime !== runtime) {
|
|
371
|
+
previous.runtime.dispose();
|
|
372
|
+
previewAssetCapabilities.clear();
|
|
373
|
+
}
|
|
374
|
+
return runtime;
|
|
375
|
+
});
|
|
376
|
+
projectionRuntimeOpening = Object.freeze({ projectRevision: revision, promise });
|
|
377
|
+
try {
|
|
378
|
+
return await promise;
|
|
379
|
+
}
|
|
380
|
+
finally {
|
|
381
|
+
if (projectionRuntimeOpening?.promise === promise)
|
|
382
|
+
projectionRuntimeOpening = undefined;
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
const currentPreviewAssets = async () => localPreviewAssetsFromRuntimeSnapshot((await currentProjectionRuntime()).readProjectionSnapshot());
|
|
386
|
+
const pendingWriterViewDeliveries = [];
|
|
387
|
+
const drainWriterViewEvents = async (turnId, enqueue = true) => {
|
|
388
|
+
const current = await currentViewRuntime();
|
|
389
|
+
const inspection = current.runtime.inspect();
|
|
390
|
+
const drained = [];
|
|
391
|
+
for (const receipt of await current.runtime.drainPendingSceneCrossings({ turn_id: turnId, max_events: 64 })) {
|
|
392
|
+
let events;
|
|
393
|
+
try {
|
|
394
|
+
events = current.runtime.readAuthorViewEventsByOperation(receipt.operation_id);
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
if (error.code === "E_REFERENCE_MISSING")
|
|
398
|
+
continue;
|
|
399
|
+
throw error;
|
|
400
|
+
}
|
|
401
|
+
const deliveries = await projectLocalPreviewAuthorViewEvents({ capture_sha256: inspection.capture_sha256, binding: current.runtime.readViewInteractionBinding(), surface: "pc", author_view_events: events });
|
|
402
|
+
if (deliveries.length > 0) {
|
|
403
|
+
const entry = Object.freeze({ operation_id: receipt.operation_id, deliveries });
|
|
404
|
+
drained.push(entry);
|
|
405
|
+
if (enqueue)
|
|
406
|
+
pendingWriterViewDeliveries.push(entry);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return Object.freeze(drained);
|
|
410
|
+
};
|
|
411
|
+
const currentAuthenticatedPrincipal = async () => {
|
|
412
|
+
if (auth?.status().state !== "signed_in")
|
|
413
|
+
return null;
|
|
414
|
+
const session = await auth.requestModel({ kind: "session" });
|
|
415
|
+
const principalId = session.body.principal_id;
|
|
416
|
+
if (session.status !== 200 || typeof principalId !== "string" || principalId.length === 0) {
|
|
417
|
+
throw new LocalGuiHttpError("E_AUTH_REQUIRED", 401, "本地 GUI 账户主体不可用");
|
|
418
|
+
}
|
|
419
|
+
return principalId;
|
|
420
|
+
};
|
|
421
|
+
let viewEventIngress;
|
|
422
|
+
const currentViewRuntime = async () => {
|
|
423
|
+
const before = await host.inspect();
|
|
424
|
+
const runtime = await currentProjectionRuntime();
|
|
425
|
+
const after = await host.inspect();
|
|
426
|
+
if (before.project_revision !== after.project_revision) {
|
|
427
|
+
throw new LocalGuiHttpError("E_FILE_CHANGED", 409, "Preview source 在读取期间变化,请刷新后重试");
|
|
428
|
+
}
|
|
429
|
+
return Object.freeze({ projectRevision: after.project_revision, runtime });
|
|
430
|
+
};
|
|
431
|
+
const currentViewEventIngress = async () => {
|
|
432
|
+
const current = await currentViewRuntime();
|
|
433
|
+
if (viewEventIngress?.projectRevision === current.projectRevision && viewEventIngress.runtime === current.runtime)
|
|
434
|
+
return viewEventIngress.ingress;
|
|
435
|
+
const inspection = current.runtime.inspect();
|
|
436
|
+
const ingress = await createLocalPreviewViewEventIngress({
|
|
437
|
+
capture_sha256: inspection.capture_sha256,
|
|
438
|
+
binding: current.runtime.readViewInteractionBinding(),
|
|
439
|
+
surface: "pc",
|
|
440
|
+
readCurrentFrame: () => null,
|
|
441
|
+
deriveExecutionContext: (event) => {
|
|
442
|
+
const id = createHash("sha256").update(`${inspection.capture_sha256}\u0000${event.interaction_id}`, "utf8").digest("hex").slice(0, 48);
|
|
443
|
+
return Object.freeze({ operation_id: `local.view.${id}`, turn_id: `local.view.turn.${id}` });
|
|
444
|
+
},
|
|
445
|
+
executeEvent: (input) => current.runtime.executeEvent(input),
|
|
446
|
+
});
|
|
447
|
+
viewEventIngress = Object.freeze({ projectRevision: current.projectRevision, runtime: current.runtime, ingress });
|
|
448
|
+
return ingress;
|
|
449
|
+
};
|
|
450
|
+
let previewSessionPromise;
|
|
451
|
+
const previewSession = async () => {
|
|
452
|
+
previewSessionPromise ??= LocalPreviewSession.open({
|
|
453
|
+
root: host.root,
|
|
454
|
+
projectId: (await host.inspect()).project_id,
|
|
455
|
+
readOnlyProjection: async () => {
|
|
456
|
+
const runtime = await currentProjectionRuntime();
|
|
457
|
+
const runtimeSnapshot = runtime.readProjectionSnapshot();
|
|
458
|
+
const identity = runtimeSnapshot.capture_identity;
|
|
459
|
+
if (typeof identity !== "object" || identity === null || Array.isArray(identity))
|
|
460
|
+
throw new LocalGuiHttpError("E_OPERATION_UNCERTAIN", 500, "Preview capture identity 无效");
|
|
461
|
+
const viewId = requiredString(identity.local_view_id, "local_view_id");
|
|
462
|
+
const captureId = requiredString(identity.capture_sha256, "capture_sha256");
|
|
463
|
+
const saveId = `local.preview.${captureId.slice(0, 24)}`;
|
|
464
|
+
const assets = await currentPreviewAssets();
|
|
465
|
+
const player = runtime.playerReadModels();
|
|
466
|
+
return new LocalPreviewReadOnlyProjection(localPreviewCoreFromRuntimeSnapshot(runtimeSnapshot), {
|
|
467
|
+
progress: () => player.progress(),
|
|
468
|
+
ownedResources: () => player.ownedResources(),
|
|
469
|
+
customState: async () => {
|
|
470
|
+
const principalId = await currentAuthenticatedPrincipal();
|
|
471
|
+
const store = await LocalPreviewViewStateStore.open({ captureId, namespace: LOCAL_PREVIEW_VIEW_STATE_NAMESPACE, saveId, viewId, principalId }, await runtime.openViewState(viewId));
|
|
472
|
+
return await store.entries();
|
|
473
|
+
},
|
|
474
|
+
assets: {
|
|
475
|
+
list: async () => assets.list(),
|
|
476
|
+
getUrl: async (asset) => `/api/preview/asset?id=${encodeURIComponent(asset.id)}`,
|
|
477
|
+
},
|
|
478
|
+
});
|
|
479
|
+
},
|
|
480
|
+
writerCommitted: async ({ turn_id }) => { await drainWriterViewEvents(turn_id); },
|
|
481
|
+
hostNotificationSnapshot: async () => {
|
|
482
|
+
const runtime = await currentProjectionRuntime();
|
|
483
|
+
const snapshot = runtime.readProjectionSnapshot();
|
|
484
|
+
const identity = snapshot.capture_identity;
|
|
485
|
+
const captureId = requiredString(identity.capture_sha256, "capture_sha256");
|
|
486
|
+
const viewId = requiredString(identity.local_view_id, "local_view_id");
|
|
487
|
+
const saveId = `local.preview.${captureId.slice(0, 24)}`;
|
|
488
|
+
const principalId = await currentAuthenticatedPrincipal();
|
|
489
|
+
const store = await LocalPreviewViewStateStore.open({ captureId, namespace: LOCAL_PREVIEW_VIEW_STATE_NAMESPACE, saveId, viewId, principalId }, await runtime.openViewState(viewId));
|
|
490
|
+
const entries = await store.entries();
|
|
491
|
+
const owned = await runtime.playerReadModels().ownedResources();
|
|
492
|
+
return Object.freeze({ state: Object.freeze({ generation: (await (await runtime.openViewState(viewId)).read({ captureId, namespace: LOCAL_PREVIEW_VIEW_STATE_NAMESPACE, saveId, viewId })).generation, entries: Object.freeze(entries.map((entry) => Object.freeze({ key: entry.key, revision: entry.revision }))) }), owned_resources: owned });
|
|
493
|
+
},
|
|
494
|
+
runtime: currentProjectionRuntime,
|
|
495
|
+
});
|
|
496
|
+
return previewSessionPromise;
|
|
497
|
+
};
|
|
498
|
+
const preview = options.previewRuntime ?? new LocalPreviewRuntime();
|
|
499
|
+
const issuePreviewAssetCapability = async (id) => {
|
|
500
|
+
const resolver = await currentPreviewAssets();
|
|
501
|
+
const asset = resolver.list().find((entry) => entry.id === id);
|
|
502
|
+
if (asset === undefined)
|
|
503
|
+
throw new LocalGuiHttpError("E_NOT_FOUND", 404, "Preview asset 不存在");
|
|
504
|
+
const resolved = resolver.resolveBytes(asset);
|
|
505
|
+
const token = randomBytes(32).toString("base64url");
|
|
506
|
+
previewAssetCapabilities.set(token, Object.freeze({ asset: Object.freeze({ mime: resolved.mime, sha256: resolved.sha256, bytes: resolved.bytes }) }));
|
|
507
|
+
return Object.freeze({ url: `${origin}/preview-media/${token}`, mime: resolved.mime, size: resolved.bytes.byteLength, sha256: resolved.sha256 });
|
|
508
|
+
};
|
|
509
|
+
const jobs = new Map();
|
|
510
|
+
const modelCatalogTokens = new Map();
|
|
511
|
+
const readAccountChatCatalog = options.accountChatCatalogReader ?? ((input) => fetchLocalAccountChatCatalog(input));
|
|
512
|
+
const version = options.versionChecker ?? createLocalGuiVersionChecker();
|
|
513
|
+
void version.check();
|
|
514
|
+
const sessionToken = randomBytes(32).toString("base64url");
|
|
515
|
+
const previewWorldlineRequestEpoch = randomBytes(16).toString("base64url");
|
|
516
|
+
const previewWorldlineRequests = new Map();
|
|
517
|
+
const worldlineRequest = (method, params, requestId) => {
|
|
518
|
+
if (typeof requestId !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/u.test(requestId))
|
|
519
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, "worldline request_id 无效");
|
|
520
|
+
const paramsDigest = createHash("sha256").update(JSON.stringify(params)).digest("hex");
|
|
521
|
+
const existing = previewWorldlineRequests.get(requestId);
|
|
522
|
+
if (existing !== undefined) {
|
|
523
|
+
if (existing.method !== method || existing.paramsDigest !== paramsDigest)
|
|
524
|
+
throw new LocalGuiHttpError("E_OPERATION_ID_CONFLICT", 409, "worldline request_id 已绑定不同意图");
|
|
525
|
+
return existing;
|
|
526
|
+
}
|
|
527
|
+
const context = Object.freeze({
|
|
528
|
+
requestId: createHash("sha256").update(sessionToken).update("\u0000").update(previewWorldlineRequestEpoch).update("\u0000").update(requestId).digest("hex"),
|
|
529
|
+
createdAtMs: Date.now(),
|
|
530
|
+
});
|
|
531
|
+
const entry = { method, paramsDigest, context };
|
|
532
|
+
previewWorldlineRequests.set(requestId, entry);
|
|
533
|
+
return entry;
|
|
534
|
+
};
|
|
535
|
+
const executeWorldlineRequest = async (entry, operation) => {
|
|
536
|
+
if (Object.prototype.hasOwnProperty.call(entry, "result"))
|
|
537
|
+
return entry.result;
|
|
538
|
+
entry.pending ??= operation().then((result) => { entry.result = result; return result; }, (error) => { delete entry.pending; throw error; });
|
|
539
|
+
return await entry.pending;
|
|
540
|
+
};
|
|
541
|
+
const defaultAssets = fileURLToPath(new URL("../assets/gui", import.meta.url));
|
|
542
|
+
const defaultGeneratedAssets = fileURLToPath(new URL("./gui", import.meta.url));
|
|
543
|
+
let assetsRoot;
|
|
544
|
+
let generatedAssetsRoot;
|
|
545
|
+
try {
|
|
546
|
+
assetsRoot = await realpath(resolve(options.assetsDirectory ?? defaultAssets));
|
|
547
|
+
if (!(await stat(assetsRoot)).isDirectory())
|
|
548
|
+
throw new Error("not a directory");
|
|
549
|
+
generatedAssetsRoot = await realpath(resolve(options.generatedAssetsDirectory ?? defaultGeneratedAssets));
|
|
550
|
+
if (!(await stat(generatedAssetsRoot)).isDirectory())
|
|
551
|
+
throw new Error("not a directory");
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
await host.close();
|
|
555
|
+
throw new LocalGuiHttpError("E_GUI_UNAVAILABLE", 500, "本地 GUI 静态资源不可用");
|
|
556
|
+
}
|
|
557
|
+
let origin = "";
|
|
558
|
+
let auth;
|
|
559
|
+
const server = createServer(async (request, response) => {
|
|
560
|
+
try {
|
|
561
|
+
if (request.headers.host !== origin.slice("http://".length)) {
|
|
562
|
+
throw new LocalGuiHttpError("E_GUI_HOST_FORBIDDEN", 403, "Host 无效");
|
|
563
|
+
}
|
|
564
|
+
const url = new URL(request.url ?? "/", origin);
|
|
565
|
+
if (request.method === "GET" && url.pathname === "/desktop-login/callback") {
|
|
566
|
+
if (auth === undefined)
|
|
567
|
+
throw new LocalGuiHttpError("E_CAPABILITY_UNAVAILABLE", 404, "认证能力不可用");
|
|
568
|
+
await auth.handleCallback(url);
|
|
569
|
+
sendHeaders(response, { Location: "/" });
|
|
570
|
+
response.setHeader("Set-Cookie", `${LOCAL_GUI_SESSION_COOKIE}=${sessionToken}; HttpOnly; SameSite=Strict; Path=/`);
|
|
571
|
+
response.statusCode = 303;
|
|
572
|
+
response.end();
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (url.pathname.startsWith("/api/")) {
|
|
576
|
+
assertApiRequest(request, origin, sessionToken);
|
|
577
|
+
if (request.method === "GET" && url.pathname === "/api/status") {
|
|
578
|
+
sendJson(response, 200, success(await host.inspect()));
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
if (request.method === "GET" && url.pathname === "/api/tools") {
|
|
582
|
+
sendJson(response, 200, success({ tools: AUTHORING_BRIDGE_TOOLS.map((descriptor) => ({ ...descriptor, available: host.isToolAvailable(descriptor.name) })) }));
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (request.method === "GET" && url.pathname === "/api/graph") {
|
|
586
|
+
sendJson(response, 200, success(await host.readGraphProjectionReadback()));
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
if (request.method === "GET" && url.pathname === "/api/score") {
|
|
590
|
+
if (url.searchParams.size !== 0)
|
|
591
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, "score 不接受查询参数");
|
|
592
|
+
const local = await readLocalGuiScore(host);
|
|
593
|
+
sendJson(response, 200, success({ ...local, official: await readLocalGuiOfficialScore(auth) }));
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
if (request.method === "GET" && url.pathname === "/api/score/tag-benchmark") {
|
|
597
|
+
if (url.searchParams.size !== 1)
|
|
598
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, "tag benchmark 查询参数无效");
|
|
599
|
+
sendJson(response, 200, success(await readLocalGuiTagBenchmark(host, requiredString(url.searchParams.get("tag"), "tag"), auth)));
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (request.method === "GET" && url.pathname === "/api/gallery") {
|
|
603
|
+
const areaValue = url.searchParams.get("area");
|
|
604
|
+
if (areaValue !== "scenario" && areaValue !== "view")
|
|
605
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, "Gallery area 无效");
|
|
606
|
+
const ownerKind = url.searchParams.get("owner_kind");
|
|
607
|
+
const ownerId = url.searchParams.get("owner_id");
|
|
608
|
+
if (areaValue === "scenario" && ownerKind !== "entity" && ownerKind !== "relation")
|
|
609
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, "Scenario Gallery owner_kind 无效");
|
|
610
|
+
if (areaValue === "scenario" && (ownerId === null || ownerId.length === 0))
|
|
611
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, "Scenario Gallery owner_id 无效");
|
|
612
|
+
if (areaValue === "view" && (ownerKind !== null || ownerId !== null))
|
|
613
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, "View Gallery 不接受 owner");
|
|
614
|
+
sendJson(response, 200, success({ images: await listLocalGalleryImages(host.root, areaValue, ownerKind, ownerId ?? undefined) }));
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
if (request.method === "GET" && url.pathname === "/api/gallery/file") {
|
|
618
|
+
if (url.searchParams.size !== 1)
|
|
619
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, "Gallery file 查询参数无效");
|
|
620
|
+
sendGalleryImage(response, await readLocalGalleryImage(host.root, requiredString(url.searchParams.get("path"), "path")));
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (request.method === "GET" && url.pathname === "/api/auth/status") {
|
|
624
|
+
sendJson(response, 200, success(auth === undefined ? { state: "signed_out", available: false } : { ...auth.status(), available: true }));
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
if (request.method === "GET" && url.pathname === "/api/auth/account") {
|
|
628
|
+
sendJson(response, 200, success(auth === undefined ? { state: "signed_out", available: false } : { ...await auth.account(), available: true }));
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (request.method === "GET" && url.pathname === "/api/model-settings") {
|
|
632
|
+
if (url.searchParams.size !== 0)
|
|
633
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, "模型设置不接受查询参数");
|
|
634
|
+
const projectId = (await host.inspect()).project_id;
|
|
635
|
+
const accountCatalog = auth?.status().state !== "signed_in" ? undefined : await readAccountChatCatalog({ projectRoot: host.root, projectId });
|
|
636
|
+
const catalogToken = accountCatalog === undefined ? undefined : randomBytes(24).toString("base64url");
|
|
637
|
+
if (catalogToken !== undefined && accountCatalog !== undefined)
|
|
638
|
+
modelCatalogTokens.set(catalogToken, Object.freeze({ accountIdentity: accountCatalog.accountIdentity, catalogDigest: accountCatalog.catalogDigest, models: accountCatalog.models.map((entry) => entry.model) }));
|
|
639
|
+
sendJson(response, 200, success({
|
|
640
|
+
config: await readLocalModelSettings(host.root),
|
|
641
|
+
account_catalog: accountCatalog === undefined ? { state: "sign_in_required", models: [] } : { state: "ready", catalog_digest: accountCatalog.catalogDigest, catalog_token: catalogToken, models: accountCatalog.models },
|
|
642
|
+
}));
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
if (request.method === "GET" && url.pathname === "/api/version") {
|
|
646
|
+
sendJson(response, 200, success({ ...version.get(), can_update: false, availability: "unavailable" }));
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if (request.method === "GET" && url.pathname === "/api/preview/session") {
|
|
650
|
+
const after = url.searchParams.get("after_revision");
|
|
651
|
+
const timeout = url.searchParams.get("timeout_ms");
|
|
652
|
+
if ((after === null) !== (timeout === null))
|
|
653
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, "Preview session wait 参数无效");
|
|
654
|
+
const session = await previewSession();
|
|
655
|
+
if (after === null)
|
|
656
|
+
sendJson(response, 200, success({ ...await session.inspectForHost(), view_event_deliveries: pendingWriterViewDeliveries.splice(0) }));
|
|
657
|
+
else {
|
|
658
|
+
const revision = Number(after);
|
|
659
|
+
const timeoutMs = Number(timeout);
|
|
660
|
+
sendJson(response, 200, success({ ...await session.waitForRevision(revision, timeoutMs), view_event_deliveries: pendingWriterViewDeliveries.splice(0) }));
|
|
661
|
+
}
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
if (request.method === "GET" && url.pathname === "/api/preview/view-config") {
|
|
665
|
+
if (url.searchParams.size !== 0)
|
|
666
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, "Preview View 配置不接受查询参数");
|
|
667
|
+
try {
|
|
668
|
+
const current = await currentViewRuntime();
|
|
669
|
+
const inspection = current.runtime.inspect();
|
|
670
|
+
sendJson(response, 200, success(await readLocalPreviewViewInteractionConfig({
|
|
671
|
+
capture_sha256: inspection.capture_sha256,
|
|
672
|
+
binding: current.runtime.readViewInteractionBinding(),
|
|
673
|
+
surface: "pc",
|
|
674
|
+
})));
|
|
675
|
+
}
|
|
676
|
+
catch {
|
|
677
|
+
sendJson(response, 200, success(null));
|
|
678
|
+
}
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
if (request.method === "GET" && url.pathname === "/api/preview/view-delivery-recovery") {
|
|
682
|
+
if (url.searchParams.size !== 0)
|
|
683
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, "View delivery recovery 不接受查询参数");
|
|
684
|
+
const session = await previewSession();
|
|
685
|
+
const current = await currentViewRuntime();
|
|
686
|
+
const inspection = current.runtime.inspect();
|
|
687
|
+
const recovered = [];
|
|
688
|
+
for (const item of session.pendingViewDeliveryFailures()) {
|
|
689
|
+
if (item.capture_sha256 !== inspection.capture_sha256)
|
|
690
|
+
continue;
|
|
691
|
+
const events = current.runtime.readAuthorViewEventsByOperation(item.operation_id);
|
|
692
|
+
const deliveries = await projectLocalPreviewAuthorViewEvents({ capture_sha256: inspection.capture_sha256, binding: current.runtime.readViewInteractionBinding(), surface: "pc", author_view_events: events });
|
|
693
|
+
recovered.push(Object.freeze({ operation_id: item.operation_id, deliveries }));
|
|
694
|
+
}
|
|
695
|
+
sendJson(response, 200, success(recovered));
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
if (request.method === "GET" && url.pathname === "/api/preview/asset") {
|
|
699
|
+
if (url.searchParams.size !== 1)
|
|
700
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, "Preview asset 查询参数无效");
|
|
701
|
+
const id = requiredString(url.searchParams.get("id"), "id");
|
|
702
|
+
const resolver = await currentPreviewAssets();
|
|
703
|
+
const asset = resolver.list().find((entry) => entry.id === id);
|
|
704
|
+
if (asset === undefined)
|
|
705
|
+
throw new LocalGuiHttpError("E_NOT_FOUND", 404, "Preview asset 不存在");
|
|
706
|
+
sendPreviewAsset(request, response, resolver.resolveBytes(asset));
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
if (request.method === "GET" && url.pathname === "/api/preview/asset-capability") {
|
|
710
|
+
if (url.searchParams.size !== 1)
|
|
711
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, "Preview asset capability 查询参数无效");
|
|
712
|
+
sendJson(response, 200, success(await issuePreviewAssetCapability(requiredString(url.searchParams.get("id"), "id"))));
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
if (request.method === "GET" && url.pathname === "/api/files") {
|
|
716
|
+
sendJson(response, 200, success({ area: area(url.searchParams.get("area")), files: await listLocalGuiFiles(host.root, area(url.searchParams.get("area"))) }));
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
if (request.method === "GET" && url.pathname === "/api/file") {
|
|
720
|
+
sendJson(response, 200, success(await readLocalGuiFile(host.root, area(url.searchParams.get("area")), requiredString(url.searchParams.get("path"), "path"))));
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
const jobMatch = request.method === "GET" ? /^\/api\/jobs\/(job_[a-f0-9]{32})$/.exec(url.pathname) : null;
|
|
724
|
+
if (jobMatch !== null) {
|
|
725
|
+
const job = jobs.get(jobMatch[1]);
|
|
726
|
+
if (job === undefined)
|
|
727
|
+
throw new LocalGuiHttpError("E_NOT_FOUND", 404, "job 不存在");
|
|
728
|
+
sendJson(response, 200, success(jobView(job)));
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
if (request.method !== "POST")
|
|
732
|
+
throw new LocalGuiHttpError("E_NOT_FOUND", 404, "API 不存在");
|
|
733
|
+
if (url.pathname === "/api/auth/start") {
|
|
734
|
+
if (auth === undefined)
|
|
735
|
+
throw new LocalGuiHttpError("E_CAPABILITY_UNAVAILABLE", 400, "认证能力不可用");
|
|
736
|
+
const started = await auth.start();
|
|
737
|
+
if (acceptsJson(request)) {
|
|
738
|
+
sendJson(response, 200, success({ authorization_url: started.authorization_url }));
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
sendHeaders(response, { Location: started.authorization_url });
|
|
742
|
+
response.statusCode = 303;
|
|
743
|
+
response.end();
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
const body = await readJsonBody(request);
|
|
747
|
+
if (url.pathname === "/api/preview/asset-capabilities/revoke") {
|
|
748
|
+
exactKeys(body, ["tokens"]);
|
|
749
|
+
if (!Array.isArray(body.tokens) || body.tokens.length > 128 || body.tokens.some((token) => typeof token !== "string" || !LOCAL_PREVIEW_ASSET_CAPABILITY_TOKEN.test(token))) {
|
|
750
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, "Preview asset capability token 无效");
|
|
751
|
+
}
|
|
752
|
+
for (const token of body.tokens)
|
|
753
|
+
previewAssetCapabilities.delete(token);
|
|
754
|
+
sendJson(response, 200, success({ ok: true }));
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
if (url.pathname === "/api/tool") {
|
|
758
|
+
const name = requiredString(body.name, "name");
|
|
759
|
+
const descriptor = authoringToolDescriptor(name);
|
|
760
|
+
if (!isRecord(body.input))
|
|
761
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, "input 必须是 object");
|
|
762
|
+
let call;
|
|
763
|
+
if (descriptor.project_effect) {
|
|
764
|
+
if (!isRecord(body.effect_identity))
|
|
765
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, "effect_identity 必须是 object");
|
|
766
|
+
call = { name, input: body.input, effect_identity: {
|
|
767
|
+
operation_id: requiredString(body.effect_identity.operation_id, "operation_id"),
|
|
768
|
+
expected_project_revision: requiredRevision(body.effect_identity.expected_project_revision),
|
|
769
|
+
} };
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
call = { name, input: body.input };
|
|
773
|
+
}
|
|
774
|
+
sendJson(response, 200, success(await host.dispatchTool(call)));
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
if (url.pathname === "/api/snapshot") {
|
|
778
|
+
sendJson(response, 200, success(await host.snapshot({ operation_id: requiredString(body.operation_id, "operation_id"), expected_project_revision: requiredRevision(body.expected_project_revision) })));
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
if (url.pathname === "/api/file") {
|
|
782
|
+
if (body.area !== undefined && body.area !== "view")
|
|
783
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, "GUI 只能写 view 区域");
|
|
784
|
+
sendJson(response, 200, success(await writeLocalGuiViewFile(host.root, requiredString(body.path, "path"), requiredText(body.content, "content"), requiredString(body.expected_sha256, "expected_sha256"))));
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
if (url.pathname === "/api/model-settings") {
|
|
788
|
+
exactKeys(body, ["model", "expected_sha256", "catalog_token"]);
|
|
789
|
+
if (auth?.status().state !== "signed_in")
|
|
790
|
+
throw new LocalGuiHttpError("E_AUTH_REQUIRED", 401, "请先登录账户");
|
|
791
|
+
const catalogToken = requiredString(body.catalog_token, "catalog_token");
|
|
792
|
+
const expected = modelCatalogTokens.get(catalogToken);
|
|
793
|
+
if (expected === undefined)
|
|
794
|
+
throw new LocalGuiHttpError("E_FILE_CHANGED", 409, "模型目录已变化,请重新读取");
|
|
795
|
+
const current = await readAccountChatCatalog({ projectRoot: host.root, projectId: (await host.inspect()).project_id });
|
|
796
|
+
modelCatalogTokens.delete(catalogToken);
|
|
797
|
+
if (current.accountIdentity !== expected.accountIdentity || current.catalogDigest !== expected.catalogDigest || !current.models.some((entry) => entry.model === body.model) || !expected.models.includes(String(body.model)))
|
|
798
|
+
throw new LocalGuiHttpError("E_FILE_CHANGED", 409, "账户或模型目录已变化,请重新读取");
|
|
799
|
+
sendJson(response, 200, success(await writeLocalModelSettings(host.root, { model: body.model, expected_sha256: body.expected_sha256 })));
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
if (url.pathname === "/api/gallery/import") {
|
|
803
|
+
sendJson(response, 200, success(await importLocalGalleryImage(host, {
|
|
804
|
+
operation_id: requiredString(body.operation_id, "operation_id"),
|
|
805
|
+
expected_project_revision: requiredRevision(body.expected_project_revision),
|
|
806
|
+
area: requiredString(body.area, "area"),
|
|
807
|
+
...(body.owner_kind === undefined ? {} : { owner_kind: requiredString(body.owner_kind, "owner_kind") }),
|
|
808
|
+
...(body.owner_id === undefined ? {} : { owner_id: requiredString(body.owner_id, "owner_id") }),
|
|
809
|
+
filename: requiredString(body.filename, "filename"),
|
|
810
|
+
content_base64: requiredString(body.content_base64, "content_base64"),
|
|
811
|
+
})));
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (url.pathname === "/api/preview") {
|
|
815
|
+
const action = requiredString(body.action, "action");
|
|
816
|
+
if (action === "start")
|
|
817
|
+
sendJson(response, 200, success(await preview.start(host.root, origin)));
|
|
818
|
+
else if (action === "stop")
|
|
819
|
+
sendJson(response, 200, success(await preview.stop()));
|
|
820
|
+
else if (action === "status")
|
|
821
|
+
sendJson(response, 200, success(preview.inspect()));
|
|
822
|
+
else
|
|
823
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, "preview action 无效");
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
if (url.pathname === "/api/preview/session/command") {
|
|
827
|
+
const method = requiredString(body.method, "method");
|
|
828
|
+
if (!isRecord(body.params))
|
|
829
|
+
throw new LocalGuiHttpError("E_SCHEMA_INVALID", 400, "params 必须是 object");
|
|
830
|
+
if (method === "worldline.list" || method === "worldline.fork" || method === "worldline.activate") {
|
|
831
|
+
exactKeys(body, ["method", "params", "request_id"]);
|
|
832
|
+
const entry = worldlineRequest(method, body.params, body.request_id);
|
|
833
|
+
sendJson(response, 202, success(await executeWorldlineRequest(entry, async () => await (await previewSession()).handle(method, body.params, entry.context))));
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
sendJson(response, 202, success(await (await previewSession()).handle(method, body.params)));
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
if (url.pathname === "/api/preview/view-event") {
|
|
840
|
+
exactKeys(body, ["event"]);
|
|
841
|
+
let committed;
|
|
842
|
+
try {
|
|
843
|
+
const result = await (await currentViewEventIngress()).handleTrustedOpaqueFrameData(body.event);
|
|
844
|
+
if (result.ok !== true) {
|
|
845
|
+
sendJson(response, 200, success(result));
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
committed = result;
|
|
849
|
+
const event = body.event;
|
|
850
|
+
const interactionId = requiredString(event.interaction_id, "interaction_id");
|
|
851
|
+
const current = await currentViewRuntime();
|
|
852
|
+
const captureSha256 = current.runtime.inspect().capture_sha256;
|
|
853
|
+
const id = createHash("sha256").update(`${captureSha256}\u0000${interactionId}`, "utf8").digest("hex").slice(0, 48);
|
|
854
|
+
const viewEventDeliveries = await drainWriterViewEvents(`local.view.turn.${id}`, false);
|
|
855
|
+
sendJson(response, 200, success(Object.freeze({ ...result, view_event_deliveries: viewEventDeliveries })));
|
|
856
|
+
}
|
|
857
|
+
catch (error) {
|
|
858
|
+
const publicError = toPublicError(error);
|
|
859
|
+
if (committed !== undefined) {
|
|
860
|
+
sendJson(response, 200, success({ ok: false, code: publicError.code, player_message: "操作已提交,但界面更新失败,请刷新查看结果", committed: true, delivered_count: 0 }));
|
|
861
|
+
}
|
|
862
|
+
else {
|
|
863
|
+
sendJson(response, 200, success({ ok: false, code: publicError.code, player_message: "当前预览未提供可用的 View 互动配置", committed: false, delivered_count: 0 }));
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
if (url.pathname === "/api/preview/view-delivery-failed") {
|
|
869
|
+
exactKeys(body, ["operation_id"]);
|
|
870
|
+
const current = await currentViewRuntime();
|
|
871
|
+
const operationId = requiredString(body.operation_id, "operation_id");
|
|
872
|
+
current.runtime.readAuthorViewEventsByOperation(operationId);
|
|
873
|
+
await (await previewSession()).recordViewDeliveryFailure({ operation_id: operationId, capture_sha256: current.runtime.inspect().capture_sha256 });
|
|
874
|
+
sendJson(response, 200, success({ ok: true }));
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
if (url.pathname === "/api/preview/view-delivery-resolved") {
|
|
878
|
+
exactKeys(body, ["operation_id"]);
|
|
879
|
+
await (await previewSession()).resolveViewDeliveryFailure(requiredString(body.operation_id, "operation_id"));
|
|
880
|
+
sendJson(response, 200, success({ ok: true }));
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
if (url.pathname === "/api/jobs") {
|
|
884
|
+
if (options.runCommand === undefined)
|
|
885
|
+
throw new LocalGuiHttpError("E_CAPABILITY_UNAVAILABLE", 400, "异步命令能力不可用");
|
|
886
|
+
sendJson(response, 202, success(jobView(startJob(jobs, options.runCommand, validateJobArgs(body.args), auth))));
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
if (url.pathname === "/api/auth/logout") {
|
|
890
|
+
if (auth === undefined)
|
|
891
|
+
throw new LocalGuiHttpError("E_CAPABILITY_UNAVAILABLE", 400, "认证能力不可用");
|
|
892
|
+
sendJson(response, 200, success(await auth.logout()));
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
if (url.pathname === "/api/update") {
|
|
896
|
+
sendJson(response, 409, failure("E_UPDATE_UNAVAILABLE", "官方发布基线与事务 updater 尚不可用"));
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
throw new LocalGuiHttpError("E_NOT_FOUND", 404, "API 不存在");
|
|
900
|
+
}
|
|
901
|
+
const mediaMatch = (request.method === "GET" || request.method === "HEAD") && url.search === "" ? /^\/preview-media\/([A-Za-z0-9_-]{43})$/u.exec(url.pathname) : null;
|
|
902
|
+
if (mediaMatch !== null) {
|
|
903
|
+
const capability = previewAssetCapabilities.get(mediaMatch[1]);
|
|
904
|
+
if (capability === undefined) {
|
|
905
|
+
previewAssetCapabilities.delete(mediaMatch[1]);
|
|
906
|
+
throw new LocalGuiHttpError("E_NOT_FOUND", 404, "Preview asset capability 不存在");
|
|
907
|
+
}
|
|
908
|
+
sendPreviewAsset(request, response, capability.asset);
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
const hasSession = tokensEqual(requestCookie(request, LOCAL_GUI_SESSION_COOKIE), sessionToken);
|
|
912
|
+
if (!hasSession) {
|
|
913
|
+
if (!isAllowedTopLevelNavigation(request, origin))
|
|
914
|
+
throw new LocalGuiHttpError("E_GUI_ORIGIN_FORBIDDEN", 403, "只允许本地同源顶层导航");
|
|
915
|
+
response.setHeader("Set-Cookie", `${LOCAL_GUI_SESSION_COOKIE}=${sessionToken}; HttpOnly; SameSite=Strict; Path=/`);
|
|
916
|
+
}
|
|
917
|
+
else if ((request.headers["sec-fetch-site"] === "cross-site" && !isNavigationDocument(request)) || request.headers.origin === "null" || (request.headers.origin !== undefined && request.headers.origin !== origin)) {
|
|
918
|
+
throw new LocalGuiHttpError("E_GUI_ORIGIN_FORBIDDEN", 403, "请求来源无效");
|
|
919
|
+
}
|
|
920
|
+
if (request.method !== "GET")
|
|
921
|
+
throw new LocalGuiHttpError("E_NOT_FOUND", 404, "资源不存在");
|
|
922
|
+
if (["/graph.js", "/graph.css", "/score.js", "/score.css", "/trigger.js", "/trigger.css", "/graph-layout-worker.js", "/graph-layout.wasm", "/plane-generator.js", "/plane-generator_bg.wasm", "/plane-generation.js", "/procedural-city.js", "/road-generation.js", "/road-parcels.js", "/spatial-templates.js"].includes(url.pathname)) {
|
|
923
|
+
await serveStatic(response, generatedAssetsRoot, url.pathname);
|
|
924
|
+
}
|
|
925
|
+
else {
|
|
926
|
+
await serveStatic(response, assetsRoot, url.pathname);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
catch (error) {
|
|
930
|
+
if (response.headersSent) {
|
|
931
|
+
response.destroy();
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
const rendered = publicFailure(error);
|
|
935
|
+
sendJson(response, rendered.status, rendered.body);
|
|
936
|
+
}
|
|
937
|
+
});
|
|
938
|
+
const requestedPort = options.testOnlyPort ?? LOCAL_GUI_PORT;
|
|
939
|
+
try {
|
|
940
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
941
|
+
const onError = (error) => { server.off("listening", onListening); rejectPromise(error); };
|
|
942
|
+
const onListening = () => { server.off("error", onError); resolvePromise(); };
|
|
943
|
+
server.once("error", onError);
|
|
944
|
+
server.once("listening", onListening);
|
|
945
|
+
server.listen({ host: LOCAL_GUI_HOST, port: requestedPort });
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
catch (error) {
|
|
949
|
+
await host.close();
|
|
950
|
+
if (error.code === "EADDRINUSE") {
|
|
951
|
+
throw new LocalGuiHttpError("E_GUI_PORT_IN_USE", 409, "本地 GUI 固定端口已被占用");
|
|
952
|
+
}
|
|
953
|
+
throw error;
|
|
954
|
+
}
|
|
955
|
+
const address = server.address();
|
|
956
|
+
if (address === null || typeof address === "string" || address.address !== LOCAL_GUI_HOST) {
|
|
957
|
+
await host.close();
|
|
958
|
+
server.close();
|
|
959
|
+
throw new LocalGuiHttpError("E_GUI_UNAVAILABLE", 500, "本地 GUI 未绑定 loopback");
|
|
960
|
+
}
|
|
961
|
+
origin = `http://${LOCAL_GUI_HOST}:${address.port}`;
|
|
962
|
+
if (options.authOptions !== undefined) {
|
|
963
|
+
const projectId = (await host.inspect()).project_id;
|
|
964
|
+
auth = createLocalGuiAuth({ ...options.authOptions, projectId, projectRoot: host.root, loopbackOrigin: origin });
|
|
965
|
+
}
|
|
966
|
+
let closed = false;
|
|
967
|
+
return {
|
|
968
|
+
url: `${origin}/`,
|
|
969
|
+
host: LOCAL_GUI_HOST,
|
|
970
|
+
port: address.port,
|
|
971
|
+
...(auth === undefined ? {} : { auth }),
|
|
972
|
+
async close() {
|
|
973
|
+
if (closed)
|
|
974
|
+
return;
|
|
975
|
+
closed = true;
|
|
976
|
+
previewAssetCapabilities.clear();
|
|
977
|
+
await new Promise((resolvePromise) => {
|
|
978
|
+
server.close(() => resolvePromise());
|
|
979
|
+
server.closeAllConnections();
|
|
980
|
+
});
|
|
981
|
+
await preview.stop();
|
|
982
|
+
projectionRuntime?.runtime.dispose();
|
|
983
|
+
await host.close();
|
|
984
|
+
jobs.clear();
|
|
985
|
+
},
|
|
986
|
+
};
|
|
987
|
+
}
|