@deepblend/dsh-blender-ui 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +1241 -725
- package/lib/index.js +245 -0
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
* Plane: Host composition (the Web Client half is `./client.js`)
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
+
import { homedir } from 'node:os'
|
|
27
|
+
|
|
26
28
|
import { Service } from '@deepseek-ai/cordis'
|
|
27
29
|
import z from '@deepseek-ai/schemastery'
|
|
28
30
|
|
|
@@ -39,6 +41,9 @@ import {
|
|
|
39
41
|
buildRevisionDiff,
|
|
40
42
|
buildSceneTree,
|
|
41
43
|
buildSettingsCard,
|
|
44
|
+
buildDiagnosticsBundle,
|
|
45
|
+
DIAGNOSTICS_JOB_LIMIT,
|
|
46
|
+
DIAGNOSTICS_FAILURE_LIMIT,
|
|
42
47
|
matchUiRoute,
|
|
43
48
|
assertKnownConfigKeys,
|
|
44
49
|
} from '@deepblend/dsh-blender-contracts'
|
|
@@ -46,6 +51,95 @@ import {
|
|
|
46
51
|
/** Route path serving the settings card document (M0's surface, kept verbatim). */
|
|
47
52
|
export const CAPABILITIES_ROUTE = '/deepblend/capabilities'
|
|
48
53
|
|
|
54
|
+
/** Route path serving the standalone fullscreen workbench document (SPEC §20 M6). */
|
|
55
|
+
export const WORKBENCH_PAGE_ROUTE = '/deepblend/workbench'
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The element the standalone page reserves for the workbench.
|
|
59
|
+
*
|
|
60
|
+
* Named once, and read by the page's own bootstrap below and by
|
|
61
|
+
* `e2e/workbench-page.e2e.mjs`, so "the page mounted something" is answered by
|
|
62
|
+
* the same string on both sides.
|
|
63
|
+
*/
|
|
64
|
+
export const WORKBENCH_PAGE_ROOT_ID = 'deepblend-workbench'
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The bootstrap the standalone page runs.
|
|
68
|
+
*
|
|
69
|
+
* Twelve lines, and every one of them is about ARRIVING at the bundle rather
|
|
70
|
+
* than about the workbench:
|
|
71
|
+
*
|
|
72
|
+
* 1. `__DSH_BOOT_READY__` — the tail injection, resolved once the boot rows
|
|
73
|
+
* have run. Awaiting it is what makes this script safe wherever the
|
|
74
|
+
* injection table happens to be spliced.
|
|
75
|
+
* 2. `create({ boot: __DSH_BOOT__, staticModules: {} })` — the SAME module
|
|
76
|
+
* system the console builds, over the SAME graph global. The seed is empty
|
|
77
|
+
* because this page supplies no platform modules: React is a console seed
|
|
78
|
+
* word, the workbench core does not use it, and `client.js` reads it lazily
|
|
79
|
+
* precisely so that this call can pass `{}`.
|
|
80
|
+
* 3. `import('@deepblend/dsh-blender-ui')` — the console's own graph row,
|
|
81
|
+
* fetched from the console's own bundle route. There is no second copy of
|
|
82
|
+
* the workbench anywhere for this to find.
|
|
83
|
+
* 4. `mountStandalone(root)` — the one function that differs between the faces.
|
|
84
|
+
*
|
|
85
|
+
* A failure is reported INTO the page rather than only to the console, because a
|
|
86
|
+
* blank full-screen page is indistinguishable from a page that never booted.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} rootId
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
export function workbenchPageBootstrap(rootId) {
|
|
92
|
+
return `<script>(async () => {
|
|
93
|
+
const root = document.getElementById(${JSON.stringify(rootId)})
|
|
94
|
+
try {
|
|
95
|
+
await globalThis.__DSH_BOOT_READY__?.promise
|
|
96
|
+
const modules = window.__ModuleLoader__.create({ boot: window.__DSH_BOOT__, staticModules: {} })
|
|
97
|
+
const bundle = await modules.import('@deepblend/dsh-blender-ui')
|
|
98
|
+
await bundle.mountStandalone(root)
|
|
99
|
+
root.dataset.deepblendStandalone = 'ready'
|
|
100
|
+
} catch (error) {
|
|
101
|
+
root.dataset.deepblendStandalone = 'failed'
|
|
102
|
+
root.textContent = 'DeepBlend 工作台未能启动:' + String((error && error.message) || error)
|
|
103
|
+
}
|
|
104
|
+
})()</script>`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The standalone workbench document.
|
|
109
|
+
*
|
|
110
|
+
* Deliberately minimal, and deliberately NOT the console: no shell bundle, no
|
|
111
|
+
* sidebar, no conversation — one root element and the bootstrap. Everything the
|
|
112
|
+
* page shows arrives from the bundle the bootstrap imports and from the Host
|
|
113
|
+
* routes that bundle reads.
|
|
114
|
+
*
|
|
115
|
+
* `<head>` and `<body>` are present because `webServer.renderIndex` splices the
|
|
116
|
+
* boot injections into them; without the tags the rows would be prepended to the
|
|
117
|
+
* document instead, and the bootstrap script would run before the module loader
|
|
118
|
+
* existed.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} rootId
|
|
121
|
+
* @param {{ title?: string }} [options]
|
|
122
|
+
* @returns {string}
|
|
123
|
+
*/
|
|
124
|
+
export function workbenchPageDocument(rootId, options = {}) {
|
|
125
|
+
const title = options.title ?? 'DeepBlend 工作台'
|
|
126
|
+
return [
|
|
127
|
+
'<!doctype html>',
|
|
128
|
+
'<html lang="zh">',
|
|
129
|
+
'<head>',
|
|
130
|
+
'<meta charset="utf-8">',
|
|
131
|
+
'<meta name="viewport" content="width=device-width,initial-scale=1">',
|
|
132
|
+
`<title>${title}</title>`,
|
|
133
|
+
'</head>',
|
|
134
|
+
`<body style="margin:0;height:100vh">`,
|
|
135
|
+
`<div id="${rootId}" data-deepblend-standalone="loading" style="height:100vh"></div>`,
|
|
136
|
+
workbenchPageBootstrap(rootId),
|
|
137
|
+
'</body>',
|
|
138
|
+
'</html>',
|
|
139
|
+
'',
|
|
140
|
+
].join('\n')
|
|
141
|
+
}
|
|
142
|
+
|
|
49
143
|
/** Service key for the UI-facing host controller. */
|
|
50
144
|
export const BLENDER_UI_SERVICE = 'blenderUi'
|
|
51
145
|
|
|
@@ -96,6 +190,12 @@ export default class BlenderUiHost extends Service {
|
|
|
96
190
|
assertKnownConfigKeys(UiConfig, config, 'deepblend-blender-ui')
|
|
97
191
|
this.config = config
|
|
98
192
|
this._routeDisposer = null
|
|
193
|
+
// Kept for ONE reason: the standalone page document has to be rendered
|
|
194
|
+
// through `webServer.renderIndex`, which is what splices the boot injections
|
|
195
|
+
// (`window.__ModuleLoader__`, `window.__DSH_BOOT__`, the bootstrap batch) into
|
|
196
|
+
// a body. That is the whole of this row's dependence on the server object —
|
|
197
|
+
// no file, no process, no path.
|
|
198
|
+
this._webServer = null
|
|
99
199
|
|
|
100
200
|
// One handler per route id, and no other way in. A test asserts the key set
|
|
101
201
|
// equals UI_ROUTES exactly, in both directions.
|
|
@@ -104,6 +204,7 @@ export default class BlenderUiHost extends Service {
|
|
|
104
204
|
if (!config.serveRoute) return
|
|
105
205
|
|
|
106
206
|
ctx.inject(['webServer'], (serverCtx) => {
|
|
207
|
+
this._webServer = serverCtx.webServer
|
|
107
208
|
// Route registration belongs to this fiber, so unmounting the row removes
|
|
108
209
|
// the route (SPEC §4.4 / lifecycle: every side effect is reversible).
|
|
109
210
|
const dispose = serverCtx.webServer.register({
|
|
@@ -114,6 +215,7 @@ export default class BlenderUiHost extends Service {
|
|
|
114
215
|
this._routeDisposer = dispose
|
|
115
216
|
return () => {
|
|
116
217
|
this._routeDisposer = null
|
|
218
|
+
this._webServer = null
|
|
117
219
|
dispose()
|
|
118
220
|
}
|
|
119
221
|
})
|
|
@@ -156,6 +258,13 @@ export default class BlenderUiHost extends Service {
|
|
|
156
258
|
await this._sendArtifact(request, response, matched.params)
|
|
157
259
|
return
|
|
158
260
|
}
|
|
261
|
+
// The second response that is not JSON, and the only one that is a
|
|
262
|
+
// document: the standalone workbench. It reads nothing from the request —
|
|
263
|
+
// no query, no body, no parameter — so there is no input here to validate.
|
|
264
|
+
if (matched.route.id === 'workbench.page') {
|
|
265
|
+
this._sendWorkbenchPage(request, response)
|
|
266
|
+
return
|
|
267
|
+
}
|
|
159
268
|
const body = await readRequestBody(request)
|
|
160
269
|
const result = await this._handlers[matched.route.id]({
|
|
161
270
|
params: matched.params,
|
|
@@ -201,6 +310,46 @@ export default class BlenderUiHost extends Service {
|
|
|
201
310
|
response.end(request.method === 'HEAD' ? undefined : artifact.bytes)
|
|
202
311
|
}
|
|
203
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Serve the standalone fullscreen workbench document (SPEC §20 M6).
|
|
315
|
+
*
|
|
316
|
+
* This is the only route that answers with HTML, and the only place in this
|
|
317
|
+
* package that asks the web server for anything other than a route seat. It
|
|
318
|
+
* reads no request input, touches no project and no path, and derives nothing
|
|
319
|
+
* from the caller: the document is a constant plus whatever the composition's
|
|
320
|
+
* own index injections add.
|
|
321
|
+
*
|
|
322
|
+
* `renderIndex` is what makes the page a real client surface rather than a
|
|
323
|
+
* static one — it splices in the boot rows (`window.__ModuleLoader__`, the
|
|
324
|
+
* `@deepseek-ai/dsh-client-modules` bootstrap, `window.__DSH_BOOT__`) that the
|
|
325
|
+
* console's own index.html gets, so the page can import this package's client
|
|
326
|
+
* bundle out of the graph instead of being handed a copy of it.
|
|
327
|
+
*
|
|
328
|
+
* When there is no web server there is no route either (the row only registers
|
|
329
|
+
* one inside `ctx.inject(['webServer'])`), so the fallback below is unreachable
|
|
330
|
+
* in a composed deployment; it exists so that driving `_handle` against a stub
|
|
331
|
+
* server in a test cannot throw on a missing method.
|
|
332
|
+
*
|
|
333
|
+
* @param {import('node:http').IncomingMessage} request
|
|
334
|
+
* @param {import('node:http').ServerResponse} response
|
|
335
|
+
*/
|
|
336
|
+
_sendWorkbenchPage(request, response) {
|
|
337
|
+
const document = workbenchPageDocument(WORKBENCH_PAGE_ROOT_ID)
|
|
338
|
+
const server = this._webServer
|
|
339
|
+
const html = server !== null && typeof server.renderIndex === 'function'
|
|
340
|
+
? server.renderIndex(document)
|
|
341
|
+
: document
|
|
342
|
+
const bytes = Buffer.from(html, 'utf8')
|
|
343
|
+
response.setHeader('content-type', 'text/html; charset=utf-8')
|
|
344
|
+
// Never cached, for the same reason the JSON routes are not: this document
|
|
345
|
+
// names the bundle revision it will load, and a cached copy would pin a page
|
|
346
|
+
// to a revision the process no longer serves.
|
|
347
|
+
response.setHeader('cache-control', 'no-store')
|
|
348
|
+
response.setHeader('content-length', String(bytes.length))
|
|
349
|
+
response.statusCode = 200
|
|
350
|
+
response.end(request.method === 'HEAD' ? undefined : bytes)
|
|
351
|
+
}
|
|
352
|
+
|
|
204
353
|
/**
|
|
205
354
|
* @param {import('node:http').ServerResponse} response
|
|
206
355
|
* @param {number} status
|
|
@@ -332,6 +481,102 @@ export function createHandlers(ctx) {
|
|
|
332
481
|
}
|
|
333
482
|
},
|
|
334
483
|
|
|
484
|
+
/**
|
|
485
|
+
* The shareable diagnostic bundle (ledger C10).
|
|
486
|
+
*
|
|
487
|
+
* IT PROBES BLENDER, AND THAT IS THE POINT. The first question a maintainer asks about a report is
|
|
488
|
+
* "does Blender work on that machine", and a bundle that answered "not probed" would send the
|
|
489
|
+
* conversation back to the user. The probe is the same one the settings card already makes
|
|
490
|
+
* (coalesced, cached for `capabilitiesCacheMs`), and a FAILED probe is a structured value here
|
|
491
|
+
* rather than an error page — a machine without Blender is exactly the machine whose bundle
|
|
492
|
+
* somebody is about to send.
|
|
493
|
+
*
|
|
494
|
+
* `config` IS AN ALLOWLIST. Every key below is named on purpose. A spread would publish whatever
|
|
495
|
+
* configuration key is added next, and a diagnostic bundle is the last place that should happen.
|
|
496
|
+
*/
|
|
497
|
+
diagnostics: async () => {
|
|
498
|
+
let capabilities = null
|
|
499
|
+
let probeError = null
|
|
500
|
+
try {
|
|
501
|
+
capabilities = await studio().describeCapabilities({})
|
|
502
|
+
} catch (cause) {
|
|
503
|
+
const error = cause instanceof BlenderError
|
|
504
|
+
? cause
|
|
505
|
+
: new BlenderError(
|
|
506
|
+
BlenderErrorCode.CAPABILITY_PROBE_FAILED,
|
|
507
|
+
cause instanceof Error ? cause.message : String(cause),
|
|
508
|
+
)
|
|
509
|
+
probeError = { code: error.code ?? BlenderErrorCode.CAPABILITY_PROBE_FAILED, message: error.message ?? '' }
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const listed = await studio().listProjects()
|
|
513
|
+
const projects = []
|
|
514
|
+
const failures = []
|
|
515
|
+
for (const record of listed.projects) {
|
|
516
|
+
const jobs = await studio().listJobs({ projectId: record.projectId })
|
|
517
|
+
const byStatus = {}
|
|
518
|
+
for (const job of jobs.jobs) byStatus[job.status] = (byStatus[job.status] ?? 0) + 1
|
|
519
|
+
const failed = jobs.jobs.filter(job => job.status === 'failed').slice(-DIAGNOSTICS_JOB_LIMIT)
|
|
520
|
+
for (const job of failed) {
|
|
521
|
+
failures.push({
|
|
522
|
+
projectId: record.projectId,
|
|
523
|
+
jobId: job.jobId,
|
|
524
|
+
type: job.type ?? null,
|
|
525
|
+
errorCode: job.errorCode ?? null,
|
|
526
|
+
message: job.error ?? null,
|
|
527
|
+
frames: job.frames ?? null,
|
|
528
|
+
updatedAt: job.updatedAt ?? null,
|
|
529
|
+
})
|
|
530
|
+
}
|
|
531
|
+
projects.push({
|
|
532
|
+
projectId: record.projectId,
|
|
533
|
+
title: record.title ?? null,
|
|
534
|
+
currentRevision: record.currentRevision ?? null,
|
|
535
|
+
revisionCount: record.revisionCount ?? 0,
|
|
536
|
+
updatedAt: record.updatedAt ?? null,
|
|
537
|
+
jobs: {
|
|
538
|
+
total: jobs.jobs.length,
|
|
539
|
+
byStatus,
|
|
540
|
+
unfinished: jobs.unfinished?.length ?? 0,
|
|
541
|
+
recoveryFindings: jobs.recovery?.length ?? 0,
|
|
542
|
+
recent: jobs.jobs.slice(-DIAGNOSTICS_JOB_LIMIT).map(job => ({
|
|
543
|
+
jobId: job.jobId,
|
|
544
|
+
type: job.type ?? null,
|
|
545
|
+
status: job.status,
|
|
546
|
+
revision: job.revision ?? null,
|
|
547
|
+
frames: job.frames ?? null,
|
|
548
|
+
errorCode: job.errorCode ?? null,
|
|
549
|
+
updatedAt: job.updatedAt ?? null,
|
|
550
|
+
})),
|
|
551
|
+
},
|
|
552
|
+
})
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// The Host projects its own configuration (the keys are declared there, and this half's schema
|
|
556
|
+
// declares none of them), and the same projection supplies the workspace root below — one read,
|
|
557
|
+
// so the two fields cannot disagree about where this deployment keeps its state.
|
|
558
|
+
const projected = studio().describeConfiguration?.() ?? {}
|
|
559
|
+
|
|
560
|
+
return buildDiagnosticsBundle({
|
|
561
|
+
generatedAt: new Date().toISOString(),
|
|
562
|
+
product: { name: 'DeepBlend Studio', version: studio().productVersion?.() ?? null, hostApiVersion: HOST_API_VERSION },
|
|
563
|
+
environment: { node: process.version, platform: process.platform, arch: process.arch, home: homedir() },
|
|
564
|
+
blender: {
|
|
565
|
+
probed: true,
|
|
566
|
+
note: 'Blender was probed for this export (the same probe the settings card makes).',
|
|
567
|
+
capabilities,
|
|
568
|
+
error: probeError,
|
|
569
|
+
},
|
|
570
|
+
config: projected,
|
|
571
|
+
store: { projectsRoot: listed.projectsRoot ?? null, workspaceRoot: projected.workspaceRoot ?? null, projects },
|
|
572
|
+
failures,
|
|
573
|
+
notes: [
|
|
574
|
+
`jobs per project are capped at ${DIAGNOSTICS_JOB_LIMIT}`,
|
|
575
|
+
`failures are capped at ${DIAGNOSTICS_FAILURE_LIMIT}`,
|
|
576
|
+
],
|
|
577
|
+
})
|
|
578
|
+
},
|
|
579
|
+
|
|
335
580
|
/** Everything the panel needs to render itself from scratch, in one request. */
|
|
336
581
|
state: async ({ query }) => {
|
|
337
582
|
const listed = await studio().listProjects()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepblend/dsh-blender-ui",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "DeepBlend workbench UI: Host half (typed HTTP API) and Web Client half (sidebar, panels, tool cards).",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
}
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@deepblend/dsh-blender-contracts": "0.1
|
|
22
|
+
"@deepblend/dsh-blender-contracts": "0.2.1"
|
|
23
23
|
},
|
|
24
24
|
"license": "MIT",
|
|
25
25
|
"repository": {
|