@huaqiu/dsh-tool-schematic-gen 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.mjs ADDED
@@ -0,0 +1,1439 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { defineTool } from "@deepseek-ai/dsh-tools";
6
+ //#region src/config.ts
7
+ /**
8
+ * `@huaqiu/dsh-tool-schematic-gen` — config, agent identity, run-body and
9
+ * filename helpers (pure, dependency-free).
10
+ *
11
+ * Faithful TypeScript port of the `hq-edge` schematic-gen node half, with ONE
12
+ * deliberate change per migration plan review #9: the demo eda.cn account is
13
+ * REMOVED. The account now always comes from the `huaqiuAuth` service
14
+ * (`getUserInfo()` → `x-user-id` / `x-user-token`); there is no baked-in
15
+ * default credential.
16
+ *
17
+ * @module @huaqiu/dsh-tool-schematic-gen
18
+ */
19
+ /** CopilotKit `agentId` values the two tools drive. */
20
+ const agentIds = {
21
+ /** description → final KiCad schematic (`.kicad_sch` files). */
22
+ SCHEMATIC: "schemagen",
23
+ /** description → module graph → KiCad project zip. */
24
+ SYSTEM: "modular_circuit"
25
+ };
26
+ /** Default production CopilotKit endpoint (the prod value the reference scripts POST to). */
27
+ const DEFAULT_COPILOTKIT_URL = "https://gen.eda.cn/api/copilotkit";
28
+ /** Default production export-zip endpoint. */
29
+ const DEFAULT_EXPORT_ZIP_URL = "https://gen.eda.cn/api/modular_circuit/export-zip";
30
+ /**
31
+ * Language hint sent to the agent when the caller omits `user_language`.
32
+ *
33
+ * This was a bare `'简体中文'` literal inside `buildRunBody`, which pinned every
34
+ * agent reply to Chinese even for an English UI. The node half has no way to
35
+ * read the host UI locale (the tool is invoked by the model, not the browser),
36
+ * so the value is a named, overridable default instead of an inline literal.
37
+ */
38
+ const DEFAULT_AGENT_LANGUAGE = "简体中文";
39
+ /**
40
+ * Resolve the production endpoints from env. No credential defaults: the
41
+ * account is never baked in (migration plan review #9).
42
+ */
43
+ function resolveConfig(env) {
44
+ const e = env && typeof env === "object" ? env : {};
45
+ const get = (k, d) => typeof e[k] === "string" && e[k].length > 0 ? e[k] : d;
46
+ return {
47
+ copilotkitUrl: get("HQ_EDA_COPILOTKIT_URL", DEFAULT_COPILOTKIT_URL),
48
+ exportZipUrl: get("HQ_EDA_EXPORT_ZIP_URL", DEFAULT_EXPORT_ZIP_URL),
49
+ cookie: typeof e["HQ_EDA_COOKIE"] === "string" && e["HQ_EDA_COOKIE"].length > 0 ? e["HQ_EDA_COOKIE"] : null,
50
+ defaultLanguage: get("HQ_EDA_DEFAULT_LANGUAGE", DEFAULT_AGENT_LANGUAGE)
51
+ };
52
+ }
53
+ /** Build the headers for the CopilotKit SSE POST. */
54
+ function buildHeaders(config, account, threadId) {
55
+ const h = {
56
+ accept: "text/event-stream",
57
+ "content-type": "application/json",
58
+ "x-user-id": account.userId,
59
+ "x-user-token": account.userToken,
60
+ "x-thread-id": threadId,
61
+ Referer: "https://gen.eda.cn/"
62
+ };
63
+ if (config.cookie) h.cookie = config.cookie;
64
+ return h;
65
+ }
66
+ /** Build the headers for the export-zip POST (JSON in, zip out). */
67
+ function buildExportHeaders(config, account) {
68
+ const h = {
69
+ "content-type": "application/json",
70
+ "x-user-id": account.userId,
71
+ "x-user-token": account.userToken,
72
+ Referer: "https://gen.eda.cn/"
73
+ };
74
+ if (config.cookie) h.cookie = config.cookie;
75
+ return h;
76
+ }
77
+ /** Minimal empty state for the schematic agent. */
78
+ function emptySchematicState(config, account, language) {
79
+ return {
80
+ user_id: account.userId,
81
+ token: account.userToken,
82
+ commits: [],
83
+ requirement: "",
84
+ architecture: null,
85
+ circuit: {},
86
+ report: null,
87
+ schFiles: [],
88
+ kicadPro: "",
89
+ outProject: "",
90
+ project_achieve_url: "",
91
+ reportOk: false,
92
+ reportStage: "",
93
+ error: ""
94
+ };
95
+ }
96
+ /** Empty state for the system-design agent. */
97
+ function emptySystemState(config, account, language) {
98
+ return {
99
+ design_name: null,
100
+ pending_module_replacement_req: null,
101
+ available_modules: null,
102
+ connection_outdated: true,
103
+ commented_outline: null,
104
+ user_option: null,
105
+ modules_alternatives: null,
106
+ user_id: account.userId,
107
+ token: account.userToken,
108
+ user_language: language,
109
+ user_input: "",
110
+ design_plan: "",
111
+ original_bom_list: [],
112
+ top_block: null,
113
+ search_plan: [],
114
+ bom_list: [],
115
+ module_list: [],
116
+ connect_result: { connections: [] },
117
+ erc_passed: false,
118
+ connection_count: 0,
119
+ pending_bom_updates: [],
120
+ connect_agent_summary: "",
121
+ connect_iteration_history: [],
122
+ bom_exclusion_list: {},
123
+ reflect_retry_count: 0,
124
+ task_completed: false,
125
+ final_report_content: "",
126
+ circuit_url: null,
127
+ module_graph: null,
128
+ kicad_project_zip_url: null
129
+ };
130
+ }
131
+ /**
132
+ * Build the CopilotKit `agent/run` body. A FRESH uuid is assigned to both
133
+ * `threadId` and `runId` (and reused for `x-thread-id`) so every call is a
134
+ * one-shot, independent run.
135
+ */
136
+ function buildRunBody(agentId, description, config, account, language, threadId) {
137
+ const tid = typeof threadId === "string" && threadId.length > 0 ? threadId : randomUUID();
138
+ const runId = randomUUID();
139
+ const msgId = randomUUID();
140
+ const lang = typeof language === "string" && language.length > 0 ? language : config.defaultLanguage;
141
+ const state = agentId === agentIds.SCHEMATIC ? emptySchematicState(config, account, lang) : emptySystemState(config, account, lang);
142
+ return {
143
+ method: "agent/run",
144
+ params: { agentId },
145
+ body: {
146
+ threadId: tid,
147
+ runId,
148
+ tools: [],
149
+ context: [{
150
+ description: "Current Module Circuit Design State",
151
+ value: JSON.stringify({ user_language: lang })
152
+ }],
153
+ forwardedProps: {},
154
+ state,
155
+ messages: [{
156
+ id: msgId,
157
+ role: "user",
158
+ content: String(description || "")
159
+ }]
160
+ }
161
+ };
162
+ }
163
+ /**
164
+ * Derive a human-readable, filesystem-safe zip basename from the design name.
165
+ * Keeps Unicode letters/digits (CJK included) intact; only illegal path chars
166
+ * and whitespace are replaced.
167
+ */
168
+ function sanitizeZipBaseName(designName) {
169
+ return String(designName || "").replace(/[\\/:*?"<>|\u0000-\u001f]+/g, " ").replace(/\s+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "").slice(0, 60) || "circuit";
170
+ }
171
+ //#endregion
172
+ //#region src/trace.ts
173
+ /**
174
+ * Custom-event names the eda.cn agents are known to use. Matching is
175
+ * deliberately tolerant: any name ending in `_TRACE` is accepted too, so a
176
+ * backend rename does not silently kill progress.
177
+ */
178
+ const KNOWN_TRACE_EVENT_NAMES = [
179
+ "SCHEMATIC_GENERATOR_TRACE",
180
+ "SCHEMAGEN_TRACE",
181
+ "MODULAR_CIRCUIT_TRACE",
182
+ "MODULE_GEN_TRACE"
183
+ ];
184
+ function isTraceEventName(name) {
185
+ if (typeof name !== "string" || name.length === 0) return false;
186
+ if (KNOWN_TRACE_EVENT_NAMES.includes(name)) return true;
187
+ return /_TRACE$/i.test(name);
188
+ }
189
+ function isRecord(v) {
190
+ return !!v && typeof v === "object" && !Array.isArray(v);
191
+ }
192
+ /**
193
+ * Validate one decoded `CUSTOM` payload as a {@link TraceEvent}.
194
+ * Returns `null` for anything else (interrupts, future event shapes).
195
+ */
196
+ function parseTraceEvent(value) {
197
+ if (!isRecord(value)) return null;
198
+ const kind = value["kind"];
199
+ const phase = value["phase"];
200
+ const ts = value["ts"];
201
+ if (phase !== "start" && phase !== "end") return null;
202
+ const timestamp = typeof ts === "number" && Number.isFinite(ts) ? ts : Date.now();
203
+ if (kind === "node") {
204
+ const node = value["node"];
205
+ if (typeof node !== "string" || node.length === 0) return null;
206
+ const ev = {
207
+ kind: "node",
208
+ phase,
209
+ node,
210
+ ts: timestamp
211
+ };
212
+ const scope = value["scope"];
213
+ if (typeof scope === "string" && scope.length > 0) ev.scope = scope;
214
+ const note = value["note"];
215
+ if (typeof note === "string" && note.length > 0) ev.note = note;
216
+ return ev;
217
+ }
218
+ if (kind === "tool") {
219
+ const name = value["name"];
220
+ if (typeof name !== "string" || name.length === 0) return null;
221
+ const scope = value["scope"];
222
+ const ev = {
223
+ kind: "tool",
224
+ phase,
225
+ scope: typeof scope === "string" && scope.length > 0 ? scope : name,
226
+ name,
227
+ ts: timestamp
228
+ };
229
+ const path = value["path"];
230
+ if (typeof path === "string" && path.length > 0) ev.path = path;
231
+ if (value["ok"] === false) ev.ok = false;
232
+ return ev;
233
+ }
234
+ return null;
235
+ }
236
+ /**
237
+ * Collect every {@link TraceEvent} inside one `CUSTOM` payload. The backend
238
+ * normally writes one event per chunk, but array payloads are accepted so a
239
+ * batched writer cannot drop the stream.
240
+ */
241
+ function collectTraceEvents(value) {
242
+ if (Array.isArray(value)) {
243
+ const out = [];
244
+ for (const item of value) {
245
+ const ev = parseTraceEvent(item);
246
+ if (ev) out.push(ev);
247
+ }
248
+ return out;
249
+ }
250
+ const ev = parseTraceEvent(value);
251
+ return ev ? [ev] : [];
252
+ }
253
+ /**
254
+ * Canonical `a>b>c` path for one event — the breadcrumb the card nests by.
255
+ *
256
+ * Mirrors `TraceTimeline.buildStatusEntries`: a node's path is its own scope
257
+ * (falling back to its name when the backend omits scope), while a tool's path
258
+ * is its caller scope with the tool name appended.
259
+ */
260
+ function tracePath(ev) {
261
+ if (ev.kind === "node") return ev.scope && ev.scope.length > 0 ? ev.scope : ev.node;
262
+ if (ev.path && ev.path.length > 0) return ev.path;
263
+ return ev.scope === ev.name ? ev.name : `${ev.scope}>${ev.name}`;
264
+ }
265
+ /** Display name of one event (`node:foo` for nodes, bare name for tools). */
266
+ function traceName(ev) {
267
+ return ev.kind === "node" ? `node:${ev.node}` : ev.name;
268
+ }
269
+ /**
270
+ * Pair `start`/`end` events into {@link TraceFrame}s, preserving nesting via
271
+ * `path`.
272
+ *
273
+ * Uses the same per-key open stack as `buildStatusEntries`, so nested calls
274
+ * with the same name (a tool re-entered inside its own subtree) close in LIFO
275
+ * order instead of cross-contaminating.
276
+ */
277
+ function pairTraceEvents(events) {
278
+ const frames = [];
279
+ const open = /* @__PURE__ */ new Map();
280
+ let seq = 0;
281
+ for (const ev of events) {
282
+ const name = traceName(ev);
283
+ const path = tracePath(ev);
284
+ const key = `${path}|${name}`;
285
+ if (ev.phase === "start") {
286
+ const stack = open.get(key);
287
+ const index = frames.length;
288
+ if (stack) stack.push(index);
289
+ else open.set(key, [index]);
290
+ frames.push({
291
+ id: `trace-${seq++}`,
292
+ name,
293
+ path,
294
+ status: "running",
295
+ startedAt: ev.ts
296
+ });
297
+ continue;
298
+ }
299
+ const index = open.get(key)?.pop();
300
+ if (index === void 0) continue;
301
+ const failed = ev.kind === "tool" && ev.ok === false;
302
+ frames[index] = {
303
+ ...frames[index],
304
+ status: failed ? "failed" : "finished",
305
+ finishedAt: ev.ts
306
+ };
307
+ }
308
+ return frames;
309
+ }
310
+ /**
311
+ * Convert the **standard AG-UI tool-call lifecycle** (`TOOL_CALL_START` /
312
+ * `TOOL_CALL_END`) into {@link TraceEvent}s.
313
+ *
314
+ * This exists because the two eda.cn agents report progress differently:
315
+ *
316
+ * - `schemagen` (schematic) emits LangGraph `custom`-stream `TraceEvent`s,
317
+ * republished as AG-UI `CUSTOM` events named `SCHEMATIC_GENERATOR_TRACE`.
318
+ * - `modular_circuit` (system design) does NOT emit those. Its stack is
319
+ * rebuilt by the agent server from LangGraph *tasks* and published as the
320
+ * ordinary AG-UI `TOOL_CALL_START` / `TOOL_CALL_END` pair — see
321
+ * `ModuleGenTraceProvider.onToolCallStartEvent` in hq-eda-ai.
322
+ *
323
+ * Without this adapter the system-design tool reported no progress at all.
324
+ *
325
+ * Port of `createToolInstancePath` + `toToolTraceEvent` in
326
+ * `apps/web/src/lib/modular_circuit/context/ModuleGenTraceProvider.tsx`.
327
+ */
328
+ var ToolCallTracker = class ToolCallTracker {
329
+ active = /* @__PURE__ */ new Map();
330
+ /** Forget every open call — invoke on `RUN_STARTED`. */
331
+ reset() {
332
+ this.active.clear();
333
+ }
334
+ /**
335
+ * Build an instance path, reusing the deepest currently-open call whose
336
+ * semantic path is a prefix of this one so nested tools nest properly.
337
+ */
338
+ instancePath(semanticPath, toolCallId) {
339
+ let parent;
340
+ for (const candidate of this.active.values()) {
341
+ if (!semanticPath.startsWith(`${candidate.semanticPath}>`)) continue;
342
+ if (!parent || candidate.semanticPath.length > parent.semanticPath.length) parent = candidate;
343
+ }
344
+ const segments = (parent ? semanticPath.slice(parent.semanticPath.length + 1) : semanticPath).split(">").map((s) => s.trim()).filter((s) => s.length > 0);
345
+ const leaf = segments.length - 1;
346
+ if (leaf >= 0) segments[leaf] = `${segments[leaf]}::task:${toolCallId}`;
347
+ const current = segments.join(">");
348
+ return parent ? `${parent.instancePath}>${current}` : current;
349
+ }
350
+ /** Split a semantic path into its parent scope and leaf display name. */
351
+ static split(semanticPath) {
352
+ const segments = semanticPath.split(">").map((s) => s.trim()).filter((s) => s.length > 0);
353
+ const name = segments[segments.length - 1] || "unknown";
354
+ return {
355
+ scope: segments.length > 1 ? segments.slice(0, -1).join(">") : name,
356
+ name
357
+ };
358
+ }
359
+ /** Open a tool call. Returns the `start` trace event. */
360
+ start(toolCallId, semanticPath, ts = Date.now()) {
361
+ const instancePath = this.instancePath(semanticPath, toolCallId);
362
+ if (toolCallId) this.active.set(toolCallId, {
363
+ semanticPath,
364
+ instancePath
365
+ });
366
+ const { scope, name } = ToolCallTracker.split(semanticPath);
367
+ return {
368
+ kind: "tool",
369
+ phase: "start",
370
+ scope,
371
+ name,
372
+ path: instancePath,
373
+ ts
374
+ };
375
+ }
376
+ /**
377
+ * Close a tool call. Returns the `end` trace event, or `null` when the id
378
+ * was never opened (a late/duplicate frame we must not invent a span for).
379
+ */
380
+ end(toolCallId, ts = Date.now()) {
381
+ const known = this.active.get(toolCallId);
382
+ if (!known) return null;
383
+ this.active.delete(toolCallId);
384
+ const { scope, name } = ToolCallTracker.split(known.semanticPath);
385
+ return {
386
+ kind: "tool",
387
+ phase: "end",
388
+ scope,
389
+ name,
390
+ path: known.instancePath,
391
+ ts,
392
+ ok: true
393
+ };
394
+ }
395
+ /** Ids still open. Exposed for diagnostics/tests. */
396
+ get openIds() {
397
+ return [...this.active.keys()];
398
+ }
399
+ };
400
+ //#endregion
401
+ //#region src/sse.ts
402
+ /**
403
+ * Overall SSE / zip budget — the eda.cn design agents routinely run 9–12+
404
+ * minutes per generation (observed runs exceeded the old 10-min cap), so
405
+ * allow 30 minutes before aborting the stream. This single constant drives
406
+ * both the backend SSE stream timeout and the agent-facing tool timeout hint
407
+ * (`TOOL_TIMEOUT_MS` in tools.ts references it).
408
+ */
409
+ const HTTP_TIMEOUT_MS = 18e5;
410
+ /**
411
+ * Apply one `STATE_DELTA` op set to the accumulating state. Only **top-level**
412
+ * patches (`/key`) are applied; nested paths are ignored because the final
413
+ * `STATE_SNAPSHOT` is authoritative for the deliverable fields.
414
+ */
415
+ function applyDelta(delta, state) {
416
+ if (!Array.isArray(delta)) return;
417
+ for (const op of delta) {
418
+ if (!op || typeof op !== "object") continue;
419
+ const record = op;
420
+ const path = typeof record.path === "string" ? record.path : "";
421
+ const m = /^\/([^/]+)$/.exec(path);
422
+ if (!m) continue;
423
+ const key = m[1];
424
+ if (record.op === "remove") delete state[key];
425
+ else if (record.op === "add" || record.op === "replace") state[key] = record.value;
426
+ }
427
+ }
428
+ /** Read a string field that may be camelCase (AG-UI) or snake_case (older builds). */
429
+ function strField(rec, ...keys) {
430
+ for (const key of keys) {
431
+ const v = rec[key];
432
+ if (typeof v === "string" && v.length > 0) return v;
433
+ }
434
+ return "";
435
+ }
436
+ /** Parse one `todo_progress` entry; anything else is dropped. */
437
+ function parseTodo(value) {
438
+ if (!value || typeof value !== "object") return null;
439
+ const rec = value;
440
+ const content = typeof rec["content"] === "string" ? rec["content"] : "";
441
+ if (content.length === 0) return null;
442
+ const status = rec["status"];
443
+ return {
444
+ content,
445
+ status: status === "completed" ? "completed" : status === "in_progress" ? "in_progress" : "pending"
446
+ };
447
+ }
448
+ /** Parse a design-stage announcement (`kind: "progress"`). */
449
+ function parseNote(value) {
450
+ const message = typeof value["message"] === "string" ? value["message"].trim() : "";
451
+ if (message.length === 0) return null;
452
+ const phase = value["phase"];
453
+ const stage = typeof value["stage"] === "string" ? value["stage"] : "";
454
+ return {
455
+ phase: phase === "complete" ? "complete" : phase === "error" ? "error" : "start",
456
+ stage,
457
+ message,
458
+ ts: typeof value["ts"] === "number" && Number.isFinite(value["ts"]) ? value["ts"] : Date.now()
459
+ };
460
+ }
461
+ /**
462
+ * Handle one decoded SSE event, mutating `state` and returning any text /
463
+ * lifecycle signals for the caller to aggregate.
464
+ *
465
+ * `tracker` is required only for agents that report progress through the
466
+ * standard AG-UI tool-call lifecycle instead of `CUSTOM` trace events —
467
+ * that is, `modular_circuit` (system design).
468
+ */
469
+ function handleEvent(evt, state, tracker) {
470
+ if (!evt || typeof evt !== "object") return {};
471
+ const record = evt;
472
+ const rec = evt;
473
+ const type = record.type;
474
+ if (type === "STATE_SNAPSHOT") {
475
+ if (record.snapshot && typeof record.snapshot === "object") Object.assign(state, record.snapshot);
476
+ return { stateChanged: true };
477
+ }
478
+ if (type === "STATE_DELTA") {
479
+ applyDelta(record.delta, state);
480
+ return { stateChanged: true };
481
+ }
482
+ if (type === "RUN_STARTED") {
483
+ tracker?.reset();
484
+ return { runStarted: true };
485
+ }
486
+ if (type === "TOOL_CALL_START" && tracker) {
487
+ const id = strField(rec, "toolCallId", "tool_call_id");
488
+ const name = strField(rec, "toolCallName", "tool_call_name") || "unknown";
489
+ return { trace: [tracker.start(id, name)] };
490
+ }
491
+ if (type === "TOOL_CALL_END" && tracker) {
492
+ const id = strField(rec, "toolCallId", "tool_call_id");
493
+ const ev = tracker.end(id);
494
+ return ev ? { trace: [ev] } : {};
495
+ }
496
+ if (type === "CUSTOM") {
497
+ const name = typeof record.name === "string" ? record.name : "";
498
+ if (name === "SYSTEM_DESIGN_EVENT") {
499
+ const value = record.value;
500
+ if (value && typeof value === "object") {
501
+ const payload = value;
502
+ if (payload["kind"] === "todo_progress" && Array.isArray(payload["todos"])) {
503
+ const todos = [];
504
+ for (const item of payload["todos"]) {
505
+ const todo = parseTodo(item);
506
+ if (todo) todos.push(todo);
507
+ }
508
+ return todos.length > 0 ? { todos } : {};
509
+ }
510
+ if (payload["kind"] === "progress") {
511
+ const note = parseNote(payload);
512
+ return note ? { note } : {};
513
+ }
514
+ }
515
+ return {};
516
+ }
517
+ if (!isTraceEventName(name)) return {};
518
+ const events = collectTraceEvents(record.value);
519
+ return events.length > 0 ? { trace: events } : {};
520
+ }
521
+ if (type === "TEXT_MESSAGE_CONTENT") return { text: typeof evt.delta === "string" ? evt.delta : "" };
522
+ if (type === "RUN_FINISHED") return { finished: true };
523
+ if (type === "RUN_ERROR") return { error: typeof record.error === "string" ? record.error : typeof record.message === "string" ? record.message : "unknown run error" };
524
+ return {};
525
+ }
526
+ /** Parse one `data: …` SSE block into event(s) and route them through `handleEvent`. */
527
+ function dispatchRaw(raw, state, acc) {
528
+ const lines = raw.split(/\r?\n/);
529
+ for (const line of lines) {
530
+ const trimmed = line.trim();
531
+ if (!trimmed.startsWith("data:")) continue;
532
+ const payload = trimmed.slice(5).trim();
533
+ if (!payload) continue;
534
+ let evt;
535
+ try {
536
+ evt = JSON.parse(payload);
537
+ } catch {
538
+ continue;
539
+ }
540
+ const r = handleEvent(evt, state, acc.tracker);
541
+ if (r.text) acc.text += r.text;
542
+ if (r.finished) acc.finished = true;
543
+ if (r.error) acc.error = r.error;
544
+ if (r.trace && r.trace.length > 0) {
545
+ for (const ev of r.trace) acc.trace.push(ev);
546
+ acc.onTrace?.(r.trace);
547
+ }
548
+ if (r.todos && r.todos.length > 0) acc.onTodos?.(r.todos);
549
+ if (r.note) acc.onNote?.(r.note);
550
+ if (r.stateChanged) acc.onState?.(state);
551
+ }
552
+ }
553
+ /** Decode a chunk, split on SSE boundaries, dispatch complete events, return
554
+ * the unterminated remainder. */
555
+ function feed(chunkStr, state, leftover, acc) {
556
+ const parts = (leftover + chunkStr).split(/\r?\n\r?\n/);
557
+ const newLeftover = parts.pop() || "";
558
+ for (const raw of parts) {
559
+ if (raw.trim().length === 0) continue;
560
+ dispatchRaw(raw, state, acc);
561
+ }
562
+ return newLeftover;
563
+ }
564
+ /**
565
+ * POST to the CopilotKit endpoint and consume the SSE stream until it ends or
566
+ * the budget elapses, accumulating the agent state.
567
+ */
568
+ async function consumeCopilotkit(url, body, headers, options = {}) {
569
+ const { signal, timeoutMs = HTTP_TIMEOUT_MS, fetchImpl = fetch } = options;
570
+ const controller = new AbortController();
571
+ const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error("schematic-gen: the design agent did not finish within " + timeoutMs + "ms")), timeoutMs);
572
+ let onAbort = null;
573
+ if (signal) {
574
+ if (signal.aborted) controller.abort();
575
+ else {
576
+ onAbort = () => controller.abort();
577
+ signal.addEventListener("abort", onAbort, { once: true });
578
+ }
579
+ }
580
+ let res;
581
+ try {
582
+ res = await fetchImpl(url, {
583
+ method: "POST",
584
+ headers,
585
+ body: JSON.stringify(body),
586
+ signal: controller.signal
587
+ });
588
+ } catch (err) {
589
+ clearTimeout(timer);
590
+ if (onAbort && signal) signal.removeEventListener("abort", onAbort);
591
+ throw new Error("schematic-gen: failed to reach the design API: " + String(err?.message || err));
592
+ }
593
+ if (!res || !res.ok) {
594
+ clearTimeout(timer);
595
+ if (onAbort && signal) signal.removeEventListener("abort", onAbort);
596
+ if (res && res.status === 401) try {
597
+ options.onUnauthorized?.();
598
+ } catch {}
599
+ throw new Error("schematic-gen: the design API returned HTTP " + String(res && res.status) + " (expected 200 with an SSE stream)");
600
+ }
601
+ if (!res.body || typeof res.body.getReader !== "function") {
602
+ clearTimeout(timer);
603
+ if (onAbort && signal) signal.removeEventListener("abort", onAbort);
604
+ throw new Error("schematic-gen: the design API response had no stream body");
605
+ }
606
+ const reader = res.body.getReader();
607
+ const decoder = new TextDecoder();
608
+ let buf = "";
609
+ const state = {};
610
+ const acc = {
611
+ text: "",
612
+ finished: false,
613
+ error: "",
614
+ trace: [],
615
+ onTrace: options.onTrace,
616
+ onState: options.onState,
617
+ onTodos: options.onTodos,
618
+ onNote: options.onNote,
619
+ tracker: options.toolCallTrace ? new ToolCallTracker() : void 0
620
+ };
621
+ try {
622
+ for (;;) {
623
+ const { done, value } = await reader.read();
624
+ if (done) break;
625
+ buf = feed(decoder.decode(value, { stream: true }), state, buf, acc);
626
+ }
627
+ if (buf.length > 0) dispatchRaw(buf, state, acc);
628
+ } finally {
629
+ clearTimeout(timer);
630
+ if (onAbort && signal) signal.removeEventListener("abort", onAbort);
631
+ try {
632
+ await reader.cancel();
633
+ } catch {}
634
+ }
635
+ if (acc.error) throw new Error("schematic-gen: the design agent reported an error: " + acc.error + (acc.text ? " — " + acc.text.slice(0, 300) : ""));
636
+ return {
637
+ state,
638
+ finished: acc.finished,
639
+ text: acc.text,
640
+ trace: acc.trace
641
+ };
642
+ }
643
+ /**
644
+ * POST the module graph to the production export-zip route and return the zip
645
+ * as a Buffer. The route reads `req.json()` as the `MODULE_GRAPH` and responds
646
+ * with `application/zip`.
647
+ */
648
+ async function exportModuleGraphZip(exportZipUrl, moduleGraph, config, account, options = {}) {
649
+ const { signal, timeoutMs = HTTP_TIMEOUT_MS, fetchImpl = fetch } = options;
650
+ const controller = new AbortController();
651
+ const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error("schematic-gen: the export-zip service did not respond within " + timeoutMs + "ms")), timeoutMs);
652
+ let onAbort = null;
653
+ if (signal) {
654
+ if (signal.aborted) controller.abort();
655
+ else {
656
+ onAbort = () => controller.abort();
657
+ signal.addEventListener("abort", onAbort, { once: true });
658
+ }
659
+ }
660
+ let res;
661
+ try {
662
+ res = await fetchImpl(exportZipUrl, {
663
+ method: "POST",
664
+ headers: buildExportHeaders(config, account),
665
+ body: JSON.stringify(moduleGraph),
666
+ signal: controller.signal
667
+ });
668
+ } catch (err) {
669
+ clearTimeout(timer);
670
+ if (onAbort && signal) signal.removeEventListener("abort", onAbort);
671
+ throw new Error("schematic-gen: failed to export the module graph to zip: " + String(err?.message || err));
672
+ } finally {
673
+ clearTimeout(timer);
674
+ if (onAbort && signal) signal.removeEventListener("abort", onAbort);
675
+ }
676
+ if (!res || !res.ok) {
677
+ let detail = "";
678
+ try {
679
+ const j = await res.json();
680
+ detail = typeof j.error === "string" ? j.error : "";
681
+ } catch {}
682
+ throw new Error("schematic-gen: the export-zip service returned HTTP " + String(res && res.status) + (detail ? " — " + detail : ""));
683
+ }
684
+ const ab = await res.arrayBuffer();
685
+ return Buffer.from(ab);
686
+ }
687
+ //#endregion
688
+ //#region src/tools.ts
689
+ /**
690
+ * `@huaqiu/dsh-tool-schematic-gen` — the two agent-visible tools.
691
+ *
692
+ * Faithful TypeScript port of the `hq-edge` plugin's tool bodies, adapted to
693
+ * the published DSH plugin surface:
694
+ * - the eda.cn account always comes from the `huaqiuAuth` service (no demo
695
+ * credentials — migration plan review #9);
696
+ * - generated artifacts are stored in the `huaqiuArtifacts` service
697
+ * (in-process, not a loopback);
698
+ * - tools are `defineTool` with `output.schema = { type: 'json' }` and a
699
+ * structured (lossless-JSON) result.
700
+ *
701
+ * Tools:
702
+ * generate_schematic_from_description description → KiCad schematic
703
+ * generate_system_module_graph description → module graph → KiCad zip
704
+ *
705
+ * @module @huaqiu/dsh-tool-schematic-gen
706
+ */
707
+ /** The normalized domain values are lossless-JSON plain objects except that
708
+ * optional fields are `undefined`, which fails DSH's lossless-JSON validation. */
709
+ function asJson(value) {
710
+ return JSON.parse(JSON.stringify(value));
711
+ }
712
+ /** Console tag for log filtering. */
713
+ const LOG_TAG$1 = "[dsh-schematic-gen]";
714
+ /** Agent-facing timeout hints. */
715
+ const TOOL_TIMEOUT_MS = {
716
+ generate_schematic_from_description: HTTP_TIMEOUT_MS,
717
+ generate_system_module_graph: HTTP_TIMEOUT_MS
718
+ };
719
+ function renderJson(_args, value) {
720
+ return [{
721
+ type: "text",
722
+ text: JSON.stringify(value)
723
+ }];
724
+ }
725
+ /** Resolve the eda.cn account from the auth capability (no baked-in creds). */
726
+ async function resolveAccount(auth) {
727
+ if (!auth || typeof auth.getUserInfo !== "function") return null;
728
+ try {
729
+ const info = await auth.getUserInfo();
730
+ if (info && typeof info.id === "string" && typeof info.token === "string" && info.id.length > 0 && info.token.length > 0) return {
731
+ userId: info.id,
732
+ userToken: info.token
733
+ };
734
+ return null;
735
+ } catch (err) {
736
+ console.warn(LOG_TAG$1, "could not resolve the eda.cn account", String(err?.message || err));
737
+ return null;
738
+ }
739
+ }
740
+ /** Fresh run id, injectable for tests. */
741
+ function newRunId(env) {
742
+ return typeof env.deps?.uuidImpl === "function" ? env.deps.uuidImpl() : randomUUID();
743
+ }
744
+ /**
745
+ * Bind one tool invocation to the progress store.
746
+ *
747
+ * Every returned callback is a no-op when there is no store or no `callId`,
748
+ * so a tool can call these unconditionally.
749
+ */
750
+ function progressFor(env, exec, toolName, kind) {
751
+ const store = env.progress;
752
+ const callId = typeof exec?.callId === "string" && exec.callId.length > 0 ? exec.callId : "";
753
+ const live = store && callId ? {
754
+ store,
755
+ callId
756
+ } : null;
757
+ if (live) live.store.start(live.callId, toolName, kind);
758
+ return {
759
+ onTrace: (events) => {
760
+ if (live) live.store.pushTrace(live.callId, events);
761
+ },
762
+ onState: (state) => {
763
+ if (live) live.store.updateState(live.callId, state);
764
+ },
765
+ onTodos: (todos) => {
766
+ if (live) live.store.setTodos(live.callId, todos);
767
+ },
768
+ onNote: (note) => {
769
+ if (live) live.store.setNote(live.callId, note);
770
+ },
771
+ done: () => {
772
+ if (live) live.store.finish(live.callId);
773
+ },
774
+ failed: (message) => {
775
+ if (live) live.store.fail(live.callId, message);
776
+ }
777
+ };
778
+ }
779
+ /** Store a generated artifact in the user-wide preview store (in-process). */
780
+ async function createPreviewArtifact(env, type, filename, content, contentEncoding) {
781
+ if (!env.artifacts || typeof env.artifacts.create !== "function") throw new Error("schematic-gen: huaqiuArtifacts service unavailable — cannot store preview artifact");
782
+ return env.artifacts.create({
783
+ type,
784
+ filename,
785
+ content,
786
+ contentEncoding
787
+ });
788
+ }
789
+ /**
790
+ * Pull the schematic deliverable out of the final agent state. `schFiles`
791
+ * carry the `.kicad_sch` content inline, so the text is returned verbatim.
792
+ */
793
+ function extractSchematic(state) {
794
+ const schFiles = (Array.isArray(state.schFiles) ? state.schFiles : []).map((f) => {
795
+ const file = f && typeof f === "object" ? f : {};
796
+ return {
797
+ filename: typeof file.filename === "string" ? file.filename : "",
798
+ content: typeof file.content === "string" ? file.content : typeof file.content === "object" && file.content !== null ? JSON.stringify(file.content) : String(file.content ?? "")
799
+ };
800
+ }).filter((f) => f.filename.length > 0);
801
+ return {
802
+ outProject: typeof state.outProject === "string" ? state.outProject : "",
803
+ project_achieve_url: typeof state.project_achieve_url === "string" ? state.project_achieve_url : "",
804
+ kicadPro: typeof state.kicadPro === "string" ? state.kicadPro : "",
805
+ schFiles,
806
+ error: typeof state.error === "string" ? state.error : ""
807
+ };
808
+ }
809
+ /** Pull the module graph out of the final system-design state. */
810
+ function extractModuleGraph(state) {
811
+ const mg = state && state.module_graph;
812
+ return mg && typeof mg === "object" ? mg : null;
813
+ }
814
+ /**
815
+ * Store each `.kicad_sch` sheet as a preview artifact and return the
816
+ * structured result. Artifact creation is BEST-EFFORT and non-fatal: on any
817
+ * failure the raw content is preserved inline so the result card can still
818
+ * render it.
819
+ */
820
+ async function materializeSchematicArtifacts(env, schFiles) {
821
+ const outFiles = schFiles.map((f) => ({ filename: f.filename }));
822
+ const artifacts = [];
823
+ let anyFailed = false;
824
+ let errorNote = "";
825
+ for (let i = 0; i < schFiles.length; i++) {
826
+ const file = schFiles[i];
827
+ try {
828
+ const created = await createPreviewArtifact(env, "schematic", file.filename, file.content);
829
+ artifacts.push({
830
+ id: created.id,
831
+ type: created.type,
832
+ filename: created.filename,
833
+ size: created.size
834
+ });
835
+ } catch (storeErr) {
836
+ anyFailed = true;
837
+ outFiles[i].content = file.content;
838
+ const msg = String(storeErr?.message || storeErr);
839
+ errorNote += (errorNote ? "; " : "") + file.filename + ": " + msg;
840
+ }
841
+ }
842
+ const result = { schFiles: outFiles };
843
+ if (artifacts.length > 0) result.schArtifacts = artifacts;
844
+ if (anyFailed) {
845
+ result.note = "Preview artifact storage partially or fully unavailable (" + errorNote + ").";
846
+ result.agentNote = "Sheets without an artifact id still carry their source inline, so the result card can render them directly. Full source is always in the project zip/export.";
847
+ }
848
+ return result;
849
+ }
850
+ /**
851
+ * Structured `needs_auth` result returned when the eda.cn login is missing —
852
+ * the signal that makes login a human-in-the-loop step. The web client
853
+ * (dsh-auth client half) renders a login card with an embedded auth.eda.cn
854
+ * iframe for this result; the model asks the user to complete the login and
855
+ * then retries the tool. Throwing here would hide that HIT surface.
856
+ */
857
+ function needsAuth(kind) {
858
+ return {
859
+ status: "needs_auth",
860
+ kind,
861
+ hint: "This tool requires a Huaqiu EDA (eda.cn) login. The web client is showing a login card with an embedded eda.cn login iframe — ask the user to complete the login there (or use the 华秋EDA login button in the sidebar), then call this tool again."
862
+ };
863
+ }
864
+ /**
865
+ * `generate_schematic_from_description` body — stream `schemagen`, extract the
866
+ * inline `.kicad_sch` files, then store each sheet as a preview artifact.
867
+ */
868
+ async function runGenerateSchematic(args, exec, env) {
869
+ const account = await resolveAccount(env.auth);
870
+ if (!account) return needsAuth("schematic");
871
+ const threadId = newRunId(env);
872
+ const body = buildRunBody(agentIds.SCHEMATIC, typeof args.description === "string" ? args.description : "", env.config, account, typeof args.user_language === "string" ? args.user_language : void 0, threadId);
873
+ const prog = progressFor(env, exec, "generate_schematic_from_description", "schematic");
874
+ let state;
875
+ let text;
876
+ try {
877
+ const res = await consumeCopilotkit(env.config.copilotkitUrl, body, buildHeaders(env.config, account, threadId), {
878
+ signal: exec?.signal,
879
+ timeoutMs: env.timeoutMs,
880
+ fetchImpl: env.deps?.fetchImpl,
881
+ onTrace: prog.onTrace,
882
+ onState: prog.onState,
883
+ onUnauthorized: () => {
884
+ env.auth.logout();
885
+ }
886
+ });
887
+ state = res.state;
888
+ text = res.text;
889
+ } catch (err) {
890
+ prog.failed(String(err?.message || err));
891
+ throw err;
892
+ }
893
+ const extracted = extractSchematic(state);
894
+ if (extracted.error) {
895
+ const message = "schematic-gen: the schematic agent finished with an error: " + extracted.error + (text ? " — " + text.slice(0, 300) : "");
896
+ prog.failed(message);
897
+ throw new Error(message);
898
+ }
899
+ if (extracted.schFiles.length === 0) {
900
+ const message = "schematic-gen: the schematic agent produced no .kicad_sch files." + (text ? " Assistant said: " + text.slice(0, 300) : "");
901
+ prog.failed(message);
902
+ throw new Error(message);
903
+ }
904
+ const materialized = await materializeSchematicArtifacts(env, extracted.schFiles);
905
+ const result = {
906
+ status: "generated",
907
+ kind: "schematic",
908
+ design_name: extracted.outProject || "",
909
+ schFiles: materialized.schFiles,
910
+ schArtifacts: materialized.schArtifacts,
911
+ kicadPro: extracted.kicadPro,
912
+ project_achieve_url: extracted.project_achieve_url
913
+ };
914
+ if (materialized.note) result.note = materialized.note;
915
+ if (materialized.agentNote) result.agentNote = materialized.agentNote;
916
+ prog.done();
917
+ return result;
918
+ }
919
+ /**
920
+ * `generate_system_module_graph` body — stream `modular_circuit`, extract the
921
+ * module graph, POST it to export-zip, return the KiCad project zip stored as
922
+ * a `zip` preview artifact.
923
+ */
924
+ async function runGenerateSystem(args, exec, env) {
925
+ const account = await resolveAccount(env.auth);
926
+ if (!account) return needsAuth("system");
927
+ const threadId = newRunId(env);
928
+ const body = buildRunBody(agentIds.SYSTEM, typeof args.description === "string" ? args.description : "", env.config, account, typeof args.user_language === "string" ? args.user_language : void 0, threadId);
929
+ const prog = progressFor(env, exec, "generate_system_module_graph", "system");
930
+ let state;
931
+ let text;
932
+ try {
933
+ const res = await consumeCopilotkit(env.config.copilotkitUrl, body, buildHeaders(env.config, account, threadId), {
934
+ signal: exec?.signal,
935
+ timeoutMs: env.timeoutMs,
936
+ fetchImpl: env.deps?.fetchImpl,
937
+ onTrace: prog.onTrace,
938
+ onState: prog.onState,
939
+ onTodos: prog.onTodos,
940
+ onNote: prog.onNote,
941
+ toolCallTrace: true,
942
+ onUnauthorized: () => {
943
+ env.auth.logout();
944
+ }
945
+ });
946
+ state = res.state;
947
+ text = res.text;
948
+ } catch (err) {
949
+ prog.failed(String(err?.message || err));
950
+ throw err;
951
+ }
952
+ const moduleGraph = extractModuleGraph(state);
953
+ if (!moduleGraph) {
954
+ const errState = typeof state.error === "string" && state.error ? state.error : "";
955
+ const message = "schematic-gen: the system design agent produced no module_graph." + (errState ? " Error: " + errState : "") + (text ? " Assistant said: " + text.slice(0, 300) : "");
956
+ prog.failed(message);
957
+ throw new Error(message);
958
+ }
959
+ const zipBuf = await exportModuleGraphZip(env.config.exportZipUrl, moduleGraph, env.config, account, {
960
+ signal: exec?.signal,
961
+ timeoutMs: env.timeoutMs,
962
+ fetchImpl: env.deps?.fetchImpl
963
+ }).catch((err) => {
964
+ prog.failed(String(err?.message || err));
965
+ throw err;
966
+ });
967
+ const designName = typeof state.design_name === "string" && state.design_name ? state.design_name : "circuit";
968
+ const connectionCount = typeof state.connection_count === "number" ? state.connection_count : Array.isArray(moduleGraph.connections) ? moduleGraph.connections.length : 0;
969
+ const moduleNames = Array.isArray(moduleGraph.modules) ? moduleGraph.modules.map((m) => m && typeof m.name === "string" ? m.name : "").filter((n) => n.length > 0) : [];
970
+ const result = {
971
+ status: "generated",
972
+ kind: "system",
973
+ design_name: designName,
974
+ module_count: moduleNames.length,
975
+ connection_count: connectionCount,
976
+ module_names: moduleNames,
977
+ zip_bytes: zipBuf.length
978
+ };
979
+ const notes = [];
980
+ let zipArtifact = null;
981
+ try {
982
+ const created = await createPreviewArtifact(env, "zip", sanitizeZipBaseName(designName) + ".zip", zipBuf.toString("base64"), "base64");
983
+ zipArtifact = {
984
+ id: created.id,
985
+ type: created.type,
986
+ filename: created.filename,
987
+ size: created.size
988
+ };
989
+ } catch (storeErr) {
990
+ notes.push("Could not store the project zip as an artifact (" + String(storeErr?.message || storeErr) + "); kept it in the result instead.");
991
+ }
992
+ if (zipArtifact) result.zipArtifact = zipArtifact;
993
+ else if (zipBuf.length <= 1e6) result.zip = "data:application/zip;base64," + zipBuf.toString("base64");
994
+ else {
995
+ const fileName = "hq-eda-" + sanitizeZipBaseName(designName) + "-" + newRunId(env).slice(0, 8) + ".zip";
996
+ const dir = env.deps?.tmpDirImpl && env.deps.tmpDirImpl() || tmpdir();
997
+ const filePath = join(dir, fileName);
998
+ try {
999
+ await (env.deps?.writeFileImpl || writeFile)(filePath, zipBuf);
1000
+ result.zip_path = filePath;
1001
+ } catch (err) {
1002
+ result.zip = "data:application/zip;base64," + zipBuf.toString("base64");
1003
+ notes.push("Could not write the zip to a temp file (" + String(err?.message || err) + "); returned inline instead.");
1004
+ }
1005
+ }
1006
+ if (notes.length > 0) result.note = notes.join(" ");
1007
+ prog.done();
1008
+ return result;
1009
+ }
1010
+ /** Agent-awareness note about the eda.cn login gate, appended to both tool
1011
+ * descriptions: a `needs_auth` result surfaces a login HIT (embedded eda.cn
1012
+ * iframe card) — wait for the user to log in, then retry. Never invent
1013
+ * credentials or fake success. */
1014
+ const AUTH_GATE_NOTE = "AUTH: This tool requires a Huaqiu EDA (eda.cn) account. If the result has status \"needs_auth\", the web client is showing a login card with an embedded eda.cn login iframe (the human-in-the-loop step). Ask the user to complete the login there or via the 华秋EDA login button in the sidebar (you may use ask_user_question to wait, offering a \"retry now that I have logged in\" option and a \"cancel\" option — phrase BOTH in the language the user is writing in), then call this tool again. Never invent credentials and never claim success when the result is needs_auth.";
1015
+ function createSchematicTool(env) {
1016
+ return defineTool({
1017
+ name: "generate_schematic_from_description",
1018
+ 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 per sheet), 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,
1019
+ parameters: {
1020
+ description: {
1021
+ type: "string",
1022
+ required: true,
1023
+ description: "The circuit design prompt, in natural language. Be specific about components, voltages and any required behaviour."
1024
+ },
1025
+ user_language: {
1026
+ type: "string",
1027
+ description: "Optional language hint for the agent — pass the language the user is writing in (e.g. \"简体中文\" or \"English\"). Omit to use the deployment default."
1028
+ }
1029
+ },
1030
+ output: {
1031
+ schema: { type: "json" },
1032
+ render: renderJson
1033
+ },
1034
+ async execute(args, exec) {
1035
+ return asJson(await runGenerateSchematic(args, exec, env));
1036
+ },
1037
+ timeoutMs: TOOL_TIMEOUT_MS.generate_schematic_from_description
1038
+ });
1039
+ }
1040
+ function createSystemTool(env) {
1041
+ return defineTool({
1042
+ name: "generate_system_module_graph",
1043
+ description: "Generate a hardware system design (module graph) from a natural-language description — e.g. \"design a small smart alarm clock\". Calls the online HQ-EDA system-design agent, which plans the modules, searches/selects parts, wires the connections, and produces a module graph; the graph is then exported to a KiCad project zip. Returns: a zipArtifact reference (preview-artifact id of the full project zip — the zip is never inlined into the conversation) and a summary (design name, module count, connection count, module names). Use this when the user wants a whole system/module-level design, not a single schematic or symbol. IMPORTANT: The generated system design renders automatically as a result card in the web client — a canvas preview of the project root schematic (fetched from the zip artifact) and a Download button for 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 design was generated, its module count, and that the project zip is downloadable from the card. " + AUTH_GATE_NOTE,
1044
+ parameters: {
1045
+ description: {
1046
+ type: "string",
1047
+ required: true,
1048
+ description: "The system design prompt, in natural language. Describe the product or function you want, e.g. \"an ESP32-C3 based smart fan\"."
1049
+ },
1050
+ user_language: {
1051
+ type: "string",
1052
+ description: "Optional language hint for the agent — pass the language the user is writing in (e.g. \"简体中文\" or \"English\"). Omit to use the deployment default."
1053
+ }
1054
+ },
1055
+ output: {
1056
+ schema: { type: "json" },
1057
+ render: renderJson
1058
+ },
1059
+ async execute(args, exec) {
1060
+ return asJson(await runGenerateSystem(args, exec, env));
1061
+ },
1062
+ timeoutMs: TOOL_TIMEOUT_MS.generate_system_module_graph
1063
+ });
1064
+ }
1065
+ /** Build the two tool definitions against a runtime env. */
1066
+ function createSchematicGenTools(env) {
1067
+ return [createSchematicTool(env), createSystemTool(env)];
1068
+ }
1069
+ //#endregion
1070
+ //#region src/progress.ts
1071
+ /**
1072
+ * `@huaqiu/dsh-tool-schematic-gen` — live run progress (node half).
1073
+ *
1074
+ * Long design runs take 10+ minutes. The tool body sits on the node half and
1075
+ * returns once, so the browser card would otherwise show a frozen label for
1076
+ * the whole run. This module is the node side of the fix:
1077
+ *
1078
+ * tool body ──pushTrace/updateState──▶ ProgressStore (keyed by callId)
1079
+ * │
1080
+ * ctx.webServer ◀──────┘ GET …/progress/<callId>
1081
+ * │
1082
+ * browser card polls
1083
+ *
1084
+ * **Why `callId` is the key.** `defineTool`'s second argument is a
1085
+ * `ToolRunContext`, which extends `ToolExecutionInput` and therefore carries
1086
+ * `callId`. The `tool.call.toolview` slot passes the very same string to the
1087
+ * browser component as `ToolCallOwnerProps.callId` — documented in DSH as
1088
+ * "stable across running and settled forms". So no hand-rolled correlation id
1089
+ * has to travel through the tool result.
1090
+ *
1091
+ * **Two independent progress signals**, because the backend is not guaranteed
1092
+ * to emit trace events:
1093
+ * 1. `frames` — a real call stack, when AG-UI `CUSTOM` trace events arrive.
1094
+ * 2. `stage` — a coarse ladder derived from the agent's own state keys,
1095
+ * which always arrives via `STATE_SNAPSHOT`/`STATE_DELTA`.
1096
+ * The card renders the stack when it exists and the ladder otherwise, and
1097
+ * always shows the elapsed timer.
1098
+ *
1099
+ * @module @huaqiu/dsh-tool-schematic-gen
1100
+ */
1101
+ /** A non-empty value: null/empty-string/empty-array/empty-object/false are not. */
1102
+ function filled(v) {
1103
+ if (v === null || v === void 0 || v === false) return false;
1104
+ if (typeof v === "string") return v.trim().length > 0;
1105
+ if (Array.isArray(v)) return v.length > 0;
1106
+ if (typeof v === "object") return Object.keys(v).length > 0;
1107
+ return true;
1108
+ }
1109
+ /** Connections live one level down: `connect_result.connections`. */
1110
+ function connectionsFilled(state) {
1111
+ const cr = state["connect_result"];
1112
+ if (cr && typeof cr === "object") return filled(cr["connections"]);
1113
+ return false;
1114
+ }
1115
+ /**
1116
+ * Stage ladders, ordered. Derived from the agent's own initial state
1117
+ * (`emptySchematicState` / `emptySystemState` in config.ts), so a stage is
1118
+ * "reached" exactly when the agent has written that part of the design.
1119
+ */
1120
+ const STAGE_LADDERS = {
1121
+ schematic: [
1122
+ {
1123
+ key: "requirement",
1124
+ reached: (s) => filled(s["requirement"])
1125
+ },
1126
+ {
1127
+ key: "architecture",
1128
+ reached: (s) => filled(s["architecture"])
1129
+ },
1130
+ {
1131
+ key: "circuit",
1132
+ reached: (s) => filled(s["circuit"])
1133
+ },
1134
+ {
1135
+ key: "report",
1136
+ reached: (s) => filled(s["report"]) || filled(s["reportStage"])
1137
+ },
1138
+ {
1139
+ key: "output",
1140
+ reached: (s) => filled(s["schFiles"])
1141
+ }
1142
+ ],
1143
+ system: [
1144
+ {
1145
+ key: "plan",
1146
+ reached: (s) => filled(s["design_plan"])
1147
+ },
1148
+ {
1149
+ key: "search",
1150
+ reached: (s) => filled(s["search_plan"])
1151
+ },
1152
+ {
1153
+ key: "bom",
1154
+ reached: (s) => filled(s["bom_list"])
1155
+ },
1156
+ {
1157
+ key: "modules",
1158
+ reached: (s) => filled(s["module_list"])
1159
+ },
1160
+ {
1161
+ key: "connect",
1162
+ reached: connectionsFilled
1163
+ },
1164
+ {
1165
+ key: "erc",
1166
+ reached: (s) => s["erc_passed"] === true
1167
+ },
1168
+ {
1169
+ key: "export",
1170
+ reached: (s) => filled(s["module_graph"])
1171
+ }
1172
+ ]
1173
+ };
1174
+ /** Resolve how far a run has got, purely from the agent state snapshot. */
1175
+ function stageOf(kind, state) {
1176
+ const ladder = STAGE_LADDERS[kind];
1177
+ if (!ladder || ladder.length === 0) return null;
1178
+ let reached = 0;
1179
+ for (const spec of ladder) {
1180
+ if (!spec.reached(state)) break;
1181
+ reached += 1;
1182
+ }
1183
+ const index = Math.min(reached, ladder.length - 1);
1184
+ return {
1185
+ index,
1186
+ total: ladder.length,
1187
+ key: ladder[index].key
1188
+ };
1189
+ }
1190
+ /**
1191
+ * In-memory progress registry. Deliberately NOT persisted: progress is only
1192
+ * meaningful while the run is live, and a stale doc after a restart would be
1193
+ * worse than none.
1194
+ */
1195
+ var ProgressStore = class {
1196
+ runs = /* @__PURE__ */ new Map();
1197
+ ttlMs;
1198
+ now;
1199
+ constructor(options = {}) {
1200
+ this.ttlMs = options.ttlMs ?? 18e5;
1201
+ this.now = options.now ?? (() => Date.now());
1202
+ }
1203
+ /** Register a run. Safe to call twice for the same id (idempotent). */
1204
+ start(callId, toolName, kind) {
1205
+ if (!callId) return;
1206
+ if (this.runs.has(callId)) return;
1207
+ const ts = this.now();
1208
+ this.runs.set(callId, {
1209
+ doc: {
1210
+ callId,
1211
+ toolName,
1212
+ kind,
1213
+ status: "running",
1214
+ startedAt: ts,
1215
+ updatedAt: ts,
1216
+ frames: [],
1217
+ stage: null,
1218
+ todos: null,
1219
+ note: null,
1220
+ error: null
1221
+ },
1222
+ events: [],
1223
+ state: {}
1224
+ });
1225
+ }
1226
+ /** Append trace events and re-pair the frame list. */
1227
+ pushTrace(callId, events) {
1228
+ const rec = this.runs.get(callId);
1229
+ if (!rec || events.length === 0) return;
1230
+ for (const ev of events) rec.events.push(ev);
1231
+ if (rec.events.length > 2e3) rec.events.splice(0, rec.events.length - 2e3);
1232
+ rec.doc.frames = pairTraceEvents(rec.events).slice(-500);
1233
+ rec.doc.updatedAt = this.now();
1234
+ }
1235
+ /** Merge an agent state snapshot and re-evaluate the stage ladder. */
1236
+ updateState(callId, state) {
1237
+ const rec = this.runs.get(callId);
1238
+ if (!rec) return;
1239
+ Object.assign(rec.state, state);
1240
+ rec.doc.stage = stageOf(rec.doc.kind, rec.state);
1241
+ rec.doc.updatedAt = this.now();
1242
+ }
1243
+ /** Replace the todo list. Only ever set by the system-design agent. */
1244
+ setTodos(callId, todos) {
1245
+ const rec = this.runs.get(callId);
1246
+ if (!rec) return;
1247
+ rec.doc.todos = todos.map((todo) => ({ ...todo }));
1248
+ rec.doc.updatedAt = this.now();
1249
+ }
1250
+ /** Record the latest stage announcement. */
1251
+ setNote(callId, note) {
1252
+ const rec = this.runs.get(callId);
1253
+ if (!rec) return;
1254
+ if (rec.doc.note && note.ts < rec.doc.note.ts) return;
1255
+ rec.doc.note = { ...note };
1256
+ rec.doc.updatedAt = this.now();
1257
+ if (note.phase === "error" && rec.doc.status === "running") {
1258
+ rec.doc.status = "failed";
1259
+ rec.doc.error = note.message;
1260
+ }
1261
+ }
1262
+ finish(callId) {
1263
+ const rec = this.runs.get(callId);
1264
+ if (!rec) return;
1265
+ rec.doc.status = "completed";
1266
+ rec.doc.updatedAt = this.now();
1267
+ for (const f of rec.doc.frames) if (f.status === "running") {
1268
+ f.status = "finished";
1269
+ f.finishedAt = rec.doc.updatedAt;
1270
+ }
1271
+ }
1272
+ fail(callId, message) {
1273
+ const rec = this.runs.get(callId);
1274
+ if (!rec) return;
1275
+ rec.doc.status = "failed";
1276
+ rec.doc.error = message;
1277
+ rec.doc.updatedAt = this.now();
1278
+ for (const f of rec.doc.frames) if (f.status === "running") {
1279
+ f.status = "failed";
1280
+ f.finishedAt = rec.doc.updatedAt;
1281
+ }
1282
+ }
1283
+ /** Snapshot for the HTTP route, or `null` when unknown/expired. */
1284
+ get(callId) {
1285
+ const rec = this.runs.get(callId);
1286
+ if (!rec) return null;
1287
+ if (this.now() - rec.doc.updatedAt > this.ttlMs) {
1288
+ this.runs.delete(callId);
1289
+ return null;
1290
+ }
1291
+ return rec.doc;
1292
+ }
1293
+ /** Every live run. Used by the route when the caller has no callId. */
1294
+ list() {
1295
+ const out = [];
1296
+ for (const callId of [...this.runs.keys()]) {
1297
+ const doc = this.get(callId);
1298
+ if (doc) out.push(doc);
1299
+ }
1300
+ return out;
1301
+ }
1302
+ delete(callId) {
1303
+ this.runs.delete(callId);
1304
+ }
1305
+ /** Drop expired runs. Returns how many were removed. */
1306
+ sweep() {
1307
+ const now = this.now();
1308
+ let removed = 0;
1309
+ for (const [callId, rec] of [...this.runs]) if (now - rec.doc.updatedAt > this.ttlMs) {
1310
+ this.runs.delete(callId);
1311
+ removed += 1;
1312
+ }
1313
+ return removed;
1314
+ }
1315
+ get size() {
1316
+ return this.runs.size;
1317
+ }
1318
+ };
1319
+ //#endregion
1320
+ //#region src/routes.ts
1321
+ const PROGRESS_ROUTE_PREFIX = "/api/v1/huaqiu/schematic-gen/progress";
1322
+ function sendJson(res, status, body) {
1323
+ const payload = JSON.stringify(body);
1324
+ res.writeHead(status, {
1325
+ "content-type": "application/json; charset=utf-8",
1326
+ "cache-control": "no-store"
1327
+ });
1328
+ res.end(payload);
1329
+ }
1330
+ /** Split the sub-path off the prefix. Returns `null` on a bad shape. */
1331
+ function parsePath(req) {
1332
+ const url = req.url ?? "";
1333
+ const q = url.indexOf("?");
1334
+ const pathname = q >= 0 ? url.slice(0, q) : url;
1335
+ if (!pathname.startsWith("/api/v1/huaqiu/schematic-gen/progress")) return null;
1336
+ const rest = pathname.slice(37);
1337
+ if (rest === "") return { callId: null };
1338
+ if (!rest.startsWith("/")) return null;
1339
+ const segs = rest.split("/").filter(Boolean);
1340
+ if (segs.length !== 1) return null;
1341
+ let callId;
1342
+ try {
1343
+ callId = decodeURIComponent(segs[0]);
1344
+ } catch {
1345
+ return null;
1346
+ }
1347
+ return { callId: callId.length > 0 ? callId : null };
1348
+ }
1349
+ function createProgressHandler(store) {
1350
+ return (req, res) => {
1351
+ if (req.method !== "GET" && req.method !== "HEAD") {
1352
+ sendJson(res, 405, { error: "method not allowed" });
1353
+ return;
1354
+ }
1355
+ const parsed = parsePath(req);
1356
+ if (!parsed) {
1357
+ sendJson(res, 404, { error: "not found" });
1358
+ return;
1359
+ }
1360
+ if (parsed.callId === null) {
1361
+ sendJson(res, 200, { runs: store.list() });
1362
+ return;
1363
+ }
1364
+ const doc = store.get(parsed.callId);
1365
+ if (!doc) {
1366
+ sendJson(res, 404, { error: "no live run for this call id" });
1367
+ return;
1368
+ }
1369
+ sendJson(res, 200, doc);
1370
+ };
1371
+ }
1372
+ //#endregion
1373
+ //#region src/index.ts
1374
+ /** Plugin id — matches package.json. */
1375
+ const name = "@huaqiu/dsh-tool-schematic-gen";
1376
+ /**
1377
+ * Cordis services this half depends on.
1378
+ *
1379
+ * `webServer` carries the live-progress route that the browser card polls.
1380
+ * It is already transitively required, because `@huaqiu/dsh-artifacts`
1381
+ * (which we inject as `huaqiuArtifacts) declares it too.
1382
+ */
1383
+ const inject = [
1384
+ "tools",
1385
+ "huaqiuAuth",
1386
+ "huaqiuArtifacts",
1387
+ "webServer"
1388
+ ];
1389
+ /** Console tag for filtering in logs. */
1390
+ const LOG_TAG = "[dsh-schematic-gen]";
1391
+ /**
1392
+ * Host plugin body — register the two generation tools.
1393
+ *
1394
+ * @param ctx - real cordis context (node side).
1395
+ * @returns disposer — unregisters both tools on plugin dispose.
1396
+ */
1397
+ function apply(ctx, config = {}) {
1398
+ if (!ctx.tools || typeof ctx.tools.register !== "function") throw new Error("@huaqiu/dsh-tool-schematic-gen requires the DSH `tools` service (ctx.tools.register).");
1399
+ if (!ctx.huaqiuAuth || !ctx.huaqiuAuth.auth || typeof ctx.huaqiuAuth.auth.getUserInfo !== "function") throw new Error("@huaqiu/dsh-tool-schematic-gen requires the `huaqiuAuth` service (provided by @huaqiu/dsh-auth) — the eda.cn account is never baked in.");
1400
+ if (!ctx.huaqiuArtifacts || typeof ctx.huaqiuArtifacts.create !== "function") throw new Error("@huaqiu/dsh-tool-schematic-gen requires the `huaqiuArtifacts` service (provided by @huaqiu/dsh-artifacts).");
1401
+ const auth = ctx.huaqiuAuth;
1402
+ const artifacts = ctx.huaqiuArtifacts;
1403
+ const configOverride = {};
1404
+ if (config.copilotkitUrl) configOverride.HQ_EDA_COPILOTKIT_URL = config.copilotkitUrl;
1405
+ if (config.exportZipUrl) configOverride.HQ_EDA_EXPORT_ZIP_URL = config.exportZipUrl;
1406
+ const finalConfig = resolveConfig({
1407
+ ...typeof process !== "undefined" ? process.env : void 0,
1408
+ ...configOverride
1409
+ });
1410
+ const progress = new ProgressStore();
1411
+ if (ctx.webServer && typeof ctx.webServer.register === "function") ctx.effect(() => ctx.webServer.register({
1412
+ kind: "prefix",
1413
+ path: PROGRESS_ROUTE_PREFIX,
1414
+ handler: createProgressHandler(progress)
1415
+ }));
1416
+ else console.warn(LOG_TAG, "webServer unavailable — live progress reporting is disabled");
1417
+ const disposers = createSchematicGenTools({
1418
+ config: finalConfig,
1419
+ auth: auth.auth,
1420
+ artifacts,
1421
+ timeoutMs: HTTP_TIMEOUT_MS,
1422
+ progress,
1423
+ deps: {}
1424
+ }).map((tool) => ctx.tools.register(tool));
1425
+ console.log(LOG_TAG, "registered agent tools", {
1426
+ tools: disposers.length,
1427
+ copilotkitUrl: finalConfig.copilotkitUrl,
1428
+ exportZipUrl: finalConfig.exportZipUrl,
1429
+ auth: "huaqiuAuth"
1430
+ });
1431
+ return function dispose() {
1432
+ for (const disposeTool of disposers) try {
1433
+ disposeTool();
1434
+ } catch {}
1435
+ progress.sweep();
1436
+ };
1437
+ }
1438
+ //#endregion
1439
+ export { agentIds, apply, inject, name };