@exccoder/dsh-media-workbench 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1110 @@
1
+ import { api, fileUrl } from '../lib/api.js'
2
+ import { $, $$, clamp, debounce, downloadJson, esc, fmtTime } from '../lib/dom.js'
3
+ import { icon } from '../lib/icons.js'
4
+ import { app, t } from '../lib/state.js'
5
+ import { NODE_TYPES } from './constellation-model.js'
6
+
7
+ const W = 252
8
+ const PORT_TOP = 62
9
+ const PORT_GAP = 26
10
+
11
+ export function mount(root) {
12
+ const graphs = () => app.state?.graphs ?? []
13
+ let graph = null
14
+ let selected = null
15
+ let selectedEdge = null
16
+ let transform = { x: 60, y: 40, k: 1 }
17
+ let dirty = false
18
+ let drag = null
19
+ let pan = null
20
+ let connect = null
21
+ let undoStack = []
22
+ let redoStack = []
23
+ let saveTimer = null
24
+ let mounted = true
25
+
26
+ root.innerHTML = `
27
+ <section class="bench bench-graph">
28
+ <aside class="graph-palette">
29
+ <div class="palette-head">
30
+ <span class="eyebrow">${esc(t('节点库', 'NODES'))}</span>
31
+ <p>${esc(t('点一下加到画布', 'Click to add'))}</p>
32
+ </div>
33
+ <div class="palette-list" id="palette">
34
+ ${Object.entries(NODE_TYPES).map(([type, def]) => `<button type="button" class="palette-item" data-add="${type}" style="--c:${def.color}">
35
+ <span class="palette-ic">${icon(def.icon, 17)}</span>
36
+ <span><b>${esc(def.label)}</b><small>${esc(def.hint)}</small></span>
37
+ <span class="palette-plus">${icon('plus', 13)}</span>
38
+ </button>`).join('')}
39
+ </div>
40
+ <div class="palette-foot">
41
+ <span class="field-label">${esc(t('从模板开始', 'START FROM'))}</span>
42
+ <div class="template-list" id="templates">
43
+ <button type="button" data-template="inspiration"><b>${esc(t('灵感成片', 'Spark to frame'))}</b><small>${esc(t('提示词 → 写作 → 画面', 'Prompt → writing → image'))}</small></button>
44
+ <button type="button" data-template="card"><b>${esc(t('图文卡片', 'Postcard'))}</b><small>${esc(t('短句 + 极简配图', 'Line + minimal image'))}</small></button>
45
+ <button type="button" data-template="video"><b>${esc(t('短片分镜', 'Short film'))}</b><small>${esc(t('旁白 → 首帧 → 视频', 'Voice → frame → video'))}</small></button>
46
+ </div>
47
+ <p class="palette-tip">${icon('info', 13)} ${esc(t('星图必须是 DAG,连线时自动检查类型和环路。', 'The graph must stay a DAG; connections check type and cycles.'))}</p>
48
+ </div>
49
+ </aside>
50
+
51
+ <section class="graph-main">
52
+ <header class="graph-toolbar">
53
+ <div class="graph-id">
54
+ <details class="menu graph-switch" id="graph-switch">
55
+ <summary class="icon-btn" title="${esc(t('切换或新建星图', 'Switch or create graph'))}">${icon('layers', 15)}</summary>
56
+ <div class="menu-panel menu-graphs" id="graph-menu"></div>
57
+ </details>
58
+ <input id="graph-name" class="graph-name" maxlength="40" aria-label="${esc(t('星图名称', 'Graph name'))}">
59
+ </div>
60
+ <div class="graph-actions">
61
+ <span class="graph-status" id="graph-status"></span>
62
+ <button class="btn btn-small btn-primary" id="run">${icon('play', 13)}${esc(t('运行', 'Run'))}</button>
63
+ <button class="btn btn-small btn-ghost" id="stop" hidden>${icon('stop', 13)}${esc(t('停止', 'Stop'))}</button>
64
+ <span class="tool-sep"></span>
65
+ <button class="icon-btn" id="undo" title="${esc(t('撤销', 'Undo'))}">${icon('undo', 15)}</button>
66
+ <button class="icon-btn" id="redo" title="${esc(t('重做', 'Redo'))}">${icon('redo', 15)}</button>
67
+ <button class="icon-btn inspector-toggle" id="toggle-inspector" title="${esc(t('节点参数', 'Node settings'))}">${icon('settings', 15)}</button>
68
+ <button class="icon-btn" id="fit" title="${esc(t('适应画布 (F)', 'Fit (F)'))}">${icon('fit', 15)}</button>
69
+ <details class="menu" id="more-menu">
70
+ <summary class="icon-btn" title="${esc(t('更多', 'More'))}">${icon('more', 16)}</summary>
71
+ <div class="menu-panel">
72
+ <button type="button" data-menu="new">${icon('plus', 14)}${esc(t('新建空白星图', 'New blank graph'))}</button>
73
+ <button type="button" data-menu="save-blueprint">${icon('layers', 14)}${esc(t('保存为蓝图', 'Save as blueprint'))}</button>
74
+ <span class="menu-label">${esc(t('我的蓝图', 'MY BLUEPRINTS'))}</span>
75
+ <div id="blueprint-list" class="menu-blueprints"></div>
76
+ <div class="menu-sep"></div>
77
+ <button type="button" data-menu="import">${icon('upload', 14)}${esc(t('导入 JSON', 'Import JSON'))}</button>
78
+ <button type="button" data-menu="export">${icon('download', 14)}${esc(t('导出 JSON', 'Export JSON'))}</button>
79
+ <div class="menu-sep"></div>
80
+ <button type="button" data-menu="delete" class="danger-quiet">${icon('trash', 14)}${esc(t('删除这张星图', 'Delete graph'))}</button>
81
+ </div>
82
+ </details>
83
+ </div>
84
+ </header>
85
+
86
+ <div class="graph-canvas" id="canvas">
87
+ <div class="world" id="world">
88
+ <svg class="edge-layer" id="edges" width="12000" height="12000" aria-hidden="true"></svg>
89
+ <div class="node-layer" id="nodes"></div>
90
+ </div>
91
+ <div class="canvas-empty" id="canvas-empty">
92
+ <div class="empty-art">${icon('star', 30)}</div>
93
+ <h3>${esc(t('把一句话,接成一条线', 'Wire a line into a flow'))}</h3>
94
+ <p>${esc(t('从左边加节点,或选一个模板。从节点右侧的圆点拖到另一个节点,就连起来了。', 'Add nodes from the left, or pick a template. Drag from a dot on the right edge to connect.'))}</p>
95
+ </div>
96
+ <div class="canvas-hints">
97
+ <span>${esc(t('滚轮缩放', 'Wheel to zoom'))}</span><span>${esc(t('拖动画布', 'Drag to pan'))}</span><span>${esc(t('双击加提示词', 'Double-click for a prompt'))}</span>
98
+ </div>
99
+ <div class="zoom-tools">
100
+ <button class="icon-btn" id="zoom-in" title="+">${icon('zoomIn', 15)}</button>
101
+ <span id="zoom-label">100%</span>
102
+ <button class="icon-btn" id="zoom-out" title="-">${icon('zoomOut', 15)}</button>
103
+ </div>
104
+ <input type="file" id="import-file" accept="application/json,.json" hidden>
105
+ </div>
106
+ </section>
107
+
108
+ <aside class="graph-inspector" id="inspector"></aside>
109
+ </section>
110
+ `
111
+
112
+ const canvas = $('#canvas')
113
+ const world = $('#world')
114
+ const nodesEl = $('#nodes')
115
+ const edgesEl = $('#edges')
116
+
117
+ // 选择初始星图
118
+ const savedId = sessionStorage.getItem('media-workbench-modern:graph')
119
+ graph = graphs().find(row => row.id === savedId) ?? graphs()[0] ?? null
120
+ if (graph) {
121
+ graph = clone(graph)
122
+ sessionStorage.setItem('media-workbench-modern:graph', graph.id)
123
+ }
124
+
125
+ function clone(value) {
126
+ return JSON.parse(JSON.stringify(value))
127
+ }
128
+
129
+ function activeGraph() {
130
+ return graph
131
+ }
132
+
133
+ function bound() {
134
+ return { width: canvas.clientWidth, height: canvas.clientHeight }
135
+ }
136
+
137
+ function applyTransform() {
138
+ world.style.transform = `translate(${transform.x}px, ${transform.y}px) scale(${transform.k})`
139
+ $('#zoom-label').textContent = `${Math.round(transform.k * 100)}%`
140
+ }
141
+
142
+ function screenToWorld(clientX, clientY) {
143
+ const rect = canvas.getBoundingClientRect()
144
+ return {
145
+ x: (clientX - rect.left - transform.x) / transform.k,
146
+ y: (clientY - rect.top - transform.y) / transform.k,
147
+ }
148
+ }
149
+
150
+ // ---------- 渲染 ----------
151
+ function nodeHtml(node) {
152
+ const def = NODE_TYPES[node.type]
153
+ const status = node.status ?? 'idle'
154
+ const isSelected = selected === node.id
155
+ const inputs = def.inputs.map((port, index) => `
156
+ <span class="port port-in" data-node="${node.id}" data-port="${port.id}" data-dir="in" style="top:${PORT_TOP + index * PORT_GAP}px" title="${esc(port.type)}">
157
+ <i></i><em>${esc(portLabel(port.type))}</em>
158
+ </span>`).join('')
159
+ const outputs = def.outputs.map((port, index) => `
160
+ <span class="port port-out" data-node="${node.id}" data-port="${port.id}" data-dir="out" style="top:${PORT_TOP + index * PORT_GAP}px" title="${esc(port.type)}">
161
+ <em>${esc(portLabel(port.type))}</em><i></i>
162
+ </span>`).join('')
163
+ return `<article class="gnode ${isSelected ? 'is-selected' : ''}" data-node-id="${node.id}" data-type="${node.type}" data-status="${status}" style="left:${node.x}px;top:${node.y}px;width:${W}px;--c:${def.color}">
164
+ <header class="gnode-head" data-drag="1">
165
+ <span class="gnode-ic">${icon(def.icon, 14)}</span>
166
+ <span class="gnode-title">${esc(def.label)}</span>
167
+ <span class="gnode-status" title="${esc(status)}"></span>
168
+ </header>
169
+ <div class="gnode-body">${bodyHtml(node)}</div>
170
+ <div class="gnode-foot"><span>${esc(footText(node))}</span>${node.output?.kind === 'text' ? `<button class="gnode-open" data-open-text="${node.id}">${esc(t('看全文', 'Open'))}</button>` : ''}</div>
171
+ ${inputs}${outputs}
172
+ </article>`
173
+ }
174
+
175
+ function portLabel(type) {
176
+ return { text: t('文本', 'text'), image: t('图像', 'image'), video: t('视频', 'video'), media: t('作品', 'media') }[type] ?? type
177
+ }
178
+
179
+ function bodyHtml(node) {
180
+ const config = node.config ?? {}
181
+ if (node.status === 'running') return `<div class="node-running"><i></i><span>${esc(t('正在运行…', 'Running…'))}</span></div>`
182
+ if (node.status === 'error') return `<p class="node-error">${esc(node.error || t('运行失败', 'Failed'))}</p>`
183
+ if (node.type === 'prompt' || node.type === 'note') {
184
+ return `<p class="node-text">${esc(config.text || (node.type === 'prompt' ? t('双击写一句提示…', 'Write a prompt…') : t('写点备注…', 'A note…')))}</p>`
185
+ }
186
+ if (node.type === 'write') {
187
+ const out = node.output?.text
188
+ return `<p class="node-text">${esc(out || config.instruction || '')}</p><div class="node-tags"><span>${esc(config.tone || '自然叙述')}</span><span>${esc(lengthLabel(config.length))}</span></div>`
189
+ }
190
+ if (node.type === 'image') {
191
+ if (node.output?.file) return `<div class="node-media"><img src="${fileUrl(node.output.file)}" alt="" loading="lazy"></div>`
192
+ return `<div class="node-placeholder">${icon('image', 18)}<span>${esc(config.style || '电影感')} · ${esc(config.ratio || '16:9')}</span></div>${config.prompt ? `<p class="node-text">${esc(config.prompt)}</p>` : ''}`
193
+ }
194
+ if (node.type === 'video') {
195
+ if (node.output?.file) return `<div class="node-media"><video src="${fileUrl(node.output.file)}" muted preload="metadata" playsinline></video><span class="media-badge">${icon('film', 12)}</span></div>`
196
+ if (node.output?.frames?.length) return `<div class="node-strip">${node.output.frames.slice(0, 4).map(file => `<img src="${fileUrl(file)}" alt="">`).join('')}</div>`
197
+ return `<div class="node-placeholder">${icon('film', 18)}<span>${esc(config.duration || 5)}s · ${esc(config.ratio || '16:9')} · ${esc(modeLabel(config.mode))}</span></div>`
198
+ }
199
+ if (node.type === 'preview') {
200
+ const out = node.output
201
+ if (out?.kind === 'image') return `<div class="node-media"><img src="${fileUrl(out.file)}" alt=""></div>`
202
+ if (out?.kind === 'video') {
203
+ if (out.file) return `<div class="node-media"><video src="${fileUrl(out.file)}" muted preload="metadata" playsinline controls></video></div>`
204
+ if (out.frames?.length) return `<div class="node-strip">${out.frames.slice(0, 4).map(file => `<img src="${fileUrl(file)}" alt="">`).join('')}</div>`
205
+ }
206
+ if (out?.kind === 'text') return `<p class="node-text">${esc(out.text)}</p>`
207
+ return `<div class="node-placeholder">${icon('eye', 18)}<span>${esc(t('运行后在这里看作品', 'Output appears here'))}</span></div>`
208
+ }
209
+ return ''
210
+ }
211
+
212
+ function lengthLabel(value) {
213
+ return { short: '短', medium: '中', long: '长' }[value] ?? '中'
214
+ }
215
+
216
+ function modeLabel(value) {
217
+ return { auto: t('自动', 'auto'), text: t('文生', 'text'), storyboard: t('分镜', 'storyboard'), image: t('图生', 'image') }[value] ?? t('自动', 'auto')
218
+ }
219
+
220
+ function footText(node) {
221
+ if (node.status === 'running') return t('进行中', 'Running')
222
+ if (node.status === 'error') return t('失败', 'Error')
223
+ if (node.output) return t('已完成', 'Done') + (node.output.kind === 'image' ? ' · image' : node.output.kind === 'video' ? ' · video' : '')
224
+ const def = NODE_TYPES[node.type]
225
+ return def?.hint ?? ''
226
+ }
227
+
228
+ function nodePortY(node, portId, dir) {
229
+ const def = NODE_TYPES[node.type]
230
+ const index = (dir === 'in' ? def.inputs : def.outputs).findIndex(port => port.id === portId)
231
+ return node.y + PORT_TOP + Math.max(0, index) * PORT_GAP
232
+ }
233
+
234
+ function edgePath(edge) {
235
+ const from = graph.nodes.find(node => node.id === edge.from.node)
236
+ const to = graph.nodes.find(node => node.id === edge.to.node)
237
+ if (!from || !to) return ''
238
+ const x1 = from.x + W
239
+ const y1 = nodePortY(from, edge.from.port, 'out')
240
+ const x2 = to.x
241
+ const y2 = nodePortY(to, edge.to.port, 'in')
242
+ const dx = Math.max(36, Math.abs(x2 - x1) * 0.45)
243
+ return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`
244
+ }
245
+
246
+ function renderEdges() {
247
+ if (!graph) { edgesEl.innerHTML = ''; return }
248
+ const defs = `<defs><marker id="edge-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path class="edge-arrow" d="M 0 0 L 10 5 L 0 10 z"></path></marker></defs>`
249
+ edgesEl.innerHTML = defs + graph.edges.map(edge => {
250
+ const path = edgePath(edge)
251
+ if (!path) return ''
252
+ return `<g class="edge ${selectedEdge === edge.id ? 'is-selected' : ''}" data-edge-id="${edge.id}">
253
+ <path class="edge-hit" d="${path}"></path>
254
+ <path class="edge-line" d="${path}" marker-end="url(#edge-arrow)"></path>
255
+ </g>`
256
+ }).join('') + (connect?.temp ? `<path class="edge-temp" d="${connect.temp}"></path>` : '')
257
+ }
258
+
259
+ function renderNodes() {
260
+ if (!graph) { nodesEl.innerHTML = ''; return }
261
+ nodesEl.innerHTML = graph.nodes.map(nodeHtml).join('')
262
+ renderEdges()
263
+ $('#canvas-empty').hidden = graph.nodes.length > 0
264
+ const nameEl = $('#graph-name')
265
+ if (nameEl && document.activeElement !== nameEl) nameEl.value = graph.name ?? ''
266
+ paintGraphSelect()
267
+ paintStatus()
268
+ renderInspector()
269
+ }
270
+
271
+ function paintGraphSelect() {
272
+ const menu = $('#graph-menu')
273
+ if (!menu) return
274
+ menu.innerHTML = graphs().map(row => `<button type="button" data-graph="${row.id}" class="${row.id === graph?.id ? 'is-current' : ''}">${icon(row.id === graph?.id ? 'check' : 'layers', 13)}<span>${esc(row.name)}</span></button>`).join('')
275
+ + `<div class="menu-sep"></div><button type="button" data-new-graph>${icon('plus', 13)}${esc(t('新建空白星图', 'New blank graph'))}</button>`
276
+ const blueprints = app.state?.blueprints ?? []
277
+ const list = $('#blueprint-list')
278
+ if (list) {
279
+ list.innerHTML = blueprints.length
280
+ ? blueprints.slice(0, 8).map(row => `<button type="button" data-blueprint="${row.id}">${icon('layers', 13)}<span>${esc(row.name || '蓝图')}</span></button>`).join('')
281
+ : `<p class="menu-empty">${esc(t('还没有保存过蓝图', 'No blueprints yet'))}</p>`
282
+ }
283
+ }
284
+
285
+ function paintStatus() {
286
+ const el = $('#graph-status')
287
+ if (!graph) { el.textContent = ''; return }
288
+ const run = graph.run
289
+ if (run?.status === 'running') {
290
+ el.className = 'graph-status is-running'
291
+ el.innerHTML = `<i></i>${esc(t('运行中', 'Running'))} ${run.wave ?? 1}/${run.waves ?? 1}`
292
+ } else if (run?.status === 'done') {
293
+ el.className = 'graph-status is-done'
294
+ el.innerHTML = `${icon('check', 12)}${esc(t('运行完成', 'Finished'))}`
295
+ } else if (run?.status === 'error') {
296
+ el.className = 'graph-status is-error'
297
+ el.innerHTML = `${icon('alert', 12)}${esc(run.error || t('有节点失败', 'Some nodes failed'))}`
298
+ } else if (run?.status === 'stopped') {
299
+ el.className = 'graph-status'
300
+ el.textContent = t('已停止', 'Stopped')
301
+ } else if (dirty) {
302
+ el.className = 'graph-status is-saving'
303
+ el.textContent = t('保存中…', 'Saving…')
304
+ } else {
305
+ el.className = 'graph-status'
306
+ el.textContent = ''
307
+ }
308
+ const running = run?.status === 'running'
309
+ $('#run').disabled = running
310
+ $('#stop').hidden = !running
311
+ $('#graph-name').disabled = running
312
+ }
313
+
314
+ // ---------- 检查器 ----------
315
+ function renderInspector() {
316
+ const host = $('#inspector')
317
+ if (selectedEdge) {
318
+ host.innerHTML = `<div class="inspect-head"><span class="eyebrow">${esc(t('连线', 'EDGE'))}</span><button class="icon-btn" data-inspect-close>${icon('x', 15)}</button></div>
319
+ <p class="inspect-help">${esc(t('这条线把上游的输出接到下游的输入。按 Delete 删除。', 'This edge feeds an upstream output into a downstream input. Press Delete to remove.'))}</p>
320
+ <button class="btn btn-small btn-ghost danger-quiet" data-delete-edge>${icon('trash', 13)}${esc(t('删除连线', 'Delete edge'))}</button>`
321
+ host.querySelector('[data-inspect-close]').addEventListener('click', () => {
322
+ selectedEdge = null
323
+ document.querySelector('.bench-graph')?.classList.remove('is-inspector-open')
324
+ renderNodes()
325
+ })
326
+ host.querySelector('[data-delete-edge]').addEventListener('click', () => deleteEdge(selectedEdge))
327
+ return
328
+ }
329
+ const node = graph?.nodes.find(item => item.id === selected)
330
+ if (!node) {
331
+ host.innerHTML = `<div class="inspect-empty"><span class="inspect-art">${icon('star', 24)}</span>
332
+ <h3>${esc(t('选中一个节点', 'Select a node'))}</h3>
333
+ <p>${esc(t('在画布上点节点,这里会出现参数;点空白处取消选择。', 'Click a node to edit its settings. Click empty space to deselect.'))}</p>
334
+ <div class="inspect-legend">
335
+ <span><i style="background:#d97757"></i>${esc(t('提示词', 'Prompt'))}</span>
336
+ <span><i style="background:#b4833e"></i>${esc(t('写作', 'Writing'))}</span>
337
+ <span><i style="background:#8a6fd1"></i>${esc(t('图像', 'Image'))}</span>
338
+ <span><i style="background:#3f8f92"></i>${esc(t('视频', 'Video'))}</span>
339
+ </div></div>`
340
+ return
341
+ }
342
+ const def = NODE_TYPES[node.type]
343
+ host.innerHTML = `<div class="inspect-head">
344
+ <div><span class="eyebrow">${esc(def.label)}</span><h3>${esc(t('节点参数', 'Node settings'))}</h3></div>
345
+ <button class="icon-btn" data-inspect-close>${icon('x', 15)}</button>
346
+ </div>
347
+ <div class="inspect-fields" id="inspect-fields">${fieldsHtml(node)}</div>
348
+ ${node.error ? `<p class="inspect-error">${esc(node.error)}</p>` : ''}
349
+ ${outputHtml(node)}
350
+ <div class="inspect-actions">
351
+ <button class="btn btn-small btn-primary" data-run-node>${icon('play', 13)}${esc(t('运行到这里', 'Run to here'))}</button>
352
+ <button class="btn btn-small btn-ghost" data-dup-node>${icon('duplicate', 13)}${esc(t('复制', 'Duplicate'))}</button>
353
+ <button class="btn btn-small btn-ghost danger-quiet" data-del-node>${icon('trash', 13)}</button>
354
+ </div>`
355
+ host.querySelector('[data-inspect-close]').addEventListener('click', () => {
356
+ selected = null
357
+ document.querySelector('.bench-graph')?.classList.remove('is-inspector-open')
358
+ renderNodes()
359
+ })
360
+ host.querySelector('[data-run-node]').addEventListener('click', () => run([node.id]))
361
+ host.querySelector('[data-dup-node]').addEventListener('click', () => duplicateNode(node.id))
362
+ host.querySelector('[data-del-node]').addEventListener('click', () => deleteNode(node.id))
363
+ bindFields(node)
364
+ const open = host.querySelector('[data-open-output]')
365
+ if (open) open.addEventListener('click', () => openOutput(node))
366
+ }
367
+
368
+ function fieldsHtml(node) {
369
+ const c = node.config ?? {}
370
+ if (node.type === 'prompt') {
371
+ return `<label class="field"><span class="field-label">${esc(t('提示词', 'Prompt'))}</span>
372
+ <textarea rows="5" data-field="text">${esc(c.text ?? '')}</textarea></label>
373
+ <p class="inspect-help">${esc(t('这是流程的起点。把一句话写清楚,下游的写作和画面都会读到它。', 'The start of the flow. One clear line feeds everything downstream.'))}</p>`
374
+ }
375
+ if (node.type === 'note') {
376
+ return `<label class="field"><span class="field-label">${esc(t('便签', 'Note'))}</span><textarea rows="4" data-field="text">${esc(c.text ?? '')}</textarea></label>`
377
+ }
378
+ if (node.type === 'write') {
379
+ return `<label class="field"><span class="field-label">${esc(t('写作指令', 'Instruction'))}</span>
380
+ <textarea rows="4" data-field="instruction">${esc(c.instruction ?? '')}</textarea></label>
381
+ <div class="field-row">
382
+ <label class="field"><span class="field-label">${esc(t('文风', 'Voice'))}</span>
383
+ <select class="select" data-field="tone">${['自然叙述', '克制冷静', '热烈', '幽默', '诗意', '硬朗'].map(v => `<option ${v === c.tone ? 'selected' : ''}>${v}</option>`).join('')}</select></label>
384
+ <label class="field"><span class="field-label">${esc(t('篇幅', 'Length'))}</span>
385
+ <select class="select" data-field="length">${[['short', '短 · 400 字'], ['medium', '中 · 900 字'], ['long', '长 · 1600 字']].map(([v, label]) => `<option value="${v}" ${v === c.length ? 'selected' : ''}>${label}</option>`).join('')}</select></label>
386
+ </div>
387
+ <p class="inspect-help">${esc(t('会读取所有接进来的文本,再按指令写。', 'Reads every incoming text connection, then writes to the instruction.'))}</p>`
388
+ }
389
+ if (node.type === 'image') {
390
+ return `<label class="field"><span class="field-label">${esc(t('补充画面描述', 'Extra prompt'))}</span>
391
+ <textarea rows="3" data-field="prompt" placeholder="${esc(t('可选:补充上游没有说到的细节', 'Optional details to add'))}">${esc(c.prompt ?? '')}</textarea></label>
392
+ <div class="field-row">
393
+ <label class="field"><span class="field-label">${esc(t('风格', 'Style'))}</span>
394
+ <select class="select" data-field="style">${['电影感', '插画', '水墨', '赛博朋克', '胶片', '3D', '极简', '浮世绘', '概念艺术'].map(v => `<option ${v === c.style ? 'selected' : ''}>${v}</option>`).join('')}</select></label>
395
+ <label class="field"><span class="field-label">${esc(t('画幅', 'Aspect'))}</span>
396
+ <select class="select" data-field="ratio">${['1:1', '4:3', '3:2', '16:9', '9:16', '2:3'].map(v => `<option ${v === c.ratio ? 'selected' : ''}>${v}</option>`).join('')}</select></label>
397
+ </div>
398
+ <p class="inspect-help">${esc(t('上游的文本会作为提示词,上游的图像会作为参考图。', 'Incoming text becomes the prompt; an incoming image becomes the reference.'))}</p>`
399
+ }
400
+ if (node.type === 'video') {
401
+ return `<label class="field"><span class="field-label">${esc(t('补充描述', 'Extra prompt'))}</span>
402
+ <textarea rows="3" data-field="prompt">${esc(c.prompt ?? '')}</textarea></label>
403
+ <div class="field-row">
404
+ <label class="field"><span class="field-label">${esc(t('时长', 'Seconds'))}</span>
405
+ <select class="select" data-field="duration">${[5, 8, 10, 15].map(v => `<option value="${v}" ${Number(c.duration) === v ? 'selected' : ''}>${v}s</option>`).join('')}</select></label>
406
+ <label class="field"><span class="field-label">${esc(t('画幅', 'Aspect'))}</span>
407
+ <select class="select" data-field="ratio">${['1:1', '4:3', '3:2', '16:9', '9:16', '2:3'].map(v => `<option ${v === c.ratio ? 'selected' : ''}>${v}</option>`).join('')}</select></label>
408
+ </div>
409
+ <label class="field"><span class="field-label">${esc(t('模式', 'Mode'))}</span>
410
+ <select class="select" data-field="mode">
411
+ <option value="auto" ${c.mode === 'auto' ? 'selected' : ''}>${esc(t('自动(有首帧就走图生)', 'Auto'))}</option>
412
+ <option value="text" ${c.mode === 'text' ? 'selected' : ''}>${esc(t('文生视频', 'Text to video'))}</option>
413
+ <option value="storyboard" ${c.mode === 'storyboard' ? 'selected' : ''}>${esc(t('分镜短片(用生图)', 'Storyboard (image provider)'))}</option>
414
+ </select></label>`
415
+ }
416
+ if (node.type === 'preview') {
417
+ return `<p class="inspect-help">${esc(t('把接进来的作品显示出来。可以同时接图像、视频和文本。', 'Shows whatever is connected: image, video or text.'))}</p>`
418
+ }
419
+ return ''
420
+ }
421
+
422
+ function outputHtml(node) {
423
+ if (!node.output) return ''
424
+ const out = node.output
425
+ if (out.kind === 'image') {
426
+ return `<div class="inspect-output"><span class="field-label">${esc(t('产出', 'Output'))}</span><img src="${fileUrl(out.file)}" alt=""><button class="text-btn" data-open-output>${esc(t('查看 / 下载', 'Open / download'))}</button></div>`
427
+ }
428
+ if (out.kind === 'video') {
429
+ return `<div class="inspect-output"><span class="field-label">${esc(t('产出', 'Output'))}</span>${out.file ? `<video src="${fileUrl(out.file)}" controls preload="metadata" playsinline></video>` : `<div class="node-strip">${(out.frames ?? []).map(file => `<img src="${fileUrl(file)}" alt="">`).join('')}</div>`}<button class="text-btn" data-open-output>${esc(t('查看 / 下载', 'Open / download'))}</button></div>`
430
+ }
431
+ if (out.kind === 'text') {
432
+ return `<div class="inspect-output"><span class="field-label">${esc(t('产出', 'Output'))}</span><p class="inspect-text">${esc(out.text)}</p></div>`
433
+ }
434
+ return ''
435
+ }
436
+
437
+ function bindFields(node) {
438
+ $('#inspect-fields')?.querySelectorAll('[data-field]').forEach(el => {
439
+ const field = el.dataset.field
440
+ const handler = () => {
441
+ if (!node.config) node.config = {}
442
+ node.config[field] = el.type === 'number' ? Number(el.value) : el.value
443
+ dirty = true
444
+ paintStatus()
445
+ scheduleSave()
446
+ // 只更新该节点的预览,避免输入框失焦
447
+ const nodeEl = nodesEl.querySelector(`[data-node-id="${node.id}"] .gnode-body`)
448
+ if (nodeEl) nodeEl.innerHTML = bodyHtml(node)
449
+ const foot = nodesEl.querySelector(`[data-node-id="${node.id}"] .gnode-foot span`)
450
+ if (foot) foot.textContent = footText(node)
451
+ }
452
+ el.addEventListener(el.tagName === 'SELECT' ? 'change' : 'input', handler)
453
+ })
454
+ }
455
+
456
+ function openOutput(node) {
457
+ const out = node.output
458
+ if (!out) return
459
+ if (out.kind === 'image') {
460
+ const layer = document.createElement('div')
461
+ layer.className = 'output-lightbox'
462
+ layer.innerHTML = `<img src="${fileUrl(out.file)}" alt=""><div class="output-bar"><span>${out.width ? `${out.width} × ${out.height}` : ''}</span><a class="btn btn-small btn-ghost" href="${fileUrl(out.file)}" download>${icon('download', 13)}${esc(t('下载', 'Download'))}</a></div>`
463
+ app.showLayer(layer, { className: 'layer-lightbox' })
464
+ } else if (out.kind === 'video') {
465
+ window.open(fileUrl(out.file), '_blank', 'noopener')
466
+ }
467
+ }
468
+
469
+ // ---------- 图操作 ----------
470
+ function addNode(type, x, y) {
471
+ if (!graph) return
472
+ const def = NODE_TYPES[type]
473
+ const node = {
474
+ id: `n_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`,
475
+ type,
476
+ x: Math.round(x),
477
+ y: Math.round(y),
478
+ config: { ...def.defaults },
479
+ status: 'idle',
480
+ output: null,
481
+ error: '',
482
+ }
483
+ pushHistory()
484
+ graph.nodes.push(node)
485
+ selected = node.id
486
+ selectedEdge = null
487
+ dirty = true
488
+ renderNodes()
489
+ scheduleSave()
490
+ }
491
+
492
+ function deleteNode(id) {
493
+ if (!graph) return
494
+ const node = graph.nodes.find(item => item.id === id)
495
+ if (!node) return
496
+ pushHistory()
497
+ graph.nodes = graph.nodes.filter(item => item.id !== id)
498
+ graph.edges = graph.edges.filter(edge => edge.from.node !== id && edge.to.node !== id)
499
+ if (selected === id) selected = null
500
+ dirty = true
501
+ renderNodes()
502
+ scheduleSave()
503
+ }
504
+
505
+ function duplicateNode(id) {
506
+ if (!graph) return
507
+ const source = graph.nodes.find(item => item.id === id)
508
+ if (!source) return
509
+ pushHistory()
510
+ const copy = clone(source)
511
+ copy.id = `n_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`
512
+ copy.x += 36
513
+ copy.y += 36
514
+ copy.status = 'idle'
515
+ copy.output = null
516
+ copy.error = ''
517
+ graph.nodes.push(copy)
518
+ selected = copy.id
519
+ dirty = true
520
+ renderNodes()
521
+ scheduleSave()
522
+ }
523
+
524
+ function deleteEdge(id) {
525
+ if (!graph) return
526
+ pushHistory()
527
+ graph.edges = graph.edges.filter(edge => edge.id !== id)
528
+ selectedEdge = null
529
+ dirty = true
530
+ renderNodes()
531
+ scheduleSave()
532
+ }
533
+
534
+ function connectPorts(from, to) {
535
+ if (!graph) return
536
+ const check = canConnectLocal(graph, from, to)
537
+ if (!check.ok) {
538
+ app.toast(check.reason, 'error')
539
+ return
540
+ }
541
+ pushHistory()
542
+ graph.edges.push({
543
+ id: `e_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`,
544
+ from,
545
+ to,
546
+ })
547
+ dirty = true
548
+ renderNodes()
549
+ scheduleSave()
550
+ }
551
+
552
+ function canConnectLocal(g, from, to) {
553
+ if (!from || !to) return { ok: false, reason: '连接不完整' }
554
+ if (from.node === to.node) return { ok: false, reason: t('不能连接自己', 'Cannot connect a node to itself') }
555
+ const source = g.nodes.find(n => n.id === from.node)
556
+ const target = g.nodes.find(n => n.id === to.node)
557
+ if (!source || !target) return { ok: false, reason: t('节点不存在', 'Node missing') }
558
+ const out = (NODE_TYPES[source.type].outputs ?? []).find(p => p.id === from.port)
559
+ const input = (NODE_TYPES[target.type].inputs ?? []).find(p => p.id === to.port)
560
+ if (!out || !input) return { ok: false, reason: t('端口不存在', 'Port missing') }
561
+ if (out.type !== input.type && input.type !== 'media') return { ok: false, reason: t('端口类型不匹配', 'Port types do not match') }
562
+ if (g.edges.some(e => e.from.node === from.node && e.from.port === from.port && e.to.node === to.node && e.to.port === to.port)) return { ok: false, reason: t('已经连过了', 'Already connected') }
563
+ if (!input.multi && g.edges.some(e => e.to.node === to.node && e.to.port === to.port)) return { ok: false, reason: t('这个输入只能接一条线', 'This input takes one connection') }
564
+ if (createsCycle(g, from.node, to.node)) return { ok: false, reason: t('会形成环路', 'This would create a cycle') }
565
+ return { ok: true }
566
+ }
567
+
568
+ function createsCycle(g, fromNode, toNode) {
569
+ const adj = new Map()
570
+ for (const edge of g.edges) {
571
+ if (!adj.has(edge.from.node)) adj.set(edge.from.node, [])
572
+ adj.get(edge.from.node).push(edge.to.node)
573
+ }
574
+ if (!adj.has(fromNode)) adj.set(fromNode, [])
575
+ adj.get(fromNode).push(toNode)
576
+ const seen = new Set()
577
+ const stack = [toNode]
578
+ while (stack.length) {
579
+ const cur = stack.pop()
580
+ if (cur === fromNode) return true
581
+ if (seen.has(cur)) continue
582
+ seen.add(cur)
583
+ for (const next of adj.get(cur) ?? []) stack.push(next)
584
+ }
585
+ return false
586
+ }
587
+
588
+ // ---------- 撤销 ----------
589
+ function snapshot() {
590
+ return JSON.stringify({ nodes: graph.nodes, edges: graph.edges })
591
+ }
592
+
593
+ function pushHistory() {
594
+ if (!graph) return
595
+ undoStack.push(snapshot())
596
+ if (undoStack.length > 60) undoStack.shift()
597
+ redoStack = []
598
+ paintHistoryButtons()
599
+ }
600
+
601
+ function restore(json) {
602
+ const data = JSON.parse(json)
603
+ graph.nodes = data.nodes
604
+ graph.edges = data.edges
605
+ selected = null
606
+ selectedEdge = null
607
+ dirty = true
608
+ renderNodes()
609
+ scheduleSave()
610
+ }
611
+
612
+ function undo() {
613
+ if (!undoStack.length) return
614
+ redoStack.push(snapshot())
615
+ restore(undoStack.pop())
616
+ paintHistoryButtons()
617
+ }
618
+
619
+ function redo() {
620
+ if (!redoStack.length) return
621
+ undoStack.push(snapshot())
622
+ restore(redoStack.pop())
623
+ paintHistoryButtons()
624
+ }
625
+
626
+ function paintHistoryButtons() {
627
+ $('#undo').disabled = !undoStack.length
628
+ $('#redo').disabled = !redoStack.length
629
+ }
630
+ paintHistoryButtons()
631
+
632
+ // ---------- 保存与切换 ----------
633
+ const scheduleSave = debounce(() => { void saveNow() }, 700)
634
+
635
+ async function saveNow() {
636
+ if (!graph) return
637
+ paintStatus()
638
+ try {
639
+ const payload = {
640
+ id: graph.id,
641
+ name: graph.name,
642
+ nodes: graph.nodes.map(node => ({ id: node.id, type: node.type, x: node.x, y: node.y, config: node.config })),
643
+ edges: graph.edges.map(edge => ({ id: edge.id, from: edge.from, to: edge.to })),
644
+ }
645
+ const data = await api('graph/save', { graph: payload })
646
+ graph.updatedAt = data.graph.updatedAt
647
+ const index = app.state.graphs.findIndex(row => row.id === graph.id)
648
+ if (index >= 0) app.state.graphs[index] = { ...app.state.graphs[index], ...data.graph, nodes: app.state.graphs[index].nodes }
649
+ dirty = false
650
+ paintStatus()
651
+ } catch (err) {
652
+ app.toast(err.message, 'error')
653
+ }
654
+ }
655
+
656
+ async function switchGraph(id, { force = false } = {}) {
657
+ if (!force && id === graph?.id) return
658
+ if (dirty) await saveNow()
659
+ const next = graphs().find(row => row.id === id)
660
+ if (!next) return
661
+ graph = clone(next)
662
+ selected = null
663
+ selectedEdge = null
664
+ undoStack = []
665
+ redoStack = []
666
+ paintHistoryButtons()
667
+ sessionStorage.setItem('media-workbench-modern:graph', graph.id)
668
+ fitView(false)
669
+ renderNodes()
670
+ updateRunTimer()
671
+ }
672
+
673
+ $('#graph-menu').addEventListener('click', event => {
674
+ const item = event.target.closest('[data-graph]')
675
+ if (item) {
676
+ $('#graph-switch').removeAttribute('open')
677
+ switchGraph(item.dataset.graph)
678
+ return
679
+ }
680
+ if (event.target.closest('[data-new-graph]')) {
681
+ $('#graph-switch').removeAttribute('open')
682
+ createGraph({})
683
+ }
684
+ })
685
+ $('#graph-name').addEventListener('input', () => {
686
+ if (!graph) return
687
+ graph.name = $('#graph-name').value.slice(0, 40) || '未命名星图'
688
+ dirty = true
689
+ paintStatus()
690
+ scheduleSave()
691
+ paintGraphSelect()
692
+ })
693
+
694
+ $('#palette').addEventListener('click', event => {
695
+ const btn = event.target.closest('[data-add]')
696
+ if (!btn) return
697
+ const rect = canvas.getBoundingClientRect()
698
+ const point = screenToWorld(rect.left + rect.width / 2, rect.top + rect.height / 2)
699
+ addNode(btn.dataset.add, point.x - W / 2 + (Math.random() * 60 - 30), point.y - 80 + (Math.random() * 60 - 30))
700
+ })
701
+
702
+ $('#templates').addEventListener('click', async event => {
703
+ const btn = event.target.closest('[data-template]')
704
+ if (!btn) return
705
+ await createGraph({ template: btn.dataset.template })
706
+ })
707
+
708
+ $('#more-menu').addEventListener('click', async event => {
709
+ const blueprint = event.target.closest('[data-blueprint]')
710
+ if (blueprint) {
711
+ $('#more-menu').removeAttribute('open')
712
+ try {
713
+ const data = await api('blueprint/use', { id: blueprint.dataset.blueprint })
714
+ await app.refresh()
715
+ await switchGraph(data.graph.id, { force: true })
716
+ app.toast(t('蓝图已展开成新的星图', 'Blueprint opened as a new graph'), 'success')
717
+ } catch (err) { app.toast(err.message, 'error') }
718
+ return
719
+ }
720
+ const btn = event.target.closest('[data-menu]')
721
+ if (!btn) return
722
+ $('#more-menu').removeAttribute('open')
723
+ const action = btn.dataset.menu
724
+ if (action === 'new') await createGraph({})
725
+ if (action === 'export') {
726
+ if (!graph) return
727
+ downloadJson({ schemaVersion: 1, name: graph.name, nodes: graph.nodes, edges: graph.edges }, `${graph.name || 'modern-media'}-star-map.json`)
728
+ }
729
+ if (action === 'import') $('#import-file').click()
730
+ if (action === 'save-blueprint') {
731
+ if (!graph) return
732
+ try {
733
+ await saveNow()
734
+ await api('blueprint/save', { graph: { name: graph.name, nodes: graph.nodes, edges: graph.edges } })
735
+ await app.refresh()
736
+ app.toast(t('已保存为蓝图,下次可以整张复用', 'Saved as a blueprint'), 'success')
737
+ } catch (err) { app.toast(err.message, 'error') }
738
+ }
739
+ if (action === 'delete') {
740
+ if (!graph) return
741
+ const ok = await app.confirm({ title: t('删除这张星图?', 'Delete this star map?'), message: t('画布和运行结果都会被移除。', 'The graph and its run results will be removed.'), confirmText: t('删除', 'Delete'), danger: true })
742
+ if (!ok) return
743
+ try {
744
+ await api('graph/action', { id: graph.id, action: 'delete' })
745
+ await app.refresh()
746
+ const next = graphs()[0] ?? null
747
+ if (next) { graph = clone(next); renderNodes(); fitView(false) } else { graph = null; renderNodes() }
748
+ } catch (err) { app.toast(err.message, 'error') }
749
+ }
750
+ })
751
+
752
+ $('#import-file').addEventListener('change', async () => {
753
+ const file = $('#import-file').files?.[0]
754
+ if (!file) return
755
+ try {
756
+ const text = await file.text()
757
+ const payload = JSON.parse(text)
758
+ const data = await api('graph/import', { payload })
759
+ await app.refresh()
760
+ await switchGraph(data.graph.id, { force: true })
761
+ app.toast(t('星图已导入', 'Graph imported'), 'success')
762
+ } catch (err) { app.toast(t('导入失败:', 'Import failed: ') + err.message, 'error') }
763
+ $('#import-file').value = ''
764
+ })
765
+
766
+ async function createGraph(options) {
767
+ try {
768
+ if (dirty) await saveNow()
769
+ const data = await api('graph/create', options)
770
+ await app.refresh()
771
+ await switchGraph(data.graph.id, { force: true })
772
+ app.toast(t('星图已创建', 'Graph created'), 'success')
773
+ } catch (err) { app.toast(err.message, 'error') }
774
+ }
775
+
776
+ // ---------- 运行 ----------
777
+ let runTimer = null
778
+
779
+ async function run(targets = []) {
780
+ if (!graph) return
781
+ if (!graph.nodes.length) {
782
+ app.toast(t('先加几个节点', 'Add some nodes first'), 'error')
783
+ return
784
+ }
785
+ try {
786
+ await api('graph/run', { id: graph.id, targetIds: targets })
787
+ graph.run = { status: 'running', wave: 0, waves: 1, targets }
788
+ for (const node of graph.nodes) if (!targets.length || targets.includes(node.id)) { node.status = 'idle'; node.output = null; node.error = '' }
789
+ selected = null
790
+ renderNodes()
791
+ paintStatus()
792
+ app.toast(targets.length ? t('正在运行到选中的节点', 'Running to the selected node') : t('星图开始运行', 'Star map started'), 'success')
793
+ updateRunTimer()
794
+ } catch (err) { app.toast(err.message, 'error') }
795
+ }
796
+
797
+ function updateRunTimer() {
798
+ if (runTimer) { clearInterval(runTimer); runTimer = null }
799
+ if (graph?.run?.status !== 'running') return
800
+ runTimer = setInterval(() => {
801
+ if (!mounted) return
802
+ if (document.hidden) return
803
+ app.refresh().catch(() => {})
804
+ }, 2000)
805
+ }
806
+
807
+ $('#run').addEventListener('click', () => run())
808
+ $('#stop').addEventListener('click', async () => {
809
+ try { await api('graph/stop', { id: graph.id }); app.toast(t('已停止', 'Stopped'), 'success') } catch (err) { app.toast(err.message, 'error') }
810
+ })
811
+ $('#undo').addEventListener('click', undo)
812
+ $('#redo').addEventListener('click', redo)
813
+ $('#fit').addEventListener('click', () => fitView(true))
814
+ $('#toggle-inspector').addEventListener('click', () => {
815
+ document.querySelector('.bench-graph')?.classList.toggle('is-inspector-open')
816
+ })
817
+ $('#zoom-in').addEventListener('click', () => zoomBy(1.2))
818
+ $('#zoom-out').addEventListener('click', () => zoomBy(1 / 1.2))
819
+
820
+ function zoomBy(factor) {
821
+ const rect = canvas.getBoundingClientRect()
822
+ zoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, factor)
823
+ }
824
+
825
+ function zoomAt(clientX, clientY, factor) {
826
+ const next = clamp(transform.k * factor, 0.35, 2)
827
+ const rect = canvas.getBoundingClientRect()
828
+ const px = clientX - rect.left
829
+ const py = clientY - rect.top
830
+ const wx = (px - transform.x) / transform.k
831
+ const wy = (py - transform.y) / transform.k
832
+ transform = { x: px - wx * next, y: py - wy * next, k: next }
833
+ applyTransform()
834
+ }
835
+
836
+ function fitView(animate = true) {
837
+ if (!graph || !graph.nodes.length) {
838
+ transform = { x: 60, y: 40, k: 1 }
839
+ applyTransform()
840
+ return
841
+ }
842
+ const minX = Math.min(...graph.nodes.map(n => n.x)) - 28
843
+ const minY = Math.min(...graph.nodes.map(n => n.y)) - 64
844
+ const maxX = Math.max(...graph.nodes.map(n => n.x + W)) + 28
845
+ const maxY = Math.max(...graph.nodes.map(n => n.y + 230)) + 36
846
+ const bw = maxX - minX
847
+ const bh = maxY - minY
848
+ const { width, height } = bound()
849
+ let k = Math.min((width - 44) / bw, (height - 80) / bh)
850
+ k = clamp(k, 0.42, 1.1)
851
+ // 小图别缩得太小;但必须先保证放得下,宁可小一点也不要裁掉两端
852
+ if (graph.nodes.length <= 4) {
853
+ const ceiling = Math.min((width - 24) / bw, (height - 40) / bh, 0.66)
854
+ k = Math.max(k, ceiling)
855
+ }
856
+ let x = (width - bw * k) / 2 - minX * k
857
+ const leftLimit = 8 - minX * k
858
+ const rightLimit = width - 8 - (minX + bw) * k
859
+ if (rightLimit >= leftLimit) x = Math.max(leftLimit, Math.min(x, rightLimit))
860
+ transform = { x, y: (height - bh * k) / 2 - minY * k, k }
861
+ if (animate) world.classList.add('is-animating')
862
+ applyTransform()
863
+ if (animate) setTimeout(() => world.classList.remove('is-animating'), 420)
864
+ }
865
+
866
+ // ---------- 画布交互 ----------
867
+ canvas.addEventListener('wheel', event => {
868
+ if (event.target.closest('.graph-inspector')) return
869
+ event.preventDefault()
870
+ const factor = Math.exp(-event.deltaY * 0.0012)
871
+ zoomAt(event.clientX, event.clientY, factor)
872
+ }, { passive: false })
873
+
874
+ canvas.addEventListener('dblclick', event => {
875
+ if (event.target.closest('.gnode')) return
876
+ const point = screenToWorld(event.clientX, event.clientY)
877
+ addNode('prompt', point.x - W / 2, point.y - 60)
878
+ })
879
+
880
+ canvas.addEventListener('pointerdown', event => {
881
+ const port = event.target.closest('.port-out')
882
+ if (port) {
883
+ event.preventDefault()
884
+ event.stopPropagation()
885
+ selected = port.dataset.node
886
+ selectedEdge = null
887
+ renderInspector()
888
+ connect = { from: { node: port.dataset.node, port: port.dataset.port }, temp: '', pointer: { x: event.clientX, y: event.clientY } }
889
+ world.classList.add('is-connecting')
890
+ canvas.setPointerCapture?.(event.pointerId)
891
+ return
892
+ }
893
+ const head = event.target.closest('.gnode-head')
894
+ if (head) {
895
+ const nodeEl = head.closest('.gnode')
896
+ const node = graph?.nodes.find(item => item.id === nodeEl?.dataset.nodeId)
897
+ if (!node) return
898
+ event.preventDefault()
899
+ selected = node.id
900
+ selectedEdge = null
901
+ nodesEl.querySelectorAll('.gnode').forEach(el => el.classList.toggle('is-selected', el.dataset.nodeId === node.id))
902
+ if (window.innerWidth <= 1200) document.querySelector('.bench-graph')?.classList.add('is-inspector-open')
903
+ renderInspector()
904
+ pushHistory()
905
+ const point = screenToWorld(event.clientX, event.clientY)
906
+ drag = { id: node.id, dx: point.x - node.x, dy: point.y - node.y, moved: false }
907
+ world.classList.add('is-dragging')
908
+ canvas.setPointerCapture?.(event.pointerId)
909
+ return
910
+ }
911
+ const nodeEl = event.target.closest('.gnode')
912
+ if (nodeEl) {
913
+ selected = nodeEl.dataset.nodeId
914
+ selectedEdge = null
915
+ if (window.innerWidth <= 1200) document.querySelector('.bench-graph')?.classList.add('is-inspector-open')
916
+ renderNodes()
917
+ return
918
+ }
919
+ const edge = event.target.closest('.edge')
920
+ if (edge) {
921
+ selectedEdge = edge.dataset.edgeId
922
+ selected = null
923
+ renderNodes()
924
+ return
925
+ }
926
+ // 空白:平移;顺带取消选择
927
+ if (event.button === 0) {
928
+ selected = null
929
+ selectedEdge = null
930
+ renderNodes()
931
+ }
932
+ pan = { x: event.clientX, y: event.clientY, tx: transform.x, ty: transform.y, moved: false }
933
+ world.classList.add('is-panning')
934
+ canvas.setPointerCapture?.(event.pointerId)
935
+ })
936
+
937
+ function onPointerMove(event) {
938
+ if (connect) {
939
+ const point = screenToWorld(event.clientX, event.clientY)
940
+ const from = graph.nodes.find(node => node.id === connect.from.node)
941
+ if (!from) return
942
+ const x1 = from.x + W
943
+ const y1 = nodePortY(from, connect.from.port, 'out')
944
+ const x2 = point.x
945
+ const y2 = point.y
946
+ const dx = Math.max(36, Math.abs(x2 - x1) * 0.45)
947
+ connect.temp = `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`
948
+ renderEdges()
949
+ return
950
+ }
951
+ if (drag) {
952
+ const point = screenToWorld(event.clientX, event.clientY)
953
+ const node = graph.nodes.find(item => item.id === drag.id)
954
+ if (!node) return
955
+ node.x = Math.max(-200, Math.round(point.x - drag.dx))
956
+ node.y = Math.max(-200, Math.round(point.y - drag.dy))
957
+ const el = nodesEl.querySelector(`[data-node-id="${node.id}"]`)
958
+ if (el) { el.style.left = `${node.x}px`; el.style.top = `${node.y}px` }
959
+ drag.moved = true
960
+ renderEdges()
961
+ return
962
+ }
963
+ if (pan) {
964
+ transform.x = pan.tx + (event.clientX - pan.x)
965
+ transform.y = pan.ty + (event.clientY - pan.y)
966
+ pan.moved = true
967
+ applyTransform()
968
+ }
969
+ }
970
+
971
+ function onPointerUp(event) {
972
+ if (connect) {
973
+ const target = document.elementFromPoint(event.clientX, event.clientY)?.closest('.port-in')
974
+ const from = connect.from
975
+ connect = null
976
+ world.classList.remove('is-connecting')
977
+ if (target && target.dataset.node) {
978
+ connectPorts(from, { node: target.dataset.node, port: target.dataset.port })
979
+ } else {
980
+ renderEdges()
981
+ }
982
+ return
983
+ }
984
+ if (drag) {
985
+ const moved = drag.moved
986
+ drag = null
987
+ world.classList.remove('is-dragging')
988
+ if (moved) {
989
+ dirty = true
990
+ scheduleSave()
991
+ paintStatus()
992
+ }
993
+ return
994
+ }
995
+ if (pan) {
996
+ const moved = pan.moved
997
+ pan = null
998
+ world.classList.remove('is-panning')
999
+ if (moved) {
1000
+ // 平移不写回文档
1001
+ }
1002
+ }
1003
+ }
1004
+
1005
+ window.addEventListener('pointermove', onPointerMove)
1006
+ window.addEventListener('pointerup', onPointerUp)
1007
+ window.addEventListener('pointercancel', onPointerUp)
1008
+
1009
+ canvas.addEventListener('click', event => {
1010
+ const openText = event.target.closest('[data-open-text]')
1011
+ if (openText) {
1012
+ const node = graph.nodes.find(item => item.id === openText.dataset.openText)
1013
+ const text = node?.output?.text ?? ''
1014
+ const layer = document.createElement('div')
1015
+ layer.className = 'text-layer'
1016
+ layer.innerHTML = `<div class="text-layer-head"><span class="eyebrow">${esc(t('节点全文', 'NODE TEXT'))}</span><button class="icon-btn" data-close>${icon('x', 15)}</button></div><pre>${esc(text)}</pre>`
1017
+ const close = app.showLayer(layer, { className: 'layer-text' })
1018
+ layer.querySelector('[data-close]').addEventListener('click', close)
1019
+ return
1020
+ }
1021
+ const nodeEl = event.target.closest('.gnode')
1022
+ if (nodeEl && selected !== nodeEl.dataset.nodeId) {
1023
+ selected = nodeEl.dataset.nodeId
1024
+ selectedEdge = null
1025
+ if (window.innerWidth <= 1200) document.querySelector('.bench-graph')?.classList.add('is-inspector-open')
1026
+ renderNodes()
1027
+ }
1028
+ })
1029
+
1030
+ function onKey(event) {
1031
+ const tag = event.target.tagName
1032
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
1033
+ if (event.key === 'Delete' || event.key === 'Backspace') {
1034
+ if (selected) { event.preventDefault(); deleteNode(selected) }
1035
+ else if (selectedEdge) { event.preventDefault(); deleteEdge(selectedEdge) }
1036
+ }
1037
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'z') {
1038
+ event.preventDefault()
1039
+ event.shiftKey ? redo() : undo()
1040
+ }
1041
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault(); run() }
1042
+ if (event.key.toLowerCase() === 'f' && !event.metaKey && !event.ctrlKey) { event.preventDefault(); fitView(true) }
1043
+ if (event.key === 'Escape') {
1044
+ if (connect) { connect = null; world.classList.remove('is-connecting'); renderEdges() }
1045
+ else { selected = null; selectedEdge = null; renderNodes() }
1046
+ }
1047
+ }
1048
+ window.addEventListener('keydown', onKey)
1049
+
1050
+ // ---------- 与全局状态同步 ----------
1051
+ function update() {
1052
+ if (!mounted) return
1053
+ const server = graphs().find(row => row.id === graph?.id)
1054
+ if (!server) return
1055
+ // 运行态同步:状态、产出、进度
1056
+ const runChanged = JSON.stringify(server.run ?? null) !== JSON.stringify(graph.run ?? null)
1057
+ let nodesChanged = false
1058
+ for (const node of graph.nodes) {
1059
+ const remote = server.nodes.find(item => item.id === node.id)
1060
+ if (!remote) continue
1061
+ if (node.status !== remote.status || (remote.output && JSON.stringify(node.output) !== JSON.stringify(remote.output)) || node.error !== remote.error) {
1062
+ node.status = remote.status
1063
+ node.output = remote.output ?? null
1064
+ node.error = remote.error ?? ''
1065
+ nodesChanged = true
1066
+ }
1067
+ }
1068
+ if (runChanged) { graph.run = server.run ?? null; paintStatus(); updateRunTimer() }
1069
+ const editingInspector = Boolean(document.activeElement?.closest?.('#inspector'))
1070
+ if (nodesChanged && !drag && !editingInspector) {
1071
+ const scroller = nodesEl.scrollTop
1072
+ renderNodes()
1073
+ nodesEl.scrollTop = scroller
1074
+ } else if (runChanged && !editingInspector) {
1075
+ renderNodes()
1076
+ }
1077
+ if (!dirty) paintGraphSelect()
1078
+ if (server.updatedAt && !dirty && server.updatedAt !== graph.updatedAt) {
1079
+ // 其他窗口改过这张图:只在当前没有本地修改时接管
1080
+ if (JSON.stringify(server.nodes.map(n => n.id)) === JSON.stringify(graph.nodes.map(n => n.id))) {
1081
+ graph.updatedAt = server.updatedAt
1082
+ }
1083
+ }
1084
+ }
1085
+
1086
+ // ---------- 初始化 ----------
1087
+ if (!graph) {
1088
+ $('#canvas-empty').hidden = false
1089
+ $('#inspector').innerHTML = `<div class="inspect-empty"><p>${esc(t('还没有星图', 'No star map yet'))}</p><button class="btn btn-small btn-primary" id="first-graph">${esc(t('新建一张', 'Create one'))}</button></div>`
1090
+ $('#first-graph')?.addEventListener('click', () => createGraph({}))
1091
+ } else {
1092
+ fitView(false)
1093
+ renderNodes()
1094
+ updateRunTimer()
1095
+ }
1096
+ applyTransform()
1097
+
1098
+ return {
1099
+ update,
1100
+ unmount() {
1101
+ mounted = false
1102
+ if (runTimer) clearInterval(runTimer)
1103
+ window.removeEventListener('pointermove', onPointerMove)
1104
+ window.removeEventListener('pointerup', onPointerUp)
1105
+ window.removeEventListener('pointercancel', onPointerUp)
1106
+ window.removeEventListener('keydown', onKey)
1107
+ if (dirty) saveNow().catch(() => {})
1108
+ },
1109
+ }
1110
+ }