@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/LICENSE +21 -0
- package/README.md +8 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +106004 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.mts +35 -0
- package/lib/index.mjs +1439 -0
- package/package.json +58 -0
- package/src/client/ecad.ts +147 -0
- package/src/client/hit-card.tsx +378 -0
- package/src/client/i18n.ts +148 -0
- package/src/client/index.ts +176 -0
- package/src/client/login-url.ts +91 -0
- package/src/client/parse.ts +153 -0
- package/src/client/progress.ts +161 -0
- package/src/client/stack-frame.tsx +269 -0
- package/src/client/theme.ts +228 -0
- package/src/client/trace-names.ts +308 -0
- package/src/client/trace-tree.ts +185 -0
- package/src/config.ts +203 -0
- package/src/index.ts +130 -0
- package/src/progress.ts +333 -0
- package/src/routes.ts +80 -0
- package/src/sse.ts +448 -0
- package/src/tools.ts +590 -0
- package/src/trace.ts +336 -0
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
|
+
})
|