@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.
Files changed (218) hide show
  1. package/LICENSE +46 -0
  2. package/assets/gui/WELogo-title.svg +11 -0
  3. package/assets/gui/app.js +1326 -0
  4. package/assets/gui/index.html +118 -0
  5. package/assets/gui/local-preview-asset-urls.js +61 -0
  6. package/assets/gui/local-preview-bridge.js +393 -0
  7. package/assets/gui/spatial-template-panel.js +233 -0
  8. package/assets/gui/styles.css +115 -0
  9. package/assets/gui/terrain-render.js +120 -0
  10. package/assets/gui/workspace-status.css +17 -0
  11. package/assets/gui/workspace-status.js +255 -0
  12. package/assets/gui/world-map-globe.js +187 -0
  13. package/assets/gui/world-map-worker.js +62 -0
  14. package/assets/gui/world-map.css +30 -0
  15. package/assets/gui/world-map.js +2466 -0
  16. package/dist/account-model-proxy.d.ts +21 -0
  17. package/dist/account-model-proxy.js +470 -0
  18. package/dist/authoring-browser-runtime.d.ts +7 -0
  19. package/dist/authoring-browser-runtime.js +110 -0
  20. package/dist/authoring-ingestion.d.ts +38 -0
  21. package/dist/authoring-ingestion.js +410 -0
  22. package/dist/authoring-model-connection.d.ts +166 -0
  23. package/dist/authoring-model-connection.js +757 -0
  24. package/dist/chinese-lexical.d.ts +152 -0
  25. package/dist/chinese-lexical.js +555 -0
  26. package/dist/chromium-preview-browser-driver.d.ts +43 -0
  27. package/dist/chromium-preview-browser-driver.js +575 -0
  28. package/dist/cli/download.d.ts +12 -0
  29. package/dist/cli/download.js +50 -0
  30. package/dist/cli/dump.d.ts +32 -0
  31. package/dist/cli/dump.js +91 -0
  32. package/dist/cli/ingest.d.ts +49 -0
  33. package/dist/cli/ingest.js +225 -0
  34. package/dist/cli/review.d.ts +10 -0
  35. package/dist/cli/review.js +44 -0
  36. package/dist/cli/upload.d.ts +57 -0
  37. package/dist/cli/upload.js +112 -0
  38. package/dist/cli-command.d.ts +60 -0
  39. package/dist/cli-command.js +613 -0
  40. package/dist/cli.d.ts +2 -0
  41. package/dist/cli.js +182 -0
  42. package/dist/desktop-authoring-runtime.d.ts +88 -0
  43. package/dist/desktop-authoring-runtime.js +1602 -0
  44. package/dist/desktop-sidecar.d.ts +11 -0
  45. package/dist/desktop-sidecar.js +143 -0
  46. package/dist/document-dump.d.ts +113 -0
  47. package/dist/document-dump.js +431 -0
  48. package/dist/document-graph-extractor.d.ts +125 -0
  49. package/dist/document-graph-extractor.js +588 -0
  50. package/dist/document-ingestion-journal.d.ts +68 -0
  51. package/dist/document-ingestion-journal.js +471 -0
  52. package/dist/document-ingestion.d.ts +183 -0
  53. package/dist/document-ingestion.js +976 -0
  54. package/dist/document-query-path-proof.d.ts +30 -0
  55. package/dist/document-query-path-proof.js +34 -0
  56. package/dist/document-query-path-renderer.d.ts +50 -0
  57. package/dist/document-query-path-renderer.js +75 -0
  58. package/dist/document-query-proof-paths.d.ts +22 -0
  59. package/dist/document-query-proof-paths.js +71 -0
  60. package/dist/document-query.d.ts +97 -0
  61. package/dist/document-query.js +1106 -0
  62. package/dist/document-relation-role.d.ts +14 -0
  63. package/dist/document-relation-role.js +86 -0
  64. package/dist/document-source.d.ts +107 -0
  65. package/dist/document-source.js +255 -0
  66. package/dist/embedding-cache.d.ts +35 -0
  67. package/dist/embedding-cache.js +317 -0
  68. package/dist/frozen-review-local-pipeline.d.ts +88 -0
  69. package/dist/frozen-review-local-pipeline.js +609 -0
  70. package/dist/frozen-review-source-capture.d.ts +15 -0
  71. package/dist/frozen-review-source-capture.js +551 -0
  72. package/dist/frozen-review-wasm-encryptor.d.ts +16 -0
  73. package/dist/frozen-review-wasm-encryptor.js +72 -0
  74. package/dist/frozen-review.d.ts +131 -0
  75. package/dist/frozen-review.js +802 -0
  76. package/dist/graph-traversal.d.ts +23 -0
  77. package/dist/graph-traversal.js +100 -0
  78. package/dist/gui/graph-layout-worker.js +1 -0
  79. package/dist/gui/graph-layout.wasm +0 -0
  80. package/dist/gui/graph.css +1 -0
  81. package/dist/gui/graph.js +76 -0
  82. package/dist/gui/plane-generation.js +112 -0
  83. package/dist/gui/plane-generator.js +310 -0
  84. package/dist/gui/plane-generator_bg.wasm +0 -0
  85. package/dist/gui/procedural-city.js +595 -0
  86. package/dist/gui/road-generation.js +129 -0
  87. package/dist/gui/road-parcels.js +1584 -0
  88. package/dist/gui/score.css +1 -0
  89. package/dist/gui/score.js +54 -0
  90. package/dist/gui/spatial-runtime.js +1231 -0
  91. package/dist/gui/spatial-templates.js +200 -0
  92. package/dist/gui/trigger.css +1 -0
  93. package/dist/gui/trigger.js +142 -0
  94. package/dist/harness-callback-transport.d.ts +17 -0
  95. package/dist/harness-callback-transport.js +128 -0
  96. package/dist/harness-document-ingestion.d.ts +77 -0
  97. package/dist/harness-document-ingestion.js +343 -0
  98. package/dist/index.d.ts +43 -0
  99. package/dist/index.js +43 -0
  100. package/dist/ingestion-source-reconciliation.d.ts +88 -0
  101. package/dist/ingestion-source-reconciliation.js +253 -0
  102. package/dist/ladybug-runtime.d.ts +117 -0
  103. package/dist/ladybug-runtime.js +886 -0
  104. package/dist/local-account-embedding.d.ts +34 -0
  105. package/dist/local-account-embedding.js +294 -0
  106. package/dist/local-gallery.d.ts +35 -0
  107. package/dist/local-gallery.js +227 -0
  108. package/dist/local-gui-auth.d.ts +96 -0
  109. package/dist/local-gui-auth.js +703 -0
  110. package/dist/local-gui-contract.d.ts +53 -0
  111. package/dist/local-gui-contract.js +5 -0
  112. package/dist/local-gui-image-assets.d.ts +17 -0
  113. package/dist/local-gui-image-assets.js +96 -0
  114. package/dist/local-gui-preview-client.d.ts +3 -0
  115. package/dist/local-gui-preview-client.js +111 -0
  116. package/dist/local-gui-score.d.ts +72 -0
  117. package/dist/local-gui-score.js +184 -0
  118. package/dist/local-gui-server.d.ts +10 -0
  119. package/dist/local-gui-server.js +987 -0
  120. package/dist/local-gui-version.d.ts +17 -0
  121. package/dist/local-gui-version.js +147 -0
  122. package/dist/local-gui-workspace.d.ts +14 -0
  123. package/dist/local-gui-workspace.js +171 -0
  124. package/dist/local-model-settings.d.ts +28 -0
  125. package/dist/local-model-settings.js +124 -0
  126. package/dist/local-preview-assets.d.ts +28 -0
  127. package/dist/local-preview-assets.js +124 -0
  128. package/dist/local-preview-bound-resources.d.ts +12 -0
  129. package/dist/local-preview-bound-resources.js +80 -0
  130. package/dist/local-preview-message-store.d.ts +63 -0
  131. package/dist/local-preview-message-store.js +214 -0
  132. package/dist/local-preview-player-read-models.d.ts +47 -0
  133. package/dist/local-preview-player-read-models.js +90 -0
  134. package/dist/local-preview-projections.d.ts +61 -0
  135. package/dist/local-preview-projections.js +117 -0
  136. package/dist/local-preview-runtime.d.ts +105 -0
  137. package/dist/local-preview-runtime.js +553 -0
  138. package/dist/local-preview-session.d.ts +73 -0
  139. package/dist/local-preview-session.js +395 -0
  140. package/dist/local-preview-source.d.ts +40 -0
  141. package/dist/local-preview-source.js +192 -0
  142. package/dist/local-preview-view-events.d.ts +101 -0
  143. package/dist/local-preview-view-events.js +179 -0
  144. package/dist/local-preview-view-state-sqlite.d.ts +24 -0
  145. package/dist/local-preview-view-state-sqlite.js +263 -0
  146. package/dist/local-preview-view-state.d.ts +93 -0
  147. package/dist/local-preview-view-state.js +108 -0
  148. package/dist/local-preview-vite-config.d.ts +6 -0
  149. package/dist/local-preview-vite-config.js +122 -0
  150. package/dist/local-preview-world-effects.d.ts +9 -0
  151. package/dist/local-preview-world-effects.js +55 -0
  152. package/dist/local-preview-worldline-store.d.ts +47 -0
  153. package/dist/local-preview-worldline-store.js +282 -0
  154. package/dist/local-preview-worldlines.d.ts +95 -0
  155. package/dist/local-preview-worldlines.js +136 -0
  156. package/dist/mcp-async-dispatcher.d.ts +3 -0
  157. package/dist/mcp-async-dispatcher.js +129 -0
  158. package/dist/mcp-line-queue.d.ts +14 -0
  159. package/dist/mcp-line-queue.js +184 -0
  160. package/dist/mcp-pipe-internal.d.ts +6 -0
  161. package/dist/mcp-pipe-internal.js +151 -0
  162. package/dist/mcp-pipe-response.d.ts +14 -0
  163. package/dist/mcp-pipe-response.js +50 -0
  164. package/dist/mcp-stdio-response.d.ts +12 -0
  165. package/dist/mcp-stdio-response.js +27 -0
  166. package/dist/mcp.d.ts +27 -0
  167. package/dist/mcp.js +194 -0
  168. package/dist/prepared-embedding-cache.d.ts +38 -0
  169. package/dist/prepared-embedding-cache.js +238 -0
  170. package/dist/preview-browser-adapter.d.ts +53 -0
  171. package/dist/preview-browser-adapter.js +171 -0
  172. package/dist/preview-runtime.d.ts +67 -0
  173. package/dist/preview-runtime.js +460 -0
  174. package/dist/project-auth-transport.d.ts +27 -0
  175. package/dist/project-auth-transport.js +121 -0
  176. package/dist/project-auth.d.ts +51 -0
  177. package/dist/project-auth.js +108 -0
  178. package/dist/project-git-capture-recovery.d.ts +16 -0
  179. package/dist/project-git-capture-recovery.js +347 -0
  180. package/dist/project-git-cli.d.ts +5 -0
  181. package/dist/project-git-cli.js +40 -0
  182. package/dist/project-git-scenario-source.d.ts +10 -0
  183. package/dist/project-git-scenario-source.js +240 -0
  184. package/dist/project-git-view-source.d.ts +13 -0
  185. package/dist/project-git-view-source.js +134 -0
  186. package/dist/project-git.d.ts +97 -0
  187. package/dist/project-git.js +656 -0
  188. package/dist/project-host.d.ts +59 -0
  189. package/dist/project-host.js +471 -0
  190. package/dist/project-writer-lease.d.ts +18 -0
  191. package/dist/project-writer-lease.js +430 -0
  192. package/dist/public-error.d.ts +7 -0
  193. package/dist/public-error.js +61 -0
  194. package/dist/retrieval-benchmark.d.ts +46 -0
  195. package/dist/retrieval-benchmark.js +58 -0
  196. package/dist/safe-project-path.d.ts +8 -0
  197. package/dist/safe-project-path.js +93 -0
  198. package/dist/scenario-writer.d.ts +32 -0
  199. package/dist/scenario-writer.js +191 -0
  200. package/dist/transfer/download.d.ts +159 -0
  201. package/dist/transfer/download.js +598 -0
  202. package/dist/transfer/transport.d.ts +26 -0
  203. package/dist/transfer/transport.js +1 -0
  204. package/dist/transfer/upload.d.ts +162 -0
  205. package/dist/transfer/upload.js +354 -0
  206. package/dist/transfer-types.d.ts +95 -0
  207. package/dist/transfer-types.js +1 -0
  208. package/dist/transfer.d.ts +18 -0
  209. package/dist/transfer.js +273 -0
  210. package/dist/trigger-runtime.d.ts +121 -0
  211. package/dist/trigger-runtime.js +153 -0
  212. package/dist/vendor/wasm-core/asset-hashes.json +5 -0
  213. package/dist/vendor/wasm-core/wasm_core.d.ts +490 -0
  214. package/dist/vendor/wasm-core/wasm_core.js +1 -0
  215. package/dist/vendor/wasm-core/wasm_core_bg.wasm +0 -0
  216. package/dist/view-watcher.d.ts +9 -0
  217. package/dist/view-watcher.js +34 -0
  218. package/package.json +110 -0
@@ -0,0 +1,1326 @@
1
+ import { attachLocalPreviewBridge } from "./local-preview-bridge.js";
2
+ import { renderWorkspaceStatus } from "./workspace-status.js";
3
+
4
+ const API = "/api";
5
+ const state = {
6
+ status: null,
7
+ graph: { entities: [], relations: [], spatial_world: null, cg_defs: [] },
8
+ graphLayout: { positions: {} },
9
+ graphEntityKinds: [],
10
+ graphSchema: {},
11
+ graphLayoutDirty: false,
12
+ graphSaveBusy: false,
13
+ graphEditorInitial: null,
14
+ graphEditorEpoch: 0,
15
+ graphPanelTab: "metadata",
16
+ graphHighlightNodeId: null,
17
+ graphPropertiesDraft: null,
18
+ graphPropertiesOwner: null,
19
+ graphRowDraft: null,
20
+ graphRowDraftOwner: null,
21
+ selectedGraph: null,
22
+ graphMode: "entity",
23
+ files: { view: [], docs: [] },
24
+ currentViewFile: null,
25
+ tools: [],
26
+ selectedTool: null,
27
+ toolOperationId: "",
28
+ events: [],
29
+ jobs: [],
30
+ pollingJobs: new Set(),
31
+ validatedTriggerText: null,
32
+ triggerSchema: null,
33
+ triggerDocument: null,
34
+ triggerDraftRevision: null,
35
+ triggerValidation: null,
36
+ triggerSimulation: null,
37
+ triggerPositions: {},
38
+ triggerBlueprintLayout: { positions: {}, boards: [], membership: {} },
39
+ triggerLayoutPending: null,
40
+ triggerLayoutSaving: false,
41
+ triggerLoadState: "idle",
42
+ triggerLoadError: null,
43
+ triggerBusy: false,
44
+ triggerDirty: false,
45
+ triggerEntities: [],
46
+ triggerSpatialWorld: null,
47
+ };
48
+
49
+ const $ = (selector, root = document) => root.querySelector(selector);
50
+ const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
51
+ const formatJson = (value) => JSON.stringify(value, null, 2);
52
+ const asArray = (value) => Array.isArray(value) ? value : [];
53
+ const asObject = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
54
+ const operationId = (prefix) => `${prefix}_${Date.now().toString(36)}_${crypto.randomUUID().replaceAll("-", "").slice(0, 12)}`;
55
+ function chevronIcon() {
56
+ const ns = "http://www.w3.org/2000/svg";
57
+ const icon = document.createElementNS(ns, "svg");
58
+ for (const [key, value] of Object.entries({ viewBox: "0 0 24 24", width: "16", height: "16", fill: "none", stroke: "currentColor", "stroke-width": "1.75", "aria-hidden": "true" })) icon.setAttribute(key, value);
59
+ const path = document.createElementNS(ns, "path"); path.setAttribute("d", "m9 5 7 7-7 7"); icon.append(path);
60
+ return icon;
61
+ }
62
+ let graphModule;
63
+ let graphController;
64
+ let graphPropertyController;
65
+ let graphPropertyPropsFactory;
66
+ let graphRenderVersion = 0;
67
+ let graphLayoutSaveQueue;
68
+ let triggerModule;
69
+ let triggerController;
70
+ let triggerRenderVersion = 0;
71
+ let triggerLayoutWorker = null;
72
+ let triggerLoadFailure = null;
73
+ let triggerStatusHost = null;
74
+ let previewRequestId = 0;
75
+ let versionRequestId = 0;
76
+ let lastKnownCurrentVersion = "";
77
+ let previewBridge;
78
+ let worldMapController;
79
+ let worldMapModulePromise;
80
+ let worldMapViewEpoch = 0;
81
+ let spatialTemplateController;
82
+ let spatialTemplateModulePromise;
83
+ let spatialTemplateLibrary = [];
84
+ let modelSettingsRequestEpoch = 0;
85
+
86
+ function spatialTemplateRecords(value) {
87
+ const root = asObject(value);
88
+ const presets = Array.isArray(root.presets) ? root.presets : Array.isArray(value) ? value : [];
89
+ return presets.filter((preset) => preset && typeof preset === "object");
90
+ }
91
+
92
+ function worldTemplateConfiguration(source) {
93
+ return asObject(asObject(asObject(source).topology).templates);
94
+ }
95
+
96
+ async function readSpatialTemplateState() {
97
+ const [library, spatial] = await Promise.all([
98
+ callTool("spatial_template_library", {}),
99
+ callTool("world_search", { query: "spatial:", mode: "keyword", limit: 1 }),
100
+ ]);
101
+ spatialTemplateLibrary = spatialTemplateRecords(library);
102
+ const source = asObject(spatial).spatial_world ?? null;
103
+ const configuration = worldTemplateConfiguration(source);
104
+ worldMapController?.setCityTemplates?.(cityTemplatesFromLibrary());
105
+ const current = await loadStatusStrict();
106
+ const revision = source?.revision ?? current.project_revision;
107
+ if (!Number.isSafeInteger(revision) || revision !== current.project_revision) throw new Error("项目已变化,请重新读取模板。");
108
+ return { presets: spatialTemplateLibrary, configuration, revision };
109
+ }
110
+
111
+ function cityTemplatesFromLibrary() {
112
+ const latest = new Map();
113
+ for (const preset of spatialTemplateLibrary) {
114
+ if (preset.kind !== "city" || typeof preset.id !== "string" || typeof preset.label !== "string" || !preset.parameters || typeof preset.parameters !== "object") continue;
115
+ const previous = latest.get(preset.id);
116
+ if (!previous || preset.revision > previous.revision) latest.set(preset.id, preset);
117
+ }
118
+ return [...latest.values()].map((preset) => ({ id: preset.id, label: preset.label, parameters: preset.parameters }));
119
+ }
120
+
121
+ async function openSpatialTemplates(kind) {
122
+ const root = $("#spatial-template-root");
123
+ try {
124
+ const snapshot = await readSpatialTemplateState();
125
+ spatialTemplateModulePromise ??= import("./spatial-template-panel.js");
126
+ const module = await spatialTemplateModulePromise;
127
+ const update = async (operation, selection) => {
128
+ const before = await readSpatialTemplateState();
129
+ const op = ["city", "parcel", "building", "interior"].includes(operation)
130
+ ? { type: "apply_template_selection", kind: operation, selection, confirmedImpact: true }
131
+ : null;
132
+ if (!op) throw new Error("未知模板类别。");
133
+ await callTool("world_apply", { operations: [op] }, {
134
+ operation_id: operationId("gui_spatial_template_apply"), expected_project_revision: before.revision,
135
+ });
136
+ const readback = await readSpatialTemplateState();
137
+ const saved = asObject(readback.configuration)[operation];
138
+ if (!saved || saved.presetId !== selection.presetId || saved.revision !== selection.revision) throw new Error("模板写入未通过 source 读回验证。");
139
+ return readback;
140
+ };
141
+ const saveDefinition = async (definition) => {
142
+ const before = await readSpatialTemplateState();
143
+ const editable = typeof definition.id === "string" && definition.id.startsWith("custom:");
144
+ const sameId = editable ? before.presets.filter((preset) => preset.kind === definition.kind && preset.id === definition.id) : [];
145
+ const nextRevision = sameId.length === 0 ? 1 : Math.max(...sameId.map((preset) => preset.revision)) + 1;
146
+ const id = sameId.length === 0 ? `custom:${definition.kind}:${crypto.randomUUID()}` : definition.id;
147
+ const savedDefinition = { kind: definition.kind, id, revision: nextRevision, label: definition.label, parameters: definition.parameters };
148
+ await callTool("world_apply", { operations: [{ type: "save_template_definition", definition: savedDefinition }] }, {
149
+ operation_id: operationId("gui_spatial_template_save"), expected_project_revision: before.revision,
150
+ });
151
+ const readback = await readSpatialTemplateState();
152
+ if (!readback.presets.some((preset) => preset.kind === savedDefinition.kind && preset.id === id && preset.revision === nextRevision)) throw new Error("模板定义写入未通过 source 读回验证。");
153
+ return readback;
154
+ };
155
+ const options = {
156
+ ...snapshot,
157
+ initialKind: kind,
158
+ onKindChange: (nextKind) => { document.querySelectorAll("[data-template-kind]").forEach((button) => button.classList.toggle("active", button.dataset.templateKind === nextKind)); },
159
+ onSaveDefinition: (selectionKind, definition) => saveDefinition({ ...definition, kind: selectionKind }),
160
+ onApply: (selectionKind, selection) => update(selectionKind, selection),
161
+ };
162
+ if (!spatialTemplateController) spatialTemplateController = module.mountSpatialTemplatePanel(root, options);
163
+ else spatialTemplateController.update({ ...options, activeKind: kind });
164
+ } catch (error) {
165
+ renderWorkspaceStatus(root, { state: "error", surface: "模板", error, onRetry: () => { void openSpatialTemplates(kind); }, retryLabel: "重试读取" });
166
+ reportFailure("SPATIAL_TEMPLATE", error);
167
+ }
168
+ }
169
+
170
+ async function readWorldMapSource() {
171
+ const spatial = asObject(await callTool("world_search", { query: "spatial:", mode: "keyword", limit: 1 }));
172
+ const graph = normalizeGraph(await api("/graph"));
173
+ const current = await loadStatusStrict();
174
+ const source = spatial.spatial_world ?? null;
175
+ const revision = source?.revision ?? current.project_revision;
176
+ if (!Number.isSafeInteger(revision) || revision !== current.project_revision) throw new Error("项目已变化,请重新读取地图。");
177
+ const actorChoices = graph.entities.filter(entity => entity.kind === "character" || entity.kind === "npc")
178
+ .map(entity => ({ id: String(entity.id), label: graphNodeLabel(entity) }));
179
+ worldMapController?.setCityTemplates?.(cityTemplatesFromLibrary());
180
+ return { projectId: current.project_id, revision, source, actorChoices };
181
+ }
182
+
183
+ async function openWorldMap(epoch) {
184
+ const root = $("#world-map-root");
185
+ if (!worldMapController) renderWorkspaceStatus(root, { state: "loading", surface: "map" });
186
+ try {
187
+ if (spatialTemplateLibrary.length === 0) {
188
+ try { spatialTemplateLibrary = spatialTemplateRecords(await callTool("spatial_template_library", {})); }
189
+ catch (error) { reportFailure("SPATIAL_TEMPLATE", error); }
190
+ }
191
+ worldMapModulePromise ??= import("./world-map.js");
192
+ const module = await worldMapModulePromise;
193
+ if (epoch !== worldMapViewEpoch) return;
194
+ worldMapController ??= module.mountWorldMap(root, {
195
+ read: readWorldMapSource,
196
+ cityTemplates: cityTemplatesFromLibrary(),
197
+ setControlledActor: async (actorId, expectedRevision) => {
198
+ await callTool("world_apply", { operations: [{ type: "spatial_set_controlled_actor", actorId }] }, {
199
+ operation_id: operationId("gui_spatial_actor"), expected_project_revision: expectedRevision,
200
+ });
201
+ return readWorldMapSource();
202
+ },
203
+ createCity: async (city, expectedRevision) => {
204
+ await callTool("world_apply", { operations: [{ type: "spatial_city_create", city }] }, {
205
+ operation_id: operationId("gui_city_create"), expected_project_revision: expectedRevision,
206
+ });
207
+ return readWorldMapSource();
208
+ },
209
+ save: async (generation, expectedRevision, confirmed) => {
210
+ await callTool("world_apply", { operations: [{ type: "spatial_set_generation", generation, confirmed }] }, {
211
+ operation_id: operationId("gui_map_save"), expected_project_revision: expectedRevision,
212
+ });
213
+ return readWorldMapSource();
214
+ },
215
+ saveRoadGeneration: async (roadGeneration, expectedRevision, confirmed) => {
216
+ await callTool("world_apply", { operations: [{ type: "spatial_set_road_generation", roadGeneration, confirmed }] }, {
217
+ operation_id: operationId("gui_road_generation_save"), expected_project_revision: expectedRevision,
218
+ });
219
+ return readWorldMapSource();
220
+ },
221
+ notify: (message, error) => toast(message, error),
222
+ });
223
+ await worldMapController.open();
224
+ } catch (error) {
225
+ worldMapController?.destroy(); worldMapController = undefined; worldMapModulePromise = undefined;
226
+ renderWorkspaceStatus(root, { state: "error", surface: "map", error, onRetry: () => { void openWorldMap(worldMapViewEpoch); }, retryLabel: "重试加载" });
227
+ reportFailure("MAP", error);
228
+ }
229
+ }
230
+
231
+ function errorMessage(error) {
232
+ if (error && typeof error === "object") {
233
+ const code = typeof error.code === "string" ? error.code : "E_REQUEST_FAILED";
234
+ const message = typeof error.message === "string" ? error.message : String(error);
235
+ return `${code}: ${message}`;
236
+ }
237
+ return String(error || "请求失败");
238
+ }
239
+
240
+ async function api(path, options = {}) {
241
+ const response = await fetch(`${API}${path}`, {
242
+ credentials: "same-origin",
243
+ headers: { "content-type": "application/json", ...(options.headers || {}) },
244
+ ...options,
245
+ });
246
+ let envelope;
247
+ try { envelope = await response.json(); }
248
+ catch { throw { code: "E_INVALID_RESPONSE", message: `NodeHost 返回了非 JSON 响应(HTTP ${response.status})` }; }
249
+ if (!response.ok || !envelope || envelope.ok !== true) {
250
+ throw envelope?.error || { code: `E_HTTP_${response.status}`, message: response.statusText || "请求失败" };
251
+ }
252
+ return envelope.result;
253
+ }
254
+
255
+ function addEvent(kind, message, error = false) {
256
+ state.events.unshift({ at: new Date(), kind, message, error });
257
+ state.events = state.events.slice(0, 40);
258
+ renderEvents();
259
+ }
260
+
261
+ function toast(message, error = false) {
262
+ const element = document.createElement("div");
263
+ element.className = `toast${error ? " error" : ""}`;
264
+ element.textContent = message;
265
+ $("#toasts").append(element);
266
+ setTimeout(() => element.remove(), 4500);
267
+ }
268
+
269
+ function reportFailure(kind, error, target) {
270
+ const message = errorMessage(error);
271
+ if (target) target.textContent = message;
272
+ addEvent(kind, message, true);
273
+ toast(message, true);
274
+ }
275
+
276
+ function renderEvents() {
277
+ const root = $("#event-log"); root.replaceChildren();
278
+ if (state.events.length === 0) { const p = document.createElement("p"); p.className = "empty-copy"; p.textContent = "还没有操作。"; root.append(p); return; }
279
+ state.events.forEach((event) => {
280
+ const row = document.createElement("div"); row.className = `event-item${event.error ? " error" : ""}`;
281
+ const time = document.createElement("time"); time.textContent = event.at.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
282
+ const kind = document.createElement("b"); kind.textContent = event.kind;
283
+ const message = document.createElement("span"); message.textContent = event.message;
284
+ row.append(time, kind, message); root.append(row);
285
+ });
286
+ }
287
+
288
+ function setConnection(connected, label) {
289
+ const connection = $("#connection-state"); connection.className = `connection ${connected ? "online" : "offline"}`; connection.lastChild.textContent = label;
290
+ const dot = $("#side-dot"); dot.className = `pulse-dot ${connected ? "online" : "offline"}`;
291
+ $("#side-status").textContent = connected ? "NodeHost 已连接" : "NodeHost 连接失败";
292
+ }
293
+
294
+ function renderStatus() {
295
+ const project = asObject(state.status);
296
+ const projectId = project.project_id || "—";
297
+ const revision = project.project_revision ?? 0;
298
+ const viewState = project.view_state || "";
299
+ const previewReady = project.view_dependencies_installed === true;
300
+ const worldReady = typeof project.scenario_integrity === "string" && !["", "missing", "invalid", "error"].includes(project.scenario_integrity.toLowerCase());
301
+ $("#project-name").textContent = "本地作者项目";
302
+ $("#project-id").textContent = projectId;
303
+ $("#project-revision").textContent = String(revision);
304
+ globalThis.dispatchEvent(new CustomEvent("worldengine:project-revision-change", { detail: { project_revision: revision } }));
305
+ $("#expected-revision").value = String(revision);
306
+ $("#runtime-tag").textContent = previewReady && worldReady ? "可以继续" : "需要准备";
307
+ $("#project-health-copy").textContent = previewReady
308
+ ? "作品已准备好。你可以继续完善世界设定,或打开实时预览查看画面。"
309
+ : "作品内容已保留;安装本地 View 依赖后即可打开实时预览。";
310
+ const metrics = [
311
+ ["世界设定", worldReady ? "已就绪" : "等待完善", "角色与关系"],
312
+ ["作品画面", previewReady ? "可以预览" : "尚未就绪", viewState ? "Vite 实时更新" : "等待创建画面"],
313
+ ];
314
+ const metricRoot = $("#project-metrics"); metricRoot.replaceChildren();
315
+ metrics.forEach(([label, value, suffix]) => { const card = document.createElement("div"); card.className = "metric"; const small = document.createElement("small"); small.textContent = label; const strong = document.createElement("strong"); strong.textContent = String(value); const em = document.createElement("em"); em.textContent = suffix; card.append(small, strong, em); metricRoot.append(card); });
316
+ const details = [
317
+ ["Project ID", projectId], ["Snapshot ID", project.snapshot_id || "—"],
318
+ ["Snapshot digest", project.snapshot_digest || "—"], ["Scenario integrity", project.scenario_integrity || "—"],
319
+ ["Root dependencies", project.root_dependencies_installed ? "已安装" : "未安装"], ["View dependencies", project.view_dependencies_installed ? "已安装" : "未安装"],
320
+ ];
321
+ const dl = $("#status-details"); dl.replaceChildren();
322
+ details.forEach(([key, value]) => { const div = document.createElement("div"); const dt = document.createElement("dt"); const dd = document.createElement("dd"); dt.textContent = key; dd.textContent = String(value); dd.title = String(value); div.append(dt, dd); dl.append(div); });
323
+ }
324
+
325
+ async function loadStatus(silent = false) {
326
+ try { state.status = asObject(await api("/status")); renderStatus(); setConnection(true, "已连接"); if (!silent) addEvent("STATUS", "项目状态已刷新"); }
327
+ catch (error) { setConnection(false, "连接失败"); reportFailure("STATUS", error); }
328
+ }
329
+ async function loadStatusStrict() { state.status = asObject(await api("/status")); renderStatus(); setConnection(true, "已连接"); return state.status; }
330
+
331
+ function switchView(name) {
332
+ const mapEpoch = ++worldMapViewEpoch;
333
+ document.body.classList.toggle("world-map-active", name === "world-map");
334
+ if (name !== "world-map") worldMapController?.close();
335
+ document.body.classList.toggle("graph-active", name === "graph");
336
+ $$(".nav-item,.nav-subitem").forEach((button) => button.classList.toggle("active", button.dataset.view === name));
337
+ $$("[data-view-panel]").forEach((panel) => panel.classList.toggle("active", panel.dataset.viewPanel === name));
338
+ $("#main").focus({ preventScroll: true });
339
+ if (name === "graph" && state.graph.entities.length === 0) loadGraph();
340
+ if (name === "trigger") { if (state.triggerSchema === null && state.triggerLoadState === "idle") void readTriggers(); else void renderTriggerBlueprint(); }
341
+ if (name === "view") preview("start");
342
+ if (name === "world-map") void openWorldMap(mapEpoch);
343
+ if (name === "spatial-templates") void openSpatialTemplates(document.querySelector("[data-template-kind].active")?.dataset.templateKind ?? "city");
344
+ }
345
+
346
+ function normalizeGraph(result) {
347
+ const root = asObject(result);
348
+ const graph = asObject(root.graph || root.logical_graph || root);
349
+ const authoring = asObject(root.authoring);
350
+ const entities = Array.isArray(authoring.entities) ? authoring.entities : asArray(graph.entities || graph.nodes || root.entities || root.nodes);
351
+ const relations = Array.isArray(authoring.relations) ? authoring.relations : asArray(graph.relations || graph.edges || root.relations || root.edges);
352
+ const runtimeDefinitions = asObject(authoring.runtime_definitions || root.runtime_definitions || graph.runtime_definitions);
353
+ return {
354
+ entities: entities.map((entity) => ({ ...entity, id: entity.entity_id ?? entity.id, props: asObject(entity.props) })),
355
+ relations: relations.map((relation) => ({ ...relation, src: relation.from_id ?? relation.src, dst: relation.to_id ?? relation.dst, kind: relation.kind ?? relation.title, fields: asObject(relation.fields ?? relation.props) })),
356
+ spatial_world: root.spatial_world ?? graph.spatial_world ?? null,
357
+ metadata: authoring.metadata ?? root.metadata ?? graph.metadata ?? null,
358
+ cg_defs: asArray(runtimeDefinitions.cg_defs),
359
+ };
360
+ }
361
+
362
+ async function loadGraph() {
363
+ const canvas = $("#graph-canvas");
364
+ if (!graphController) renderWorkspaceStatus(canvas, { state: "loading", surface: "graph" });
365
+ try {
366
+ const [graphResult, spatialResult, layoutResult, schemaResult] = await Promise.allSettled([
367
+ api("/graph"),
368
+ callTool("world_search", { query: "spatial:", mode: "keyword", limit: 1 }),
369
+ callTool("interaction_layout_read", { viewport: "pc" }),
370
+ callTool("world_schema", {}),
371
+ ]);
372
+ if (graphResult.status === "rejected") throw graphResult.reason;
373
+ if (layoutResult.status === "rejected") throw layoutResult.reason;
374
+ state.graph = normalizeGraph(graphResult.value);
375
+ if (state.selectedGraph?.type === "entity") { const currentId = state.selectedGraph.item.id; const readback = state.graph.entities.find((item) => item.id === currentId); if (readback) state.selectedGraph = { type: "entity", item: readback }; }
376
+ else if (state.selectedGraph?.type === "relation") { const currentId = graphRelationId(state.selectedGraph.item); const readback = state.graph.relations.find((item) => graphRelationId(item) === currentId); if (readback) state.selectedGraph = { type: "relation", item: readback }; }
377
+ state.graphLayout = await parseGraphLayoutWithSharedGeometry(layoutResult.value);
378
+ state.graphSchema = schemaResult.status === "fulfilled" ? asObject(schemaResult.value) : {}; state.graphEntityKinds = asArray(state.graphSchema.entity_kinds).filter((kind) => typeof kind === "string");
379
+ if (spatialResult.status === "fulfilled") state.graph.spatial_world = asObject(spatialResult.value).spatial_world ?? null;
380
+ await renderGraph(); addEvent("GRAPH", `已读回 ${state.graph.entities.length} 个实体、${state.graph.relations.length} 条关系`);
381
+ }
382
+ catch (error) {
383
+ graphController?.destroy(); graphController = undefined; graphModule = undefined;
384
+ renderWorkspaceStatus(canvas, { state: "error", surface: "graph", error, onRetry: () => { void loadGraph(); }, retryLabel: "重新读取" });
385
+ reportFailure("GRAPH", error);
386
+ }
387
+ }
388
+
389
+ function parseGraphLayout(value, validateBounds) {
390
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw { code: "E_SCHEMA_INVALID", message: "图谱布局读回格式无效" };
391
+ if (value.graph_canvas === undefined) return { positions: {}, groups: [] };
392
+ const layout = value.graph_canvas;
393
+ if (!layout || typeof layout !== "object" || !layout.positions || typeof layout.positions !== "object" || Array.isArray(layout.positions)) throw { code: "E_SCHEMA_INVALID", message: "图谱坐标读回格式无效" };
394
+ for (const point of Object.values(layout.positions)) {
395
+ if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) throw { code: "E_SCHEMA_INVALID", message: "图谱坐标不是有限数值" };
396
+ }
397
+ const groups = layout.groups === undefined ? [] : layout.groups;
398
+ if (!Array.isArray(groups)) throw { code: "E_SCHEMA_INVALID", message: "图谱视觉分组读回格式无效" };
399
+ const ids = new Set();
400
+ for (const group of groups) {
401
+ if (!group || typeof group !== "object" || typeof group.id !== "string" || !group.id || ids.has(group.id)
402
+ || !Number.isFinite(group.x) || !Number.isFinite(group.y) || !Number.isFinite(group.width) || !Number.isFinite(group.height)
403
+ || (group.label !== undefined && typeof group.label !== "string")
404
+ || (group.nodeIds !== undefined && (!Array.isArray(group.nodeIds) || group.nodeIds.some((id) => typeof id !== "string")))) {
405
+ throw { code: "E_SCHEMA_INVALID", message: "图谱视觉分组字段无效" };
406
+ }
407
+ ids.add(group.id);
408
+ }
409
+ if (groups.length > 0 && typeof validateBounds !== "function") throw { code: "E_SCHEMA_INVALID", message: "图谱视觉分组校验器不可用" };
410
+ if (groups.some((group) => !validateBounds(group, groups, group.id))) throw { code: "E_SCHEMA_INVALID", message: "图谱视觉分组不能重叠、嵌套或小于最小尺寸" };
411
+ return { ...layout, groups };
412
+ }
413
+
414
+ async function parseGraphLayoutWithSharedGeometry(value) {
415
+ graphModule ??= import("/graph.js");
416
+ const { isGraphGroupBoundsValid } = await graphModule;
417
+ return parseGraphLayout(value, isGraphGroupBoundsValid);
418
+ }
419
+
420
+ function graphRelationId(relation) { return relation.relation_id || JSON.stringify([relation.src, relation.dst, relation.kind]); }
421
+
422
+ function graphNodeLabel(entity) {
423
+ if (Array.isArray(entity.name) && typeof entity.name[0] === "string") return entity.name[0];
424
+ const props = asObject(entity.props);
425
+ const name = props.name ?? props.label;
426
+ return typeof name === "string" ? name : Array.isArray(name) && typeof name[0] === "string" ? name[0] : String(entity.id);
427
+ }
428
+
429
+ async function renderGraph() {
430
+ const version = ++graphRenderVersion;
431
+ const { entities, relations } = state.graph;
432
+ $("#graph-summary").textContent = `${entities.length} 节点 · ${relations.length} 关系${graphLayoutSaveQueue?.isConflicted() ? " · 布局冲突,刷新前请保留未保存位置" : state.graphLayoutDirty ? " · 布局未保存" : ""}`;
433
+ renderSpatial();
434
+ // 只加载共享包的编译产物;此处不再定义节点、边或布局算法。
435
+ graphModule ??= import("/graph.js");
436
+ const { mountLocalGraph } = await graphModule;
437
+ if (version !== graphRenderVersion) return;
438
+ const layout = state.graphLayout;
439
+ const props = {
440
+ nodes: entities.map((entity) => ({
441
+ id: entity.id, label: graphNodeLabel(entity), kind: entity.kind,
442
+ content: { name: graphNodeLabel(entity), props: Object.fromEntries(Object.entries(asObject(entity.props)).map(([key, value]) => [key, { value }])) },
443
+ disabled: asArray(layout.disabled_node_ids).includes(entity.id),
444
+ pending: asArray(layout.pending_node_ids).includes(entity.id),
445
+ ...(["ready", "pending", "missing"].includes(layout.vector_state_by_node_id?.[entity.id]) ? { vector: layout.vector_state_by_node_id[entity.id] } : {}),
446
+ })),
447
+ relations: relations.map((relation) => ({ id: graphRelationId(relation), source: relation.src, target: relation.dst, label: relation.kind })),
448
+ selectedNodeId: state.selectedGraph?.type === "entity" ? state.selectedGraph.item.id : null,
449
+ selectedRelationId: state.selectedGraph?.type === "relation" ? graphRelationId(state.selectedGraph.item) : state.selectedGraph?.type === "group" ? state.selectedGraph.item.pairId : null,
450
+ highlightNodeId: state.graphHighlightNodeId,
451
+ searchQuery: "", disabled: state.graphSaveBusy,
452
+ positions: layout.positions,
453
+ groups: layout.groups,
454
+ onSelectNode: (id) => { const entity = state.graph.entities.find((item) => item.id === id); if (entity) openGraphDetails("entity", entity); },
455
+ onSelectRelation: (id) => { const relation = state.graph.relations.find((item) => graphRelationId(item) === id); if (relation) openGraphDetails("relation", relation); },
456
+ onSelectRelationGroup: openGraphRelationGroup,
457
+ onClearSelection: clearGraphSelection,
458
+ onCommitLayout: commitGraphLayout,
459
+ onCommitPositions: commitGraphPositions,
460
+ onRelationDragIntent: (source, target) => {
461
+ openGraphEditor("relation", null, { source, target });
462
+ },
463
+ };
464
+ if (graphController) graphController.update(props);
465
+ else { const root = $("#graph-canvas"); root.replaceChildren(); graphController = mountLocalGraph(root, props); }
466
+ }
467
+
468
+ function graphLayoutQueue() {
469
+ graphLayoutSaveQueue ??= createGraphLayoutSaveQueue({
470
+ onOptimistic: (layout) => { state.graphLayout = layout; state.graphLayoutDirty = true; void renderGraph().catch((error) => reportFailure("GRAPH_RENDER", error)); },
471
+ save: (layout) => executeEffect("interaction_layout_apply", { viewport: "pc", patch: buildGraphLayoutPatch(layout) }, "gui_graph_layout"),
472
+ onSaved: async (layout, receipt) => {
473
+ if (Number.isSafeInteger(receipt?.next_project_revision)) state.status = withGraphProjectRevision(state.status, receipt.next_project_revision);
474
+ if (!graphLayoutSaveQueue.isLatest(layout)) return;
475
+ const [layoutResult] = await Promise.all([callTool("interaction_layout_read", { viewport: "pc" }), loadStatusStrict()]);
476
+ if (!graphLayoutSaveQueue.isLatest(layout)) return;
477
+ state.graphLayout = await parseGraphLayoutWithSharedGeometry(layoutResult); state.graphLayoutDirty = false; addEvent("GRAPH_LAYOUT", "布局已保存并读回"); await renderGraph();
478
+ },
479
+ onError: async (error) => { state.graphLayoutDirty = true; reportFailure("GRAPH_LAYOUT", error); try { await loadStatusStrict(); } catch (readbackError) { reportFailure("GRAPH_LAYOUT_READBACK", readbackError); } await renderGraph(); },
480
+ });
481
+ return graphLayoutSaveQueue;
482
+ }
483
+ function commitGraphLayout(layout) { graphLayoutQueue().enqueue({ ...state.graphLayout, ...layout }); }
484
+ function commitGraphPositions(positions) { commitGraphLayout({ positions }); }
485
+
486
+ function renderSpatial() {
487
+ const root = $("#spatial-canvas"); root.replaceChildren(); const spatial = asObject(state.graph.spatial_world); const objects = asArray(spatial.objects);
488
+ if (!state.graph.spatial_world || objects.length === 0) { const p = document.createElement("p"); p.className = "empty-copy"; p.style.padding = "18px"; p.textContent = state.graph.spatial_world ? "空间地图还没有地点等对象,目前仅支持查看坐标。" : "尚未设置空间地图。这里用于查看地点等对象的二维坐标,不是实体关系图。"; root.append(p); return; }
489
+ const ns = "http://www.w3.org/2000/svg"; const svg = document.createElementNS(ns, "svg"); svg.setAttribute("viewBox", "0 0 900 460"); const topology = asObject(spatial.topology); const width = Number(topology.width) || 100, height = Number(topology.height) || 100;
490
+ objects.forEach((object) => { const group = document.createElementNS(ns, "g"); group.classList.add("graph-node"); group.setAttribute("transform", `translate(${45 + (Number(object.x) / width) * 810} ${35 + (Number(object.y) / height) * 390})`); const circle = document.createElementNS(ns, "circle"); circle.setAttribute("r", object.kind === "region" ? "30" : "21"); const label = document.createElementNS(ns, "text"); label.setAttribute("text-anchor", "middle"); label.setAttribute("y", "4"); label.textContent = String(object.label || object.id).slice(0, 18); const kind = document.createElementNS(ns, "text"); kind.classList.add("kind"); kind.setAttribute("text-anchor", "middle"); kind.setAttribute("y", "35"); kind.textContent = `${object.kind || "spatial"} · ${object.x},${object.y}`; group.append(circle, label, kind); svg.append(group); }); root.append(svg);
491
+ }
492
+
493
+ const ENTITY_TYPES = Object.freeze({ character: "人物", location: "地点", faction: "势力", item: "物品", event: "事件", other: "其他" });
494
+ // WORLD_GRAPH_PURE_BEGIN
495
+ const GRAPH_CUSTOM_KIND_SENTINEL = "__worldengine_ui_custom_kind__";
496
+ function graphOwn(value, key) { return value !== null && typeof value === "object" && !Array.isArray(value) && Object.hasOwn(value, key); }
497
+ function graphFriendlyField(props, primaryKey, fallbackKey, arrayFirst = false) {
498
+ const key = graphOwn(props, primaryKey) ? primaryKey : graphOwn(props, fallbackKey) ? fallbackKey : primaryKey;
499
+ const present = graphOwn(props, key); const raw = present ? props[key] : undefined;
500
+ if (!present) return { key, value: "", editable: true, mode: "absent", raw };
501
+ if (typeof raw === "string") return { key, value: raw, editable: true, mode: "string", raw };
502
+ if (arrayFirst && Array.isArray(raw) && typeof raw[0] === "string") return { key, value: raw[0], editable: true, mode: "array", raw: [...raw] };
503
+ return { key, value: "", editable: false, mode: "complex", raw };
504
+ }
505
+ function graphFriendlyRaw(raw) {
506
+ if (typeof raw === "string") return { key: null, value: raw, editable: true, mode: "string", raw };
507
+ return { key: null, value: "", editable: raw === undefined, mode: raw === undefined ? "absent" : "complex", raw };
508
+ }
509
+ function graphAliasValues(form) { if (typeof form.querySelectorAll === "function") return [...form.querySelectorAll('[name="alias"]')].map((input) => input.value.trim()).filter(Boolean); return String(form.elements.aliases?.value || "").split(/[,,、;;\n]+/u).map((value) => value.trim()).filter(Boolean); }
510
+ function makeGraphEditorInitial(mode, item) {
511
+ const sourceProps = mode === "relation" ? item?.fields ?? item?.props : item?.props;
512
+ const props = sourceProps && typeof sourceProps === "object" && !Array.isArray(sourceProps) ? sourceProps : {};
513
+ const canonicalNames = Array.isArray(item?.name) && item.name.every((name) => typeof name === "string") ? [...item.name] : null;
514
+ const description = mode === "relation" && item && Object.hasOwn(item, "content") ? { ...graphFriendlyRaw(item.content), source: "content" } : { ...graphFriendlyField(props, "description", "summary"), source: "props" };
515
+ return { mode, itemKind: item?.kind ?? null, row: item ? structuredClone(item) : null, canonical: mode === "entity" ? typeof item?.entity_id === "string" : typeof item?.relation_id === "string", name: canonicalNames ? { key: "name", value: canonicalNames[0] ?? "", editable: true, mode: "array", raw: canonicalNames } : graphFriendlyField(props, "name", "label", true), description };
516
+ }
517
+ function buildGraphOperation(form, initial) {
518
+ const parsed = JSON.parse(form.elements.props.value);
519
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw { code: "E_SCHEMA_INVALID", message: "属性 JSON 必须是 object" };
520
+ const props = parsed;
521
+ const descriptionInput = initial.mode === "entity" ? form.elements.description : form.elements.relation_description;
522
+ if (initial.description.source === "props" && initial.description.editable && descriptionInput.value !== initial.description.value) props[initial.description.key] = descriptionInput.value;
523
+ if (initial.mode === "entity") {
524
+ if (!initial.canonical && initial.name.editable && form.elements.name.value !== initial.name.value) {
525
+ if (initial.name.mode === "array") { const current = props[initial.name.key]; const aliases = Array.isArray(current) ? current.slice(1) : initial.name.raw.slice(1); props[initial.name.key] = [form.elements.name.value, ...aliases]; }
526
+ else props[initial.name.key] = form.elements.name.value;
527
+ }
528
+ const selectedKind = form.elements.entity_type.value;
529
+ const kind = selectedKind === GRAPH_CUSTOM_KIND_SENTINEL ? (initial.itemKind === "__custom__" ? initial.itemKind : form.elements.custom_kind.value.trim()) : selectedKind;
530
+ if (!kind) throw { code: "E_SCHEMA_INVALID", message: "自定义类型不能为空" };
531
+ const aliases = graphAliasValues(form); const names = [form.elements.name.value.trim(), ...aliases].filter(Boolean);
532
+ const row = { ...(initial.row || {}), entity_id: initial.row?.entity_id || initial.row?.id || form.elements.id.value.trim(), name: names, kind, props };
533
+ if (form.elements.entity_view_access.value) row.view_access = form.elements.entity_view_access.value; else delete row.view_access;
534
+ if (form.elements.online_supplement.checked) { row.props.realworld_provenance = "has_prototype"; if (form.elements.prototype_name.value.trim()) row.props.prototype_name = form.elements.prototype_name.value.trim(); else delete row.props.prototype_name; } else { delete row.props.realworld_provenance; delete row.props.prototype_name; }
535
+ return { type: "upsert_entity_row", entity: row };
536
+ }
537
+ const row = { ...(initial.row || {}), relation_id: initial.row?.relation_id || form.elements.id.value.trim(), from_id: form.elements.src.value.trim(), to_id: form.elements.dst.value.trim(), kind: form.elements.kind.value.trim(), fields: props };
538
+ if (form.elements.relation_title.value.trim()) row.title = form.elements.relation_title.value.trim(); else delete row.title;
539
+ if (initial.description.editable && form.elements.relation_description.value !== initial.description.value) row.content = form.elements.relation_description.value;
540
+ if (form.elements.relation_view_access.value) row.view_access = form.elements.relation_view_access.value; else delete row.view_access;
541
+ row.initial_available = form.elements.relation_initial_available.checked;
542
+ return { type: "upsert_relation_row", relation: row };
543
+ }
544
+ function buildGraphLayoutPatch(layout, positions) { return { graph_canvas: positions === undefined ? layout : { ...layout, positions } }; }
545
+ function withGraphProjectRevision(status, revision) { const source = status && typeof status === "object" && !Array.isArray(status) ? status : {}; return { ...source, project_revision: revision, ...(source.project && typeof source.project === "object" && !Array.isArray(source.project) ? { project: { ...source.project, project_revision: revision } } : {}) }; }
546
+ function partitionGraphRelations(relations, entityId) { return { outgoing: relations.filter((relation) => relation.src === entityId), incoming: relations.filter((relation) => relation.dst === entityId) }; }
547
+ function createGraphLayoutSaveQueue(callbacks) {
548
+ let pending = null; let running = false; let dirty = false; let latest = null; let conflicted = false; const idleWaiters = [];
549
+ const settleIdle = () => { if (running || pending !== null) return; while (idleWaiters.length > 0) idleWaiters.shift()(); };
550
+ const drain = async () => {
551
+ if (running || conflicted || pending === null) return; running = true;
552
+ while (pending !== null) {
553
+ const current = pending; pending = null;
554
+ try { const receipt = await callbacks.save(current); await callbacks.onSaved(current, receipt); if (pending === null && latest === current) dirty = false; }
555
+ catch (error) { if (pending === null) pending = current; dirty = true; if (error?.code === "E_STALE_PROJECT_REVISION") conflicted = true; await callbacks.onError(error, pending); break; }
556
+ }
557
+ running = false; settleIdle();
558
+ };
559
+ return {
560
+ enqueue(positions) { latest = positions; pending = positions; dirty = true; callbacks.onOptimistic(positions); void drain(); },
561
+ retry() { void drain(); },
562
+ isLatest(positions) { return latest === positions && pending === null; },
563
+ isDirty() { return dirty; },
564
+ isConflicted() { return conflicted; },
565
+ idle() { return !running && pending === null ? Promise.resolve() : new Promise((resolve) => idleWaiters.push(resolve)); },
566
+ };
567
+ }
568
+ function beginGraphSave(graphState, button) { if (graphState.graphSaveBusy) return false; graphState.graphSaveBusy = true; button.disabled = true; return true; }
569
+ function endGraphSave(graphState, button) { graphState.graphSaveBusy = false; button.disabled = false; }
570
+ function shouldCloseGraphEditor(submittedEpoch, currentEpoch) { return submittedEpoch === currentEpoch; }
571
+ function mergeGraphTabDraft(type, item, owner, propertiesOwner, propertiesDraft) { if (propertiesOwner !== owner || !propertiesDraft) return item; return type === "entity" ? { ...item, props: propertiesDraft } : type === "relation" ? { ...item, fields: propertiesDraft } : item; }
572
+ function graphSaveRoute(editorHidden, selectionType, panelTab) { if (!editorHidden) return "metadata"; if (selectionType && selectionType !== "group" && panelTab === "attributes") return "attributes"; return null; }
573
+ // WORLD_GRAPH_PURE_END
574
+ function entityTypeLabel(kind) { return ENTITY_TYPES[kind] || `其他(${kind})`; }
575
+ function graphCloseIcon() { const ns = "http://www.w3.org/2000/svg"; const svg = document.createElementNS(ns, "svg"); svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("aria-hidden", "true"); const path = document.createElementNS(ns, "path"); path.setAttribute("d", "M6 6l12 12M18 6 6 18"); svg.append(path); return svg; }
576
+ function graphFabIcon(kind) { const ns = "http://www.w3.org/2000/svg"; const svg = document.createElementNS(ns, "svg"); svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("aria-hidden", "true"); const path = document.createElementNS(ns, "path"); path.setAttribute("d", kind === "add" ? "M12 5v14M5 12h14" : kind === "save" ? "M5 4h12l2 2v14H5zM8 4v6h8V4M8 20v-6h8v6" : "M4 7h16M9 7V4h6v3m-9 0 1 13h10l1-13M10 11v5m4-5v5"); svg.append(path); return svg; }
577
+ function renderGraphAliases(values = []) { const root = $("#graph-alias-list"); root.replaceChildren(); values.forEach((value) => { const row = document.createElement("div"); row.className = "graph-alias-row"; const input = document.createElement("input"); input.name = "alias"; input.value = value; input.setAttribute("aria-label", "别名"); const remove = document.createElement("button"); remove.type = "button"; remove.className = "graph-fab danger"; remove.setAttribute("aria-label", "删除别名"); remove.title = "删除别名"; remove.replaceChildren(graphFabIcon("delete")); remove.addEventListener("click", () => row.remove()); row.append(input, remove); root.append(row); }); }
578
+ function addGraphAlias() { renderGraphAliases([...graphAliasValues($("#graph-editor")), ""]); const inputs = $$('#graph-alias-list [name="alias"]'); inputs.at(-1)?.focus(); }
579
+ function updateGraphOnlineSearchField() { const input = $("#graph-editor").elements.online_supplement; input.setAttribute("aria-checked", String(input.checked)); $("#prototype-name-field").hidden = !input.checked; }
580
+ function resetGraphHighlight() { if (state.graphHighlightNodeId === null) return; state.graphHighlightNodeId = null; void renderGraph().catch((error) => reportFailure("GRAPH_RENDER", error)); }
581
+ function closeGraphPanel() { resetGraphHighlight(); state.graphEditorEpoch += 1; state.graphEditorInitial = null; $("#graph-side-panel").hidden = true; $("#graph-detail-view").hidden = false; $("#graph-editor").hidden = true; $("#graph-editor-cg").hidden = true; }
582
+ function clearGraphSelection() { state.selectedGraph = null; closeGraphPanel(); void renderGraph().catch((error) => reportFailure("GRAPH_RENDER", error)); }
583
+ function detailRow(label, value) { const row = document.createElement("div"); const key = document.createElement("span"); const content = document.createElement("p"); key.textContent = label; content.textContent = String(value ?? "—"); row.append(key, content); return row; }
584
+ function graphPropertyValue(value) { if (typeof value === "string") return value; if (typeof value === "number" || typeof value === "boolean") return String(value); if (value === null) return "空值"; return "复杂值,请在原始信息中查看"; }
585
+ function graphRelationGroup(title, relations, direction, ownerId) {
586
+ const section = document.createElement("section"); section.className = "graph-relation-group"; const heading = document.createElement("h3"); heading.textContent = title; section.append(heading);
587
+ if (relations.length === 0) { const empty = document.createElement("p"); empty.className = "graph-relation-empty"; empty.textContent = "暂无关系"; section.append(empty); return section; }
588
+ relations.forEach((relation) => {
589
+ const otherId = direction === "outgoing" ? relation.dst : relation.src; const other = state.graph.entities.find((entity) => entity.id === otherId);
590
+ const row = document.createElement("div"); row.className = "graph-relation-row";
591
+ const edgeButton = document.createElement("button"); edgeButton.type = "button"; edgeButton.className = "graph-relation-edge"; edgeButton.textContent = `${relation.kind} · ${graphNodeLabel(other || { id: otherId })}`; edgeButton.addEventListener("click", () => openGraphDetails("relation", relation));
592
+ const nodeButton = document.createElement("button"); nodeButton.type = "button"; nodeButton.className = "graph-relation-node graph-fab"; nodeButton.setAttribute("aria-label", "查看关联节点"); nodeButton.title = "查看关联节点"; nodeButton.replaceChildren(graphNodeLinkIcon()); nodeButton.disabled = !other; if (other) { nodeButton.addEventListener("click", () => openGraphDetails("entity", other)); nodeButton.addEventListener("mouseenter", () => setGraphHighlightNode(otherId)); nodeButton.addEventListener("focus", () => setGraphHighlightNode(otherId)); nodeButton.addEventListener("mouseleave", () => clearGraphHighlightNode(otherId)); nodeButton.addEventListener("blur", () => clearGraphHighlightNode(otherId)); }
593
+ row.append(edgeButton, nodeButton); section.append(row);
594
+ });
595
+ return section;
596
+ }
597
+ function graphNodeLinkIcon() { const ns = "http://www.w3.org/2000/svg"; const svg = document.createElementNS(ns, "svg"); svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("aria-hidden", "true"); const circle = document.createElementNS(ns, "circle"); circle.setAttribute("cx", "9"); circle.setAttribute("cy", "12"); circle.setAttribute("r", "4"); const path = document.createElementNS(ns, "path"); path.setAttribute("d", "M13 12h7m-3-3 3 3-3 3"); svg.append(circle, path); return svg; }
598
+ function setGraphHighlightNode(id) { if (state.graphHighlightNodeId === id) return; state.graphHighlightNodeId = id; void renderGraph().catch((error) => reportFailure("GRAPH_RENDER", error)); }
599
+ function clearGraphHighlightNode(id) { if (state.graphHighlightNodeId !== id) return; state.graphHighlightNodeId = null; void renderGraph().catch((error) => reportFailure("GRAPH_RENDER", error)); }
600
+ function openGraphRelationGroup(selection) {
601
+ resetGraphHighlight();
602
+ const relations = asArray(selection.relationIds).map((id) => state.graph.relations.find((relation) => graphRelationId(relation) === id)).filter(Boolean);
603
+ state.selectedGraph = { type: "group", item: { ...selection, relations } }; state.graphPropertiesOwner = null; state.graphPropertiesDraft = null; state.graphPanelTab = "metadata"; renderGraphPanelSelection(); void renderGraph().catch((error) => reportFailure("GRAPH_RENDER", error));
604
+ }
605
+ function graphItemProperties(type, item) { return asObject(type === "relation" ? item.fields ?? item.props : item.props); }
606
+ function openGraphDetails(type, item) {
607
+ resetGraphHighlight();
608
+ const previousKey = graphSelectionKey(state.selectedGraph); const preserveTab = state.selectedGraph?.type === "entity" && type === "entity"; state.selectedGraph = { type, item }; if (previousKey !== graphSelectionKey(state.selectedGraph)) { state.graphEditorEpoch += 1; state.graphPropertiesOwner = null; state.graphPropertiesDraft = null; state.graphRowDraft = null; state.graphRowDraftOwner = null; } if (!preserveTab) state.graphPanelTab = "metadata"; renderGraphPanelSelection(); void renderGraph().catch((error) => reportFailure("GRAPH_RENDER", error));
609
+ }
610
+ function graphSelectionKey(selection) { if (!selection) return null; return selection.type === "entity" ? `entity:${selection.item.id}` : selection.type === "relation" ? `relation:${graphRelationId(selection.item)}` : `group:${selection.item.pairId}`; }
611
+ function renderGraphPanelSelection() {
612
+ const selection = state.selectedGraph; if (!selection) return; const type = selection.type; const owner = graphSelectionKey(selection); let item = state.graphRowDraftOwner === owner && state.graphRowDraft ? state.graphRowDraft : selection.item;
613
+ if (state.graphPanelTab === "metadata") item = mergeGraphTabDraft(type, item, owner, state.graphPropertiesOwner, state.graphPropertiesDraft);
614
+ $$("[data-graph-panel-tab]").forEach((button) => { const active = button.dataset.graphPanelTab === state.graphPanelTab; button.classList.toggle("active", active); button.setAttribute("aria-selected", String(active)); });
615
+ if (state.graphPanelTab === "metadata" && type !== "group") { openGraphEditor(type, item); return; }
616
+ graphPropertyController?.destroy(); graphPropertyController = undefined; graphPropertyPropsFactory = undefined; $("#graph-editor-cg").hidden = true;
617
+ $("#graph-side-panel").hidden = false; $("#graph-detail-view").hidden = false; $("#graph-editor").hidden = true;
618
+ $("#graph-panel-kicker").textContent = type === "entity" ? entityTypeLabel(item.kind) : type === "group" ? "关系组" : "关系";
619
+ $("#graph-panel-title").textContent = type === "entity" ? graphNodeLabel(item) : type === "group" ? `${item.relations.length} 条关系` : item.kind;
620
+ $("#save-graph-item").disabled = type === "group" || !["metadata", "attributes"].includes(state.graphPanelTab); $("#delete-graph-item").disabled = type === "group";
621
+ const content = $("#graph-detail-content"); content.replaceChildren();
622
+ if (type === "group") { const chosen = state.graphPanelTab === "incoming" ? item.relations.filter((relation) => relation.dst === item.source) : state.graphPanelTab === "outgoing" ? item.relations.filter((relation) => relation.src === item.source) : item.relations; content.append(graphRelationGroup(state.graphPanelTab === "incoming" ? `指入 ${item.source}` : state.graphPanelTab === "outgoing" ? `从 ${item.source} 指出` : "全部关系", chosen, state.graphPanelTab === "incoming" ? "incoming" : "outgoing", item.source)); return; }
623
+ const props = graphItemProperties(type, item); const description = type === "relation" && Object.hasOwn(item, "content") ? graphFriendlyRaw(item.content) : graphFriendlyField(props, "description", "summary"); const descriptionLabel = description.editable ? description.value : "复杂值,请查看原始属性";
624
+ if (state.graphPanelTab === "metadata") {
625
+ if (type === "entity") { const names = Array.isArray(item.name) ? { value: item.name[0] ?? "", aliases: item.name.slice(1) } : (() => { const legacy = graphFriendlyField(props, "name", "label", true); return { value: legacy.editable ? legacy.value : "复杂值", aliases: legacy.mode === "array" ? legacy.raw.slice(1) : [] }; })(); content.append(detailRow("类型", entityTypeLabel(item.kind)), detailRow("名称", names.value), detailRow("别名", names.aliases.length ? names.aliases.join("、") : "—"), detailRow("简介", descriptionLabel), detailRow("访问策略", item.view_access || "继承"), detailRow("状态", item.disabled === true ? "禁用" : "启用"), detailRow("联网补充", graphOnlineSupplementLabel(props))); renderGraphCgDefinitions(content, selection); }
626
+ else { const source = state.graph.entities.find((entity) => entity.id === item.src); const target = state.graph.entities.find((entity) => entity.id === item.dst); const endpoints = document.createElement("div"); endpoints.className = "graph-edge-endpoints"; [["起点", source, item.src], ["终点", target, item.dst]].forEach(([label, entity, id]) => { const button = document.createElement("button"); button.type = "button"; button.textContent = `${label}:${graphNodeLabel(entity || { id })}`; button.disabled = !entity; if (entity) button.addEventListener("click", () => openGraphDetails("entity", entity)); endpoints.append(button); }); content.append(endpoints, detailRow("关系类型", item.kind), detailRow("显示标题", item.title || "—"), detailRow("内容", descriptionLabel), detailRow("访问策略", item.view_access || "继承"), detailRow("初始可见", item.initial_available === false ? "否" : "是")); renderGraphCgDefinitions(content, selection); }
627
+ } else if (state.graphPanelTab === "attributes") renderGraphProperties(content, props, selection);
628
+ else if (type === "entity") { const groups = partitionGraphRelations(state.graph.relations, item.id); const relations = state.graphPanelTab === "incoming" ? groups.incoming : groups.outgoing; content.append(graphRelationGroup(state.graphPanelTab === "incoming" ? "指入关系" : "指出关系", relations, state.graphPanelTab, item.id)); }
629
+ else content.append(detailRow("提示", "请先跳转到起点或终点节点查看方向关系。"));
630
+ }
631
+ function graphOnlineSupplementLabel(props) { const provenance = graphPropValue(props.realworld_provenance); const prototype = graphPropValue(props.prototype_name); return provenance === "has_prototype" ? `已允许${typeof prototype === "string" && prototype ? ` · ${prototype}` : ""}` : "未启用"; }
632
+ function graphPropValue(value) { return value && typeof value === "object" && !Array.isArray(value) && value._system_prop_envelope_v1 === true && Object.hasOwn(value, "value") ? value.value : value; }
633
+ function renderGraphCgDefinitions(content, selection) {
634
+ const available = Boolean(state.graphSchema.runtime_definitions?.cg_defs); const anchorId = selection.type === "entity" ? selection.item.id : selection.item.relation_id || graphRelationId(selection.item);
635
+ const matches = asArray(state.graph.cg_defs).filter((cg) => cg?.anchor?.id === anchorId); const section = document.createElement("section"); section.className = "graph-cg-list"; const heading = document.createElement("div"); heading.className = "graph-property-heading"; const title = document.createElement("h3"); title.textContent = "CG"; const add = document.createElement("button"); add.type = "button"; add.className = "graph-fab"; add.setAttribute("aria-label", `选择 CG 图片,当前 ${matches.length} 个`); add.title = "选择 CG 图片"; add.replaceChildren(graphFabIcon("add")); add.disabled = !available; add.addEventListener("click", () => graphCgEditor(null, selection, null, add)); heading.append(title, add); section.append(heading);
636
+ if (!available) { const note = document.createElement("p"); note.className = "graph-capability-note"; note.textContent = "当前 Host 未提供 CG runtime definition adapter。"; section.append(note); }
637
+ content.append(section);
638
+ }
639
+ function graphCgAssetUrl(assetPath) { return `${API}/${String(assetPath).startsWith("gallery/") ? "gallery/file" : "asset"}?path=${encodeURIComponent(assetPath)}`; }
640
+ function graphCgAssetLabel(assetPath) { const file = String(assetPath || "").split(/[\\/]/u).at(-1) || "CG 图片"; return file.replace(/\.[^.]+$/u, "") || "CG 图片"; }
641
+ function graphCgDefinition(cg, selection, attributePath, assetPath) {
642
+ if (cg) return { ...cg, asset_path: assetPath };
643
+ const anchorId = selection.type === "entity" ? selection.item.id : selection.item.relation_id || graphRelationId(selection.item);
644
+ const anchor = attributePath ? { kind: "attr", id: anchorId, attr_key: attributePath[0] } : { kind: selection.type === "entity" ? "node" : "edge", id: anchorId };
645
+ return { cg_id: `cg_${crypto.randomUUID()}`, name: graphCgAssetLabel(assetPath), asset_path: assetPath, anchor };
646
+ }
647
+ async function importGraphCgImage(file, selection) {
648
+ const ownerId = selection.type === "entity" ? selection.item.id : selection.item.relation_id || graphRelationId(selection.item);
649
+ const bytes = new Uint8Array(await file.arrayBuffer()); let binary = ""; for (let offset = 0; offset < bytes.length; offset += 0x8000) binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
650
+ const expectedRevision = Number(state.status?.project_revision ?? state.status?.project?.project_revision ?? state.status?.revision); if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) throw { code: "E_SCHEMA_INVALID", message: "当前项目 revision 无效,请重新读取后再试" };
651
+ const ownerKind = selection.type === "entity" ? "entity" : "relation"; const imported = await api("/gallery/import", { method: "POST", body: JSON.stringify({ operation_id: operationId("gallery_import"), expected_project_revision: expectedRevision, area: "scenario", owner_kind: ownerKind, owner_id: ownerId, filename: file.name, content_base64: btoa(binary) }) });
652
+ if (!imported || imported.path !== imported.asset_path || typeof imported.asset_path !== "string" || !imported.asset_path.startsWith("gallery/scenario/") || imported.owner_kind !== ownerKind || imported.owner_id !== ownerId || imported.readback_verified !== true || !Number.isSafeInteger(imported.next_project_revision) || typeof imported.mime !== "string" || !Number.isInteger(imported.size) || typeof imported.sha256 !== "string") throw { code: "E_INVALID_RESPONSE", message: "NodeHost 未返回 canonical Gallery 图片回读" };
653
+ return imported;
654
+ }
655
+ function graphCgImportFailure(error) { return String(error?.code || "").includes("MIGRATION") ? "项目需要先完成图片库迁移;没有写入 CG 绑定。" : `图片导入失败:${errorMessage(error)}`; }
656
+ function graphCgEditor(cg, selection, attributePath = null, opener = document.activeElement) {
657
+ if (attributePath && attributePath.length > 1) { const warning = document.createElement("p"); warning.className = "graph-capability-note"; warning.setAttribute("role", "alert"); warning.textContent = "当前 Host 不支持深层属性 CG 锚点;没有创建或修改任何绑定。"; return warning; }
658
+ $(".graph-cg-modal-backdrop")?.click();
659
+ const backdrop = document.createElement("div"); backdrop.className = "graph-cg-modal-backdrop";
660
+ const dialog = document.createElement("section"); dialog.className = "graph-cg-modal"; dialog.setAttribute("role", "dialog"); dialog.setAttribute("aria-modal", "true"); const titleId = `graph-cg-modal-title-${crypto.randomUUID()}`; dialog.setAttribute("aria-labelledby", titleId);
661
+ const header = document.createElement("header"); const title = document.createElement("h2"); title.id = titleId; title.textContent = cg ? "更换 CG 图片" : "选择 CG 图片"; const close = document.createElement("button"); close.type = "button"; close.className = "graph-fab"; close.setAttribute("aria-label", "关闭图片选择"); close.title = "关闭图片选择"; close.replaceChildren(graphCloseIcon()); header.append(title, close);
662
+ const body = document.createElement("div"); body.className = "graph-cg-modal-body"; const currentSection = document.createElement("section"); currentSection.className = "graph-cg-current"; const currentHeading = document.createElement("div"); currentHeading.className = "graph-cg-current-heading"; const currentTitle = document.createElement("h3"); currentTitle.textContent = "当前绑定图片"; const importButton = document.createElement("button"); importButton.type = "button"; importButton.className = "graph-fab"; importButton.setAttribute("aria-label", "选择本地图片"); importButton.title = "选择本地图片"; importButton.replaceChildren(graphFabIcon("add")); const fileInput = document.createElement("input"); fileInput.type = "file"; fileInput.accept = "image/png,image/jpeg,image/webp,image/gif,image/avif"; fileInput.hidden = true; currentHeading.append(currentTitle, importButton); const currentGrid = document.createElement("div"); currentGrid.className = "graph-cg-current-grid"; const status = document.createElement("p"); status.className = "graph-cg-modal-status"; status.setAttribute("role", "status"); currentSection.append(currentHeading, currentGrid); body.append(currentSection, status); dialog.append(header, body, fileInput); backdrop.append(dialog); document.body.append(backdrop);
663
+ const modalOwner = graphSelectionKey(selection); const anchorId = selection.type === "entity" ? selection.item.id : selection.item.relation_id || graphRelationId(selection.item); const matches = asArray(state.graph.cg_defs).filter((candidate) => candidate?.anchor?.id === anchorId && (!attributePath || (candidate.anchor.kind === "attr" && candidate.anchor.attr_key === attributePath[0]))); let editingCg = cg; let closed = false;
664
+ const syncSelection = () => { $$(".graph-cg-current-cell", currentGrid).forEach((cell) => { const selected = cell.dataset.cgId === editingCg?.cg_id; cell.classList.toggle("selected", selected); cell.setAttribute("aria-pressed", String(selected)); }); title.textContent = editingCg ? "更换 CG 图片" : "CG 图片"; status.textContent = editingCg ? "已选择一个绑定;点击加号从本机更换图片。" : "点击加号从本机选择图片并新建 CG。"; status.setAttribute("role", "status"); };
665
+ matches.forEach((candidate, index) => { const cell = document.createElement("button"); cell.type = "button"; cell.className = "graph-cg-current-cell"; cell.dataset.cgId = candidate.cg_id; cell.setAttribute("aria-label", `选择当前 CG ${index + 1}`); const image = document.createElement("img"); image.src = graphCgAssetUrl(candidate.asset_path); image.alt = `当前 CG 图片 ${index + 1}`; image.loading = "lazy"; image.decoding = "async"; cell.append(image); cell.addEventListener("click", () => { editingCg = editingCg?.cg_id === candidate.cg_id ? null : candidate; syncSelection(); }); currentGrid.append(cell); });
666
+ if (matches.length === 0) { const empty = document.createElement("p"); empty.className = "graph-cg-modal-empty"; empty.textContent = "当前还没有绑定图片。"; currentGrid.append(empty); } syncSelection();
667
+ const closeModal = () => { if (closed) return; closed = true; document.removeEventListener("keydown", onKeyDown, true); backdrop.remove(); if (opener instanceof HTMLElement && opener.isConnected) opener.focus(); };
668
+ const modalControls = () => $$("button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[href],[tabindex]:not([tabindex='-1'])", dialog).filter((control) => !control.hidden);
669
+ const onKeyDown = (event) => {
670
+ if (event.key === "Escape") { event.preventDefault(); event.stopImmediatePropagation(); closeModal(); return; }
671
+ if (event.key !== "Tab") return; const controls = modalControls(); if (controls.length === 0) { event.preventDefault(); return; } const first = controls[0]; const last = controls.at(-1); if (event.shiftKey && (document.activeElement === first || !dialog.contains(document.activeElement))) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
672
+ };
673
+ close.addEventListener("click", closeModal); backdrop.addEventListener("click", (event) => { if (event.target === backdrop) closeModal(); }); document.addEventListener("keydown", onKeyDown, true); close.focus();
674
+ importButton.addEventListener("click", () => fileInput.click());
675
+ fileInput.addEventListener("change", async () => {
676
+ const file = fileInput.files?.[0]; fileInput.value = ""; if (!file) return;
677
+ if (file.size <= 0 || file.size > 2 * 1024 * 1024) { status.textContent = "图片必须大于 0 且不超过 2 MiB。"; status.setAttribute("role", "alert"); return; }
678
+ if (graphSelectionKey(state.selectedGraph) !== modalOwner) { closeModal(); return; }
679
+ importButton.disabled = true; status.textContent = "正在导入本地图片…";
680
+ try {
681
+ const imported = await importGraphCgImage(file, selection); if (!backdrop.isConnected) return; if (graphSelectionKey(state.selectedGraph) !== modalOwner) { closeModal(); return; } state.status = withGraphProjectRevision(state.status, imported.next_project_revision);
682
+ status.textContent = "图片已安全导入,正在保存 CG…"; const definition = graphCgDefinition(editingCg, selection, attributePath, imported.asset_path); if (await saveGraphCg({ type: "upsert_cg", cg: definition }, importButton)) closeModal();
683
+ } catch (error) { if (!backdrop.isConnected) return; status.textContent = graphCgImportFailure(error); status.setAttribute("role", "alert"); }
684
+ finally { if (backdrop.isConnected) importButton.disabled = false; }
685
+ });
686
+ return backdrop;
687
+ }
688
+ async function saveGraphCg(operation, button) { if (!beginGraphSave(state, button)) return false; refreshGraphPropertyTree(); const owner = graphSelectionKey(state.selectedGraph); try { await executeEffect("world_apply", { operations: [operation] }, "gui_graph_cg"); toast("CG 已保存"); await loadStatus(true); await loadGraph(); if (owner === graphSelectionKey(state.selectedGraph) && state.graphPanelTab === "metadata") { const host = $("#graph-editor-cg"); host.replaceChildren(); renderGraphCgDefinitions(host, state.selectedGraph); } return true; } catch (error) { reportFailure("WORLD_APPLY", error); return false; } finally { endGraphSave(state, button); refreshGraphPropertyTree(); } }
689
+ function renderGraphProperties(content, props, selection) {
690
+ const owner = graphSelectionKey(selection); if (state.graphPropertiesOwner !== owner) { state.graphPropertiesOwner = owner; state.graphPropertiesDraft = structuredClone(props); }
691
+ const properties = document.createElement("section"); properties.className = "graph-property-list"; const mount = document.createElement("div"); mount.className = "graph-property-tree-mount"; properties.append(mount); content.append(properties); void mountGraphPropertyTree(mount, selection, owner);
692
+ }
693
+ async function mountGraphPropertyTree(element, selection, owner) {
694
+ graphModule ??= import("/graph.js"); const { mountLocalWorldPropertyTree } = await graphModule;
695
+ if (!element.isConnected || graphSelectionKey(state.selectedGraph) !== owner || state.graphPanelTab !== "attributes") return;
696
+ const cgBindings = asArray(state.graph.cg_defs).filter((cg) => cg?.anchor?.kind === "attr" && cg.anchor.id === selection.item.id).map((cg) => ({ path: [cg.anchor.attr_key], count: 1, entry: cg.name }));
697
+ const makeProps = () => ({ value: state.graphPropertiesDraft, cgBindings, disabled: state.graphSaveBusy, canEditCg: (path) => path.length === 1, onDraftChange: (next) => { state.graphPropertiesDraft = next; graphPropertyController?.update(makeProps()); }, onEditCg: (path) => { graphCgEditor(null, selection, path, document.activeElement); } });
698
+ graphPropertyPropsFactory = makeProps; graphPropertyController = mountLocalWorldPropertyTree(element, makeProps());
699
+ }
700
+ function refreshGraphPropertyTree() { if (graphPropertyController && graphPropertyPropsFactory) graphPropertyController.update(graphPropertyPropsFactory()); }
701
+ async function saveGraphPropertyDraft(selection, button) { if (!selection || !button || !beginGraphSave(state, button)) return; refreshGraphPropertyTree(); try { const owner = graphSelectionKey(selection); const item = state.graphRowDraftOwner === owner && state.graphRowDraft ? state.graphRowDraft : selection.item; const operation = selection.type === "entity" ? { type: "upsert_entity_row", entity: { ...item, entity_id: item.entity_id || item.id, props: state.graphPropertiesDraft } } : { type: "upsert_relation_row", relation: { ...item, relation_id: item.relation_id || graphRelationId(item), from_id: item.from_id || item.src, to_id: item.to_id || item.dst, fields: state.graphPropertiesDraft } }; await executeEffect("world_apply", { operations: [operation] }, "gui_graph_properties"); toast("属性已保存"); await loadStatus(true); await loadGraph(); if (owner === graphSelectionKey(state.selectedGraph)) { state.graphRowDraft = operation.entity || operation.relation; state.graphRowDraftOwner = owner; } } catch (error) { reportFailure("WORLD_APPLY", error); } finally { endGraphSave(state, button); refreshGraphPropertyTree(); } }
702
+ function openGraphEditor(mode, item = null, intent = null) {
703
+ state.graphEditorEpoch += 1;
704
+ state.graphMode = mode; state.selectedGraph = item ? { type: mode, item } : null; $("#graph-side-panel").hidden = false; $("#graph-detail-view").hidden = true; $("#graph-editor").hidden = false;
705
+ const form = $("#graph-editor"); form.reset(); form.elements.props.value = formatJson(mode === "relation" ? item?.fields ?? item?.props ?? {} : item?.props || {});
706
+ state.graphEditorInitial = makeGraphEditorInitial(mode, item);
707
+ state.graphRowDraft = item ? structuredClone(item) : null; state.graphRowDraftOwner = graphSelectionKey(state.selectedGraph);
708
+ const relation = mode === "relation"; $("#entity-fields").hidden = relation; $("#relation-fields").hidden = !relation;
709
+ form.elements.name.required = !relation; form.elements.src.required = relation; form.elements.dst.required = relation; form.elements.kind.required = relation;
710
+ $("#graph-panel-kicker").textContent = relation ? "关系" : "节点"; $("#graph-panel-title").textContent = item ? (relation ? item.title || item.kind : graphNodeLabel(item)) : `新建${relation ? "关系" : "节点"}`;
711
+ const cgHost = $("#graph-editor-cg"); cgHost.replaceChildren(); cgHost.hidden = !item; if (item) renderGraphCgDefinitions(cgHost, { type: mode, item });
712
+ $("#delete-graph-item").disabled = !item; $("#save-graph-item").disabled = false;
713
+ if (relation) {
714
+ form.elements.src.value = item?.src || intent?.source || ""; form.elements.dst.value = item?.dst || intent?.target || ""; form.elements.kind.value = item?.kind || "";
715
+ form.elements.src.readOnly = false; form.elements.dst.readOnly = false; form.elements.kind.readOnly = false;
716
+ const description = state.graphEditorInitial.description; form.elements.relation_title.value = item?.title || ""; form.elements.relation_description.value = description.value; form.elements.relation_description.disabled = !description.editable; form.elements.relation_view_access.value = item?.view_access || ""; form.elements.relation_initial_available.checked = item?.initial_available !== false; $("#relation-description-help").hidden = !form.elements.relation_description.disabled; form.elements.id.value = item?.relation_id || `relation:${crypto.randomUUID()}`;
717
+ } else {
718
+ const kind = item?.kind || "character"; const select = form.elements.entity_type; $$("option[data-runtime-kind]", select).forEach((option) => option.remove());
719
+ for (const runtimeKind of [...new Set([...state.graphEntityKinds, kind])]) if (!Object.hasOwn(ENTITY_TYPES, runtimeKind) && runtimeKind !== GRAPH_CUSTOM_KIND_SENTINEL) { const option = document.createElement("option"); option.value = runtimeKind; option.dataset.runtimeKind = "true"; option.textContent = entityTypeLabel(runtimeKind); select.append(option); }
720
+ select.value = kind; select.disabled = false; form.elements.custom_kind.value = ""; $("#custom-kind-field").hidden = true; form.elements.custom_kind.required = false;
721
+ const { name, description } = state.graphEditorInitial; form.elements.name.value = name.value; renderGraphAliases(name.mode === "array" ? name.raw.slice(1) : []); form.elements.name.disabled = !name.editable; form.elements.entity_view_access.value = item?.view_access || ""; form.elements.entity_initial_hidden.checked = false; form.elements.entity_initial_hidden.setAttribute("aria-checked", "false"); form.elements.online_supplement.checked = graphPropValue(item?.props?.realworld_provenance) === "has_prototype"; form.elements.prototype_name.value = typeof graphPropValue(item?.props?.prototype_name) === "string" ? graphPropValue(item.props.prototype_name) : ""; updateGraphOnlineSearchField(); form.elements.description.value = description.value; form.elements.description.disabled = !description.editable; form.elements.id.value = item?.entity_id || item?.id || `entity:${crypto.randomUUID()}`;
722
+ }
723
+ queueMicrotask(() => (relation ? form.elements.kind : form.elements.name).focus());
724
+ }
725
+ function updateGraphCustomKindField() { const custom = $("#graph-editor").elements.entity_type.value === GRAPH_CUSTOM_KIND_SENTINEL; $("#custom-kind-field").hidden = !custom; $("#graph-editor").elements.custom_kind.required = custom; }
726
+ function captureGraphMetadataDraft() { if ($("#graph-editor").hidden || !state.graphEditorInitial || !state.selectedGraph) return; try { const operation = buildGraphOperation($("#graph-editor"), state.graphEditorInitial); state.graphRowDraft = operation.entity || operation.relation; state.graphRowDraftOwner = graphSelectionKey(state.selectedGraph); state.graphPropertiesDraft = structuredClone(state.graphMode === "entity" ? state.graphRowDraft.props : state.graphRowDraft.fields); state.graphPropertiesOwner = state.graphRowDraftOwner; } catch (error) { reportFailure("WORLD_DRAFT", error); throw error; } }
727
+
728
+ async function executeEffect(name, input, prefix = name) {
729
+ const revision = Number(state.status?.project_revision ?? state.status?.project?.project_revision ?? state.status?.revision ?? $("#project-revision").textContent);
730
+ const id = operationId(prefix);
731
+ return api("/tool", { method: "POST", body: JSON.stringify({ name, input, effect_identity: { operation_id: id, expected_project_revision: revision } }) });
732
+ }
733
+
734
+ async function submitGraphEditor(event) {
735
+ event.preventDefault(); const form = event.currentTarget;
736
+ if (state.graphEditorInitial === null) return;
737
+ const saveButton = $("#save-graph-item"); if (!beginGraphSave(state, saveButton)) return; const submittedEpoch = state.graphEditorEpoch; const submittedOwner = graphSelectionKey(state.selectedGraph); const submittedMode = state.graphMode; let operation;
738
+ try { operation = buildGraphOperation(form, state.graphEditorInitial); }
739
+ catch (error) { endGraphSave(state, saveButton); reportFailure("WORLD_APPLY", error); return; }
740
+ try {
741
+ await renderGraph();
742
+ await executeEffect("world_apply", { operations: [operation] }, `gui_${operation.type}`); addEvent("WORLD_APPLY", `${operation.type} 已提交并读回`); toast("世界图已保存"); await loadStatus(true); await loadGraph();
743
+ if (shouldCloseGraphEditor(submittedEpoch, state.graphEditorEpoch) && submittedOwner === graphSelectionKey(state.selectedGraph)) { const savedItem = operation.type === "upsert_entity_row" ? state.graph.entities.find((item) => item.id === operation.entity.entity_id) || operation.entity : state.graph.relations.find((item) => graphRelationId(item) === operation.relation.relation_id) || operation.relation; state.selectedGraph = { type: submittedMode, item: savedItem }; state.graphRowDraft = savedItem; state.graphRowDraftOwner = submittedOwner; state.graphEditorInitial = makeGraphEditorInitial(submittedMode, savedItem); $("#delete-graph-item").disabled = false; }
744
+ } catch (error) { reportFailure("WORLD_APPLY", error); }
745
+ finally { endGraphSave(state, saveButton); await renderGraph(); }
746
+ }
747
+ function saveActiveGraphPanel() { const selection = state.selectedGraph; const route = graphSaveRoute($("#graph-editor").hidden, selection?.type, state.graphPanelTab); if (route === "metadata") { $("#graph-editor").requestSubmit(); return; } if (route === "attributes") void saveGraphPropertyDraft(selection, $("#save-graph-item")); }
748
+
749
+ async function deleteGraphItem() {
750
+ if (!state.selectedGraph) return; const { type, item } = state.selectedGraph;
751
+ const operation = type === "entity" ? { type: "delete_entity", id: item.id } : { type: "delete_relation", src: item.src, dst: item.dst, kind: item.kind };
752
+ if (!confirm(`确定删除这个${type === "entity" ? "节点" : "关系"}吗?此操作将写入世界图。`)) return;
753
+ const deleteButton = $("#delete-graph-item"); if (!beginGraphSave(state, deleteButton)) return;
754
+ try { await renderGraph(); await executeEffect("world_apply", { operations: [operation] }, `gui_${operation.type}`); toast("已删除并读回"); addEvent("WORLD_APPLY", `${operation.type} 已提交`); state.selectedGraph = null; closeGraphPanel(); await loadStatus(true); await loadGraph(); }
755
+ catch (error) { reportFailure("WORLD_APPLY", error); }
756
+ finally { endGraphSave(state, deleteButton); await renderGraph(); }
757
+ }
758
+
759
+ async function callTool(name, input, effectIdentity) {
760
+ const body = { name, input, ...(effectIdentity ? { effect_identity: effectIdentity } : {}) };
761
+ return api("/tool", { method: "POST", body: JSON.stringify(body) });
762
+ }
763
+
764
+ function getTriggerStatusHost() {
765
+ const root = $("#trigger-blueprint-root");
766
+ if (triggerStatusHost?.parentElement !== root.parentElement) {
767
+ triggerStatusHost?.remove();
768
+ triggerStatusHost = document.createElement("div");
769
+ triggerStatusHost.className = "trigger-workspace-status";
770
+ triggerStatusHost.hidden = true;
771
+ root.insertAdjacentElement("afterend", triggerStatusHost);
772
+ }
773
+ return triggerStatusHost;
774
+ }
775
+
776
+ function renderTriggerStatus(status, error = null) {
777
+ const root = $("#trigger-blueprint-root");
778
+ const host = getTriggerStatusHost();
779
+ if (status === null) {
780
+ root.hidden = false;
781
+ host.hidden = true;
782
+ host.replaceChildren();
783
+ return;
784
+ }
785
+ root.hidden = true;
786
+ host.hidden = false;
787
+ renderWorkspaceStatus(host, {
788
+ state: status,
789
+ surface: "trigger",
790
+ ...(error === null ? {} : { error, onRetry: () => { triggerLoadFailure = null; void readTriggers(); }, retryLabel: "重新读取" }),
791
+ });
792
+ }
793
+
794
+ async function readTriggers() {
795
+ state.triggerLoadState = "loading";
796
+ state.triggerLoadError = null;
797
+ state.triggerBusy = true;
798
+ triggerLoadFailure = null;
799
+ renderTriggerStatus("loading");
800
+ await waitForTriggerBlueprintLayout();
801
+ try {
802
+ const [schema, current, layout, graphResult, spatialResult] = await Promise.all([
803
+ callTool("trigger_schema", {}),
804
+ callTool("trigger_read", { include_document: true }),
805
+ callTool("trigger_blueprint_layout_read", {}),
806
+ api("/graph"),
807
+ callTool("world_search", { query: "spatial:", mode: "keyword", limit: 1 }),
808
+ ]);
809
+ const readback = asObject(current);
810
+ const layoutObject = asObject(layout);
811
+ const positions = asObject(layoutObject.positions);
812
+ state.triggerSchema = asObject(schema);
813
+ state.triggerDocument = readback.document ?? current;
814
+ state.triggerDraftRevision = Number.isSafeInteger(readback.project_revision) ? readback.project_revision : null;
815
+ state.triggerBlueprintLayout = layoutObject;
816
+ state.triggerPositions = Object.fromEntries(Object.entries(positions).filter(([, point]) => Number.isFinite(point?.x) && Number.isFinite(point?.y)));
817
+ const triggerGraph = normalizeGraph(graphResult);
818
+ state.triggerEntities = triggerGraph.entities;
819
+ state.triggerSpatialWorld = asObject(spatialResult).spatial_world ?? triggerGraph.spatial_world;
820
+ state.triggerValidation = null;
821
+ state.triggerSimulation = null;
822
+ state.triggerDirty = false;
823
+ state.validatedTriggerText = null;
824
+ state.triggerLoadState = "ready";
825
+ addEvent("TRIGGER", "触发器文档与蓝图布局已读取");
826
+ }
827
+ catch (error) { state.triggerLoadState = "error"; state.triggerLoadError = errorMessage(error); triggerLoadFailure = error; reportFailure("TRIGGER", error); }
828
+ finally { state.triggerBusy = false; ensureTriggerLayoutWorker(); await renderTriggerBlueprint(); }
829
+ }
830
+
831
+ function parseEditor(selector, label) { try { return JSON.parse($(selector).value); } catch (error) { throw { code: "E_INVALID_JSON", message: `${label} 不是有效 JSON:${error.message}` }; } }
832
+
833
+ function localTriggerCapabilities() {
834
+ return asArray(asObject(state.triggerSchema).local_effect_capabilities)
835
+ .map((capability) => typeof capability === "string" ? capability : asObject(capability).capability_id)
836
+ .filter((capability) => typeof capability === "string");
837
+ }
838
+
839
+ function triggerSpatialRefChoices() {
840
+ const spatial = asObject(state.triggerSpatialWorld);
841
+ const worldId = typeof spatial.worldId === "string" ? spatial.worldId : "";
842
+ const sourceRevision = spatial.spatialRevision;
843
+ if (!worldId || !Number.isSafeInteger(sourceRevision)) return [];
844
+ const objectChoices = asArray(spatial.objects).flatMap((object) => {
845
+ const value = asObject(object);
846
+ if (typeof value.id !== "string" || value.id.length === 0) return [];
847
+ return [{
848
+ label: typeof value.label === "string" && value.label.length > 0 ? value.label : value.id,
849
+ ref: { worldId, sourceRevision, spatialId: value.id },
850
+ }];
851
+ });
852
+ const cityChoices = asArray(asObject(asObject(spatial.topology).procedural).cities).flatMap((city) => {
853
+ const value = asObject(city);
854
+ if (typeof value.id !== "string" || value.id.length === 0) return [];
855
+ return [{
856
+ label: typeof value.label === "string" && value.label.length > 0 ? value.label : value.id,
857
+ ref: { worldId, sourceRevision, spatialId: value.id },
858
+ }];
859
+ });
860
+ return [...objectChoices, ...cityChoices];
861
+ }
862
+
863
+ async function renderTriggerBlueprint() {
864
+ const version = ++triggerRenderVersion;
865
+ const root = $("#trigger-blueprint-root");
866
+ if (state.triggerLoadState === "loading") { renderTriggerStatus("loading"); return; }
867
+ if (state.triggerLoadState === "error") { renderTriggerStatus("error", triggerLoadFailure ?? state.triggerLoadError); return; }
868
+ let mountLocalTriggerBlueprint;
869
+ try {
870
+ triggerModule ??= import("/trigger.js");
871
+ ({ mountLocalTriggerBlueprint } = await triggerModule);
872
+ } catch (error) {
873
+ if (version !== triggerRenderVersion) return;
874
+ triggerModule = undefined;
875
+ state.triggerLoadState = "error";
876
+ state.triggerLoadError = errorMessage(error);
877
+ triggerLoadFailure = error;
878
+ renderTriggerStatus("error", error);
879
+ reportFailure("TRIGGER", error);
880
+ return;
881
+ }
882
+ if (version !== triggerRenderVersion) return;
883
+ renderTriggerStatus(null);
884
+ const props = {
885
+ schema: state.triggerSchema,
886
+ document: state.triggerDocument,
887
+ layoutPositions: state.triggerPositions,
888
+ blueprintLayout: state.triggerBlueprintLayout,
889
+ localEffectCapabilities: localTriggerCapabilities(),
890
+ entityChoices: state.triggerEntities.map((entity) => ({ id: String(entity.id), label: graphNodeLabel(entity) })),
891
+ spatialRefChoices: triggerSpatialRefChoices(),
892
+ canLocateSpatialRef: typeof worldMapController?.focusObject === "function",
893
+ validation: state.triggerValidation,
894
+ simulation: state.triggerSimulation,
895
+ loadState: state.triggerLoadState,
896
+ loadError: state.triggerLoadError,
897
+ busy: state.triggerBusy,
898
+ canSimulate: !state.triggerDirty,
899
+ onRead: readTriggers,
900
+ onDraftChange: (document) => { state.triggerDocument = document; state.triggerValidation = null; state.triggerSimulation = null; state.triggerDirty = true; state.validatedTriggerText = null; renderTriggerBlueprint(); },
901
+ onValidate: validateTrigger,
902
+ onSave: applyTrigger,
903
+ onSimulate: simulateTrigger,
904
+ onLayoutChange: (positions) => queueTriggerBlueprintLayout({ ...state.triggerBlueprintLayout, positions }),
905
+ onBlueprintLayoutChange: queueTriggerBlueprintLayout,
906
+ onOpenMap: () => switchView("world-map"),
907
+ onLocateSpatialRef: (spatialId) => {
908
+ if (typeof worldMapController?.focusObject !== "function") return;
909
+ switchView("world-map");
910
+ void worldMapController.open().then(() => worldMapController?.focusObject(spatialId));
911
+ },
912
+ };
913
+ if (triggerController) triggerController.update(props);
914
+ else { root.replaceChildren(); triggerController = mountLocalTriggerBlueprint(root, props); }
915
+ }
916
+
917
+ async function validateTrigger(candidate = state.triggerDocument) {
918
+ state.triggerBusy = true; await renderTriggerBlueprint();
919
+ try {
920
+ const result = asObject(await callTool("trigger_validate", { candidate_document: candidate }));
921
+ state.triggerValidation = result;
922
+ state.validatedTriggerText = result.ok === true ? formatJson(candidate) : null;
923
+ addEvent("TRIGGER_VALIDATE", result.ok === true ? "触发器验证通过" : "触发器验证失败", result.ok !== true);
924
+ toast(result.ok === true ? "验证通过" : "触发器验证失败", result.ok !== true);
925
+ } catch (error) { state.triggerValidation = { ok: false, issues: [errorMessage(error)] }; state.validatedTriggerText = null; reportFailure("TRIGGER_VALIDATE", error); }
926
+ finally { state.triggerBusy = false; ensureTriggerLayoutWorker(); await renderTriggerBlueprint(); }
927
+ }
928
+ async function applyTrigger(document = state.triggerDocument) {
929
+ state.triggerBusy = true; await renderTriggerBlueprint();
930
+ try {
931
+ await waitForTriggerBlueprintLayout();
932
+ if (state.triggerDraftRevision === null) throw { code: "E_READ_REQUIRED", message: "缺少触发器草稿 revision,请重新读取" };
933
+ if (state.validatedTriggerText !== formatJson(document)) throw { code: "E_VALIDATION_REQUIRED", message: "当前候选蓝图尚未验证,或验证后已修改" };
934
+ const result = asObject(await callTool("trigger_apply", { operations: [{ type: "replace_document", document }] }, { operation_id: operationId("gui_trigger_apply"), expected_project_revision: state.triggerDraftRevision }));
935
+ state.triggerDraftRevision = Number.isSafeInteger(result.next_project_revision) ? result.next_project_revision : state.triggerDraftRevision + 1;
936
+ state.triggerDirty = false;
937
+ state.triggerValidation = null;
938
+ state.validatedTriggerText = null;
939
+ addEvent("TRIGGER_APPLY", "触发器已保存并读回验证"); toast("触发器已保存"); await loadStatus(true);
940
+ } catch (error) { reportFailure("TRIGGER_APPLY", error); }
941
+ finally { state.triggerBusy = false; ensureTriggerLayoutWorker(); await renderTriggerBlueprint(); }
942
+ }
943
+
944
+ async function simulateTrigger(input) {
945
+ state.triggerBusy = true; await renderTriggerBlueprint();
946
+ try {
947
+ if (state.triggerDirty) throw { code: "E_SAVE_REQUIRED", message: "候选蓝图尚未保存,不能冒充项目中的真实触发器模拟" };
948
+ const result = asObject(await callTool("trigger_simulate", input));
949
+ state.triggerSimulation = result;
950
+ addEvent("TRIGGER_SIMULATE", result.ok === false ? "触发器模拟失败" : "触发器模拟完成", result.ok === false);
951
+ if (result.ok === false) toast("触发器模拟失败", true);
952
+ } catch (error) { state.triggerSimulation = { ok: false, issues: [errorMessage(error)] }; reportFailure("TRIGGER_SIMULATE", error); }
953
+ finally { state.triggerBusy = false; ensureTriggerLayoutWorker(); await renderTriggerBlueprint(); }
954
+ }
955
+
956
+ function queueTriggerBlueprintLayout(layout) {
957
+ state.triggerBlueprintLayout = asObject(layout);
958
+ state.triggerPositions = asObject(state.triggerBlueprintLayout.positions);
959
+ state.triggerLayoutPending = state.triggerBlueprintLayout;
960
+ renderTriggerBlueprint();
961
+ ensureTriggerLayoutWorker();
962
+ }
963
+
964
+ function ensureTriggerLayoutWorker() {
965
+ if (triggerLayoutWorker !== null || state.triggerLayoutPending === null || state.triggerBusy) return;
966
+ triggerLayoutWorker = flushTriggerBlueprintLayout().finally(() => { triggerLayoutWorker = null; ensureTriggerLayoutWorker(); });
967
+ }
968
+
969
+ async function waitForTriggerBlueprintLayout() {
970
+ while (triggerLayoutWorker !== null) await triggerLayoutWorker;
971
+ }
972
+
973
+ async function flushTriggerBlueprintLayout() {
974
+ while (state.triggerLayoutPending !== null) {
975
+ const layout = state.triggerLayoutPending;
976
+ state.triggerLayoutPending = null;
977
+ if (state.triggerDraftRevision === null) { reportFailure("TRIGGER_LAYOUT", { code: "E_READ_REQUIRED", message: "缺少触发器草稿 revision,请重新读取" }); return; }
978
+ state.triggerLayoutSaving = true;
979
+ try {
980
+ const result = asObject(await callTool("trigger_blueprint_layout_apply", { patch: layout }, { operation_id: operationId("gui_trigger_layout"), expected_project_revision: state.triggerDraftRevision }));
981
+ state.triggerDraftRevision = Number.isSafeInteger(result.next_project_revision) ? result.next_project_revision : state.triggerDraftRevision + 1;
982
+ addEvent("TRIGGER_LAYOUT", "触发器文件与蓝图布局已保存");
983
+ } catch (error) {
984
+ state.triggerLayoutPending = null;
985
+ reportFailure("TRIGGER_LAYOUT", error);
986
+ return;
987
+ } finally {
988
+ state.triggerLayoutSaving = false;
989
+ }
990
+ }
991
+ await loadStatus(true);
992
+ await renderTriggerBlueprint();
993
+ }
994
+
995
+ function normalizeFiles(result) { const root = asObject(result); return asArray(root.files || root.entries || result).map((entry) => typeof entry === "string" ? { path: entry } : entry).filter((entry) => typeof entry?.path === "string"); }
996
+ async function loadFiles(area) {
997
+ const root = $(`#${area}-files`); root.textContent = "正在读取…";
998
+ try { state.files[area] = normalizeFiles(await api(`/files?area=${encodeURIComponent(area)}`)); renderFiles(area); addEvent("FILES", `${area} 文件列表已刷新`); }
999
+ catch (error) { root.textContent = errorMessage(error); reportFailure("FILES", error); }
1000
+ }
1001
+ function renderFiles(area) { const root = $(`#${area}-files`); root.replaceChildren(); if (state.files[area].length === 0) { const p = document.createElement("p"); p.className = "empty-copy"; p.textContent = "没有可用文件。"; root.append(p); return; } state.files[area].forEach((file) => { const button = document.createElement("button"); button.className = "tree-file"; button.textContent = file.path; button.title = file.path; button.addEventListener("click", () => readFile(area, file.path, button)); root.append(button); }); }
1002
+ async function readFile(area, path, button) {
1003
+ $$(".tree-file", button.parentElement).forEach((item) => item.classList.toggle("active", item === button));
1004
+ try { const result = asObject(await api(`/file?area=${encodeURIComponent(area)}&path=${encodeURIComponent(path)}`)); const content = typeof result.content === "string" ? result.content : ""; const sha256 = result.sha256 || result.content_sha256 || result.expected_sha256 || ""; if (area === "view") { state.currentViewFile = { path, sha256 }; $("#view-file-name").textContent = path; $("#view-file-sha").textContent = sha256 || "摘要不可用"; $("#view-file-content").value = content; $("#view-file-content").disabled = false; $("#save-view-file").disabled = !sha256; } else { $("#doc-file-name").textContent = path; $("#doc-content").textContent = content; } addEvent("FILE_READ", path); }
1005
+ catch (error) { reportFailure("FILE_READ", error); }
1006
+ }
1007
+ async function saveViewFile() { if (!state.currentViewFile) return; const button = $("#save-view-file"); button.disabled = true; try { const result = asObject(await api("/file", { method: "POST", body: JSON.stringify({ area: "view", path: state.currentViewFile.path, content: $("#view-file-content").value, expected_sha256: state.currentViewFile.sha256 }) })); state.currentViewFile.sha256 = result.sha256 || result.content_sha256 || state.currentViewFile.sha256; $("#view-file-sha").textContent = state.currentViewFile.sha256; toast("View 文件已保存"); addEvent("FILE_WRITE", `${state.currentViewFile.path} 已 CAS 保存`); await loadStatus(true); } catch (error) { reportFailure("FILE_WRITE", error); } finally { button.disabled = !state.currentViewFile?.sha256; } }
1008
+
1009
+ function localPreviewBridge() {
1010
+ previewBridge ??= attachLocalPreviewBridge(globalThis, () => $("#preview-frame"), () => state.status, {
1011
+ host: {
1012
+ inspect: () => api("/preview/session"),
1013
+ request: (method, params, requestId) => api("/preview/session/command", { method: "POST", body: JSON.stringify({ method, params, ...(requestId === undefined ? {} : { request_id: requestId }) }) }),
1014
+ viewInteractionConfig: () => api("/preview/view-config"),
1015
+ emitViewInteraction: (event) => api("/preview/view-event", { method: "POST", body: JSON.stringify({ event }) }),
1016
+ recoverViewDeliveries: () => api("/preview/view-delivery-recovery"),
1017
+ recordViewDeliveryFailure: (operation_id) => api("/preview/view-delivery-failed", { method: "POST", body: JSON.stringify({ operation_id }) }),
1018
+ resolveViewDeliveryFailure: (operation_id) => api("/preview/view-delivery-resolved", { method: "POST", body: JSON.stringify({ operation_id }) }),
1019
+ wait: (revision, timeoutMs) => api(`/preview/session?after_revision=${encodeURIComponent(revision)}&timeout_ms=${encodeURIComponent(timeoutMs)}`),
1020
+ fetchAssetCapability: async (id) => {
1021
+ const response = await fetch(`${API}/preview/asset-capability?id=${encodeURIComponent(id)}`, { credentials: "same-origin" });
1022
+ if (!response.ok) throw { code: `E_HTTP_${response.status}`, message: "获取本地 Preview 资源能力失败" };
1023
+ return response;
1024
+ },
1025
+ revokeAssetCapabilities: async (tokens) => {
1026
+ await fetch(`${API}/preview/asset-capabilities/revoke`, { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify({ tokens }) });
1027
+ },
1028
+ },
1029
+ onLoad: () => { $("#preview-status").textContent = "作品已载入,正在初始化 ChatPlay bridge…"; },
1030
+ onReady: () => { $("#preview-status").textContent = "实时预览与 ChatPlay bridge 已就绪;保存源文件即可看到 HMR 更新。"; },
1031
+ onRequestSettings: async () => { await openModelSettings(); },
1032
+ });
1033
+ return previewBridge;
1034
+ }
1035
+
1036
+ function modelSettingsDialog() { return $("#model-settings-dialog"); }
1037
+ function renderModelSettings(result) {
1038
+ const config = asObject(result.config); const settings = asObject(config.settings); const catalog = asObject(result.account_catalog);
1039
+ const current = typeof settings.model === "string" ? settings.model : ""; const sha256 = typeof config.sha256 === "string" ? config.sha256 : "";
1040
+ const models = asArray(catalog.models).flatMap((entry) => { const item = asObject(entry); return typeof item.model === "string" ? [{ model: item.model, label: typeof item.label === "string" ? item.label : item.model }] : []; });
1041
+ const select = $("#model-settings-select"); select.replaceChildren();
1042
+ for (const item of models) { const option = document.createElement("option"); option.value = item.model; option.textContent = item.label === item.model ? item.model : `${item.label} · ${item.model}`; select.append(option); }
1043
+ if (!models.some((item) => item.model === current) && current) { const option = document.createElement("option"); option.value = current; option.textContent = `${current}(当前配置,已不在目录)`; select.prepend(option); }
1044
+ select.value = current; select.disabled = models.length === 0;
1045
+ $("#model-settings-current").textContent = current || "未配置"; $("#model-settings-sha").value = sha256;
1046
+ $("#model-settings-catalog-token").value = typeof catalog.catalog_token === "string" ? catalog.catalog_token : "";
1047
+ const state = typeof catalog.state === "string" ? catalog.state : "unavailable";
1048
+ $("#model-settings-note").textContent = models.length === 0
1049
+ ? state === "sign_in_required" ? "请先使用左下角“未登录”完成登录。"
1050
+ : "账户模型目录暂不可用,请稍后重试。"
1051
+ : "可保存的列表来自当前已登录账户。";
1052
+ $("#model-settings-save").disabled = models.length === 0 || !models.some((item) => item.model === select.value);
1053
+ select.onchange = () => { $("#model-settings-save").disabled = !models.some((item) => item.model === select.value); };
1054
+ }
1055
+ async function openModelSettings() {
1056
+ const dialog = modelSettingsDialog(); const epoch = ++modelSettingsRequestEpoch;
1057
+ if (!dialog || typeof dialog.showModal !== "function") throw { code: "E_CAPABILITY_UNAVAILABLE", message: "模型设置窗口不可用" };
1058
+ $("#model-settings-note").textContent = "正在读取当前模型设置与官方目录…"; $("#model-settings-save").disabled = true;
1059
+ if (!dialog.open) dialog.showModal();
1060
+ if (!dialog.open) throw { code: "E_CAPABILITY_UNAVAILABLE", message: "模型设置窗口未能显示" };
1061
+ try { const result = await api("/model-settings"); if (epoch === modelSettingsRequestEpoch) renderModelSettings(asObject(result)); }
1062
+ catch (error) { if (epoch === modelSettingsRequestEpoch) $("#model-settings-note").textContent = errorMessage(error); }
1063
+ }
1064
+ async function saveModelSettings() {
1065
+ const button = $("#model-settings-save"); button.disabled = true;
1066
+ try { await api("/model-settings", { method: "POST", body: JSON.stringify({ model: $("#model-settings-select").value, expected_sha256: $("#model-settings-sha").value, catalog_token: $("#model-settings-catalog-token").value }) }); toast("预览模型设置已保存"); await openModelSettings(); }
1067
+ catch (error) { $("#model-settings-note").textContent = errorMessage(error); }
1068
+ }
1069
+ function safePreviewUrl(value) {
1070
+ let parsed;
1071
+ try { parsed = new URL(value); }
1072
+ catch { throw { code: "E_PREVIEW_URL_INVALID", message: "Vite 返回的预览地址无效" }; }
1073
+ const port = Number(parsed.port);
1074
+ if (parsed.protocol !== "http:" || parsed.hostname !== "localhost" || !Number.isInteger(port) || port < 1 || port > 65535 || parsed.username || parsed.password || !/^\/_worldengine_preview\/[a-f0-9]{32}\/$/u.test(parsed.pathname) || parsed.search || parsed.hash) {
1075
+ throw { code: "E_PREVIEW_URL_INVALID", message: "Vite 预览地址不符合隔离 loopback 约束" };
1076
+ }
1077
+ return parsed.toString();
1078
+ }
1079
+ async function waitForPreviewReady(initial, requestId) {
1080
+ let session = initial;
1081
+ for (let attempt = 0; attempt < 60; attempt += 1) {
1082
+ if (requestId !== previewRequestId) return null;
1083
+ if (session.state === "running") return session;
1084
+ if (session.state === "failed" || session.state === "stopped") throw { code: "E_PREVIEW_START_FAILED", message: session.stderr || "Vite 预览未能启动" };
1085
+ await new Promise((resolve) => setTimeout(resolve, 250));
1086
+ session = asObject(await api("/preview", { method: "POST", body: JSON.stringify({ action: "status" }) }));
1087
+ }
1088
+ throw { code: "E_PREVIEW_START_TIMEOUT", message: "等待 Vite 启动超时,请重新连接" };
1089
+ }
1090
+ async function preview(action) {
1091
+ const requestId = ++previewRequestId;
1092
+ const target = $("#preview-status");
1093
+ const open = $("#preview-open");
1094
+ target.textContent = `${action === "start" ? "正在启动本地 Vite" : "正在停止预览"}…`;
1095
+ try {
1096
+ if (action === "stop") localPreviewBridge().clear();
1097
+ let result = asObject(await api("/preview", { method: "POST", body: JSON.stringify({ action }) }));
1098
+ if (action === "stop") {
1099
+ open.hidden = true;
1100
+ target.textContent = "预览已停止。";
1101
+ addEvent("PREVIEW", target.textContent);
1102
+ return;
1103
+ }
1104
+ result = await waitForPreviewReady(result, requestId);
1105
+ if (result === null) return;
1106
+ const value = typeof result.url === "string" ? result.url : typeof result.preview_url === "string" ? result.preview_url : "";
1107
+ if (!value) throw { code: "E_PREVIEW_URL_MISSING", message: "Vite 已启动,但未返回预览地址" };
1108
+ const url = safePreviewUrl(value);
1109
+ open.href = url;
1110
+ open.hidden = false;
1111
+ localPreviewBridge().load(url);
1112
+ target.textContent = "Vite 已就绪,正在载入隔离预览…";
1113
+ addEvent("PREVIEW", target.textContent);
1114
+ } catch (error) {
1115
+ localPreviewBridge().clear();
1116
+ reportFailure("PREVIEW", error, target);
1117
+ }
1118
+ }
1119
+
1120
+ function normalizeTools(result) { const root = asObject(result); return asArray(root.tools || result).map((tool) => ({ ...tool, input_schema: tool.input_schema || tool.inputSchema || {}, project_effect: tool.project_effect === true || tool.effect === true, available: tool.available !== false && tool.enabled !== false })); }
1121
+ async function loadTools() { const root = $("#tool-list"); root.textContent = "正在读取能力…"; try { state.tools = normalizeTools(await api("/tools")); renderTools(); addEvent("TOOLS", `已读取 ${state.tools.length} 项工具能力`); } catch (error) { root.textContent = errorMessage(error); reportFailure("TOOLS", error); } }
1122
+ function renderTools() { const root = $("#tool-list"); root.replaceChildren(); state.tools.forEach((tool) => { const button = document.createElement("button"); button.className = "tool-item"; button.disabled = !tool.available; const strong = document.createElement("strong"); strong.textContent = tool.name || "未命名工具"; const small = document.createElement("small"); small.textContent = tool.available ? `${tool.domain || "runtime"} · ${tool.project_effect ? "写项目" : "只读/运行时"}` : `不可用 · ${tool.unavailable_reason || tool.reason || "当前 runtime 未提供"}`; button.append(strong, small); button.addEventListener("click", () => selectTool(tool, button)); root.append(button); }); }
1123
+ function selectTool(tool, button) { state.selectedTool = tool; state.toolOperationId = tool.project_effect ? operationId(`gui_${tool.name}`) : ""; $$(".tool-item").forEach((item) => item.classList.toggle("active", item === button)); $("#tool-name").textContent = tool.name; $("#tool-description").textContent = tool.description || `${tool.domain || "runtime"} 域当前能力`; $("#tool-effect").textContent = tool.project_effect ? "WRITE" : "READ"; $("#tool-schema").textContent = formatJson(tool.input_schema); $("#tool-input").value = formatJson(defaultFromSchema(tool.input_schema)); $("#effect-fields").hidden = !tool.project_effect; $("#operation-id").value = state.toolOperationId; $("#expected-revision").value = $("#project-revision").textContent === "—" ? "0" : $("#project-revision").textContent; $("#run-tool").disabled = !tool.available; }
1124
+ function defaultFromSchema(schema) { const object = asObject(schema); if (object.type !== "object") return {}; const result = {}; asArray(object.required).forEach((key) => { const child = asObject(object.properties?.[key]); result[key] = child.type === "array" ? [] : child.type === "integer" ? child.minimum ?? 0 : child.type === "boolean" ? false : child.type === "object" ? {} : child.enum?.[0] ?? ""; }); return result; }
1125
+ async function runSelectedTool() { const tool = state.selectedTool, target = $("#tool-result"); if (!tool) return; try { const input = parseEditor("#tool-input", "工具输入"); const identity = tool.project_effect ? { operation_id: $("#operation-id").value.trim(), expected_project_revision: Number($("#expected-revision").value) } : undefined; const result = await callTool(tool.name, input, identity); target.textContent = formatJson(result); addEvent("TOOL", `${tool.name} 执行成功`); toast(`${tool.name} 执行成功`); if (tool.project_effect) { await loadStatus(true); state.toolOperationId = operationId(`gui_${tool.name}`); $("#operation-id").value = state.toolOperationId; } } catch (error) { reportFailure("TOOL", error, target); } }
1126
+
1127
+ async function createSnapshot() { const button = $("#snapshot-button"); button.disabled = true; try { const revision = Number($("#project-revision").textContent); const id = operationId("gui_snapshot"); const result = await api("/snapshot", { method: "POST", body: JSON.stringify({ operation_id: id, expected_project_revision: revision }) }); addEvent("SNAPSHOT", `快照已创建:${asObject(result).snapshot_id || id}`); toast("项目快照已创建"); await loadStatus(true); } catch (error) { reportFailure("SNAPSHOT", error); } finally { button.disabled = false; } }
1128
+
1129
+ function jobArgs(kind, args) {
1130
+ if (kind === "doctor") return ["doctor"];
1131
+ if (kind === "recall") return ["recall", String(args.query), String(args.limit)];
1132
+ if (kind === "ingest") return ["ingest", String(args.path), String(args.scope_id), String(args.source_id), String(args.source_revision)];
1133
+ throw { code: "E_COMMAND_NOT_ALLOWED", message: `未知任务类型:${kind}` };
1134
+ }
1135
+ function jobId(job) { return job.id || job.job_id; }
1136
+ function jobState(job) { return job.state || job.status || "unknown"; }
1137
+ function isTerminalJob(job) { return ["completed", "complete", "succeeded", "failed", "error", "cancelled"].includes(jobState(job)); }
1138
+ function upsertJob(job) { const id = jobId(job); const index = state.jobs.findIndex((item) => jobId(item) === id); if (index === -1) state.jobs.unshift(job); else state.jobs[index] = job; }
1139
+ async function createJob(kind, args) { try { const result = asObject(await api("/jobs", { method: "POST", body: JSON.stringify({ args: jobArgs(kind, args) }) })); const id = jobId(result); if (id) upsertJob(result); addEvent("JOB", `${kind} 已创建${id ? `:${id}` : ""}`); toast(`${kind} 任务已创建`); switchView("jobs"); renderJobs(); if (id) await readJob(id); } catch (error) { reportFailure("JOB", error); } }
1140
+ async function loadJobs() {
1141
+ if (state.jobs.length === 0) { renderJobs(); return; }
1142
+ const refreshed = await Promise.all(state.jobs.map(async (job) => {
1143
+ const id = jobId(job);
1144
+ if (!id) return job;
1145
+ try { return await api(`/jobs/${encodeURIComponent(id)}`); } catch { return job; }
1146
+ }));
1147
+ state.jobs = refreshed; renderJobs(); state.jobs.filter((job) => !isTerminalJob(job)).forEach((job) => pollJob(jobId(job)));
1148
+ }
1149
+ function renderJobs() { const root = $("#jobs-table"); root.replaceChildren(); if (state.jobs.length === 0) { const p = document.createElement("p"); p.className = "empty-copy"; p.textContent = "当前没有任务。"; root.append(p); return; } state.jobs.forEach((job) => { const id = jobId(job) || "—"; const row = document.createElement("div"); row.className = "job-row"; const code = document.createElement("code"); code.textContent = String(id); const kind = document.createElement("span"); kind.textContent = String(asArray(job.args)[0] || "任务"); const status = document.createElement("span"); status.className = `job-state ${jobState(job)}`; status.textContent = String(jobState(job)); row.append(code, kind, status, chevronIcon()); if (id !== "—") row.addEventListener("click", () => readJob(id)); root.append(row); }); }
1150
+ async function readJob(id) { const target = $("#job-result"); target.textContent = "正在读取任务结果…"; $("#job-result-caption").textContent = String(id); try { const result = asObject(await api(`/jobs/${encodeURIComponent(id)}`)); upsertJob(result); renderJobs(); target.textContent = formatJson(result); if (!isTerminalJob(result)) pollJob(id); } catch (error) { reportFailure("JOB_READ", error, target); } }
1151
+ function pollJob(id) {
1152
+ if (!id || state.pollingJobs.has(id)) return;
1153
+ state.pollingJobs.add(id); let remaining = 120;
1154
+ const tick = async () => {
1155
+ try {
1156
+ const job = asObject(await api(`/jobs/${encodeURIComponent(id)}`)); upsertJob(job); renderJobs();
1157
+ if (isTerminalJob(job)) { state.pollingJobs.delete(id); $("#job-result-caption").textContent = String(id); $("#job-result").textContent = formatJson(job); addEvent("JOB", `${asArray(job.args)[0] || "任务"} 已${jobState(job)}`); return; }
1158
+ } catch (error) { if (remaining <= 0) reportFailure("JOB_READ", error); }
1159
+ remaining -= 1;
1160
+ if (remaining > 0) setTimeout(tick, 1000); else { state.pollingJobs.delete(id); addEvent("JOB", `${id} 仍在运行,可稍后手动刷新`); }
1161
+ };
1162
+ setTimeout(tick, 1000);
1163
+ }
1164
+
1165
+ let accountRequestEpoch = 0;
1166
+ async function loadAuth() {
1167
+ const epoch = ++accountRequestEpoch;
1168
+ const form = $("#auth-start-form"), button = $("#auth-button"), logout = $("#logout-button");
1169
+ try {
1170
+ const auth = asObject(await api("/auth/status"));
1171
+ if (epoch !== accountRequestEpoch) return;
1172
+ const connected = auth.state === "signed_in" || auth.authenticated === true || auth.connected === true;
1173
+ const pending = auth.state === "challenge_pending";
1174
+ const available = auth.available !== false;
1175
+ form.hidden = connected;
1176
+ logout.hidden = !connected;
1177
+ button.disabled = !available || pending;
1178
+ button.textContent = !available ? "登录不可用" : pending ? "等待登录…" : "未登录";
1179
+ button.title = !available ? String(auth.reason || auth.unavailable_reason || "当前 NodeHost 未提供云端认证能力") : "";
1180
+ $("#account-details").hidden = !connected;
1181
+ $("#account-retry").hidden = true;
1182
+ $("#account-name").textContent = connected ? "正在读取用户名…" : "";
1183
+ $("#account-balance").textContent = connected ? "🪙 正在读取余额…" : "";
1184
+ if (connected) {
1185
+ const result = asObject(await api("/auth/account"));
1186
+ if (epoch !== accountRequestEpoch) return;
1187
+ if (result.state !== "signed_in") { await loadAuth(); return; }
1188
+ const account = asObject(result.account);
1189
+ $("#account-name").textContent = account.profile_state === "ready" ? String(account.display_name) : "用户名读取失败";
1190
+ // 与 canonical formatUnit(units, "spend") 一致:账本整数原样千分位,不换算人民币。
1191
+ $("#account-balance").textContent = account.wallet_state === "ready" && /^(?:0|[1-9]\d*)$/u.test(account.balance_units)
1192
+ ? `🪙 ${BigInt(account.balance_units).toLocaleString("en-US")}` : "🪙 余额读取失败";
1193
+ $("#account-retry").hidden = account.profile_state === "ready" && account.wallet_state === "ready";
1194
+ }
1195
+ } catch {
1196
+ if (epoch !== accountRequestEpoch) return;
1197
+ $("#account-name").textContent = "账户读取失败"; $("#account-balance").textContent = "🪙 余额不可用";
1198
+ $("#account-retry").hidden = false;
1199
+ if (logout.hidden) { form.hidden = false; button.disabled = true; button.textContent = "登录状态不可用"; }
1200
+ }
1201
+ }
1202
+ // LOCAL_GUI_AUTH_START_BEGIN
1203
+ let authStartPending = false;
1204
+ async function startAuth(event) {
1205
+ event.preventDefault();
1206
+ if (authStartPending) return;
1207
+ const button = $("#auth-button");
1208
+ if (button.disabled) return;
1209
+ authStartPending = true;
1210
+ button.disabled = true;
1211
+ button.textContent = "正在打开登录…";
1212
+ button.title = "";
1213
+ try {
1214
+ const started = asObject(await api("/auth/start", { method: "POST", headers: { accept: "application/json" }, body: "{}" }));
1215
+ const authorizationUrl = typeof started.authorization_url === "string" ? started.authorization_url : "";
1216
+ const authorization = new URL(authorizationUrl);
1217
+ const keys = [...authorization.searchParams.keys()];
1218
+ if (!["https://late-frost.com", "https://world-engines.org"].includes(authorization.origin)
1219
+ || authorization.pathname !== "/desktop-login" || authorization.username !== "" || authorization.password !== "" || authorization.hash !== ""
1220
+ || keys.length !== 2 || new Set(keys).size !== 2 || !authorization.searchParams.get("request_id") || !authorization.searchParams.get("state")) {
1221
+ throw { code: "E_INVALID_RESPONSE", message: "登录服务返回无效授权地址" };
1222
+ }
1223
+ window.location.assign(authorization.href);
1224
+ } catch (error) {
1225
+ authStartPending = false;
1226
+ button.disabled = false;
1227
+ button.textContent = "重试登录";
1228
+ button.title = errorMessage(error);
1229
+ reportFailure("AUTH", error);
1230
+ }
1231
+ }
1232
+ // LOCAL_GUI_AUTH_START_END
1233
+ async function logoutAuth() { accountRequestEpoch += 1; $("#account-details").hidden = true; $("#account-name").textContent = ""; $("#account-balance").textContent = ""; try { await api("/auth/logout", { method: "POST", body: "{}" }); addEvent("AUTH", "已退出云端连接"); await loadAuth(); } catch (error) { reportFailure("AUTH", error); await loadAuth(); } }
1234
+
1235
+ function renderVersion(result) {
1236
+ const incomingCurrent = typeof result.current === "string" ? result.current.trim() : "";
1237
+ if (incomingCurrent) lastKnownCurrentVersion = incomingCurrent;
1238
+ const current = lastKnownCurrentVersion || "未知";
1239
+ const latest = typeof result.latest === "string" && result.latest.trim() ? result.latest.trim() : "";
1240
+ const status = typeof result.status === "string" ? result.status : "unavailable";
1241
+ const updateAvailable = status === "update_available";
1242
+ const canUpdate = updateAvailable && result.can_update === true;
1243
+ const button = $("#version-update");
1244
+ $("#version-current").textContent = current;
1245
+ $("#version-update-note").hidden = !updateAvailable;
1246
+ button.classList.toggle("is-update-available", updateAvailable);
1247
+ button.disabled = !canUpdate;
1248
+ button.title = typeof result.message === "string" && result.message
1249
+ ? result.message
1250
+ : canUpdate ? `可更新至 ${latest}`
1251
+ : updateAvailable ? `发现新版本 ${latest || ""},但当前升级器不可用`
1252
+ : status === "up_to_date" ? "已是最新版本"
1253
+ : status === "checking" ? "正在检查最新版本"
1254
+ : status === "unpublished" ? "当前版本尚未发布" : "暂时无法检查更新";
1255
+ }
1256
+
1257
+ async function loadVersion() {
1258
+ const requestId = ++versionRequestId;
1259
+ for (let attempt = 0; attempt < 8; attempt += 1) {
1260
+ try {
1261
+ const result = asObject(await api("/version"));
1262
+ if (requestId !== versionRequestId) return;
1263
+ renderVersion(result);
1264
+ if (result.status !== "checking") return;
1265
+ } catch (error) {
1266
+ if (requestId !== versionRequestId) return;
1267
+ renderVersion({ status: "unavailable", message: errorMessage(error) });
1268
+ return;
1269
+ }
1270
+ await new Promise((resolve) => setTimeout(resolve, 750));
1271
+ }
1272
+ if (requestId === versionRequestId) renderVersion({ status: "unavailable", message: "版本检查暂未完成" });
1273
+ }
1274
+
1275
+ async function updateVersion() {
1276
+ const button = $("#version-update");
1277
+ if (button.disabled || !button.classList.contains("is-update-available")) return;
1278
+ button.disabled = true;
1279
+ button.title = "正在请求更新";
1280
+ try {
1281
+ await api("/update", { method: "POST", body: "{}" });
1282
+ await loadVersion();
1283
+ } catch (error) {
1284
+ renderVersion({ status: "error", message: errorMessage(error) });
1285
+ reportFailure("UPDATE", error);
1286
+ }
1287
+ }
1288
+
1289
+ function bindEvents() {
1290
+ $("#account-retry").addEventListener("click", loadAuth);
1291
+ $("#auth-start-form").addEventListener("submit", startAuth);
1292
+ window.addEventListener("focus", loadAuth);
1293
+ window.addEventListener("pageshow", (event) => { if (event.persisted) loadAuth(); });
1294
+ $$(".graph-close").forEach((button) => button.replaceChildren(graphCloseIcon()));
1295
+ $("#primary-nav").addEventListener("click", (event) => {
1296
+ const target = event.target instanceof Element ? event.target : null;
1297
+ const toggle = target?.closest(".nav-group-toggle");
1298
+ if (toggle) {
1299
+ const group = toggle.closest(".nav-group"); const subnav = group?.querySelector(".nav-subitems");
1300
+ if (subnav) { const expanded = toggle.getAttribute("aria-expanded") === "true"; toggle.setAttribute("aria-expanded", String(!expanded)); subnav.hidden = expanded; }
1301
+ return;
1302
+ }
1303
+ const template = target?.closest("[data-template-kind]");
1304
+ if (template) { const kind = template.dataset.templateKind; if (kind) { document.querySelectorAll("[data-template-kind]").forEach((button) => button.classList.toggle("active", button === template)); switchView("spatial-templates"); } return; }
1305
+ const button = target?.closest("[data-view]"); if (button) switchView(button.dataset.view);
1306
+ });
1307
+ $$('[data-view-jump]').forEach((button) => button.addEventListener("click", () => switchView(button.dataset.viewJump)));
1308
+ $("#refresh-button").addEventListener("click", () => Promise.allSettled([loadStatus(), loadAuth()])); $("#clear-events").addEventListener("click", () => { state.events = []; renderEvents(); });
1309
+ $("#snapshot-button").addEventListener("click", createSnapshot); $$('[data-job-kind]').forEach((button) => button.addEventListener("click", () => createJob(button.dataset.jobKind, {})));
1310
+ $("#reload-graph").addEventListener("click", loadGraph);
1311
+ $("#new-graph-node").addEventListener("click", () => openGraphEditor("entity")); $("#graph-editor").addEventListener("submit", submitGraphEditor); $("#delete-graph-item").addEventListener("click", deleteGraphItem); $("#save-graph-item").addEventListener("click", saveActiveGraphPanel);
1312
+ $$("[data-graph-panel-tab]").forEach((button) => button.addEventListener("click", () => { if (!$("#graph-editor").hidden) { try { captureGraphMetadataDraft(); } catch { return; } } state.graphPanelTab = button.dataset.graphPanelTab; renderGraphPanelSelection(); }));
1313
+ $("#graph-editor").elements.entity_type.addEventListener("change", updateGraphCustomKindField);
1314
+ $("#add-graph-alias").addEventListener("click", addGraphAlias); $("#graph-editor").elements.online_supplement.addEventListener("change", updateGraphOnlineSearchField);
1315
+ $$('[data-close-graph-panel]').forEach((button) => button.addEventListener("click", clearGraphSelection)); document.addEventListener("keydown", (event) => { if (event.key === "Escape" && !$("#graph-side-panel").hidden) clearGraphSelection(); });
1316
+ $$('[data-files-refresh]').forEach((button) => button.addEventListener("click", () => loadFiles(button.dataset.filesRefresh))); $("#save-view-file").addEventListener("click", saveViewFile); $("#preview-start").addEventListener("click", () => preview("start")); $("#preview-stop").addEventListener("click", () => preview("stop"));
1317
+ $("#model-settings-close").addEventListener("click", () => modelSettingsDialog().close()); $("#model-settings-cancel").addEventListener("click", () => modelSettingsDialog().close()); $("#model-settings-save").addEventListener("click", saveModelSettings);
1318
+ $("#reload-tools").addEventListener("click", loadTools); $("#run-tool").addEventListener("click", runSelectedTool); $("#reload-jobs").addEventListener("click", loadJobs);
1319
+ $("#recall-form").addEventListener("submit", (event) => { event.preventDefault(); const data = new FormData(event.currentTarget); createJob("recall", { query: data.get("query"), limit: Number(data.get("limit")) }); });
1320
+ $("#ingest-form").addEventListener("submit", (event) => { event.preventDefault(); const data = new FormData(event.currentTarget); createJob("ingest", { path: data.get("path"), scope_id: data.get("scope_id"), source_id: data.get("source_id"), source_revision: Number(data.get("source_revision")) }); });
1321
+ $("#logout-button").addEventListener("click", logoutAuth);
1322
+ $("#version-update").addEventListener("click", updateVersion);
1323
+ }
1324
+
1325
+ bindEvents();
1326
+ Promise.allSettled([loadStatus(true), loadAuth(), loadVersion()]);