@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/src/index.ts ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Huaqiu EDA schematic & system-design generation DSH tool plugin (node half) —
3
+ * `@huaqiu/dsh-tool-schematic-gen`.
4
+ *
5
+ * Exposes two agent-visible tools that drive the online HQ-EDA CopilotKit
6
+ * agents (`schemagen`, `modular_circuit`) over SSE:
7
+ *
8
+ * generate_schematic_from_description description → KiCad schematic
9
+ * generate_system_module_graph description → module graph → KiCad zip
10
+ *
11
+ * ── Architectural boundary (migration plan §1/§8/§9) ───────────────────────────
12
+ * Self-contained DSH plugin: no `@hqedge/*` dependency, no HTTP proxy, and NO
13
+ * demo credentials. The eda.cn account is the `huaqiuAuth` capability
14
+ * (`getUserInfo()` → `x-user-id` / `x-user-token`); generated artifacts are
15
+ * stored in the user-wide `huaqiuArtifacts` service (in-process). The zip
16
+ * artifact is the single source of truth for system designs — never inlined.
17
+ *
18
+ * @module @huaqiu/dsh-tool-schematic-gen
19
+ */
20
+ import type { Context } from '@deepseek-ai/cordis'
21
+ import type { HuaqiuAuthService } from '@huaqiu/dsh-auth'
22
+ import type { HuaqiuArtifacts } from '@huaqiu/dsh-artifacts'
23
+ import type {} from '@deepseek-ai/dsh-host-webserver'
24
+ import { createSchematicGenTools, type SchematicGenDeps } from './tools.js'
25
+ import { resolveConfig } from './config.js'
26
+ import { HTTP_TIMEOUT_MS } from './sse.js'
27
+ import { ProgressStore } from './progress.js'
28
+ import { createProgressHandler, PROGRESS_ROUTE_PREFIX } from './routes.js'
29
+
30
+ /** Plugin id — matches package.json. */
31
+ export const name = '@huaqiu/dsh-tool-schematic-gen'
32
+
33
+ /**
34
+ * Cordis services this half depends on.
35
+ *
36
+ * `webServer` carries the live-progress route that the browser card polls.
37
+ * It is already transitively required, because `@huaqiu/dsh-artifacts`
38
+ * (which we inject as `huaqiuArtifacts) declares it too.
39
+ */
40
+ export const inject = ['tools', 'huaqiuAuth', 'huaqiuArtifacts', 'webServer'] as const
41
+
42
+ /** Console tag for filtering in logs. */
43
+ const LOG_TAG = '[dsh-schematic-gen]'
44
+
45
+ export interface SchematicGenPluginConfig {
46
+ /** Endpoint overrides, env-backed. */
47
+ copilotkitUrl?: string
48
+ exportZipUrl?: string
49
+ }
50
+
51
+ /**
52
+ * Host plugin body — register the two generation tools.
53
+ *
54
+ * @param ctx - real cordis context (node side).
55
+ * @returns disposer — unregisters both tools on plugin dispose.
56
+ */
57
+ export function apply(ctx: Context, config: SchematicGenPluginConfig = {}): () => void {
58
+ if (!ctx.tools || typeof ctx.tools.register !== 'function') {
59
+ throw new Error('@huaqiu/dsh-tool-schematic-gen requires the DSH `tools` service (ctx.tools.register).')
60
+ }
61
+ if (!ctx.huaqiuAuth || !ctx.huaqiuAuth.auth || typeof ctx.huaqiuAuth.auth.getUserInfo !== 'function') {
62
+ throw new Error(
63
+ '@huaqiu/dsh-tool-schematic-gen requires the `huaqiuAuth` service (provided by @huaqiu/dsh-auth) — ' +
64
+ 'the eda.cn account is never baked in.',
65
+ )
66
+ }
67
+ if (!ctx.huaqiuArtifacts || typeof ctx.huaqiuArtifacts.create !== 'function') {
68
+ throw new Error(
69
+ '@huaqiu/dsh-tool-schematic-gen requires the `huaqiuArtifacts` service (provided by @huaqiu/dsh-artifacts).',
70
+ )
71
+ }
72
+
73
+ const auth: HuaqiuAuthService = ctx.huaqiuAuth
74
+ const artifacts: HuaqiuArtifacts = ctx.huaqiuArtifacts
75
+
76
+ // Fail fast at load time on a misconfigured endpoint override.
77
+ const configOverride: Record<string, string | undefined> = {}
78
+ if (config.copilotkitUrl) configOverride.HQ_EDA_COPILOTKIT_URL = config.copilotkitUrl
79
+ if (config.exportZipUrl) configOverride.HQ_EDA_EXPORT_ZIP_URL = config.exportZipUrl
80
+ const finalConfig = resolveConfig({ ...(typeof process !== 'undefined' ? process.env : undefined), ...configOverride })
81
+
82
+ // Live progress: an in-memory store the tool bodies write to and the browser
83
+ // card polls. Best-effort — when the webServer surface is missing the tools
84
+ // still generate, they simply cannot report progress.
85
+ const progress = new ProgressStore()
86
+ if (ctx.webServer && typeof ctx.webServer.register === 'function') {
87
+ ctx.effect(() => ctx.webServer.register({
88
+ kind: 'prefix',
89
+ path: PROGRESS_ROUTE_PREFIX,
90
+ handler: createProgressHandler(progress),
91
+ }))
92
+ } else {
93
+ // eslint-disable-next-line no-console
94
+ console.warn(LOG_TAG, 'webServer unavailable — live progress reporting is disabled')
95
+ }
96
+
97
+ const deps: SchematicGenDeps = {}
98
+ const env = {
99
+ config: finalConfig,
100
+ auth: auth.auth,
101
+ artifacts,
102
+ timeoutMs: HTTP_TIMEOUT_MS,
103
+ progress,
104
+ deps,
105
+ }
106
+
107
+ const disposers = createSchematicGenTools(env).map((tool) => ctx.tools.register(tool))
108
+
109
+ // eslint-disable-next-line no-console
110
+ console.log(LOG_TAG, 'registered agent tools', {
111
+ tools: disposers.length,
112
+ copilotkitUrl: finalConfig.copilotkitUrl,
113
+ exportZipUrl: finalConfig.exportZipUrl,
114
+ auth: 'huaqiuAuth',
115
+ })
116
+
117
+ return function dispose() {
118
+ for (const disposeTool of disposers) {
119
+ try {
120
+ disposeTool()
121
+ } catch {
122
+ // One failing unregister must not hide the others.
123
+ }
124
+ }
125
+ progress.sweep()
126
+ }
127
+ }
128
+
129
+ /** Exported for tests: the agent ids this plugin drives. */
130
+ export { agentIds } from './config.js'
@@ -0,0 +1,333 @@
1
+ /**
2
+ * `@huaqiu/dsh-tool-schematic-gen` — live run progress (node half).
3
+ *
4
+ * Long design runs take 10+ minutes. The tool body sits on the node half and
5
+ * returns once, so the browser card would otherwise show a frozen label for
6
+ * the whole run. This module is the node side of the fix:
7
+ *
8
+ * tool body ──pushTrace/updateState──▶ ProgressStore (keyed by callId)
9
+ * │
10
+ * ctx.webServer ◀──────┘ GET …/progress/<callId>
11
+ * │
12
+ * browser card polls
13
+ *
14
+ * **Why `callId` is the key.** `defineTool`'s second argument is a
15
+ * `ToolRunContext`, which extends `ToolExecutionInput` and therefore carries
16
+ * `callId`. The `tool.call.toolview` slot passes the very same string to the
17
+ * browser component as `ToolCallOwnerProps.callId` — documented in DSH as
18
+ * "stable across running and settled forms". So no hand-rolled correlation id
19
+ * has to travel through the tool result.
20
+ *
21
+ * **Two independent progress signals**, because the backend is not guaranteed
22
+ * to emit trace events:
23
+ * 1. `frames` — a real call stack, when AG-UI `CUSTOM` trace events arrive.
24
+ * 2. `stage` — a coarse ladder derived from the agent's own state keys,
25
+ * which always arrives via `STATE_SNAPSHOT`/`STATE_DELTA`.
26
+ * The card renders the stack when it exists and the ladder otherwise, and
27
+ * always shows the elapsed timer.
28
+ *
29
+ * @module @huaqiu/dsh-tool-schematic-gen
30
+ */
31
+ import { pairTraceEvents, type TraceEvent, type TraceFrame } from './trace.js'
32
+
33
+ export type RunStatus = 'running' | 'completed' | 'failed'
34
+ export type RunKind = 'schematic' | 'system'
35
+
36
+ /**
37
+ * One item of the rolling todo list the system-design agent publishes
38
+ * (`SYSTEM_DESIGN_EVENT` / `kind: "todo_progress"`).
39
+ */
40
+ export interface TodoItem {
41
+ content: string
42
+ status: 'pending' | 'in_progress' | 'completed'
43
+ }
44
+
45
+ /**
46
+ * A human-readable stage announcement from the system-design agent's
47
+ * `emitWorkflowProgress` middleware — e.g. "正在整理需求并生成系统设计方案。".
48
+ *
49
+ * This is the ONLY narrative progress signal the system agent gives us; the
50
+ * coarse ladder below is derived from state keys and says nothing about what
51
+ * the agent is actually doing right now.
52
+ */
53
+ export interface ProgressNote {
54
+ phase: 'start' | 'complete' | 'error'
55
+ /** Tool/stage id, e.g. `design_plan_gen`. Empty when the agent omits it. */
56
+ stage: string
57
+ message: string
58
+ ts: number
59
+ }
60
+
61
+ /** One rung of the coarse progress ladder. `key` is an i18n key suffix. */
62
+ export interface StageSpec {
63
+ key: string
64
+ reached(state: Record<string, unknown>): boolean
65
+ }
66
+
67
+ /** A non-empty value: null/empty-string/empty-array/empty-object/false are not. */
68
+ function filled(v: unknown): boolean {
69
+ if (v === null || v === undefined || v === false) return false
70
+ if (typeof v === 'string') return v.trim().length > 0
71
+ if (Array.isArray(v)) return v.length > 0
72
+ if (typeof v === 'object') return Object.keys(v as Record<string, unknown>).length > 0
73
+ return true
74
+ }
75
+
76
+ /** Connections live one level down: `connect_result.connections`. */
77
+ function connectionsFilled(state: Record<string, unknown>): boolean {
78
+ const cr = state['connect_result']
79
+ if (cr && typeof cr === 'object') {
80
+ return filled((cr as Record<string, unknown>)['connections'])
81
+ }
82
+ return false
83
+ }
84
+
85
+ /**
86
+ * Stage ladders, ordered. Derived from the agent's own initial state
87
+ * (`emptySchematicState` / `emptySystemState` in config.ts), so a stage is
88
+ * "reached" exactly when the agent has written that part of the design.
89
+ */
90
+ export const STAGE_LADDERS: Record<RunKind, readonly StageSpec[]> = {
91
+ schematic: [
92
+ { key: 'requirement', reached: (s) => filled(s['requirement']) },
93
+ { key: 'architecture', reached: (s) => filled(s['architecture']) },
94
+ { key: 'circuit', reached: (s) => filled(s['circuit']) },
95
+ { key: 'report', reached: (s) => filled(s['report']) || filled(s['reportStage']) },
96
+ { key: 'output', reached: (s) => filled(s['schFiles']) },
97
+ ],
98
+ system: [
99
+ { key: 'plan', reached: (s) => filled(s['design_plan']) },
100
+ { key: 'search', reached: (s) => filled(s['search_plan']) },
101
+ { key: 'bom', reached: (s) => filled(s['bom_list']) },
102
+ { key: 'modules', reached: (s) => filled(s['module_list']) },
103
+ { key: 'connect', reached: connectionsFilled },
104
+ { key: 'erc', reached: (s) => s['erc_passed'] === true },
105
+ { key: 'export', reached: (s) => filled(s['module_graph']) },
106
+ ],
107
+ }
108
+
109
+ /** Resolve how far a run has got, purely from the agent state snapshot. */
110
+ export function stageOf(
111
+ kind: RunKind,
112
+ state: Record<string, unknown>,
113
+ ): { index: number; total: number; key: string } | null {
114
+ const ladder = STAGE_LADDERS[kind]
115
+ if (!ladder || ladder.length === 0) return null
116
+ let reached = 0
117
+ for (const spec of ladder) {
118
+ if (!spec.reached(state)) break
119
+ reached += 1
120
+ }
121
+ // `index` is the stage currently IN PROGRESS (0-based); clamp to the last
122
+ // stage once everything is reached.
123
+ const index = Math.min(reached, ladder.length - 1)
124
+ return { index, total: ladder.length, key: ladder[index]!.key }
125
+ }
126
+
127
+ /** Hard cap on retained frames so a pathological stream cannot grow unbounded. */
128
+ export const MAX_FRAMES = 500
129
+
130
+ /**
131
+ * Structural sink the tool bodies depend on, so `tools.ts` never imports the
132
+ * concrete store. Every method tolerates an unknown `callId`.
133
+ */
134
+ export interface RunProgress {
135
+ start(callId: string, toolName: string, kind: RunKind): void
136
+ pushTrace(callId: string, events: readonly TraceEvent[]): void
137
+ updateState(callId: string, state: Record<string, unknown>): void
138
+ setTodos(callId: string, todos: readonly TodoItem[]): void
139
+ setNote(callId: string, note: ProgressNote): void
140
+ finish(callId: string): void
141
+ fail(callId: string, message: string): void
142
+ }
143
+
144
+ /** The document the browser polls for. */
145
+ export interface ProgressDoc {
146
+ callId: string
147
+ toolName: string
148
+ kind: RunKind
149
+ status: RunStatus
150
+ startedAt: number
151
+ updatedAt: number
152
+ /** Real call stack, present only when the backend emits trace events. */
153
+ frames: TraceFrame[]
154
+ /** Coarse ladder, always present while state has been seen. */
155
+ stage: { index: number; total: number; key: string } | null
156
+ /** Rolling todo list. System-design only; `null` for schematic runs. */
157
+ todos: TodoItem[] | null
158
+ /** Latest stage announcement. System-design only; `null` until one arrives. */
159
+ note: ProgressNote | null
160
+ /** Failure text, set by `fail`. */
161
+ error: string | null
162
+ }
163
+
164
+ export interface ProgressStoreOptions {
165
+ /** Age after which a run is forgotten. Default 30 min (matches the SSE budget). */
166
+ ttlMs?: number
167
+ now?: () => number
168
+ }
169
+
170
+ interface RunRecord {
171
+ doc: ProgressDoc
172
+ /** Raw trace events retained only long enough to re-pair frames. */
173
+ events: TraceEvent[]
174
+ state: Record<string, unknown>
175
+ }
176
+
177
+ /**
178
+ * In-memory progress registry. Deliberately NOT persisted: progress is only
179
+ * meaningful while the run is live, and a stale doc after a restart would be
180
+ * worse than none.
181
+ */
182
+ export class ProgressStore {
183
+ private readonly runs = new Map<string, RunRecord>()
184
+ private readonly ttlMs: number
185
+ private readonly now: () => number
186
+
187
+ constructor(options: ProgressStoreOptions = {}) {
188
+ this.ttlMs = options.ttlMs ?? 30 * 60 * 1000
189
+ this.now = options.now ?? (() => Date.now())
190
+ }
191
+
192
+ /** Register a run. Safe to call twice for the same id (idempotent). */
193
+ start(callId: string, toolName: string, kind: RunKind): void {
194
+ if (!callId) return
195
+ if (this.runs.has(callId)) return
196
+ const ts = this.now()
197
+ this.runs.set(callId, {
198
+ doc: {
199
+ callId,
200
+ toolName,
201
+ kind,
202
+ status: 'running',
203
+ startedAt: ts,
204
+ updatedAt: ts,
205
+ frames: [],
206
+ stage: null,
207
+ todos: null,
208
+ note: null,
209
+ error: null,
210
+ },
211
+ events: [],
212
+ state: {},
213
+ })
214
+ }
215
+
216
+ /** Append trace events and re-pair the frame list. */
217
+ pushTrace(callId: string, events: readonly TraceEvent[]): void {
218
+ const rec = this.runs.get(callId)
219
+ if (!rec || events.length === 0) return
220
+ for (const ev of events) rec.events.push(ev)
221
+ if (rec.events.length > MAX_FRAMES * 4) {
222
+ // Keep the tail: newer spans are what the user is waiting on. Frames
223
+ // already derived from dropped starts simply stay unclosed.
224
+ rec.events.splice(0, rec.events.length - MAX_FRAMES * 4)
225
+ }
226
+ rec.doc.frames = pairTraceEvents(rec.events).slice(-MAX_FRAMES)
227
+ rec.doc.updatedAt = this.now()
228
+ }
229
+
230
+ /** Merge an agent state snapshot and re-evaluate the stage ladder. */
231
+ updateState(callId: string, state: Record<string, unknown>): void {
232
+ const rec = this.runs.get(callId)
233
+ if (!rec) return
234
+ Object.assign(rec.state, state)
235
+ rec.doc.stage = stageOf(rec.doc.kind, rec.state)
236
+ rec.doc.updatedAt = this.now()
237
+ }
238
+
239
+ /** Replace the todo list. Only ever set by the system-design agent. */
240
+ setTodos(callId: string, todos: readonly TodoItem[]): void {
241
+ const rec = this.runs.get(callId)
242
+ if (!rec) return
243
+ rec.doc.todos = todos.map((todo) => ({ ...todo }))
244
+ rec.doc.updatedAt = this.now()
245
+ }
246
+
247
+ /** Record the latest stage announcement. */
248
+ setNote(callId: string, note: ProgressNote): void {
249
+ const rec = this.runs.get(callId)
250
+ if (!rec) return
251
+ // A later revision always wins, but never let an older one clobber it.
252
+ if (rec.doc.note && note.ts < rec.doc.note.ts) return
253
+ rec.doc.note = { ...note }
254
+ rec.doc.updatedAt = this.now()
255
+ // An `error` announcement is the closest thing to a live failure reason.
256
+ if (note.phase === 'error' && rec.doc.status === 'running') {
257
+ rec.doc.status = 'failed'
258
+ rec.doc.error = note.message
259
+ }
260
+ }
261
+
262
+ finish(callId: string): void {
263
+ const rec = this.runs.get(callId)
264
+ if (!rec) return
265
+ rec.doc.status = 'completed'
266
+ rec.doc.updatedAt = this.now()
267
+ // Close any span still open — the stream ended, so nothing is running.
268
+ for (const f of rec.doc.frames) {
269
+ if (f.status === 'running') {
270
+ f.status = 'finished'
271
+ f.finishedAt = rec.doc.updatedAt
272
+ }
273
+ }
274
+ }
275
+
276
+ fail(callId: string, message: string): void {
277
+ const rec = this.runs.get(callId)
278
+ if (!rec) return
279
+ rec.doc.status = 'failed'
280
+ rec.doc.error = message
281
+ rec.doc.updatedAt = this.now()
282
+ // Blame only the spans that never closed — an already-finished span
283
+ // succeeded before the failure surfaced.
284
+ for (const f of rec.doc.frames) {
285
+ if (f.status === 'running') {
286
+ f.status = 'failed'
287
+ f.finishedAt = rec.doc.updatedAt
288
+ }
289
+ }
290
+ }
291
+
292
+ /** Snapshot for the HTTP route, or `null` when unknown/expired. */
293
+ get(callId: string): ProgressDoc | null {
294
+ const rec = this.runs.get(callId)
295
+ if (!rec) return null
296
+ if (this.now() - rec.doc.updatedAt > this.ttlMs) {
297
+ this.runs.delete(callId)
298
+ return null
299
+ }
300
+ return rec.doc
301
+ }
302
+
303
+ /** Every live run. Used by the route when the caller has no callId. */
304
+ list(): ProgressDoc[] {
305
+ const out: ProgressDoc[] = []
306
+ for (const callId of [...this.runs.keys()]) {
307
+ const doc = this.get(callId)
308
+ if (doc) out.push(doc)
309
+ }
310
+ return out
311
+ }
312
+
313
+ delete(callId: string): void {
314
+ this.runs.delete(callId)
315
+ }
316
+
317
+ /** Drop expired runs. Returns how many were removed. */
318
+ sweep(): number {
319
+ const now = this.now()
320
+ let removed = 0
321
+ for (const [callId, rec] of [...this.runs]) {
322
+ if (now - rec.doc.updatedAt > this.ttlMs) {
323
+ this.runs.delete(callId)
324
+ removed += 1
325
+ }
326
+ }
327
+ return removed
328
+ }
329
+
330
+ get size(): number {
331
+ return this.runs.size
332
+ }
333
+ }
package/src/routes.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * HTTP adapter for live run progress (browser half of the progress flow).
3
+ *
4
+ * Browser → same-origin DSH webServer → `ctx.webServer` prefix route
5
+ * `/api/v1/huaqiu/schematic-gen/progress`. Follows the `@huaqiu/dsh-artifacts`
6
+ * precedent exactly (one prefix route, sub-paths parsed in the handler) because
7
+ * the DSH `WebRoute` supports exact or prefix matches only — there are no `:id`
8
+ * path params, and two prefix routes on the same base path would collide.
9
+ *
10
+ * GET /api/v1/huaqiu/schematic-gen/progress → every live run
11
+ * GET /api/v1/huaqiu/schematic-gen/progress/<callId> → one run's progress doc
12
+ *
13
+ * The `<callId>` is percent-decoded, because DSH tool call ids can contain
14
+ * characters (`|`, `:`) that a client may encode.
15
+ */
16
+ import type { IncomingMessage, ServerResponse } from 'node:http'
17
+ import type { ProgressStore } from './progress.js'
18
+
19
+ export const PROGRESS_ROUTE_PREFIX = '/api/v1/huaqiu/schematic-gen/progress'
20
+
21
+ export type ProgressHandler = (
22
+ req: IncomingMessage,
23
+ res: ServerResponse,
24
+ ) => Promise<void> | void
25
+
26
+ function sendJson(res: ServerResponse, status: number, body: unknown): void {
27
+ const payload = JSON.stringify(body)
28
+ res.writeHead(status, {
29
+ 'content-type': 'application/json; charset=utf-8',
30
+ // Progress is live — never let a proxy serve a stale snapshot.
31
+ 'cache-control': 'no-store',
32
+ })
33
+ res.end(payload)
34
+ }
35
+
36
+ /** Split the sub-path off the prefix. Returns `null` on a bad shape. */
37
+ function parsePath(req: IncomingMessage): { callId: string | null } | null {
38
+ const url = req.url ?? ''
39
+ const q = url.indexOf('?')
40
+ const pathname = q >= 0 ? url.slice(0, q) : url
41
+ if (!pathname.startsWith(PROGRESS_ROUTE_PREFIX)) return null
42
+ const rest = pathname.slice(PROGRESS_ROUTE_PREFIX.length)
43
+ if (rest === '') return { callId: null }
44
+ if (!rest.startsWith('/')) return null
45
+ const segs = rest.split('/').filter(Boolean)
46
+ if (segs.length !== 1) return null
47
+ let callId: string
48
+ try {
49
+ callId = decodeURIComponent(segs[0]!)
50
+ } catch {
51
+ return null // malformed percent-encoding
52
+ }
53
+ return { callId: callId.length > 0 ? callId : null }
54
+ }
55
+
56
+ export function createProgressHandler(store: ProgressStore): ProgressHandler {
57
+ return (req, res) => {
58
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
59
+ sendJson(res, 405, { error: 'method not allowed' })
60
+ return
61
+ }
62
+ const parsed = parsePath(req)
63
+ if (!parsed) {
64
+ sendJson(res, 404, { error: 'not found' })
65
+ return
66
+ }
67
+ if (parsed.callId === null) {
68
+ // No id: hand back every live run so a client without a callId (e.g. a
69
+ // card mounted on replay) can still find its run.
70
+ sendJson(res, 200, { runs: store.list() })
71
+ return
72
+ }
73
+ const doc = store.get(parsed.callId)
74
+ if (!doc) {
75
+ sendJson(res, 404, { error: 'no live run for this call id' })
76
+ return
77
+ }
78
+ sendJson(res, 200, doc)
79
+ }
80
+ }