@huaqiu/dsh-tool-schematic-gen 0.1.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/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@huaqiu/dsh-tool-schematic-gen",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "main": "./lib/index.mjs",
6
+ "types": "./lib/index.d.mts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./lib/index.d.mts",
10
+ "default": "./lib/index.mjs"
11
+ },
12
+ "./client": {
13
+ "default": "./lib/client.js"
14
+ },
15
+ "./cordis.patch.yml": "./cordis.patch.yml",
16
+ "./package.json": "./package.json"
17
+ },
18
+ "dsh": {
19
+ "bundle": {
20
+ "patch": "./cordis.patch.yml"
21
+ },
22
+ "client": {
23
+ "platform": "web",
24
+ "inject": [
25
+ "@deepseek-ai/dsh-client-runtime"
26
+ ]
27
+ }
28
+ },
29
+ "peerDependencies": {
30
+ "@deepseek-ai/cordis": "^4.0.1",
31
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.0-rc.0 <0.2.0",
32
+ "@deepseek-ai/dsh-tools": ">=0.1.0-rc.8 <0.2.0",
33
+ "@huaqiu/dsh-auth": "^0.1.1",
34
+ "@huaqiu/dsh-artifacts": "^0.1.1",
35
+ "react": "^18"
36
+ },
37
+ "devDependencies": {
38
+ "@huaqiu/ecad-renderer": "^0.2.4",
39
+ "@huaqiu/kicad-sexpr-parser": "^0.1.1",
40
+ "@types/react": "^18.3.31",
41
+ "react": "^18.3.1",
42
+ "@huaqiu/dsh-auth": "0.1.1",
43
+ "@huaqiu/dsh-artifacts": "0.1.1"
44
+ },
45
+ "files": [
46
+ "lib",
47
+ "src",
48
+ "cordis.patch.yml"
49
+ ],
50
+ "publishConfig": {
51
+ "access": "public"
52
+ },
53
+ "scripts": {
54
+ "typecheck": "tsc --noEmit",
55
+ "build": "tsdown",
56
+ "test": "vitest run"
57
+ }
58
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * ECAD preview + artifact resolution for the schematic/system HIT card.
3
+ *
4
+ * Both renderers come from the bundled `@huaqiu/ecad-renderer` subpaths:
5
+ * - `renderSchematic` — single `.kicad_sch` sheet (schematic tool)
6
+ * - `loadProjectZip` — system-design project zip; the root sheet
7
+ * matching the `*.kicad_pro` name is selected and rendered (the published
8
+ * `renderProjectFromZip` helper is declared in d.ts but not actually
9
+ * exported by the bundle, so we drive `loadProjectZip` ourselves).
10
+ *
11
+ * Artifact content comes from the `@huaqiu/dsh-artifacts` webServer routes:
12
+ * GET /api/v1/huaqiu/artifacts/<id> → { type, filename, encoding }
13
+ * GET /api/v1/huaqiu/artifacts/<id>/content → raw bytes (base64 is already
14
+ * decoded at store time; read as ArrayBuffer, never as text)
15
+ */
16
+ import { SchematicParser } from '@huaqiu/kicad-sexpr-parser'
17
+ import { renderSchematic } from '@huaqiu/ecad-renderer/schematic'
18
+ import { loadProjectZip } from '@huaqiu/ecad-renderer/project'
19
+
20
+ export interface ResolvedText {
21
+ id: string
22
+ type: string | null
23
+ filename: string | null
24
+ text: string
25
+ }
26
+
27
+ export interface ResolvedBytes {
28
+ id: string
29
+ type: string | null
30
+ filename: string | null
31
+ bytes: Uint8Array
32
+ }
33
+
34
+ /** Resolve an artifact as text content (schematic sheets). */
35
+ export async function resolveArtifactText(artifactId: string): Promise<ResolvedText> {
36
+ const metaPath = `/api/v1/huaqiu/artifacts/${encodeURIComponent(artifactId)}`
37
+ const metaRes = await fetch(metaPath)
38
+ if (!metaRes.ok) throw new Error(`artifact metadata ${metaRes.status}`)
39
+ const meta = (await metaRes.json()) as { type?: string; filename?: string; encoding?: string }
40
+ const contentRes = await fetch(`${metaPath}/content`)
41
+ if (!contentRes.ok) throw new Error(`artifact content ${metaRes.status}`)
42
+ const text = await contentRes.text()
43
+ return {
44
+ id: artifactId,
45
+ type: typeof meta.type === 'string' ? meta.type : null,
46
+ filename: typeof meta.filename === 'string' ? meta.filename : null,
47
+ text,
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Resolve an artifact as raw bytes. The artifacts service `/content` route
53
+ * returns the ALREADY-DECODED binary bytes (base64 content is decoded at
54
+ * store time), so we read the response as an ArrayBuffer — never as text.
55
+ */
56
+ export async function resolveArtifactBytes(artifactId: string): Promise<ResolvedBytes> {
57
+ const metaPath = `/api/v1/huaqiu/artifacts/${encodeURIComponent(artifactId)}`
58
+ const metaRes = await fetch(metaPath)
59
+ if (!metaRes.ok) throw new Error(`artifact metadata ${metaRes.status}`)
60
+ const meta = (await metaRes.json()) as { type?: string; filename?: string }
61
+ const contentRes = await fetch(`${metaPath}/content`)
62
+ if (!contentRes.ok) throw new Error(`artifact content ${contentRes.status}`)
63
+ const buf = await contentRes.arrayBuffer()
64
+ const bytes = new Uint8Array(buf)
65
+ return {
66
+ id: artifactId,
67
+ type: typeof meta.type === 'string' ? meta.type : null,
68
+ filename: typeof meta.filename === 'string' ? meta.filename : null,
69
+ bytes,
70
+ }
71
+ }
72
+
73
+ function parseSchematic(source: string) {
74
+ const sp = new SchematicParser()
75
+ if (typeof sp.parse !== 'function') throw new Error('SchematicParser.parse is not a function')
76
+ return sp.parse(source)
77
+ }
78
+
79
+ /**
80
+ * Render a single schematic sheet onto a canvas. Returns the renderer dispose.
81
+ */
82
+ export async function renderSheetToCanvas(source: string, canvas: HTMLCanvasElement): Promise<() => void> {
83
+ const sch = parseSchematic(source)
84
+ const r = await renderSchematic(sch, { canvas, interactive: true })
85
+ return () => { try { r.dispose() } catch { /* ignore */ } }
86
+ }
87
+
88
+ /**
89
+ * Render a system-design project zip onto a canvas. The published
90
+ * `renderProjectFromZip` is not actually exported by the renderer bundle, so
91
+ * we use the exported `loadProjectZip` (extracts the root schematic matching
92
+ * the `*.kicad_pro` name) and render that sheet via `renderSchematic`.
93
+ */
94
+ export async function renderProjectZipToCanvas(zipBytes: Uint8Array, canvas: HTMLCanvasElement): Promise<() => void> {
95
+ const loaded = await loadProjectZip(zipBytes as unknown as Uint8Array<ArrayBuffer>)
96
+ const rootName = typeof loaded.rootSchematic === 'string' ? loaded.rootSchematic : null
97
+ const rootFile = (rootName && loaded.files.find((f) => f.filename === rootName)) || loaded.files[0]
98
+ if (!rootFile || typeof rootFile.content !== 'string') {
99
+ throw new Error('no schematic sheet found in the project zip')
100
+ }
101
+ const sch = parseSchematic(rootFile.content)
102
+ const r = await renderSchematic(sch, { canvas, interactive: true })
103
+ return () => { try { r.dispose() } catch { /* ignore */ } }
104
+ }
105
+
106
+ /** Size a canvas to its CSS box (device-pixel-ratio aware). */
107
+ export function sizeCanvasFor(canvas: HTMLCanvasElement): void {
108
+ const dpr = window.devicePixelRatio || 1
109
+ const cssW = canvas.clientWidth || 720
110
+ const cssH = canvas.clientHeight || 360
111
+ canvas.width = Math.max(100, Math.floor(cssW * dpr))
112
+ canvas.height = Math.max(100, Math.floor(cssH * dpr))
113
+ }
114
+
115
+ /** Trigger a browser download of a text artifact. */
116
+ export function downloadText(filename: string, text: string, mime = 'text/plain;charset=utf-8'): void {
117
+ try {
118
+ const blob = new Blob([text], { type: mime })
119
+ const url = URL.createObjectURL(blob)
120
+ const a = document.createElement('a')
121
+ a.href = url
122
+ a.download = filename
123
+ document.body.appendChild(a)
124
+ a.click()
125
+ a.remove()
126
+ setTimeout(() => { try { URL.revokeObjectURL(url) } catch { /* ignore */ } }, 2000)
127
+ } catch (err) {
128
+ console.warn('[hq-schematic-gen] download failed', err)
129
+ }
130
+ }
131
+
132
+ /** Trigger a browser download of binary bytes. */
133
+ export function downloadBytes(filename: string, bytes: Uint8Array, mime = 'application/zip'): void {
134
+ try {
135
+ const blob = new Blob([bytes as BlobPart], { type: mime })
136
+ const url = URL.createObjectURL(blob)
137
+ const a = document.createElement('a')
138
+ a.href = url
139
+ a.download = filename
140
+ document.body.appendChild(a)
141
+ a.click()
142
+ a.remove()
143
+ setTimeout(() => { try { URL.revokeObjectURL(url) } catch { /* ignore */ } }, 2000)
144
+ } catch (err) {
145
+ console.warn('[hq-schematic-gen] zip download failed', err)
146
+ }
147
+ }
@@ -0,0 +1,378 @@
1
+ /**
2
+ * Keyed `tool.call.toolview` HIT card for schematic generation (both
3
+ * `generate_schematic_from_description` and `generate_system_module_graph`).
4
+ *
5
+ * Faithful React/TS port of the hq-edge `GenHit` card for schematics, adapted
6
+ * to the published DSH slot contract (`ToolCallOwnerProps` + `sessionId`).
7
+ * "Regenerate" sends a user message back to the agent through
8
+ * `sessions.binding(sessionId).session.prompt(...)`; the node `ask()` stays
9
+ * the single source of truth. The `needs_auth` phase renders an inline login
10
+ * card (display + guidance; the auth plugin owns the credential handshake).
11
+ *
12
+ * Preview: a single `.kicad_sch` sheet renders via `renderSchematic`; a system
13
+ * design zip renders via `renderProjectFromZip` (root sheet auto-selected).
14
+ */
15
+ import { memo, useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
16
+ import {
17
+ projectToolCall, formatBytes, downloadFilenameFor,
18
+ type SchResult, type ToolBlockLike,
19
+ } from './parse.js'
20
+ import { type Translate, useT } from './i18n.js'
21
+ import {
22
+ resolveArtifactText, resolveArtifactBytes, renderSheetToCanvas,
23
+ renderProjectZipToCanvas, sizeCanvasFor, downloadText, downloadBytes,
24
+ } from './ecad.js'
25
+ import { useLocale, useTheme } from './theme.js'
26
+ import { buildLoginUrl, loginIframeBackground } from './login-url.js'
27
+ import { LiveProgress } from './stack-frame.jsx'
28
+
29
+ /** Login-state view used by the needs_auth card (from the auth plugin's shared localStorage). */
30
+ export interface AuthStateLike {
31
+ authenticated: boolean
32
+ nickname?: string
33
+ }
34
+
35
+ export type PromptSender = (sessionId: string | undefined, message: string) => Promise<unknown>
36
+
37
+ const TOOL_SCHEMATIC = 'generate_schematic_from_description'
38
+ const TOOL_SYSTEM = 'generate_system_module_graph'
39
+
40
+ export interface GenHitProps {
41
+ toolName: string
42
+ block?: ToolBlockLike
43
+ sessionId?: string
44
+ /**
45
+ * Tool call id from the `tool.call.toolview` slot. DSH guarantees it is
46
+ * "stable across running and settled forms", and it is the SAME string the
47
+ * node half receives as `ToolRunContext.callId` — which is exactly what the
48
+ * progress store is keyed by. No callId means no live stack, but the card
49
+ * still renders.
50
+ */
51
+ callId?: string
52
+ inspect?: () => void
53
+ authState?: AuthStateLike
54
+ sendPrompt?: PromptSender
55
+ }
56
+
57
+ function kindOf(toolName: string): 'schematic' | 'system' {
58
+ return toolName === TOOL_SYSTEM ? 'system' : 'schematic'
59
+ }
60
+
61
+ function kindTitleKey(kind: string | null): string {
62
+ return kind === 'system' ? 'card.title.system' : 'card.title.schematic'
63
+ }
64
+
65
+ function kindLabel(kind: string | null, t: Translate): string {
66
+ return kind === 'system' ? t('card.kind.system') : t('card.kind.schematic')
67
+ }
68
+
69
+ function StatusDot({ state }: { state: 'ongoing' | 'error' | 'done' }): ReactElement {
70
+ const color = state === 'done' ? 'var(--dsw-alias-state-success-primary, #34a853)'
71
+ : state === 'error' ? 'var(--dsw-alias-state-error-primary, #d93025)'
72
+ : 'var(--dsw-alias-state-running-primary, #1a73e8)'
73
+ return <span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: 8, background: color }} />
74
+ }
75
+
76
+ function statusText(phase: string, t: Translate): string {
77
+ if (phase === 'generating') return t('card.status.generating')
78
+ if (phase === 'failed') return t('card.status.failed')
79
+ return t('card.status.generated')
80
+ }
81
+
82
+ function header(kind: string | null, phase: string, t: Translate): ReactElement {
83
+ const dot = phase === 'generating' ? 'ongoing' : phase === 'failed' ? 'error' : 'done'
84
+ return (
85
+ <div className="hq-sch__header">
86
+ <span className="hq-sch__icon">⇶</span>
87
+ <span className="hq-sch__title">{t(kindTitleKey(kind))}</span>
88
+ <span className="hq-sch__status">
89
+ <StatusDot state={dot} />
90
+ <span>{statusText(phase, t)}</span>
91
+ </span>
92
+ </div>
93
+ )
94
+ }
95
+
96
+ function summary(result: SchResult, t: Translate): ReactElement | null {
97
+ const badges: ReactElement[] = []
98
+ if (result.kind) badges.push(<span className="hq-sch__badge" key="kind">{kindLabel(result.kind, t)}</span>)
99
+ if (result.designName) badges.push(<span className="hq-sch__badge hq-sch__badge--mono" key="design">{result.designName}</span>)
100
+ if (result.kind === 'system') {
101
+ if (result.moduleCount != null) badges.push(<span className="hq-sch__badge" key="mod">{t('card.meta.modules', { count: result.moduleCount })}</span>)
102
+ if (result.connectionCount != null) badges.push(<span className="hq-sch__badge" key="conn">{t('card.meta.connections', { count: result.connectionCount })}</span>)
103
+ } else if (result.fileCount != null) {
104
+ badges.push(<span className="hq-sch__badge" key="files">{t('card.meta.sheets', { count: result.fileCount })}</span>)
105
+ }
106
+ const size = result.artifact?.size
107
+ if (size != null) {
108
+ const sizeText = formatBytes(size)
109
+ if (sizeText) badges.push(<span className="hq-sch__badge" key="size">{sizeText}</span>)
110
+ }
111
+ if (badges.length === 0) return null
112
+ return <div className="hq-sch__summary">{badges}</div>
113
+ }
114
+
115
+ // ── canvas preview ──────────────────────────────────────────────────────────
116
+
117
+ interface PreviewPayload {
118
+ kind: 'schematic' | 'system'
119
+ source: string | null
120
+ bytes: Uint8Array | null
121
+ srcKey: string
122
+ }
123
+
124
+ function PreviewStage({ payload, t }: { payload: PreviewPayload; t: Translate }): ReactElement {
125
+ const canvasRef = useRef<HTMLCanvasElement | null>(null)
126
+ const [view, setView] = useState<{ view: 'loading' | 'ready' | 'error'; message: string }>({ view: 'loading', message: '' })
127
+
128
+ useEffect(() => {
129
+ let cancelled = false
130
+ let disposeViewer: (() => void) | null = null
131
+ setView({ view: 'loading', message: '' })
132
+ ;(async () => {
133
+ try {
134
+ const canvas = canvasRef.current
135
+ if (!canvas) return
136
+ await new Promise((resolve) => {
137
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(resolve)
138
+ else setTimeout(resolve, 16)
139
+ })
140
+ if (cancelled || canvas !== canvasRef.current) return
141
+ sizeCanvasFor(canvas)
142
+ if (payload.kind === 'system' && payload.bytes) {
143
+ disposeViewer = await renderProjectZipToCanvas(payload.bytes, canvas)
144
+ } else if (payload.source) {
145
+ disposeViewer = await renderSheetToCanvas(payload.source, canvas)
146
+ } else {
147
+ throw new Error('no preview source')
148
+ }
149
+ if (cancelled || canvas !== canvasRef.current) return
150
+ setView({ view: 'ready', message: '' })
151
+ } catch (e) {
152
+ if (!cancelled) {
153
+ console.warn('[hq-schematic-gen] preview render failed', e)
154
+ setView({ view: 'error', message: String((e as Error)?.message || e) })
155
+ }
156
+ }
157
+ })()
158
+ return () => {
159
+ cancelled = true
160
+ try { disposeViewer?.() } catch { /* ignore */ }
161
+ }
162
+ }, [payload.srcKey, payload.kind, payload.source, payload.bytes])
163
+
164
+ const overlay =
165
+ view.view === 'loading'
166
+ ? <div className="hq-sch__stage-msg">{t('card.preview.loading')}</div>
167
+ : view.view === 'error'
168
+ ? <div className="hq-sch__stage-msg">{t('card.preview.renderError')}{view.message}</div>
169
+ : null
170
+
171
+ return (
172
+ <div className="hq-sch__stage">
173
+ <canvas ref={canvasRef} className="hq-sch__canvas" />
174
+ {overlay}
175
+ </div>
176
+ )
177
+ }
178
+
179
+ // ── needs_auth login card ───────────────────────────────────────────────────
180
+
181
+ function LoginCard({ toolName, authState, t }: { toolName: string; authState?: AuthStateLike; t: Translate }): ReactElement {
182
+ const dark = useTheme()
183
+ const locale = useLocale()
184
+ // FILL mode (`fill=full`): this card IS the surface, so let the embed paint
185
+ // it edge-to-edge with its own `bg-background`. Without it the embed's
186
+ // `grid-rows-[20px_1fr_20px]` wrapper leaves two transparent strips above
187
+ // and below the form, which read as white gaps in dark theme.
188
+ const src = useMemo(
189
+ () => buildLoginUrl({ lang: locale, theme: dark ? 'dark' : 'light' }),
190
+ [locale, dark],
191
+ )
192
+ // Force a full remount on a theme/locale flip: Chrome keeps the old embed
193
+ // loaded when only `src` changes (the embed is a Next.js page that reads
194
+ // its URL params once on mount), silently ignoring the new `fill`/`theme`.
195
+ const remountKey = `${locale}|${dark ? 'd' : 'l'}`
196
+
197
+ return (
198
+ <div className="hq-sch">
199
+ <div className="hq-sch__header">
200
+ <span className="hq-sch__icon">⇶</span>
201
+ <span className="hq-sch__title">{t('card.auth.title')}</span>
202
+ </div>
203
+ <div className="hq-sch__login">
204
+ <p className="hq-sch__login-desc">{t('card.auth.desc', { tool: toolName })}</p>
205
+ <p className="hq-sch__login-status" style={{ color: authState?.authenticated ? '#1677ff' : '#d4380d' }}>
206
+ {authState?.authenticated
207
+ ? t('card.auth.loggedIn', {
208
+ nickname: authState.nickname ? t('card.nicknameSep', { nickname: authState.nickname }) : '',
209
+ })
210
+ : t('card.auth.loggedOut')}
211
+ </p>
212
+ <iframe
213
+ key={remountKey}
214
+ src={src}
215
+ title={t('card.auth.title')}
216
+ className="hq-sch__login-iframe"
217
+ style={{ background: loginIframeBackground(dark) }}
218
+ allow="clipboard-write"
219
+ />
220
+ </div>
221
+ </div>
222
+ )
223
+ }
224
+
225
+ // ── main card ──────────────────────────────────────────────────────────────
226
+
227
+ export const GenHit = memo(function GenHit(props: GenHitProps): ReactElement {
228
+ const t = useT()
229
+ const state = projectToolCall(props.block)
230
+ const result = state.phase === 'completed' ? state.result : null
231
+ const artifactKey = result?.artifact?.id ?? null
232
+
233
+ // Resolve preview payload from the artifact (epoch-guarded).
234
+ const [payload, setPayload] = useState<{
235
+ phase: 'idle' | 'loading' | 'ready' | 'error' | 'missing'
236
+ source: string | null
237
+ bytes: Uint8Array | null
238
+ filename: string | null
239
+ error: string | null
240
+ }>({ phase: 'idle', source: null, bytes: null, filename: null, error: null })
241
+
242
+ useEffect(() => {
243
+ if (state.phase !== 'completed' || !result || !artifactKey) return
244
+ let cancelled = false
245
+ setPayload({ phase: 'loading', source: null, bytes: null, filename: null, error: null })
246
+ ;(async () => {
247
+ try {
248
+ const kind = result.kind ?? 'schematic'
249
+ if (kind === 'system') {
250
+ const art = await resolveArtifactBytes(artifactKey)
251
+ if (cancelled) return
252
+ setPayload({ phase: 'ready', source: null, bytes: art.bytes, filename: art.filename, error: null })
253
+ } else {
254
+ const art = await resolveArtifactText(artifactKey)
255
+ if (cancelled) return
256
+ setPayload({ phase: 'ready', source: art.text, bytes: null, filename: art.filename, error: null })
257
+ }
258
+ } catch (e) {
259
+ if (!cancelled) {
260
+ console.warn('[hq-schematic-gen] artifact resolve failed', e)
261
+ setPayload({ phase: 'error', source: null, bytes: null, filename: null, error: String((e as Error)?.message || e) })
262
+ }
263
+ }
264
+ })()
265
+ return () => { cancelled = true }
266
+ // eslint-disable-next-line react-hooks/exhaustive-deps
267
+ }, [artifactKey, state.phase])
268
+
269
+ const [busy, setBusy] = useState<string | null>(null)
270
+
271
+ function onDownload(): void {
272
+ if (busy || payload.phase !== 'ready') return
273
+ const kind = result?.kind ?? 'schematic'
274
+ const filename = downloadFilenameFor(kind, result?.artifact ?? null, result?.designName ?? null)
275
+ setBusy('download')
276
+ try {
277
+ if (kind === 'system' && payload.bytes) {
278
+ downloadBytes(filename, payload.bytes)
279
+ } else if (payload.source != null) {
280
+ downloadText(filename, payload.source)
281
+ }
282
+ } finally {
283
+ setBusy(null)
284
+ }
285
+ }
286
+
287
+ function onRegenerate(): void {
288
+ if (busy) return
289
+ setBusy('regenerate')
290
+ const kind = result?.kind ?? 'schematic'
291
+ const prompt = kind === 'system' ? t('card.regeneratePrompt.system') : t('card.regeneratePrompt.schematic')
292
+ const p = props.sendPrompt
293
+ ? props.sendPrompt(props.sessionId, prompt)
294
+ : Promise.reject(new Error('no prompt sender'))
295
+ p.then(
296
+ () => setBusy(null),
297
+ (err) => { console.warn('[hq-schematic-gen] regenerate failed', err); setBusy(null) },
298
+ )
299
+ }
300
+
301
+ // needs_auth
302
+ if (state.phase === 'needs_auth') {
303
+ return <LoginCard toolName={props.toolName} authState={props.authState} t={t} />
304
+ }
305
+
306
+ const headerKind = result?.kind ?? kindOf(props.toolName)
307
+
308
+ // generating — a run takes 10+ minutes, so show live progress rather than a
309
+ // frozen label. `LiveProgress` owns its own polling and degrades to the
310
+ // coarse stage ladder when the backend emits no trace events.
311
+ if (state.phase === 'generating') {
312
+ return (
313
+ <div className="hq-sch">
314
+ {header(headerKind, 'generating', t)}
315
+ <LiveProgress callId={props.callId} kind={headerKind === 'system' ? 'system' : 'schematic'} t={t} />
316
+ </div>
317
+ )
318
+ }
319
+
320
+ // failed
321
+ if (state.phase === 'failed') {
322
+ return (
323
+ <div className="hq-sch">
324
+ {header(headerKind, 'failed', t)}
325
+ <div className="hq-sch__error">{'message' in state ? state.message : t('card.error.toolFailed')}</div>
326
+ <div className="hq-sch__actions">
327
+ <button type="button" className="hq-sch__act" onClick={onRegenerate} disabled={busy === 'regenerate'}>
328
+ ↻ {t('card.error.retry')}
329
+ </button>
330
+ </div>
331
+ </div>
332
+ )
333
+ }
334
+
335
+ // completed
336
+ if (!result) {
337
+ return <div className="hq-sch">{header(headerKind, 'failed', t)}</div>
338
+ }
339
+
340
+ let preview: ReactElement
341
+ if (payload.phase === 'ready' && (payload.source != null || payload.bytes != null)) {
342
+ const srcKey = artifactKey ?? 'preview'
343
+ preview = (
344
+ <PreviewStage
345
+ payload={{ kind: result.kind === 'system' ? 'system' : 'schematic', source: payload.source, bytes: payload.bytes, srcKey }}
346
+ t={t}
347
+ />
348
+ )
349
+ } else if (payload.phase === 'loading') {
350
+ preview = <div className="hq-sch__stage"><div className="hq-sch__stage-msg">{t('card.preview.loading')}</div></div>
351
+ } else if (payload.phase === 'error') {
352
+ preview = <div className="hq-sch__stage"><div className="hq-sch__stage-msg">{t('card.preview.resolveError')}{payload.error}</div></div>
353
+ } else {
354
+ preview = <div className="hq-sch__stage"><div className="hq-sch__stage-msg">{t('card.preview.missing')}</div></div>
355
+ }
356
+
357
+ const canDownload = payload.phase === 'ready' && (payload.source != null || payload.bytes != null)
358
+
359
+ return (
360
+ <div className="hq-sch">
361
+ {header(result.kind, 'completed', t)}
362
+ {summary(result, t)}
363
+ {preview}
364
+ {result.note ? <div className="hq-sch__note">{result.note}</div> : null}
365
+ <div className="hq-sch__actions">
366
+ <button type="button" className="hq-sch__act" onClick={onDownload} disabled={!canDownload || busy === 'download'}>
367
+ ⭳ {busy === 'download' ? t('card.action.downloading') : t('card.action.download')}
368
+ </button>
369
+ <button type="button" className="hq-sch__act" onClick={onRegenerate} disabled={busy === 'regenerate'}>
370
+ ↻ {busy === 'regenerate' ? t('card.action.regenerating') : t('card.action.regenerate')}
371
+ </button>
372
+ {typeof props.inspect === 'function'
373
+ ? <button type="button" className="hq-sch__act" onClick={() => props.inspect?.()}>{t('card.action.inspect')}</button>
374
+ : null}
375
+ </div>
376
+ </div>
377
+ )
378
+ })