@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.
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Runtime locale pack for the schematic/system HIT card (zh + en), default zh.
3
+ */
4
+ import { useCallback } from 'react'
5
+ import { useLocale } from './theme.js'
6
+
7
+ /**
8
+ * Chinese is the source of truth: `CopyKey` is derived from it and `EN` is
9
+ * typed against it, so a key added to `ZH` but missing from `EN` is a COMPILE
10
+ * error instead of a silent Chinese fallback at runtime.
11
+ */
12
+ const ZH = {
13
+ 'card.title.schematic': '生成原理图',
14
+ 'card.title.system': '生成系统设计',
15
+ 'card.kind.schematic': '原理图',
16
+ 'card.kind.system': '系统设计',
17
+ 'card.status.generating': '正在生成…',
18
+ 'card.status.generated': '已生成',
19
+ 'card.status.failed': '生成失败',
20
+ // Live progress: one label per rung of the coarse stage ladder. The keys
21
+ // match `STAGE_LADDERS` in src/progress.ts; a run can take 10+ minutes, so
22
+ // these are what the user reads while waiting.
23
+ 'card.progress.waiting': '正在等待智能体开始…',
24
+ 'card.stage.requirement': '解析需求',
25
+ 'card.stage.architecture': '设计架构',
26
+ 'card.stage.circuit': '设计电路',
27
+ 'card.stage.report': '撰写报告',
28
+ 'card.stage.output': '导出图纸',
29
+ 'card.stage.plan': '制定方案',
30
+ 'card.stage.search': '检索器件',
31
+ 'card.stage.bom': '生成 BOM',
32
+ 'card.stage.modules': '设计模块',
33
+ 'card.stage.connect': '连接网络',
34
+ 'card.stage.erc': 'ERC 检查',
35
+ 'card.stage.export': '导出工程',
36
+ 'card.preview.loading': '正在加载预览…',
37
+ 'card.preview.renderError': '预览渲染失败:',
38
+ 'card.preview.resolveError': '无法解析工件:',
39
+ 'card.preview.missing': '没有可用于预览的工件内容',
40
+ 'card.error.toolFailed': '工具调用失败',
41
+ 'card.error.retry': '重试',
42
+ 'card.meta.design': '设计',
43
+ 'card.meta.sheets': '{count} 张图纸',
44
+ 'card.meta.modules': '{count} 个模块',
45
+ 'card.meta.connections': '{count} 条连接',
46
+ 'card.action.download': '下载',
47
+ 'card.action.downloading': '下载中…',
48
+ 'card.action.regenerate': '重新生成',
49
+ 'card.action.regenerating': '重新生成中…',
50
+ 'card.action.inspect': '详情',
51
+ 'card.regeneratePrompt.schematic': '请重新生成刚才的原理图',
52
+ 'card.regeneratePrompt.system': '请重新生成刚才的系统设计',
53
+ 'card.auth.title': '华秋 EDA AI(eda.cn)登录',
54
+ 'card.auth.desc': '工具「{tool}」需要登录华秋 EDA AI 账号才能继续。请在下方的登录框完成登录(或点击左侧「华秋EDA AI登录」按钮);登录完成后,回复助手「已登录,请重试」,助手会自动重新调用该工具。',
55
+ 'card.auth.loggedIn': '✓ 已登录{nickname}—— 现在可以回复助手「已登录,请重试」,助手会重新调用工具。',
56
+ 'card.auth.loggedOut': '未登录 —— 请在上方登录华秋 EDA AI(eda.cn)账号,或点击左侧「华秋EDA AI 登录」按钮;登录完成后让助手重试。',
57
+ // Substituted into `{nickname}` by `card.auth.loggedIn`. zh uses a
58
+ // full-width colon, en a half-width one plus a space — hardcoding ':'
59
+ // made the English card read "Logged in:John".
60
+ 'card.nicknameSep': ':{nickname}',
61
+ } as const
62
+
63
+ export type CopyKey = keyof typeof ZH
64
+
65
+ const EN: Record<CopyKey, string> = {
66
+ 'card.title.schematic': 'Generated schematic',
67
+ 'card.title.system': 'Generated system design',
68
+ 'card.kind.schematic': 'schematic',
69
+ 'card.kind.system': 'system design',
70
+ 'card.status.generating': 'Generating…',
71
+ 'card.status.generated': 'Generated',
72
+ 'card.status.failed': 'Failed',
73
+ 'card.progress.waiting': 'Waiting for the agent to start…',
74
+ 'card.stage.requirement': 'Parsing requirement',
75
+ 'card.stage.architecture': 'Designing architecture',
76
+ 'card.stage.circuit': 'Designing circuit',
77
+ 'card.stage.report': 'Writing report',
78
+ 'card.stage.output': 'Exporting sheets',
79
+ 'card.stage.plan': 'Planning',
80
+ 'card.stage.search': 'Searching parts',
81
+ 'card.stage.bom': 'Building BOM',
82
+ 'card.stage.modules': 'Designing modules',
83
+ 'card.stage.connect': 'Connecting nets',
84
+ 'card.stage.erc': 'Running ERC',
85
+ 'card.stage.export': 'Exporting project',
86
+ 'card.preview.loading': 'Loading preview…',
87
+ 'card.preview.renderError': 'Preview render failed: ',
88
+ 'card.preview.resolveError': 'Could not resolve artifact: ',
89
+ 'card.preview.missing': 'No previewable artifact content',
90
+ 'card.error.toolFailed': 'Tool call failed',
91
+ 'card.error.retry': 'Retry',
92
+ 'card.meta.design': 'Design',
93
+ 'card.meta.sheets': '{count} sheet(s)',
94
+ 'card.meta.modules': '{count} module(s)',
95
+ 'card.meta.connections': '{count} connection(s)',
96
+ 'card.action.download': 'Download',
97
+ 'card.action.downloading': 'Downloading…',
98
+ 'card.action.regenerate': 'Regenerate',
99
+ 'card.action.regenerating': 'Regenerating…',
100
+ 'card.action.inspect': 'Inspect',
101
+ 'card.regeneratePrompt.schematic': 'Please regenerate the schematic above',
102
+ 'card.regeneratePrompt.system': 'Please regenerate the system design above',
103
+ 'card.auth.title': 'Huaqiu EDA AI (eda.cn) login',
104
+ // The reply phrase was hardcoded to the Chinese "已登录,请重试" even in
105
+ // this English string, telling an English-speaking user to type Chinese.
106
+ 'card.auth.desc': 'Tool "{tool}" requires a Huaqiu EDA AI login. Complete the login below (or use the 华秋EDA AI sidebar button); then reply "I have logged in, please retry" so the assistant can retry the tool.',
107
+ 'card.auth.loggedIn': '✓ Logged in{nickname} — reply "I have logged in, please retry" and the assistant will retry.',
108
+ 'card.auth.loggedOut': 'Not logged in — complete the login above, or use the 华秋EDA AI sidebar button.',
109
+ 'card.nicknameSep': ': {nickname}',
110
+ }
111
+
112
+ const COPY: Record<'zh' | 'en', Record<CopyKey, string>> = { zh: ZH, en: EN }
113
+
114
+ /** Every copy key, in declaration order (used to assert bilingual balance). */
115
+ export const COPY_KEYS = Object.keys(ZH) as CopyKey[]
116
+
117
+ export type Translate = (key: string, params?: Record<string, unknown>) => string
118
+
119
+ export function translate(lang: 'zh' | 'en', key: string, params?: Record<string, unknown>): string {
120
+ const dict = lang === 'en' ? COPY.en : COPY.zh
121
+ let v = dict[key as CopyKey]
122
+ if (v === undefined) v = COPY.zh[key as CopyKey]
123
+ if (v === undefined) v = key
124
+ if (params) {
125
+ for (const k of Object.keys(params)) {
126
+ v = v.split(`{${k}}`).join(String(params[k]))
127
+ }
128
+ }
129
+ return v
130
+ }
131
+
132
+ export const defaultT: Translate = (key, params) => translate('zh', key, params)
133
+
134
+ /**
135
+ * Translate bound to the HOST UI language.
136
+ *
137
+ * The card used to hard-pin `const t = defaultT`, which silently froze every
138
+ * string to zh and left the `en` dictionary above as dead code. `useT()`
139
+ * follows the locale store in `theme.js` instead, so the card switches
140
+ * language with the app.
141
+ */
142
+ export function useT(): Translate {
143
+ const lang = useLocale()
144
+ return useCallback(
145
+ (key: string, params?: Record<string, unknown>) => translate(lang, key, params),
146
+ [lang],
147
+ )
148
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * `@huaqiu/dsh-tool-schematic-gen` — browser half (the schematic/system HIT).
3
+ *
4
+ * Registers keyed `tool.call.toolview` entries for both
5
+ * `generate_schematic_from_description` and `generate_system_module_graph`,
6
+ * replacing the stock JSON fallback (and the auth plugin's generic row) with
7
+ * the `GenHit` card: header → summary → ECAD canvas preview (single sheet for
8
+ * the schematic tool, project zip root sheet for the system tool) → actions.
9
+ *
10
+ * The authoritative state comes from DSH itself: the frozen `ToolCallBlock` is
11
+ * projected by the pure `projectToolCall()` into a single phase. Human answers
12
+ * ("regenerate") are sent back to the agent through
13
+ * `sessions.binding(sessionId).session.prompt`, so the node `ask()` stays the
14
+ * single source of truth.
15
+ */
16
+ import { createElement, useEffect, useState, type ComponentType } from 'react'
17
+ import { GenHit } from './hit-card.jsx'
18
+ import { injectStyles, removeStyles, disposeThemeObserver } from './theme.js'
19
+ import type { AuthStateLike, PromptSender } from './hit-card.jsx'
20
+
21
+ export type { AuthStateLike, PromptSender }
22
+
23
+ const TOOLVIEW_KEYS = [
24
+ 'generate_schematic_from_description',
25
+ 'generate_system_module_graph',
26
+ ] as const
27
+
28
+ /**
29
+ * Client cordis inject: REAL service names only.
30
+ *
31
+ * `huaqiuAuth` is the client service provided by `@huaqiu/dsh-auth`. It is the
32
+ * ONLY sanctioned source of login state — this plugin must not read the
33
+ * credential cache itself (spec §10). Reading `localStorage` directly would
34
+ * duplicate the storage key and the expiry rule, and would report the user as
35
+ * permanently logged out whenever `@huaqiu/dsh-auth` runs in HQ Edge host mode
36
+ * (where there is no browser-side credential cache at all).
37
+ */
38
+ export const inject: string[] = ['slots', 'sessions', 'huaqiuAuth']
39
+
40
+ /** Minimal structural client context (dsh-client-runtime provides these). */
41
+ export interface ClientContext {
42
+ provide?(name: string, value: unknown): () => void
43
+ slots?: {
44
+ inject(key: string, callback: () => () => void): () => void
45
+ register(spec: { name: string; key?: string; id?: string }, component: unknown): unknown
46
+ }
47
+ get<T = unknown>(name: string): T | undefined
48
+ effect?(fn: () => (() => void) | void, label?: string): void
49
+ }
50
+
51
+ /**
52
+ * Structural view of the `huaqiuAuth` CLIENT service (`@huaqiu/dsh-auth`).
53
+ *
54
+ * Declared structurally, never imported: each package must remain
55
+ * independently installable. `nickname` is optional because the HQ Edge host
56
+ * contract may not supply one.
57
+ */
58
+ export interface HuaqiuAuthClientService {
59
+ auth?: {
60
+ isAuthenticated(): boolean
61
+ getUserInfo(): Promise<{ nickname?: string } | null>
62
+ onAuthStateChanged(listener: (info: { nickname?: string } | null) => void): () => void
63
+ }
64
+ }
65
+
66
+ const LOGGED_OUT: AuthStateLike = { authenticated: false }
67
+
68
+ function stateOf(info: { nickname?: string } | null): AuthStateLike {
69
+ if (!info) return LOGGED_OUT
70
+ return {
71
+ authenticated: true,
72
+ ...(info.nickname ? { nickname: info.nickname } : {}),
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Subscribe to the `huaqiuAuth` client service.
78
+ *
79
+ * Built as a hook rather than a plain read because the service resolves
80
+ * credentials asynchronously (`getUserInfo()`), while the card renders
81
+ * synchronously. The first paint uses the cheap synchronous
82
+ * `isAuthenticated()` so the card never flashes "logged out"; the effect then
83
+ * resolves the full payload once and follows every subsequent change.
84
+ */
85
+ function createUseAuthState(auth: HuaqiuAuthClientService['auth'] | undefined): () => AuthStateLike {
86
+ return function useAuthState(): AuthStateLike {
87
+ const [state, setState] = useState<AuthStateLike>(() => ({
88
+ authenticated: auth?.isAuthenticated() ?? false,
89
+ }))
90
+
91
+ useEffect(() => {
92
+ if (!auth) {
93
+ setState(LOGGED_OUT)
94
+ return
95
+ }
96
+ let alive = true
97
+ void auth.getUserInfo()
98
+ .then((info) => { if (alive) setState(stateOf(info)) })
99
+ .catch(() => { if (alive) setState(LOGGED_OUT) })
100
+ const off = auth.onAuthStateChanged((info) => {
101
+ if (alive) setState(stateOf(info))
102
+ })
103
+ return () => {
104
+ alive = false
105
+ off()
106
+ }
107
+ }, [])
108
+
109
+ return state
110
+ }
111
+ }
112
+
113
+ export function apply(ctx: ClientContext): () => void {
114
+ const disposers: Array<() => void> = []
115
+
116
+ const authService = ctx.get<HuaqiuAuthClientService | undefined>('huaqiuAuth')
117
+ const useAuthState = createUseAuthState(authService?.auth)
118
+
119
+ const sendPrompt: PromptSender = (sessionId, message) => {
120
+ const sessions = ctx.get<{ binding(id: string): { session: { prompt(content: Array<{ type: 'text'; text: string }>, mode: 'queue'): Promise<unknown> } } | undefined }>('sessions')
121
+ if (!sessions || typeof sessions.binding !== 'function' || !sessionId) {
122
+ return Promise.reject(new Error('no session conversation channel'))
123
+ }
124
+ const binding = sessions.binding(sessionId)
125
+ const session = binding?.session
126
+ if (!session || typeof session.prompt !== 'function') {
127
+ return Promise.reject(new Error('no session conversation channel'))
128
+ }
129
+ return session.prompt([{ type: 'text', text: message }], 'queue')
130
+ }
131
+
132
+ const slots = ctx.slots
133
+ if (slots && typeof slots.inject === 'function' && typeof slots.register === 'function') {
134
+ const GenHitView: ComponentType<{
135
+ toolName?: string
136
+ block?: unknown
137
+ sessionId?: string
138
+ /**
139
+ * Published as part of `ToolCallOwnerProps` and stable across the
140
+ * running and settled forms. Forwarded so the card can correlate with
141
+ * the node half's progress store.
142
+ */
143
+ callId?: string
144
+ inspect?: () => void
145
+ }> = (props) =>
146
+ createElement(GenHit, {
147
+ ...props,
148
+ toolName: props.toolName ?? '',
149
+ block: props.block as never,
150
+ sessionId: props.sessionId,
151
+ callId: props.callId,
152
+ sendPrompt,
153
+ authState: useAuthState(),
154
+ })
155
+
156
+ for (const toolName of TOOLVIEW_KEYS) {
157
+ disposers.push(slots.inject('tool.call.toolview', () => slots.register(
158
+ { name: 'tool.call.toolview', key: toolName },
159
+ GenHitView,
160
+ ) as () => void))
161
+ }
162
+ }
163
+
164
+ injectStyles()
165
+
166
+ const cleanup = (): void => {
167
+ for (const dispose of disposers) {
168
+ try { dispose() } catch { /* already disposed */ }
169
+ }
170
+ disposers.length = 0
171
+ removeStyles()
172
+ disposeThemeObserver()
173
+ }
174
+ if (ctx.effect) ctx.effect(() => cleanup, 'schematic-gen: HIT dispose')
175
+ return cleanup
176
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * auth.eda.cn embed URL contract for the `needs_auth` login card.
3
+ *
4
+ * SELF-CONTAINED ON PURPOSE: this package must stay independently
5
+ * installable, so it duplicates the ~40 lines of URL contract from
6
+ * `@huaqiu/dsh-auth` (`src/client/lib.ts`) instead of importing them. Keep
7
+ * the two in sync — `AUTH_IFRAME_VERSION` in particular is a shared
8
+ * cache-buster with the web app (`hq-eda-ai` LoginDialog).
9
+ *
10
+ * The embed has two rendering modes and this card uses FILL mode:
11
+ *
12
+ * - **Fill mode** (`fill=full`) — the embed's `DialogContent` becomes
13
+ * `w-full h-full max-w-none max-h-none left-0 top-0 rounded-none border-none`
14
+ * (`eda-cn-login/components/LoginDialog.tsx`, `fillFull` branch) and the
15
+ * page wrapper drops `bg-transparent`, so the embed paints its own
16
+ * `bg-background` edge-to-edge. That is what this card wants: the iframe
17
+ * IS the surface, and fill mode is what removes the white gaps above and
18
+ * below the form that the transparent mode's `grid-rows-[20px_1fr_20px]`
19
+ * strips would otherwise leave showing the host surface.
20
+ * - **Transparent card mode** (no `fill`) — used by the sidebar-triggered
21
+ * login dialog in `dsh-auth`, which paints its own host-colored card
22
+ * around the iframe. Not used here.
23
+ */
24
+
25
+ export const AUTH_ORIGIN = 'https://auth.eda.cn'
26
+
27
+ /**
28
+ * Contract version of the auth.eda.cn embed, shared with the web app
29
+ * (`hq-eda-ai` LoginDialog) and `dsh-auth` so all three send the same
30
+ * cache-busting `v=`.
31
+ */
32
+ export const AUTH_IFRAME_VERSION = '20260409'
33
+
34
+ /**
35
+ * Iframe height. Sized so the embed's two-column layout (WeChat QR + phone
36
+ * form, which needs an iframe viewport ≥768px — Tailwind's `md` breakpoint)
37
+ * plus the agreement checkbox fits without an internal scrollbar.
38
+ */
39
+ export const LOGIN_IFRAME_HEIGHT = 440
40
+
41
+ /** UI language of the auth.eda.cn embed. */
42
+ export type AuthLocale = 'zh' | 'en'
43
+
44
+ /** Color scheme of the auth.eda.cn embed (its own vocabulary). */
45
+ export type AuthTheme = 'light' | 'dark'
46
+
47
+ /**
48
+ * auth.eda.cn's own language ids, keyed by our locale id. The embed's
49
+ * `LanguageContext.getLangFromUrl` only accepts these two ids from
50
+ * `locales/index.ts`; anything else silently falls back to the browser
51
+ * language. `lang=zh` (what hq-eda-ai sends) is ignored for the same reason,
52
+ * but we send it too for parity and forward compatibility.
53
+ */
54
+ export const AUTH_LOCALE_ID: Record<AuthLocale, string> = { zh: 'cn', en: 'en' }
55
+
56
+ export interface LoginUrlOptions {
57
+ /** Embed UI language (default `zh`). */
58
+ lang?: AuthLocale
59
+ /** Embed color scheme (default `light`). */
60
+ theme?: AuthTheme
61
+ }
62
+
63
+ /**
64
+ * Build the auth.eda.cn embed URL for this card: FILL mode, closing on an
65
+ * outside click, in the host's language and color scheme.
66
+ */
67
+ export function buildLoginUrl(options: LoginUrlOptions = {}): string {
68
+ const url = new URL(`${AUTH_ORIGIN}/`)
69
+ url.searchParams.set('v', AUTH_IFRAME_VERSION)
70
+ url.searchParams.set('clickOutsideToClose', 'true')
71
+ // Fill mode: the card IS the surface, so let the embed paint it.
72
+ url.searchParams.set('fill', 'full')
73
+ const lang = options.lang ?? 'zh'
74
+ url.searchParams.set('locale', AUTH_LOCALE_ID[lang])
75
+ url.searchParams.set('lang', lang)
76
+ url.searchParams.set('theme', options.theme ?? 'light')
77
+ return url.toString()
78
+ }
79
+
80
+ /**
81
+ * Background for the iframe ELEMENT, matching the host surface.
82
+ *
83
+ * Even in fill mode the element needs a surface color: Blink's
84
+ * `BaseBackgroundColor()` falls back to WHITE until the embed's own CSS
85
+ * applies, which flashes a white block in dark mode on every card mount.
86
+ * The DSH alias token is preferred so custom host themes are honored, with a
87
+ * per-scheme fallback.
88
+ */
89
+ export function loginIframeBackground(dark: boolean): string {
90
+ return dark ? 'var(--dsw-alias-bg-layer-1, #20242c)' : 'var(--dsw-alias-bg-layer-1, #ffffff)'
91
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Pure projection layer for the schematic/system-design HIT card.
3
+ *
4
+ * Result shapes produced by the node half (`src/tools.ts`):
5
+ * generate_schematic_from_description →
6
+ * { status:'generated', kind:'schematic', design_name, schFiles:[{filename}],
7
+ * schArtifacts:[{id,type,filename,size}], kicadPro, project_achieve_url, note? }
8
+ * generate_system_module_graph →
9
+ * { status:'generated', kind:'system', design_name, module_count,
10
+ * connection_count, module_names, zip_bytes,
11
+ * zipArtifact:{id,type:'zip',filename,size}, note? }
12
+ * both may return { status:'needs_auth', kind, hint }
13
+ */
14
+
15
+ export interface ContentBlockLike {
16
+ type?: string
17
+ text?: string
18
+ }
19
+
20
+ export interface ToolBlockLike {
21
+ content?: readonly ContentBlockLike[]
22
+ isError?: boolean
23
+ error?: { code?: string } | null
24
+ }
25
+
26
+ export interface ArtifactRef {
27
+ id: string
28
+ type: string | null
29
+ filename: string | null
30
+ size: number | null
31
+ }
32
+
33
+ export interface SchResult {
34
+ status: string | null
35
+ kind: string | null
36
+ artifact: ArtifactRef | null
37
+ designName: string | null
38
+ fileCount: number | null
39
+ moduleCount: number | null
40
+ connectionCount: number | null
41
+ /** User-safe status detail — rendered by the card. */
42
+ note: string | null
43
+ /**
44
+ * Agent-only directive/explanation. Parsed only so the shape is explicit;
45
+ * the card deliberately never renders it.
46
+ */
47
+ agentNote: string | null
48
+ }
49
+
50
+ export type ProjectedPhase =
51
+ | { phase: 'generating' }
52
+ | { phase: 'failed'; message: string }
53
+ | { phase: 'needs_auth'; result: SchResult }
54
+ | { phase: 'completed'; result: SchResult }
55
+ | { phase: 'unknown' }
56
+
57
+ export function resultTextOf(block: ToolBlockLike | undefined): string {
58
+ const content = block?.content
59
+ if (!Array.isArray(content)) return ''
60
+ for (const c of content) {
61
+ if (c && c.type === 'text' && typeof c.text === 'string') return c.text
62
+ }
63
+ return ''
64
+ }
65
+
66
+ function firstLine(text: string): string {
67
+ const t = text.trim()
68
+ const nl = t.indexOf('\n')
69
+ return nl === -1 ? t : t.slice(0, nl)
70
+ }
71
+
72
+ function artOf(a: unknown): ArtifactRef | null {
73
+ if (!a || typeof a !== 'object') return null
74
+ const o = a as Record<string, unknown>
75
+ const id = typeof o.id === 'string' ? o.id : null
76
+ if (!id) return null
77
+ return {
78
+ id,
79
+ type: typeof o.type === 'string' ? o.type : null,
80
+ filename: typeof o.filename === 'string' ? o.filename : null,
81
+ size: typeof o.size === 'number' ? o.size : null,
82
+ }
83
+ }
84
+
85
+ export function parseSchResult(text: string): SchResult | null {
86
+ const t = text.trim()
87
+ if (!t) return null
88
+ let v: unknown
89
+ try {
90
+ v = JSON.parse(t)
91
+ } catch {
92
+ return null
93
+ }
94
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return null
95
+ const o = v as Record<string, unknown>
96
+
97
+ const status = typeof o.status === 'string' ? o.status : null
98
+ const kind = typeof o.kind === 'string' ? o.kind : null
99
+
100
+ let artifact: ArtifactRef | null = null
101
+ if (kind === 'system') {
102
+ artifact = artOf(o.zipArtifact)
103
+ } else {
104
+ const list = Array.isArray(o.schArtifacts) ? (o.schArtifacts as unknown[]) : []
105
+ artifact = list.length > 0 ? artOf(list[0]) : null
106
+ }
107
+
108
+ return {
109
+ status,
110
+ kind,
111
+ artifact,
112
+ designName: typeof o.design_name === 'string' && o.design_name ? o.design_name : null,
113
+ fileCount: Array.isArray(o.schFiles) ? (o.schFiles as unknown[]).length
114
+ : (Array.isArray(o.schArtifacts) ? (o.schArtifacts as unknown[]).length : null),
115
+ moduleCount: typeof o.module_count === 'number' ? o.module_count : null,
116
+ connectionCount: typeof o.connection_count === 'number' ? o.connection_count : null,
117
+ note: typeof o.note === 'string' ? o.note : null,
118
+ agentNote: typeof o.agentNote === 'string' ? o.agentNote : null,
119
+ }
120
+ }
121
+
122
+ export function projectToolCall(block: ToolBlockLike | undefined): ProjectedPhase {
123
+ if (!block || typeof block !== 'object') return { phase: 'unknown' }
124
+ if (!Array.isArray(block.content)) return { phase: 'generating' }
125
+ if (block.isError === true) {
126
+ const errText = resultTextOf(block)
127
+ const errCode = block.error && typeof block.error.code === 'string' ? block.error.code : null
128
+ return { phase: 'failed', message: firstLine(errText) || (errCode ? `tool error: ${errCode}` : 'generation failed') }
129
+ }
130
+ const text = resultTextOf(block)
131
+ const parsed = parseSchResult(text)
132
+ if (!parsed) return { phase: 'failed', message: firstLine(text) || 'unparseable tool result' }
133
+ if (parsed.status === 'generated') return { phase: 'completed', result: parsed }
134
+ if (parsed.status === 'needs_auth') return { phase: 'needs_auth', result: parsed }
135
+ if (parsed.kind) return { phase: 'completed', result: parsed }
136
+ return { phase: 'failed', message: `unexpected tool status: ${parsed.status}` }
137
+ }
138
+
139
+ export function formatBytes(n: number | null): string {
140
+ if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return ''
141
+ if (n < 1024) return `${n} B`
142
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
143
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`
144
+ }
145
+
146
+ /** Filename for download: prefer the artifact filename, else derive from kind. */
147
+ export function downloadFilenameFor(kind: string | null, artifact: ArtifactRef | null, designName: string | null): string {
148
+ if (artifact?.filename) return artifact.filename
149
+ const base = designName && designName.length > 0 ? designName : 'generated'
150
+ if (kind === 'system') return `${base}.zip`
151
+ if (kind === 'schematic') return `${base}.kicad_sch`
152
+ return `${base}.txt`
153
+ }