@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.
Files changed (3) hide show
  1. package/lib/client.js +1241 -725
  2. package/lib/index.js +245 -0
  3. 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.0",
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.0"
22
+ "@deepblend/dsh-blender-contracts": "0.2.1"
23
23
  },
24
24
  "license": "MIT",
25
25
  "repository": {