@huaqiu/dsh-tool-schematic-gen 0.3.13 → 0.3.15

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.13",
3
+ "version": "0.3.15",
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.13",
34
- "@huaqiu/dsh-artifacts": "^0.3.13",
33
+ "@huaqiu/dsh-auth": "^0.3.15",
34
+ "@huaqiu/dsh-artifacts": "^0.3.15",
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-artifacts": "0.3.13",
43
- "@huaqiu/dsh-tool-uncollapse": "0.3.13",
44
- "@huaqiu/dsh-auth": "0.3.13"
42
+ "@huaqiu/dsh-auth": "0.3.15",
43
+ "@huaqiu/dsh-tool-uncollapse": "0.3.15",
44
+ "@huaqiu/dsh-artifacts": "0.3.15"
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.13"
55
+ "@huaqiu/dsh-plugin-log": "0.3.15"
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 {
@@ -33,6 +34,22 @@ export interface AuthStateLike {
33
34
  nickname?: string
34
35
  }
35
36
 
37
+ /**
38
+ * Structural view of the `huaqiuAuth` CLIENT service (declared structurally,
39
+ * never imported — each package must remain independently installable).
40
+ * `login()` in HQ Edge host mode triggers the EDA login dialog through
41
+ * hq-edge (`POST /api/v1/auth/login` → EDA `TriggerLoginDialog`) instead of
42
+ * the auth.eda.cn iframe; `isHostMode()` tells the card which surface to show.
43
+ */
44
+ export interface AuthClientLike {
45
+ auth?: {
46
+ isAuthenticated(): boolean
47
+ isHostMode?(): boolean
48
+ login?(options?: { lang?: string; theme?: string }): Promise<void>
49
+ onAuthStateChanged(listener: (info: { nickname?: string } | null) => void): () => void
50
+ }
51
+ }
52
+
36
53
  export type PromptSender = (sessionId: string | undefined, message: string) => Promise<unknown>
37
54
 
38
55
  const TOOL_SCHEMATIC = 'generate_schematic_from_description'
@@ -53,6 +70,13 @@ export interface GenHitProps {
53
70
  inspect?: () => void
54
71
  authState?: AuthStateLike
55
72
  sendPrompt?: PromptSender
73
+ /**
74
+ * Lazy accessor for the `huaqiuAuth` client service (auth plugin browser
75
+ * half). Absent/undefined in a broken install — the needs_auth card then
76
+ * falls back to the embedded auth.eda.cn iframe. Lazy (not captured once)
77
+ * so plugin load order resolves correctly at click time.
78
+ */
79
+ getAuth?: () => AuthClientLike | undefined
56
80
  /**
57
81
  * Lazy accessor for the host's `hqEdge` service (edge-bridge browser half).
58
82
  * Absent/undefined in standalone DSH — the Place button is then hidden.
@@ -147,7 +171,8 @@ function PreviewStage({ payload, t }: { payload: PreviewPayload; t: Translate })
147
171
  })
148
172
  if (cancelled || canvas !== canvasRef.current) return
149
173
  sizeCanvasFor(canvas)
150
- if (payload.kind === 'system' && payload.bytes) {
174
+ if (payload.bytes) {
175
+ // A bytes payload is the full project zip — render its root sheet.
151
176
  disposeViewer = await renderProjectZipToCanvas(payload.bytes, canvas)
152
177
  } else if (payload.source) {
153
178
  disposeViewer = await renderSheetToCanvas(payload.source, canvas)
@@ -186,9 +211,16 @@ function PreviewStage({ payload, t }: { payload: PreviewPayload; t: Translate })
186
211
 
187
212
  // ── needs_auth login card ───────────────────────────────────────────────────
188
213
 
189
- function LoginCard({ toolName, authState, t }: { toolName: string; authState?: AuthStateLike; t: Translate }): ReactElement {
214
+ function LoginCard({ toolName, authState, t, getAuth }: { toolName: string; authState?: AuthStateLike; t: Translate; getAuth?: () => AuthClientLike | undefined }): ReactElement {
190
215
  const dark = useTheme()
191
216
  const locale = useLocale()
217
+ const authClient = getAuth?.()?.auth
218
+ // HQ Edge host mode: EDA owns the credential — login must ask EDA to open
219
+ // its own TriggerLoginDialog (hq-edge POST /api/v1/auth/login), not the
220
+ // auth.eda.cn iframe (a browser-pushed token is ignored by the STRICT host
221
+ // resolver). Standalone DSH keeps the inline iframe.
222
+ const hostMode = authClient?.isHostMode?.() ?? false
223
+
192
224
  // FILL mode (`fill=full`): this card IS the surface, so let the embed paint
193
225
  // it edge-to-edge with its own `bg-background`. Without it the embed's
194
226
  // `grid-rows-[20px_1fr_20px]` wrapper leaves two transparent strips above
@@ -202,6 +234,41 @@ function LoginCard({ toolName, authState, t }: { toolName: string; authState?: A
202
234
  // its URL params once on mount), silently ignoring the new `fill`/`theme`.
203
235
  const remountKey = `${locale}|${dark ? 'd' : 'l'}`
204
236
 
237
+ const statusLine = (
238
+ <p className="hq-sch__login-status" style={{ color: authState?.authenticated ? '#1677ff' : '#d4380d' }}>
239
+ {authState?.authenticated
240
+ ? t('card.auth.loggedIn', {
241
+ nickname: authState.nickname ? t('card.nicknameSep', { nickname: authState.nickname }) : '',
242
+ })
243
+ : t('card.auth.loggedOut')}
244
+ </p>
245
+ )
246
+
247
+ if (hostMode) {
248
+ return (
249
+ <div className="hq-sch">
250
+ <div className="hq-sch__header">
251
+ <span className="hq-sch__icon">⇶</span>
252
+ <span className="hq-sch__title">{t('card.auth.title')}</span>
253
+ </div>
254
+ <div className="hq-sch__login">
255
+ <p className="hq-sch__login-desc">{t('card.auth.descHost', { tool: toolName })}</p>
256
+ {statusLine}
257
+ <button
258
+ type="button"
259
+ className="hq-sch__login-btn"
260
+ onClick={() => {
261
+ void authClient?.login?.({ lang: locale, theme: dark ? 'dark' : 'light' })
262
+ .catch(() => { /* login cancelled / dialog failed — card keeps showing the button */ })
263
+ }}
264
+ >
265
+ {t('card.auth.loginBtn')}
266
+ </button>
267
+ </div>
268
+ </div>
269
+ )
270
+ }
271
+
205
272
  return (
206
273
  <div className="hq-sch">
207
274
  <div className="hq-sch__header">
@@ -210,13 +277,7 @@ function LoginCard({ toolName, authState, t }: { toolName: string; authState?: A
210
277
  </div>
211
278
  <div className="hq-sch__login">
212
279
  <p className="hq-sch__login-desc">{t('card.auth.desc', { tool: toolName })}</p>
213
- <p className="hq-sch__login-status" style={{ color: authState?.authenticated ? '#1677ff' : '#d4380d' }}>
214
- {authState?.authenticated
215
- ? t('card.auth.loggedIn', {
216
- nickname: authState.nickname ? t('card.nicknameSep', { nickname: authState.nickname }) : '',
217
- })
218
- : t('card.auth.loggedOut')}
219
- </p>
280
+ {statusLine}
220
281
  <iframe
221
282
  key={remountKey}
222
283
  src={src}
@@ -253,8 +314,11 @@ export const GenHit = memo(function GenHit(props: GenHitProps): ReactElement {
253
314
  setPayload({ phase: 'loading', source: null, bytes: null, filename: null, error: null })
254
315
  ;(async () => {
255
316
  try {
256
- const kind = result.kind ?? 'schematic'
257
- 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') {
258
322
  const art = await resolveArtifactBytes(artifactKey)
259
323
  if (cancelled) return
260
324
  setPayload({ phase: 'ready', source: null, bytes: art.bytes, filename: art.filename, error: null })
@@ -278,6 +342,8 @@ export const GenHit = memo(function GenHit(props: GenHitProps): ReactElement {
278
342
  // Outcome of the last Place attempt: 'ok' | 'error:<detail>'. Rendered as a
279
343
  // one-line status under the actions so the user knows the placement landed.
280
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)
281
347
 
282
348
  function onDownload(): void {
283
349
  if (busy || payload.phase !== 'ready') return
@@ -285,7 +351,10 @@ export const GenHit = memo(function GenHit(props: GenHitProps): ReactElement {
285
351
  const filename = downloadFilenameFor(kind, result?.artifact ?? null, result?.designName ?? null)
286
352
  setBusy('download')
287
353
  try {
288
- 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) {
289
358
  downloadBytes(filename, payload.bytes)
290
359
  } else if (payload.source != null) {
291
360
  downloadText(filename, payload.source)
@@ -295,6 +364,40 @@ export const GenHit = memo(function GenHit(props: GenHitProps): ReactElement {
295
364
  }
296
365
  }
297
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
+
298
401
  function onRegenerate(): void {
299
402
  if (busy) return
300
403
  setBusy('regenerate')
@@ -364,7 +467,7 @@ export const GenHit = memo(function GenHit(props: GenHitProps): ReactElement {
364
467
 
365
468
  // needs_auth
366
469
  if (state.phase === 'needs_auth') {
367
- return <LoginCard toolName={props.toolName} authState={props.authState} t={t} />
470
+ return <LoginCard toolName={props.toolName} authState={props.authState} t={t} getAuth={props.getAuth} />
368
471
  }
369
472
 
370
473
  const headerKind = result?.kind ?? kindOf(props.toolName)
@@ -425,6 +528,11 @@ export const GenHit = memo(function GenHit(props: GenHitProps): ReactElement {
425
528
  const placeSupport = placeSupportOf(props.getHqEdge, 'schematic')
426
529
  const placeableCount = result.artifacts.filter((a) => a.uri).length
427
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
428
536
 
429
537
  return (
430
538
  <div className="hq-sch">
@@ -436,6 +544,13 @@ export const GenHit = memo(function GenHit(props: GenHitProps): ReactElement {
436
544
  <button type="button" className="hq-sch__act" onClick={onDownload} disabled={!canDownload || busy === 'download'}>
437
545
  ⭳ {busy === 'download' ? t('card.action.downloading') : t('card.action.download')}
438
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}
439
554
  {canPlace
440
555
  ? (
441
556
  <button type="button" className="hq-sch__act" onClick={onPlace} disabled={busy === 'place'}>
@@ -450,6 +565,7 @@ export const GenHit = memo(function GenHit(props: GenHitProps): ReactElement {
450
565
  ? <button type="button" className="hq-sch__act" onClick={() => props.inspect?.()}>{t('card.action.inspect')}</button>
451
566
  : null}
452
567
  </div>
568
+ {importStatus ? <div className="hq-sch__note">{importStatus}</div> : null}
453
569
  {placeStatus ? <div className="hq-sch__note">{placeStatus}</div> : null}
454
570
  </div>
455
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': '放置失败:',
@@ -59,6 +63,8 @@ const ZH = {
59
63
  'card.auth.desc': '工具「{tool}」需要登录华秋 EDA AI 账号才能继续。请在下方的登录框完成登录(或点击左侧「华秋EDA AI登录」按钮);登录完成后,回复助手「已登录,请重试」,助手会自动重新调用该工具。',
60
64
  'card.auth.loggedIn': '✓ 已登录{nickname}—— 现在可以回复助手「已登录,请重试」,助手会重新调用工具。',
61
65
  'card.auth.loggedOut': '未登录 —— 请在上方登录华秋 EDA AI(eda.cn)账号,或点击左侧「华秋EDA AI 登录」按钮;登录完成后让助手重试。',
66
+ 'card.auth.descHost': '工具「{tool}」需要登录华秋 EDA AI 账号才能继续。点击下方按钮后,EDA(KiCad)将弹出登录窗口,请在弹出的窗口中完成登录;登录完成后回复助手「已登录,请重试」,助手会自动重新调用该工具。',
67
+ 'card.auth.loginBtn': '登录华秋 EDA AI',
62
68
  // Substituted into `{nickname}` by `card.auth.loggedIn`. zh uses a
63
69
  // full-width colon, en a half-width one plus a space — hardcoding ':'
64
70
  // made the English card read "Logged in:John".
@@ -102,6 +108,10 @@ const EN: Record<CopyKey, string> = {
102
108
  'card.action.downloading': 'Downloading…',
103
109
  'card.action.place': 'Place in editor',
104
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}',
105
115
  'card.place.done': 'Placed {count} file(s) into the editor',
106
116
  'card.place.partial': 'Placed {placed}, {failed} failed: ',
107
117
  'card.place.failed': 'Placement failed: ',
@@ -116,6 +126,8 @@ const EN: Record<CopyKey, string> = {
116
126
  '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.',
117
127
  'card.auth.loggedIn': '✓ Logged in{nickname} — reply "I have logged in, please retry" and the assistant will retry.',
118
128
  'card.auth.loggedOut': 'Not logged in — complete the login above, or use the 华秋EDA AI sidebar button.',
129
+ 'card.auth.descHost': 'Tool "{tool}" requires a Huaqiu EDA AI account. Click the button below — EDA (KiCad) will open its login dialog. Complete the login there, then reply "I have logged in, please retry" so the assistant can retry the tool.',
130
+ 'card.auth.loginBtn': 'Sign in to Huaqiu EDA AI',
119
131
  'card.nicknameSep': ': {nickname}',
120
132
  }
121
133
 
@@ -159,6 +159,7 @@ export function apply(ctx: ClientContext): () => void {
159
159
  sendPrompt,
160
160
  authState: useAuthState(),
161
161
  getHqEdge,
162
+ getAuth: () => authService,
162
163
  })
163
164
 
164
165
  for (const toolName of TOOLVIEW_KEYS) {
@@ -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 {
@@ -181,6 +181,8 @@ const CSS = `
181
181
  .hq-sch__login-desc { margin: 0 0 10px; font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-secondary, currentColor); line-height: 1.5; }
182
182
  .hq-sch__login-status { margin: 0 0 10px; font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); line-height: 1.5; }
183
183
  .hq-sch__login-iframe { width: 100%; height: ${LOGIN_IFRAME_HEIGHT}px; border: 0; border-radius: 8px; display: block; }
184
+ .hq-sch__login-btn { display: inline-flex; align-items: center; gap: 4px; border: 1px solid var(--dsw-alias-border-l1, currentColor); border-radius: 6px; padding: 6px 16px; background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.08)); color: var(--dsw-alias-label-primary, currentColor); font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); cursor: pointer; }
185
+ .hq-sch__login-btn:hover { filter: brightness(1.08); }
184
186
 
185
187
  /* ── live call stack (long-running generations) ───────────────────────────── */
186
188
  .hq-sch__progress { display: flex; align-items: center; gap: 8px; padding: 0 12px 8px; font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-secondary, currentColor); }
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,