@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/client.js +131 -15
- 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 +129 -13
- package/src/client/i18n.ts +12 -0
- package/src/client/index.ts +1 -0
- package/src/client/parse.ts +8 -1
- package/src/client/theme.ts +2 -0
- 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.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.
|
|
34
|
-
"@huaqiu/dsh-artifacts": "^0.3.
|
|
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-
|
|
43
|
-
"@huaqiu/dsh-tool-uncollapse": "0.3.
|
|
44
|
-
"@huaqiu/dsh-
|
|
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.
|
|
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
|
+
}
|
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 {
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
)
|
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': '放置失败:',
|
|
@@ -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
|
|
package/src/client/index.ts
CHANGED
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/client/theme.ts
CHANGED
|
@@ -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
|
-
*
|
|
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,
|