/** * The keyed `tool.call.toolview` HIT card for symbol/footprint generation. * * Faithful React/TS port of the original hq-edge `GenHit` card, adapted to * the published DSH slot contract: * - the component receives `ToolCallOwnerProps` + `sessionId` (session-scope * standard kit); * - "confirm"/"cancel"/"regenerate" send a user message back to the agent * through `sessions.binding(sessionId).session.prompt(...)` (single-HIL: * the node `ask()` stays authoritative, the card never invents a result); * - the `needs_auth` phase renders an inline login card (the auth plugin's * client already owns the postMessage handshake + node sync — this card is * display + guidance only); * - previews render from the artifact content via the bundled ecad-renderer. */ import { Fragment, memo, useEffect, useMemo, useRef, useState, type ReactElement } from 'react' import { projectToolCall, resultTextOf, fieldLabel, defaultFilenameFor, formatBytes, hashString, type GenResult, type ToolBlockLike } from './parse.js' import { bgaGrid, classifyDimensions, clampDimension, dimensionBounds, dimensionConfirmMessage, dimensionDeclineMessage, formatDimension, normalizeDimensions, numVal, parseDimension, pickGeometry, pinCountOf, pkgFamilyLabel, rectFromValues, summaryOf, toleranceOf, validateDimensions, type DimensionValues, } from './dims.js' import { type Translate, useT } from './i18n.js' import { resolveArtifact, renderArtifactToCanvas, sizeCanvasFor, triggerDownload } from './ecad.js' import { placeSupportOf, type HqEdgePlaceLike } from './place.js' import { useLocale, useTheme } from './theme.js' import { buildLoginUrl, loginIframeBackground } from './login-url.js' import type { AuthClientLike, AuthStateLike, PromptSender } from './index.js' const TOOL_SYMBOL = 'generate_symbol_from_image' const TOOL_FOOTPRINT_IMAGE = 'generate_footprint_from_image' const TOOL_FOOTPRINT_DIMS = 'generate_footprint_from_dimensions' export interface GenHitProps { toolName: string block?: ToolBlockLike sessionId?: string inspect?: () => void authState?: AuthStateLike sendPrompt?: PromptSender /** * Lazy accessor for the `huaqiuAuth` client service (auth plugin browser * half). Absent/undefined in a broken install — the needs_auth card then * falls back to the embedded auth.eda.cn iframe. */ getAuth?: () => AuthClientLike | undefined /** * Lazy accessor for the host's `hqEdge` service (edge-bridge browser half). * Absent/undefined in standalone DSH — the Place button is then hidden. * Lazy (not captured once) so plugin load order and HQ Edge restarts both * resolve correctly at click time. */ getHqEdge?: () => HqEdgePlaceLike | undefined } /** Kind inferred from the tool name — used while the call is not settled. */ function kindFromToolName(toolName: string): string | null { if (toolName === TOOL_SYMBOL) return 'symbol' if (toolName === TOOL_FOOTPRINT_IMAGE || toolName === TOOL_FOOTPRINT_DIMS) return 'footprint' return null } function kindLabel(kind: string | null, t: Translate): string { if (kind === 'symbol') return t('card.kind.symbol') if (kind === 'footprint') return t('card.kind.footprint') if (kind === 'schematic') return t('card.kind.schematic') if (kind === 'pcb') return t('card.kind.pcb') return kind || '' } function kindTitleKey(kind: string | null): string { if (kind === 'symbol') return 'card.title.symbol' if (kind === 'footprint') return 'card.title.footprint' return 'card.title.generic' } // ── header / summary / status ─────────────────────────────────────────────── function StatusDot({ state }: { state: 'ongoing' | 'error' | 'warning' | 'done' }): ReactElement { const color = state === 'done' ? 'var(--dsw-alias-state-success-primary, #34a853)' : state === 'error' ? 'var(--dsw-alias-state-error-primary, #d93025)' : state === 'warning' ? 'var(--dsw-alias-state-warning-primary, #f9ab00)' : 'var(--dsw-alias-state-running-primary, #1a73e8)' return } function statusDotState(phase: string): 'ongoing' | 'error' | 'warning' | 'done' { if (phase === 'generating') return 'ongoing' if (phase === 'failed') return 'error' if (phase === 'cancelled' || phase === 'needs_confirmation' || phase === 'needs_auth') return 'warning' return 'done' } function statusText(phase: string, t: Translate): string { if (phase === 'generating') return t('card.status.generating') if (phase === 'failed') return t('card.status.failed') if (phase === 'cancelled') return t('card.status.cancelled') if (phase === 'needs_confirmation') return t('card.status.confirm') return t('card.status.generated') } function genHitHeader(kind: string | null, phase: string, t: Translate): ReactElement { return (
⛁ {t(kindTitleKey(kind))} {statusText(phase, t)}
) } function genHitSummary(result: GenResult, t: Translate): ReactElement | null { const badges: ReactElement[] = [] if (result.kind) { badges.push({kindLabel(result.kind, t)}) } if (result.pkgType) { badges.push({t('card.meta.pkg')}: {result.pkgType}) } if (result.filename) { badges.push({result.filename}) } const size = result.artifact?.size if (size != null) { const sizeText = formatBytes(size) if (sizeText) badges.push({sizeText}) } if (badges.length === 0) return null return
{badges}
} // ── package silhouette (NextChat rich-footprint idea, lightweight SVG) ────── /** * Draw a recognizable simplified package silhouette for `pkgType` from the * edited values: body + leads/balls/epad. Returns SVG element descriptors in * the editor viewBox. */ function packageSilhouette( pkgType: string | null, values: DimensionValues, geom: { x: number; y: number; w: number; h: number }, ): ReactElement[] { const type = String(pkgType || '').toLowerCase() const bodyX = geom.x const bodyY = geom.y const bodyW = geom.w const bodyH = geom.h const padL = Math.max(2, Math.min(7, Math.round(Math.min(bodyW, bodyH) * 0.07))) const nodes: ReactElement[] = [] nodes.push( , ) const pins = pinCountOf(values, 8) const perSide = Math.max(2, Math.ceil(pins / 2)) const side = (edge: 'L' | 'R' | 'T' | 'B'): ReactElement[] => { const arr: ReactElement[] = [] if (edge === 'L' || edge === 'R') { const len = Math.min(padL, bodyW * 0.22) const x0 = edge === 'L' ? bodyX - len : bodyX + bodyW for (let i = 0; i < perSide; i++) { const y = bodyY + (i + 0.5) * (bodyH / perSide) const ph = Math.max(1.6, (bodyH / perSide) * 0.55) arr.push() } } else { const len2 = Math.min(padL, bodyH * 0.22) const y0 = edge === 'T' ? bodyY - len2 : bodyY + bodyH for (let j = 0; j < perSide; j++) { const x = bodyX + (j + 0.5) * (bodyW / perSide) const pw = Math.max(1.6, (bodyW / perSide) * 0.55) arr.push() } } return arr } if (type === 'bga') { const g = bgaGrid(values, Math.max(2, Math.round(Math.sqrt(pins)))) const ballR = Math.max(0.9, Math.min(bodyW, bodyH) / (Math.max(g.rows, g.cols) * 3.4)) for (let r = 0; r < g.rows; r++) { for (let c = 0; c < g.cols; c++) { nodes.push( , ) } } } else if (type === 'qfn' || type === 'son') { const ep = Math.min(bodyW * 0.55, bodyH * 0.55) nodes.push( , ) const edges = type === 'son' ? (['L', 'R'] as const) : (['L', 'R', 'T', 'B'] as const) for (const e of edges) nodes.push(...side(e)) } else if (type === 'qfp' || type === 'plcc') { nodes.push(...side('L'), ...side('R'), ...side('T'), ...side('B')) } else { nodes.push(...side('L'), ...side('R')) } return nodes } // ── ECAD canvas preview ───────────────────────────────────────────────────── interface GenHitPreviewProps { kind: string | null content: string srcKey: string | null t: Translate } function GenHitPreview({ kind, content, srcKey, t }: GenHitPreviewProps): ReactElement { const canvasRef = useRef(null) const [view, setView] = useState<{ view: 'loading' | 'ready' | 'error'; message: string }>({ view: 'loading', message: '' }) useEffect(() => { let cancelled = false let disposeViewer: (() => void) | null = null setView({ view: 'loading', message: '' }) ;(async () => { try { const canvas = canvasRef.current if (!canvas) return // Wait one layout frame so the canvas has its final CSS size. await new Promise((resolve) => { if (typeof requestAnimationFrame === 'function') requestAnimationFrame(resolve) else setTimeout(resolve, 16) }) if (cancelled || canvas !== canvasRef.current) return sizeCanvasFor(canvas) disposeViewer = await renderArtifactToCanvas(kind ?? 'symbol', content, canvas) if (cancelled || canvas !== canvasRef.current) return setView({ view: 'ready', message: '' }) } catch (e) { if (!cancelled) { console.warn('[hq-genhit] preview render failed', e) setView({ view: 'error', message: String((e as Error)?.message || e) }) } } })() return () => { cancelled = true try { disposeViewer?.() } catch { /* ignore */ } } }, [srcKey, kind, content]) const overlay = view.view === 'loading' ?
{t('card.preview.loading')}
: view.view === 'error' ?
{t('card.preview.renderError')}{view.message}
: null return (
{overlay}
) } // ── Interactive dimension editor (rich-hit) ───────────────────────────────── interface HqEdaDimensionEditorProps { dimensions: Record pkgType: string | null fileName: string | null disabled: boolean t: Translate onConfirm: (values: DimensionValues, edited: Record) => void onCancel: () => void } function pointerToViewBox( svg: SVGSVGElement, clientX: number, clientY: number, viewW: number, viewH: number, ): { x: number; y: number } { const rect = svg.getBoundingClientRect() const x = rect.width > 0 ? (clientX - rect.left) * (viewW / rect.width) : 0 const y = rect.height > 0 ? (clientY - rect.top) * (viewH / rect.height) : 0 return { x, y } } const COUNT_KEYS = new Set(['pin_count', 'pins', 'pinCount', 'rows', 'row', 'columns', 'column', 'cols', 'n_max', 'n', 'count', 'total_pins']) function HqEdaDimensionEditor(props: HqEdaDimensionEditorProps): ReactElement { const { dimensions, pkgType, fileName, disabled, t, onConfirm, onCancel } = props const normalized = useMemo(() => normalizeDimensions(dimensions), [dimensions]) const { widthKey, heightKey } = normalized const [values, setValues] = useState(() => normalized.values) const [fieldText, setFieldText] = useState>(() => { const m: Record = {} for (const k of normalized.numericKeys) m[k] = formatDimension(normalized.values[k]) return m }) const [invalid, setInvalid] = useState>({}) const [edited, setEdited] = useState>({}) const [advancedOpen, setAdvancedOpen] = useState(false) const [drag, setDrag] = useState<{ mode: 'W' | 'H' | 'WH' startX: number startY: number startW: number startH: number rectW: number rectH: number } | null>(null) const svgRef = useRef(null) const VIEW_W = 360 const VIEW_H = 230 const PAD = 34 const geom = rectFromValues(values, widthKey, heightKey, VIEW_W, VIEW_H, PAD) const labelFor = (key: string): string => fieldLabel(key, t) const fieldUnit = (key: string): string => (COUNT_KEYS.has(key) ? '' : t('card.editor.unit')) const withKey = (obj: Record, key: string, value: number): Record => { const out = { ...obj } out[key] = value return out } const withKeyBool = (obj: Record, key: string, value: boolean): Record => { const out = { ...obj } out[key] = value return out } function startDrag(mode: 'W' | 'H' | 'WH') { return (ev: React.PointerEvent): void => { if (disabled || !svgRef.current) return ev.preventDefault() const view = pointerToViewBox(svgRef.current, ev.clientX, ev.clientY, VIEW_W, VIEW_H) setDrag({ mode, startX: view.x, startY: view.y, startW: widthKey && values[widthKey] != null ? values[widthKey] : 1, startH: heightKey && values[heightKey] != null ? values[heightKey] : 1, rectW: geom.w, rectH: geom.h, }) try { svgRef.current.setPointerCapture(ev.pointerId) } catch { /* ignore */ } } } function onPointerMove(ev: React.PointerEvent): void { if (!drag || !svgRef.current) return const view = pointerToViewBox(svgRef.current, ev.clientX, ev.clientY, VIEW_W, VIEW_H) let next = values let nextEdited = edited if (drag.mode === 'W' || drag.mode === 'WH') { if (widthKey) { const bW = dimensionBounds(widthKey) const newW = clampDimension(drag.startW + (view.x - drag.startX) * (drag.startW / drag.rectW), bW.min, bW.max) next = withKey(next, widthKey, newW) nextEdited = withKeyBool(nextEdited, widthKey, true) } } if (drag.mode === 'H' || drag.mode === 'WH') { if (heightKey) { const bH = dimensionBounds(heightKey) const newH = clampDimension(drag.startH + (view.y - drag.startY) * (drag.startH / drag.rectH), bH.min, bH.max) next = withKey(next, heightKey, newH) nextEdited = withKeyBool(nextEdited, heightKey, true) } } if (next !== values) setValues(next) if (nextEdited !== edited) setEdited(nextEdited) } function endDrag(): void { setDrag(null) } function onFieldChange(key: string, ev: React.ChangeEvent): void { const raw = ev.target.value setFieldText((prev) => ({ ...prev, [key]: raw })) const n = parseDimension(raw) if (n == null) { setInvalid((prev) => ({ ...prev, [key]: true })) return } const bounds = dimensionBounds(key) setInvalid((prev) => ({ ...prev, [key]: false })) setValues((prev) => withKey(prev, key, clampDimension(n, bounds.min, bounds.max))) setEdited((prev) => withKeyBool(prev, key, true)) } function onFieldBlur(key: string): void { setFieldText((prev) => ({ ...prev, [key]: formatDimension(values[key]) })) setInvalid((prev) => ({ ...prev, [key]: false })) } function focusField(key: string): void { if (disabled || typeof document === 'undefined') return const el = document.querySelector(`.hq-genhit__field-input[data-field="${key}"]`) as HTMLInputElement | null if (el && el.focus) { el.focus() el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) } } const hasInvalid = Object.values(invalid).some(Boolean) // Dimension lines with arrowheads for W (top) and H (left). const topY = geom.y - 16 const leftX = geom.x - 16 const wMid = geom.x + geom.w / 2 const hMid = geom.y + geom.h / 2 const wLabel = widthKey && values[widthKey] != null ? `${formatDimension(values[widthKey])} ${t('card.editor.unit')}` : '' const hLabel = heightKey && values[heightKey] != null ? `${formatDimension(values[heightKey])} ${t('card.editor.unit')}` : '' const arrow = (pts: string): ReactElement => const tolText = (key: string | null): string => { const tol = toleranceOf(values, key) if (!tol || tol.min == null || tol.max == null || tol.min === tol.max) return '' return `${formatDimension(tol.min)}\u2013${formatDimension(tol.max)} ${t('card.editor.unit')}` } const wTolText = tolText(widthKey) const hTolText = tolText(heightKey) const groups = classifyDimensions(normalized.numericKeys, widthKey, heightKey) const essentialKeys: string[] = [] if (widthKey) essentialKeys.push(widthKey) if (heightKey) essentialKeys.push(heightKey) for (const ek of groups.essential) { if (ek !== widthKey && ek !== heightKey) essentialKeys.push(ek) } const advancedKeys = groups.advanced const renderField = (key: string): ReactElement => { const bounds = dimensionBounds(key) return (
{edited[key] ? {t('card.editor.edited')} : {t('card.editor.ai')}} onFieldChange(key, ev)} onBlur={() => onFieldBlur(key)} /> {fieldUnit(key)}
) } // Validation strip. const issues = validateDimensions(values) const seen = new Set() const uniqueIssues = issues.filter((it) => { if (seen.has(it.key)) return false seen.add(it.key) return true }) const issueTexts = uniqueIssues.map((it) => { const codeLabel = it.code === 'min_gt_max' ? t('card.editor.issueMinGtMax') : it.code === 'out_of_range' ? t('card.editor.issueOutOfRange') : t('card.editor.invalid') return `${labelFor(it.key)}: ${codeLabel}` }) const totalIssues = issueTexts.length + (hasInvalid ? 1 : 0) const canEditGeometry = !!(widthKey && heightKey) const summaryParts = summaryOf(values, widthKey, heightKey, t) return (
{canEditGeometry ? ( {arrow(`${geom.x},${topY} ${geom.x + 7},${topY - 3} ${geom.x + 7},${topY + 3}`)} {arrow(`${geom.x + geom.w},${topY} ${geom.x + geom.w - 7},${topY - 3} ${geom.x + geom.w - 7},${topY + 3}`)} focusField(widthKey) : undefined} > {wLabel} {wTolText ? {wTolText} : null} {arrow(`${leftX},${geom.y} ${leftX - 3},${geom.y + 7} ${leftX + 3},${geom.y + 7}`)} {arrow(`${leftX},${geom.y + geom.h} ${leftX - 3},${geom.y + geom.h - 7} ${leftX + 3},${geom.y + geom.h - 7}`)} focusField(heightKey) : undefined} > {hLabel} {hTolText ? {hTolText} : null} {packageSilhouette(pkgType, values, geom).map((node, ni) => ( {node} ))} ) : null} {canEditGeometry ?
{t('card.editor.dragHint')}
: null}
{pkgFamilyLabel(pkgType) ? {pkgFamilyLabel(pkgType)} : null} {t('card.editor.pins', { count: pinCountOf(values, 0) })} {summaryParts.map((part, pi) => ( · {part} ))}
{essentialKeys.length > 0 ?
{essentialKeys.map(renderField)}
: null} {advancedKeys.length > 0 ? (
{advancedOpen ?
{advancedKeys.map(renderField)}
: null}
) : null}
0 ? ' hq-genhit__validation--warn' : ''}`}> {totalIssues > 0 ? ( {t('card.editor.validationIssue', { n: totalIssues })} {issueTexts.length > 0 ? {issueTexts[0]} : null} ) : {t('card.editor.validationOk')}}
) } // ── needs_auth login card ─────────────────────────────────────────────────── interface LoginCardProps { toolName: string authState?: AuthStateLike t: Translate getAuth?: () => AuthClientLike | undefined } function LoginCard({ toolName, authState, t, getAuth }: LoginCardProps): ReactElement { const dark = useTheme() const locale = useLocale() const authClient = getAuth?.()?.auth // HQ Edge host mode: EDA owns the credential — login must ask EDA to open // its own TriggerLoginDialog (hq-edge POST /api/v1/auth/login), not the // auth.eda.cn iframe (a browser-pushed token is ignored by the STRICT host // resolver). Standalone DSH keeps the inline iframe. // FILL mode (`fill=full`): this card IS the surface, so let the embed paint // it edge-to-edge with its own `bg-background`. Without it the embed's // `grid-rows-[20px_1fr_20px]` wrapper leaves two transparent strips above // and below the form, which read as white gaps in dark theme. const src = useMemo( () => buildLoginUrl({ lang: locale, theme: dark ? 'dark' : 'light' }), [locale, dark], ) // Force a full remount on a theme/locale flip: Chrome keeps the old embed // loaded when only `src` changes (the embed is a Next.js page that reads // its URL params once on mount), silently ignoring the new `fill`/`theme`. const remountKey = `${locale}|${dark ? 'd' : 'l'}` const statusLine = (

{authState?.authenticated ? t('card.auth.loggedIn', { nickname: authState.nickname ? t('card.nicknameSep', { nickname: authState.nickname }) : '', }) : t('card.auth.loggedOut')}

) if (authClient?.isHostMode?.()) { return (
⛁ {t('card.auth.title')}

{t('card.auth.descHost', { tool: toolName })}

{statusLine}
) } return (
⛁ {t('card.auth.title')}

{t('card.auth.desc', { tool: toolName })}

{statusLine}