@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/client.js +77 -6
- package/lib/client.js.map +1 -1
- package/lib/index.mjs +87 -8
- package/package.json +7 -7
- package/src/client/b64.ts +14 -0
- package/src/client/hit-card.tsx +61 -4
- package/src/client/i18n.ts +8 -0
- package/src/client/parse.ts +8 -1
- package/src/tools.ts +101 -12
package/lib/index.mjs
CHANGED
|
@@ -887,8 +887,75 @@ function needsAuth(kind) {
|
|
|
887
887
|
};
|
|
888
888
|
}
|
|
889
889
|
/**
|
|
890
|
-
*
|
|
891
|
-
*
|
|
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
|
-
|
|
940
|
-
if (
|
|
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
|
|
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.
|
|
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.
|
|
34
|
-
"@huaqiu/dsh-artifacts": "^0.3.
|
|
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-
|
|
43
|
-
"@huaqiu/dsh-
|
|
44
|
-
"@huaqiu/dsh-
|
|
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.
|
|
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
|
+
}
|
package/src/client/hit-card.tsx
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
316
|
-
|
|
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
|
-
|
|
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
|
)
|
package/src/client/i18n.ts
CHANGED
|
@@ -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: ',
|
package/src/client/parse.ts
CHANGED
|
@@ -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
|
-
*
|
|
327
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
387
|
-
|
|
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
|
-
'
|
|
546
|
-
'
|
|
547
|
-
'
|
|
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
|
|
553
|
-
'
|
|
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,
|