@huaqiu/dsh-tool-schematic-gen 0.3.14 → 0.3.16

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/lib/index.mjs CHANGED
@@ -887,8 +887,75 @@ function needsAuth(kind) {
887
887
  };
888
888
  }
889
889
  /**
890
- * `generate_schematic_from_description` body — stream `schemagen`, extract the
891
- * inline `.kicad_sch` files, then store each sheet as a preview artifact.
890
+ * Host whitelist for the project-zip download — mirrors the web app's
891
+ * `/api/sch_sub_gen/download_zip` proxy (`apps/web/.../download_zip/route.ts`):
892
+ * only https/http URLs on `eda.cn` / `*.eda.cn` are allowed. The URL comes
893
+ * from the design agent's STATE_SNAPSHOT, but a node-side guard keeps a
894
+ * compromised/misbehaving agent from turning the plugin into an SSRF proxy.
895
+ */
896
+ function isAllowedZipHost(url) {
897
+ if (url.protocol !== "https:" && url.protocol !== "http:") return false;
898
+ return url.hostname === "eda.cn" || url.hostname.endsWith(".eda.cn");
899
+ }
900
+ /**
901
+ * Download the project zip from the agent-uploaded URL.
902
+ *
903
+ * `project_achieve_url` is the source of truth — the same eda.cn datastream
904
+ * URL the web app streams for export/download. It carries the full KiCad
905
+ * project (sheets AND the footprints that keep sch↔pcb in sync). A plain GET
906
+ * suffices (the web app proxies it only because of browser CORS; node has no
907
+ * such restriction). Returns null when the URL is absent, not on the eda.cn
908
+ * whitelist, or the download fails — the caller then falls back to inline
909
+ * sheets.
910
+ */
911
+ async function fetchSchematicProjectZip(env, url) {
912
+ if (!url) return null;
913
+ let target;
914
+ try {
915
+ target = new URL(url);
916
+ } catch {
917
+ log$1.warn("schematic zip download skipped — malformed url", { url });
918
+ return null;
919
+ }
920
+ if (!isAllowedZipHost(target)) {
921
+ log$1.warn("schematic zip download skipped — host not allowed", {
922
+ host: target.hostname,
923
+ url
924
+ });
925
+ return null;
926
+ }
927
+ const fetchImpl = env.deps?.fetchImpl ?? fetch;
928
+ const controller = new AbortController();
929
+ const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error("schematic-gen: zip download did not respond within 1800000ms")), HTTP_TIMEOUT_MS);
930
+ try {
931
+ const res = await fetchImpl(url, {
932
+ signal: controller.signal,
933
+ headers: { accept: "application/zip" }
934
+ });
935
+ if (!res || !res.ok) {
936
+ log$1.warn("schematic zip download failed", {
937
+ status: res && res.status,
938
+ url
939
+ });
940
+ return null;
941
+ }
942
+ const ab = await res.arrayBuffer();
943
+ return Buffer.from(ab);
944
+ } catch (err) {
945
+ log$1.warn("schematic zip download failed", {
946
+ error: String(err?.message || err),
947
+ url
948
+ });
949
+ return null;
950
+ } finally {
951
+ clearTimeout(timer);
952
+ }
953
+ }
954
+ /**
955
+ * `generate_schematic_from_description` body — stream `schemagen`, then store
956
+ * the agent-uploaded project ZIP as a `zip` preview artifact (single source of
957
+ * truth: it carries the sheets AND the footprints that keep sch↔pcb in sync).
958
+ * Sheets are kept inline only as a fallback when the zip is unavailable.
892
959
  */
893
960
  async function runGenerateSchematic(args, exec, env) {
894
961
  const account = await resolveAccount(env.auth);
@@ -926,18 +993,30 @@ async function runGenerateSchematic(args, exec, env) {
926
993
  prog.failed(message);
927
994
  throw new Error(message);
928
995
  }
929
- const materialized = await materializeSchematicArtifacts(env, extracted.schFiles);
930
996
  const result = {
931
997
  status: "generated",
932
998
  kind: "schematic",
933
999
  design_name: extracted.outProject || "",
934
- schFiles: materialized.schFiles,
935
- schArtifacts: materialized.schArtifacts,
936
1000
  kicadPro: extracted.kicadPro,
937
1001
  project_achieve_url: extracted.project_achieve_url
938
1002
  };
939
- if (materialized.note) result.note = materialized.note;
940
- if (materialized.agentNote) result.agentNote = materialized.agentNote;
1003
+ const zipBuf = await fetchSchematicProjectZip(env, extracted.project_achieve_url);
1004
+ if (zipBuf && zipBuf.length > 0) {
1005
+ result.zip_bytes = zipBuf.length;
1006
+ try {
1007
+ result.zipArtifact = await toArtifactEntry(env, await createPreviewArtifact(env, "zip", sanitizeZipBaseName(extracted.outProject || "schematic") + ".zip", zipBuf.toString("base64"), "base64"));
1008
+ } catch (storeErr) {
1009
+ result.note = "Could not store the project zip as an artifact (" + String(storeErr?.message || storeErr) + ").";
1010
+ result.agentNote = "The project zip (sheets + footprints) is still available at project_achieve_url; the card falls back to rendering the inline sheets below.";
1011
+ }
1012
+ }
1013
+ if (!result.zipArtifact) {
1014
+ const materialized = await materializeSchematicArtifacts(env, extracted.schFiles);
1015
+ result.schFiles = materialized.schFiles;
1016
+ if (materialized.schArtifacts) result.schArtifacts = materialized.schArtifacts;
1017
+ if (materialized.note) result.note = (result.note ? result.note + " " : "") + materialized.note;
1018
+ if (materialized.agentNote) result.agentNote = materialized.agentNote;
1019
+ }
941
1020
  prog.done();
942
1021
  return result;
943
1022
  }
@@ -1034,7 +1113,7 @@ const AUTH_GATE_NOTE = "AUTH: This tool requires a Huaqiu EDA (eda.cn) account.
1034
1113
  function createSchematicTool(env) {
1035
1114
  return defineTool({
1036
1115
  name: "generate_schematic_from_description",
1037
- description: "Generate a KiCad schematic (.kicad_sch files) from a natural-language description of a circuit or sub-circuit — e.g. \"design a 5V LM7805 linear regulator power supply with input and output filter capacitors\". Calls the online HQ-EDA schematic generation agent and returns schFiles (filename references), schArtifacts (preview artifact references with id/type/filename/size plus a uri when the cross-process placement channel is available), kicadPro and project_achieve_url. Use this when the user asks to draw, generate or create a circuit schematic from a description (not from an image — for that use the symbol/footprint tools). IMPORTANT: The generated schematic renders automatically as a result card in the web client — an interactive canvas preview per sheet (multi-sheet results get a sheet tab bar) and a download button for the current sheet. Do NOT paste the schematic source, file URLs, or any fenced code block into your reply; just note in one line that the schematic was generated and how many sheets it has. " + AUTH_GATE_NOTE,
1116
+ description: "Generate a KiCad schematic (.kicad_sch files) from a natural-language description of a circuit or sub-circuit — e.g. \"design a 5V LM7805 linear regulator power supply with input and output filter capacitors\". Calls the online HQ-EDA schematic generation agent and returns zipArtifact (the project zip — the single source of truth: it contains the .kicad_sch sheets AND the footprints that keep sch↔pcb in sync), plus kicadPro and project_achieve_url. Use this when the user asks to draw, generate or create a circuit schematic from a description (not from an image — for that use the symbol/footprint tools). IMPORTANT: The generated schematic renders automatically as a result card in the web client — an interactive canvas preview of the project (root sheet of the zip) and a download button that downloads the full project zip. Do NOT paste the schematic source, file URLs, or any fenced code block into your reply; just note in one line that the schematic was generated and how many sheets it has. " + AUTH_GATE_NOTE,
1038
1117
  parameters: {
1039
1118
  description: {
1040
1119
  type: "string",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huaqiu/dsh-tool-schematic-gen",
3
- "version": "0.3.14",
3
+ "version": "0.3.16",
4
4
  "type": "module",
5
5
  "main": "./lib/index.mjs",
6
6
  "types": "./lib/index.d.mts",
@@ -30,8 +30,8 @@
30
30
  "@deepseek-ai/cordis": "^4.0.1",
31
31
  "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.0",
32
32
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.0",
33
- "@huaqiu/dsh-auth": "^0.3.14",
34
- "@huaqiu/dsh-artifacts": "^0.3.14",
33
+ "@huaqiu/dsh-auth": "^0.3.16",
34
+ "@huaqiu/dsh-artifacts": "^0.3.16",
35
35
  "react": "^18"
36
36
  },
37
37
  "devDependencies": {
@@ -39,9 +39,9 @@
39
39
  "@huaqiu/kicad-sexpr-parser": "^0.1.1",
40
40
  "@types/react": "^18.3.31",
41
41
  "react": "^18.3.1",
42
- "@huaqiu/dsh-auth": "0.3.14",
43
- "@huaqiu/dsh-tool-uncollapse": "0.3.14",
44
- "@huaqiu/dsh-artifacts": "0.3.14"
42
+ "@huaqiu/dsh-artifacts": "0.3.16",
43
+ "@huaqiu/dsh-auth": "0.3.16",
44
+ "@huaqiu/dsh-tool-uncollapse": "0.3.16"
45
45
  },
46
46
  "files": [
47
47
  "lib",
@@ -52,7 +52,7 @@
52
52
  "access": "public"
53
53
  },
54
54
  "dependencies": {
55
- "@huaqiu/dsh-plugin-log": "0.3.14"
55
+ "@huaqiu/dsh-plugin-log": "0.3.16"
56
56
  },
57
57
  "scripts": {
58
58
  "typecheck": "tsc --noEmit",
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Base64-encode a byte array for transport inside JSON (the edge-bridge proxy
3
+ * serializes object bodies to JSON, so binary must travel base64). Chunked to
4
+ * avoid call-stack limits on large zips. Pure module — no DOM/ecad imports —
5
+ * so node-side tests can import it directly.
6
+ */
7
+ export function bytesToBase64(bytes: Uint8Array): string {
8
+ let bin = ''
9
+ const chunkSize = 0x8000
10
+ for (let i = 0; i < bytes.length; i += chunkSize) {
11
+ bin += String.fromCharCode(...bytes.subarray(i, i + chunkSize))
12
+ }
13
+ return btoa(bin)
14
+ }
@@ -26,6 +26,7 @@ import { placeSupportOf, type HqEdgePlaceLike } from './place.js'
26
26
  import { useLocale, useTheme } from './theme.js'
27
27
  import { buildLoginUrl, loginIframeBackground } from './login-url.js'
28
28
  import { LiveProgress } from './stack-frame.jsx'
29
+ import { bytesToBase64 } from './b64.js'
29
30
 
30
31
  /** Login-state view used by the needs_auth card (from the auth plugin's shared localStorage). */
31
32
  export interface AuthStateLike {
@@ -170,7 +171,8 @@ function PreviewStage({ payload, t }: { payload: PreviewPayload; t: Translate })
170
171
  })
171
172
  if (cancelled || canvas !== canvasRef.current) return
172
173
  sizeCanvasFor(canvas)
173
- if (payload.kind === 'system' && payload.bytes) {
174
+ if (payload.bytes) {
175
+ // A bytes payload is the full project zip — render its root sheet.
174
176
  disposeViewer = await renderProjectZipToCanvas(payload.bytes, canvas)
175
177
  } else if (payload.source) {
176
178
  disposeViewer = await renderSheetToCanvas(payload.source, canvas)
@@ -312,8 +314,11 @@ export const GenHit = memo(function GenHit(props: GenHitProps): ReactElement {
312
314
  setPayload({ phase: 'loading', source: null, bytes: null, filename: null, error: null })
313
315
  ;(async () => {
314
316
  try {
315
- const kind = result.kind ?? 'schematic'
316
- if (kind === 'system') {
317
+ // The artifact type decides the payload: a `zip` artifact is the full
318
+ // KiCad project (sheets + footprints) → bytes for project rendering /
319
+ // download; a `schematic` artifact is a single inline sheet → text.
320
+ const artType = result?.artifact?.type ?? (result?.kind === 'system' ? 'zip' : 'schematic')
321
+ if (artType === 'zip') {
317
322
  const art = await resolveArtifactBytes(artifactKey)
318
323
  if (cancelled) return
319
324
  setPayload({ phase: 'ready', source: null, bytes: art.bytes, filename: art.filename, error: null })
@@ -337,6 +342,8 @@ export const GenHit = memo(function GenHit(props: GenHitProps): ReactElement {
337
342
  // Outcome of the last Place attempt: 'ok' | 'error:<detail>'. Rendered as a
338
343
  // one-line status under the actions so the user knows the placement landed.
339
344
  const [placeStatus, setPlaceStatus] = useState<string | null>(null)
345
+ // Outcome of the last "Open in EDA" attempt (host mode import via hq-edge).
346
+ const [importStatus, setImportStatus] = useState<string | null>(null)
340
347
 
341
348
  function onDownload(): void {
342
349
  if (busy || payload.phase !== 'ready') return
@@ -344,7 +351,10 @@ export const GenHit = memo(function GenHit(props: GenHitProps): ReactElement {
344
351
  const filename = downloadFilenameFor(kind, result?.artifact ?? null, result?.designName ?? null)
345
352
  setBusy('download')
346
353
  try {
347
- if (kind === 'system' && payload.bytes) {
354
+ // A bytes payload is the full project zip — download it as-is. A text
355
+ // payload is a legacy inline sheet (no zip was stored) — download it as
356
+ // plain text.
357
+ if (payload.bytes) {
348
358
  downloadBytes(filename, payload.bytes)
349
359
  } else if (payload.source != null) {
350
360
  downloadText(filename, payload.source)
@@ -354,6 +364,40 @@ export const GenHit = memo(function GenHit(props: GenHitProps): ReactElement {
354
364
  }
355
365
  }
356
366
 
367
+ /**
368
+ * Open the generated project in the EDA editor. Host mode only: the browser
369
+ * half posts the project zip (base64 in JSON — the edge-bridge proxy cannot
370
+ * carry multipart) to hq-edge `POST /api/v1/import/kicad-b64`, which runs
371
+ * the ImportDesign pipeline (extract → gRPC → EDA opens the design).
372
+ */
373
+ async function onOpenInEda(): Promise<void> {
374
+ if (busy || payload.phase !== 'ready' || !payload.bytes) return
375
+ const api = props.getHqEdge?.()?.api
376
+ if (!api || typeof api.request !== 'function') return
377
+ setBusy('open-in-eda')
378
+ setImportStatus(null)
379
+ try {
380
+ const res = await api.request({
381
+ method: 'POST',
382
+ path: '/api/v1/import/kicad-b64',
383
+ body: {
384
+ zip_b64: bytesToBase64(payload.bytes),
385
+ filename: payload.filename ?? 'design.zip',
386
+ project_name: result?.designName ?? 'schematic',
387
+ source_vendor: 'circuit_agent',
388
+ source_format: 'kicad',
389
+ },
390
+ })
391
+ const body = (res ?? {}) as { design_id?: string; status?: string }
392
+ setImportStatus(t('card.import.done', { id: body.design_id ?? body.status ?? 'ok' }))
393
+ } catch (e) {
394
+ console.warn('[hq-schematic-gen] open-in-eda failed', e)
395
+ setImportStatus(t('card.import.failed', { detail: String((e as Error)?.message || e) }))
396
+ } finally {
397
+ setBusy(null)
398
+ }
399
+ }
400
+
357
401
  function onRegenerate(): void {
358
402
  if (busy) return
359
403
  setBusy('regenerate')
@@ -484,6 +528,11 @@ export const GenHit = memo(function GenHit(props: GenHitProps): ReactElement {
484
528
  const placeSupport = placeSupportOf(props.getHqEdge, 'schematic')
485
529
  const placeableCount = result.artifacts.filter((a) => a.uri).length
486
530
  const canPlace = !!placeSupport?.() && placeableCount > 0
531
+ // Open in EDA (host mode): available when the edge-bridge proxy exists and a
532
+ // project zip is present in the card (both system and schematic results
533
+ // store the zip as the single source of truth).
534
+ const canOpenInEda = payload.phase === 'ready' && payload.bytes != null &&
535
+ !!props.getHqEdge?.()?.api?.request
487
536
 
488
537
  return (
489
538
  <div className="hq-sch">
@@ -495,6 +544,13 @@ export const GenHit = memo(function GenHit(props: GenHitProps): ReactElement {
495
544
  <button type="button" className="hq-sch__act" onClick={onDownload} disabled={!canDownload || busy === 'download'}>
496
545
  ⭳ {busy === 'download' ? t('card.action.downloading') : t('card.action.download')}
497
546
  </button>
547
+ {canOpenInEda
548
+ ? (
549
+ <button type="button" className="hq-sch__act" onClick={onOpenInEda} disabled={busy === 'open-in-eda'}>
550
+ ⇱ {busy === 'open-in-eda' ? t('card.action.openingInEda') : t('card.action.openInEda')}
551
+ </button>
552
+ )
553
+ : null}
498
554
  {canPlace
499
555
  ? (
500
556
  <button type="button" className="hq-sch__act" onClick={onPlace} disabled={busy === 'place'}>
@@ -509,6 +565,7 @@ export const GenHit = memo(function GenHit(props: GenHitProps): ReactElement {
509
565
  ? <button type="button" className="hq-sch__act" onClick={() => props.inspect?.()}>{t('card.action.inspect')}</button>
510
566
  : null}
511
567
  </div>
568
+ {importStatus ? <div className="hq-sch__note">{importStatus}</div> : null}
512
569
  {placeStatus ? <div className="hq-sch__note">{placeStatus}</div> : null}
513
570
  </div>
514
571
  )
@@ -47,6 +47,10 @@ const ZH = {
47
47
  'card.action.downloading': '下载中…',
48
48
  'card.action.place': '放置到编辑器',
49
49
  'card.action.placing': '放置中…',
50
+ 'card.action.openInEda': '在 EDA 中打开',
51
+ 'card.action.openingInEda': '导入中…',
52
+ 'card.import.done': '已在 EDA 中打开设计({id})',
53
+ 'card.import.failed': '在 EDA 中打开失败:{detail}',
50
54
  'card.place.done': '已放置 {count} 个文件到编辑器',
51
55
  'card.place.partial': '已放置 {placed} 个,{failed} 个失败:',
52
56
  'card.place.failed': '放置失败:',
@@ -104,6 +108,10 @@ const EN: Record<CopyKey, string> = {
104
108
  'card.action.downloading': 'Downloading…',
105
109
  'card.action.place': 'Place in editor',
106
110
  'card.action.placing': 'Placing…',
111
+ 'card.action.openInEda': 'Open in EDA',
112
+ 'card.action.openingInEda': 'Importing…',
113
+ 'card.import.done': 'Design opened in EDA ({id})',
114
+ 'card.import.failed': 'Failed to open in EDA: {detail}',
107
115
  'card.place.done': 'Placed {count} file(s) into the editor',
108
116
  'card.place.partial': 'Placed {placed}, {failed} failed: ',
109
117
  'card.place.failed': 'Placement failed: ',
@@ -115,12 +115,19 @@ export function parseSchResult(text: string): SchResult | null {
115
115
  artifact = artOf(o.zipArtifact)
116
116
  if (artifact) artifacts.push(artifact)
117
117
  } else {
118
+ // The project zip is the single source of truth (sheets + footprints); the
119
+ // per-sheet artifacts are a legacy fallback when no zip was stored.
120
+ const zipRef = artOf(o.zipArtifact)
121
+ if (zipRef) {
122
+ artifact = zipRef
123
+ artifacts.push(zipRef)
124
+ }
118
125
  const list = Array.isArray(o.schArtifacts) ? (o.schArtifacts as unknown[]) : []
119
126
  for (const entry of list) {
120
127
  const ref = artOf(entry)
121
128
  if (ref) artifacts.push(ref)
122
129
  }
123
- artifact = artifacts.length > 0 ? artifacts[0]! : null
130
+ if (!artifact) artifact = artifacts.length > 0 ? artifacts[0]! : null
124
131
  }
125
132
 
126
133
  return {
package/src/tools.ts CHANGED
@@ -323,8 +323,68 @@ export function needsAuth(kind: 'schematic' | 'system'): Record<string, unknown>
323
323
  }
324
324
 
325
325
  /**
326
- * `generate_schematic_from_description` body — stream `schemagen`, extract the
327
- * inline `.kicad_sch` files, then store each sheet as a preview artifact.
326
+ * Host whitelist for the project-zip download — mirrors the web app's
327
+ * `/api/sch_sub_gen/download_zip` proxy (`apps/web/.../download_zip/route.ts`):
328
+ * only https/http URLs on `eda.cn` / `*.eda.cn` are allowed. The URL comes
329
+ * from the design agent's STATE_SNAPSHOT, but a node-side guard keeps a
330
+ * compromised/misbehaving agent from turning the plugin into an SSRF proxy.
331
+ */
332
+ function isAllowedZipHost(url: URL): boolean {
333
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return false
334
+ return url.hostname === 'eda.cn' || url.hostname.endsWith('.eda.cn')
335
+ }
336
+
337
+ /**
338
+ * Download the project zip from the agent-uploaded URL.
339
+ *
340
+ * `project_achieve_url` is the source of truth — the same eda.cn datastream
341
+ * URL the web app streams for export/download. It carries the full KiCad
342
+ * project (sheets AND the footprints that keep sch↔pcb in sync). A plain GET
343
+ * suffices (the web app proxies it only because of browser CORS; node has no
344
+ * such restriction). Returns null when the URL is absent, not on the eda.cn
345
+ * whitelist, or the download fails — the caller then falls back to inline
346
+ * sheets.
347
+ */
348
+ async function fetchSchematicProjectZip(env: SchematicGenEnv, url: string): Promise<Buffer | null> {
349
+ if (!url) return null
350
+ let target: URL
351
+ try {
352
+ target = new URL(url)
353
+ } catch {
354
+ log.warn('schematic zip download skipped — malformed url', { url })
355
+ return null
356
+ }
357
+ if (!isAllowedZipHost(target)) {
358
+ log.warn('schematic zip download skipped — host not allowed', { host: target.hostname, url })
359
+ return null
360
+ }
361
+ const fetchImpl = env.deps?.fetchImpl ?? fetch
362
+ const controller = new AbortController()
363
+ const timer = setTimeout(
364
+ () => controller.abort(new Error('schematic-gen: zip download did not respond within ' + HTTP_TIMEOUT_MS + 'ms')),
365
+ HTTP_TIMEOUT_MS,
366
+ )
367
+ try {
368
+ const res = await fetchImpl(url, { signal: controller.signal, headers: { accept: 'application/zip' } })
369
+ if (!res || !res.ok) {
370
+ log.warn('schematic zip download failed', { status: res && res.status, url })
371
+ return null
372
+ }
373
+ const ab = await res.arrayBuffer()
374
+ return Buffer.from(ab)
375
+ } catch (err) {
376
+ log.warn('schematic zip download failed', { error: String((err as Error)?.message || err), url })
377
+ return null
378
+ } finally {
379
+ clearTimeout(timer)
380
+ }
381
+ }
382
+
383
+ /**
384
+ * `generate_schematic_from_description` body — stream `schemagen`, then store
385
+ * the agent-uploaded project ZIP as a `zip` preview artifact (single source of
386
+ * truth: it carries the sheets AND the footprints that keep sch↔pcb in sync).
387
+ * Sheets are kept inline only as a fallback when the zip is unavailable.
328
388
  */
329
389
  export async function runGenerateSchematic(
330
390
  args: Record<string, unknown>,
@@ -373,18 +433,46 @@ export async function runGenerateSchematic(
373
433
  prog.failed(message)
374
434
  throw new Error(message)
375
435
  }
376
- const materialized = await materializeSchematicArtifacts(env, extracted.schFiles)
436
+
377
437
  const result: Record<string, unknown> = {
378
438
  status: 'generated',
379
439
  kind: 'schematic',
380
440
  design_name: extracted.outProject || '',
381
- schFiles: materialized.schFiles,
382
- schArtifacts: materialized.schArtifacts,
383
441
  kicadPro: extracted.kicadPro,
384
442
  project_achieve_url: extracted.project_achieve_url,
385
443
  }
386
- if (materialized.note) result.note = materialized.note
387
- if (materialized.agentNote) result.agentNote = materialized.agentNote
444
+
445
+ // The project ZIP is the single source of truth (sheets + footprints for
446
+ // sch↔pcb sync). Store it as a `zip` preview artifact — the card renders the
447
+ // zip's root sheet and downloads the zip itself.
448
+ const zipBuf = await fetchSchematicProjectZip(env, extracted.project_achieve_url)
449
+ if (zipBuf && zipBuf.length > 0) {
450
+ result.zip_bytes = zipBuf.length
451
+ try {
452
+ const safeName = sanitizeZipBaseName(extracted.outProject || 'schematic')
453
+ const created = await createPreviewArtifact(env, 'zip', safeName + '.zip', zipBuf.toString('base64'), 'base64')
454
+ result.zipArtifact = await toArtifactEntry(env, created)
455
+ } catch (storeErr) {
456
+ result.note = 'Could not store the project zip as an artifact (' +
457
+ String((storeErr as Error)?.message || storeErr) + ').'
458
+ result.agentNote =
459
+ 'The project zip (sheets + footprints) is still available at project_achieve_url; ' +
460
+ 'the card falls back to rendering the inline sheets below.'
461
+ }
462
+ }
463
+
464
+ if (!result.zipArtifact) {
465
+ // Fallback (older agent without an uploaded zip, or zip fetch/store
466
+ // failure): store each sheet as a preview artifact — the card can still
467
+ // render and download the individual sheet.
468
+ const materialized = await materializeSchematicArtifacts(env, extracted.schFiles)
469
+ result.schFiles = materialized.schFiles
470
+ if (materialized.schArtifacts) result.schArtifacts = materialized.schArtifacts
471
+ if (materialized.note) {
472
+ result.note = (result.note ? result.note + ' ' : '') + materialized.note
473
+ }
474
+ if (materialized.agentNote) result.agentNote = materialized.agentNote
475
+ }
388
476
  prog.done()
389
477
  return result
390
478
  }
@@ -542,15 +630,16 @@ function createSchematicTool(env: SchematicGenEnv) {
542
630
  'description of a circuit or sub-circuit — e.g. "design a 5V LM7805 linear ' +
543
631
  'regulator power supply with input and output filter capacitors". Calls the ' +
544
632
  'online HQ-EDA schematic generation agent and returns ' +
545
- 'schFiles (filename references), schArtifacts (preview artifact references ' +
546
- 'with id/type/filename/size plus a uri when the cross-process placement ' +
547
- 'channel is available), kicadPro and project_achieve_url. ' +
633
+ 'zipArtifact (the project zip — the single source of truth: it contains ' +
634
+ 'the .kicad_sch sheets AND the footprints that keep sch↔pcb in sync), ' +
635
+ 'plus kicadPro and project_achieve_url. ' +
548
636
  'Use this when the user asks to draw, generate or create a circuit ' +
549
637
  'schematic from a description (not from an image — for that use the ' +
550
638
  'symbol/footprint tools). ' +
551
639
  'IMPORTANT: The generated schematic renders automatically as a result card ' +
552
- 'in the web client — an interactive canvas preview per sheet (multi-sheet ' +
553
- 'results get a sheet tab bar) and a download button for the current sheet. ' +
640
+ 'in the web client — an interactive canvas preview of the project (root ' +
641
+ 'sheet of the zip) and a download button that downloads the full project ' +
642
+ 'zip. ' +
554
643
  'Do NOT paste the schematic source, file URLs, or any fenced code block ' +
555
644
  'into your reply; just note in one line that the schematic was generated ' +
556
645
  'and how many sheets it has. ' + AUTH_GATE_NOTE,