@huaqiu/component-gen-server 0.3.6

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/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@huaqiu/component-gen-server",
3
+ "version": "0.3.6",
4
+ "type": "module",
5
+ "main": "./lib/index.mjs",
6
+ "types": "./lib/index.d.mts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./lib/index.d.mts",
10
+ "default": "./lib/index.mjs"
11
+ },
12
+ "./standalone": {
13
+ "default": "./lib/standalone.mjs"
14
+ },
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "lib",
19
+ "src"
20
+ ],
21
+ "bin": {
22
+ "hq-component-gen": "./lib/standalone.mjs"
23
+ },
24
+ "dependencies": {
25
+ "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.0",
26
+ "@huaqiu/dsh-artifacts": "0.3.6",
27
+ "@huaqiu/dsh-auth": "0.3.6"
28
+ },
29
+ "peerDependencies": {
30
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.0",
31
+ "@huaqiu/dsh-tool-symbol-footprint": "^0.3.6"
32
+ },
33
+ "devDependencies": {
34
+ "tsdown": "^0.22.14",
35
+ "typescript": "^5.9.0",
36
+ "vitest": "^4.1.0",
37
+ "@huaqiu/dsh-tool-symbol-footprint": "0.3.6"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "scripts": {
43
+ "typecheck": "tsc --noEmit",
44
+ "build": "tsdown",
45
+ "test": "vitest run"
46
+ }
47
+ }
package/src/backend.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `@huaqiu/component-gen-server` — generation backend seam.
3
+ *
4
+ * The server never reimplements generation. It only defines the seam; the
5
+ * `@huaqiu/dsh-tool-symbol-footprint` plugin provides the real implementation
6
+ * (`createComponentGenBackend`) wired to its existing `runGenerate*` functions
7
+ * + `SymbolFootprintEnv`. The standalone server builds the same backend from
8
+ * the plugin's exports. Results are the raw tool-body outcomes:
9
+ *
10
+ * symbol → { status:'generated', kind:'symbol', fileUrl, filename, artifact?, … }
11
+ * extract-footprint → { status:'needs_confirmation', pkgType, fileName, dimensions, … }
12
+ * | { status:'generated', kind:'footprint', autoGenerated:true, … }
13
+ * generate-footprint→ { status:'generated', kind:'footprint', pkgType, dimensions, … }
14
+ *
15
+ * `needs_auth` (the tool body's unauthenticated result) is mapped by the job
16
+ * runner to a failed job with the `needs_auth` marker so the UI can re-trigger
17
+ * login.
18
+ */
19
+ import type { JobKind } from './types.js'
20
+
21
+ export interface GenerationExec {
22
+ signal?: AbortSignal
23
+ }
24
+
25
+ export interface ComponentGenBackend {
26
+ generateSymbol(args: { imageDataUrl: string; instruction?: string }, exec: GenerationExec): Promise<Record<string, unknown>>
27
+ extractFootprint(args: { imageDataUrl: string; packageType?: string; instruction?: string }, exec: GenerationExec): Promise<Record<string, unknown>>
28
+ generateFootprint(args: { packageType: string; fileName?: string; dimensions: Record<string, number> }, exec: GenerationExec): Promise<Record<string, unknown>>
29
+ }
30
+
31
+ export function kindLabel(kind: JobKind): 'symbol' | 'footprint' {
32
+ return kind === 'symbol' ? 'symbol' : 'footprint'
33
+ }
package/src/history.ts ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `@huaqiu/component-gen-server` — history store.
3
+ *
4
+ * Plain filesystem (no SQLite): `<dir>/history.json` + `<dir>/inputs/<id>`.
5
+ * History is user-level (not project-level). Entries are appended by the job
6
+ * runner on terminal states; input thumbnails are stored by the routes layer
7
+ * at POST /jobs time. `imageId` in an entry's `input` points into `inputs/`.
8
+ */
9
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs'
10
+ import { join, dirname } from 'node:path'
11
+ import { randomUUID } from 'node:crypto'
12
+ import type { HistoryEntry, HistoryPage, HistoryPatch, HistoryQuery } from './types.js'
13
+
14
+ const INPUT_DIR = 'inputs'
15
+
16
+ function readJsonFile<T>(path: string, fallback: T): T {
17
+ try {
18
+ if (!existsSync(path)) return fallback
19
+ return JSON.parse(readFileSync(path, 'utf8')) as T
20
+ } catch {
21
+ return fallback
22
+ }
23
+ }
24
+
25
+ function writeJsonFile(path: string, value: unknown): void {
26
+ mkdirSync(dirname(path), { recursive: true })
27
+ writeFileSync(path, JSON.stringify(value, null, 2), 'utf8')
28
+ }
29
+
30
+ /** `data:image/...;base64,....` → { mime, bytes } | null. */
31
+ export function parseDataUrl(dataUrl: string): { mime: string; bytes: Buffer } | null {
32
+ const m = /^data:([^;,]+);base64,(.+)$/s.exec(dataUrl)
33
+ if (!m) return null
34
+ try {
35
+ return { mime: m[1]!, bytes: Buffer.from(m[2]!, 'base64') }
36
+ } catch {
37
+ return null
38
+ }
39
+ }
40
+
41
+ export class HistoryStore {
42
+ private readonly dir: string
43
+ private readonly file: string
44
+ private entries: HistoryEntry[] = []
45
+
46
+ constructor(dir: string) {
47
+ this.dir = dir
48
+ this.file = join(dir, 'history.json')
49
+ this.entries = readJsonFile<HistoryEntry[]>(this.file, [])
50
+ }
51
+
52
+ /** All entries, newest first. */
53
+ private sorted(): HistoryEntry[] {
54
+ return [...this.entries].sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0))
55
+ }
56
+
57
+ async append(entry: HistoryEntry): Promise<HistoryEntry> {
58
+ this.entries = [entry, ...this.entries.filter((e) => e.id !== entry.id)]
59
+ writeJsonFile(this.file, this.entries)
60
+ return entry
61
+ }
62
+
63
+ async list(query: HistoryQuery): Promise<HistoryPage> {
64
+ const limit = Math.max(1, Math.min(100, query.limit ?? 20))
65
+ const sorted = this.sorted()
66
+ const start = query.cursor ? sorted.findIndex((e) => e.id === query.cursor) + 1 : 0
67
+ const slice = start < 0 ? [] : sorted.slice(start, start + limit)
68
+ const next = start + slice.length < sorted.length ? slice[slice.length - 1]?.id ?? null : null
69
+ return { entries: slice, nextCursor: next }
70
+ }
71
+
72
+ async get(id: string): Promise<HistoryEntry | null> {
73
+ return this.entries.find((e) => e.id === id) ?? null
74
+ }
75
+
76
+ async patch(id: string, patch: HistoryPatch): Promise<HistoryEntry | null> {
77
+ const idx = this.entries.findIndex((e) => e.id === id)
78
+ if (idx < 0) return null
79
+ const current = this.entries[idx]!
80
+ const next: HistoryEntry = {
81
+ ...current,
82
+ ...(patch.status !== undefined ? { status: patch.status } : {}),
83
+ ...(patch.error !== undefined ? { error: patch.error } : {}),
84
+ ...(patch.edited !== undefined ? { edited: patch.edited } : {}),
85
+ ...(patch.result !== undefined ? { result: patch.result } : {}),
86
+ }
87
+ this.entries[idx] = next
88
+ writeJsonFile(this.file, this.entries)
89
+ return next
90
+ }
91
+
92
+ async delete(id: string): Promise<void> {
93
+ const entry = this.entries.find((e) => e.id === id)
94
+ this.entries = this.entries.filter((e) => e.id !== id)
95
+ writeJsonFile(this.file, this.entries)
96
+ if (entry?.input?.imageId) {
97
+ try { unlinkSync(join(this.dir, INPUT_DIR, entry.input.imageId)) } catch { /* already gone */ }
98
+ try { unlinkSync(join(this.dir, INPUT_DIR, `${entry.input.imageId}.mime`)) } catch { /* already gone */ }
99
+ }
100
+ }
101
+
102
+ // ── input thumbnails ──────────────────────────────────────────────────────
103
+ // Bytes are stored as `<imageId>`; the original media type in a `<imageId>.mime`
104
+ // sidecar so reopen serves pasted/selected JPEG/WebP/… images correctly (the
105
+ // local file the user pasted may be long gone — this copy is authoritative).
106
+
107
+ async saveImage(imageId: string, dataUrl: string): Promise<void> {
108
+ const parsed = parseDataUrl(dataUrl)
109
+ if (!parsed) throw new Error('component-gen: invalid image data URL')
110
+ const dir = join(this.dir, INPUT_DIR)
111
+ mkdirSync(dir, { recursive: true })
112
+ writeFileSync(join(dir, imageId), parsed.bytes)
113
+ writeFileSync(join(dir, `${imageId}.mime`), parsed.mime, 'utf8')
114
+ }
115
+
116
+ async readImage(imageId: string): Promise<{ bytes: Uint8Array; mime: string } | null> {
117
+ const dir = join(this.dir, INPUT_DIR)
118
+ const path = join(dir, imageId)
119
+ if (!existsSync(path)) return null
120
+ let mime = 'image/png'
121
+ try {
122
+ const sidecar = readFileSync(join(dir, `${imageId}.mime`), 'utf8').trim()
123
+ if (sidecar) mime = sidecar
124
+ } catch { /* legacy entry stored before the mime sidecar existed */ }
125
+ return { bytes: readFileSync(path), mime }
126
+ }
127
+ }
128
+
129
+ export function newHistoryId(): string {
130
+ return `hst_${randomUUID().slice(0, 18)}`
131
+ }
132
+
133
+ export function newImageId(): string {
134
+ return `img_${randomUUID().slice(0, 18)}`
135
+ }
package/src/index.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `@huaqiu/component-gen-server` — public API.
3
+ *
4
+ * The shared component-gen HTTP surface. The DSH plugin mounts
5
+ * `createComponentGenRoutes(deps)` on its `webServer`; the standalone server
6
+ * (./standalone) wires the same routes + dsh-auth + dsh-artifacts + app bundle
7
+ * into one node:http server.
8
+ */
9
+ export {
10
+ createComponentGenHandler,
11
+ type ComponentGenHandler,
12
+ type ComponentGenHandlerDeps,
13
+ } from './routes.js'
14
+ export { JobStore, runGeneration, type JobMeta, type RunOutcome } from './jobs.js'
15
+ export { HistoryStore, parseDataUrl, newHistoryId, newImageId } from './history.js'
16
+ export type { ComponentGenBackend, GenerationExec } from './backend.js'
17
+ export {
18
+ COMPONENT_GEN_ROUTE_PREFIX,
19
+ MAX_IMAGE_BYTES,
20
+ type ComponentGenConfig,
21
+ type ComponentGenPage,
22
+ type HistoryEntry,
23
+ type HistoryPage,
24
+ type HistoryPatch,
25
+ type HistoryQuery,
26
+ type JobEvent,
27
+ type JobInput,
28
+ type JobKind,
29
+ type JobState,
30
+ type StartJobRequest,
31
+ } from './types.js'
32
+ import { createComponentGenHandler, type ComponentGenHandlerDeps } from './routes.js'
33
+ import { COMPONENT_GEN_ROUTE_PREFIX } from './types.js'
34
+
35
+ export interface ComponentGenWebRoute {
36
+ kind: 'prefix'
37
+ path: string
38
+ handler: ReturnType<typeof createComponentGenHandler>
39
+ }
40
+
41
+ /** Build the DSH `webServer.register(...)` route object. */
42
+ export function createComponentGenRoutes(deps: ComponentGenHandlerDeps): ComponentGenWebRoute {
43
+ return {
44
+ kind: 'prefix',
45
+ path: COMPONENT_GEN_ROUTE_PREFIX,
46
+ handler: createComponentGenHandler(deps),
47
+ }
48
+ }
package/src/jobs.ts ADDED
@@ -0,0 +1,279 @@
1
+ /**
2
+ * `@huaqiu/component-gen-server` — in-memory job store + generation runner.
3
+ *
4
+ * Jobs live in memory (no persistent queue). Each job is driven by the
5
+ * injected generation backend and reports through SSE events. On a terminal
6
+ * state the runner appends a history entry (generated / failed / cancelled).
7
+ * The `needs_confirmation` phase is a PAUSE: the job reports the extracted
8
+ * dimensions and waits — the app then starts a separate `generate-footprint`
9
+ * job with the human-approved values (single-HIL: a footprint is generated
10
+ * only from dimensions a human has seen).
11
+ */
12
+ import type { ComponentGenBackend } from './backend.js'
13
+ import { newHistoryId, type HistoryStore } from './history.js'
14
+ import type { JobEvent, JobInput, JobKind, JobState, StartJobRequest, HistoryEntry } from './types.js'
15
+ import { randomUUID } from 'node:crypto'
16
+
17
+ export interface JobMeta {
18
+ /** stored input thumbnail id (points into the history store's inputs/). */
19
+ imageId?: string
20
+ }
21
+
22
+ interface JobRecord {
23
+ state: JobState
24
+ controller: AbortController
25
+ }
26
+
27
+ export class JobStore {
28
+ private readonly jobs = new Map<string, JobRecord>()
29
+ private readonly listeners = new Map<string, Set<(e: JobEvent) => void>>()
30
+
31
+ create(req: StartJobRequest, meta: JobMeta): JobState {
32
+ const now = new Date().toISOString()
33
+ const id = `job_${randomUUID().slice(0, 18)}`
34
+ const state: JobState = {
35
+ id,
36
+ kind: req.kind,
37
+ status: 'queued',
38
+ createdAt: now,
39
+ updatedAt: now,
40
+ }
41
+ this.jobs.set(id, { state, controller: new AbortController() })
42
+ return state
43
+ }
44
+
45
+ get(id: string): JobState | undefined {
46
+ return this.jobs.get(id)?.state
47
+ }
48
+
49
+ abort(id: string): boolean {
50
+ const rec = this.jobs.get(id)
51
+ if (!rec) return false
52
+ rec.controller.abort()
53
+ return true
54
+ }
55
+
56
+ signal(id: string): AbortSignal | undefined {
57
+ return this.jobs.get(id)?.controller.signal
58
+ }
59
+
60
+ subscribe(id: string, cb: (e: JobEvent) => void): (() => void) | null {
61
+ if (!this.jobs.has(id)) return null
62
+ let set = this.listeners.get(id)
63
+ if (!set) {
64
+ set = new Set()
65
+ this.listeners.set(id, set)
66
+ }
67
+ set.add(cb)
68
+ return () => {
69
+ set?.delete(cb)
70
+ if (set && set.size === 0) this.listeners.delete(id)
71
+ }
72
+ }
73
+
74
+ private emit(id: string, event: JobEvent): void {
75
+ const set = this.listeners.get(id)
76
+ if (!set) return
77
+ for (const cb of [...set]) {
78
+ try { cb(event) } catch { /* a bad subscriber must not strand the stream */ }
79
+ }
80
+ }
81
+
82
+ /** Update job state (public — the runner writes progress/status). */
83
+ update(id: string, patch: Partial<JobState>, event?: JobEvent): JobState {
84
+ const rec = this.jobs.get(id)
85
+ if (!rec) return patch as JobState
86
+ rec.state = { ...rec.state, ...patch, updatedAt: new Date().toISOString() }
87
+ if (event) this.emit(id, event)
88
+ return rec.state
89
+ }
90
+
91
+ /** Update + emit the canonical event for a terminal state. */
92
+ settle(id: string, patch: Partial<JobState>): JobState {
93
+ const state = this.update(id, patch)
94
+ const now = new Date().toISOString()
95
+ if (state.status === 'completed') this.emit(id, { type: 'completed', job: state, at: now })
96
+ else if (state.status === 'failed') this.emit(id, { type: 'failed', error: state.error ?? 'generation failed', result: state.result, at: now })
97
+ else if (state.status === 'cancelled') this.emit(id, { type: 'cancelled', at: now })
98
+ else if (state.status === 'needs_confirmation') {
99
+ this.emit(id, {
100
+ type: 'needs_confirmation',
101
+ dimensions: state.dimensions ?? {},
102
+ pkgType: state.pkgType ?? null,
103
+ fileName: state.fileName ?? null,
104
+ at: now,
105
+ })
106
+ }
107
+ return state
108
+ }
109
+
110
+ remove(id: string): void {
111
+ this.jobs.delete(id)
112
+ this.listeners.delete(id)
113
+ }
114
+ }
115
+
116
+ function isAbortError(err: unknown): boolean {
117
+ return err instanceof Error && (err.name === 'AbortError' || /abort/i.test(err.message))
118
+ }
119
+
120
+ /** Map the tool-body `needs_auth` outcome to a job failure with the marker. */
121
+ function isNeedsAuth(result: Record<string, unknown> | undefined): boolean {
122
+ return result?.status === 'needs_auth'
123
+ }
124
+
125
+ export interface RunOutcome {
126
+ state: JobState
127
+ /** whether a history entry was appended. */
128
+ recorded: boolean
129
+ }
130
+
131
+ /**
132
+ * Run one generation to a terminal state. Returns the final JobState.
133
+ * History recording happens here so entry and state cannot drift.
134
+ */
135
+ export async function runGeneration(
136
+ store: JobStore,
137
+ backend: ComponentGenBackend,
138
+ history: HistoryStore,
139
+ id: string,
140
+ req: StartJobRequest,
141
+ meta: JobMeta,
142
+ onProgress?: (message: string) => void,
143
+ ): Promise<RunOutcome> {
144
+ if (!store.get(id)) return { state: { id, kind: req.kind, status: 'failed', error: 'job not found', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, recorded: false }
145
+ const signal = store.signal(id)
146
+
147
+ const progress = (message: string): void => {
148
+ store.update(id, { status: 'running', progress: message })
149
+ onProgress?.(message)
150
+ }
151
+ const exec = { signal }
152
+
153
+ try {
154
+ if (req.kind === 'symbol') {
155
+ progress('正在生成 Symbol…')
156
+ const result = await backend.generateSymbol(
157
+ { imageDataUrl: req.input.imageDataUrl ?? '', instruction: req.input.instruction },
158
+ exec,
159
+ )
160
+ if (isNeedsAuth(result)) return fail('needs_auth')
161
+ const state = store.settle(id, { status: 'completed', result })
162
+ await record(history, meta, req, state)
163
+ return { state, recorded: true }
164
+ }
165
+
166
+ if (req.kind === 'extract-footprint') {
167
+ progress('正在提取封装尺寸…')
168
+ const result = await backend.extractFootprint(
169
+ {
170
+ imageDataUrl: req.input.imageDataUrl ?? '',
171
+ packageType: req.input.packageType,
172
+ instruction: req.input.instruction,
173
+ },
174
+ exec,
175
+ )
176
+ if (isNeedsAuth(result)) return fail('needs_auth')
177
+ if (result.status === 'needs_confirmation') {
178
+ const dims = result.dimensions && typeof result.dimensions === 'object' ? result.dimensions : {}
179
+ const pkg = typeof result.pkgType === 'string' ? result.pkgType : (req.input.packageType ?? null)
180
+ const fileName = typeof result.fileName === 'string' ? result.fileName : null
181
+ const state = store.settle(id, {
182
+ status: 'needs_confirmation',
183
+ dimensions: dims as Record<string, unknown>,
184
+ pkgType: pkg,
185
+ fileName,
186
+ })
187
+ return { state, recorded: false }
188
+ }
189
+ if (result.status === 'cancelled') {
190
+ const state = store.settle(id, { status: 'cancelled', result })
191
+ await record(history, meta, req, state)
192
+ return { state, recorded: true }
193
+ }
194
+ const state = store.settle(id, { status: 'completed', result })
195
+ await record(history, meta, req, state)
196
+ return { state, recorded: true }
197
+ }
198
+
199
+ // generate-footprint
200
+ progress('正在生成封装…')
201
+ const result = await backend.generateFootprint(
202
+ {
203
+ packageType: req.input.packageType ?? '',
204
+ fileName: req.input.fileName,
205
+ dimensions: req.input.dimensions ?? {},
206
+ },
207
+ exec,
208
+ )
209
+ if (isNeedsAuth(result)) return fail('needs_auth')
210
+ if (result.status === 'cancelled') {
211
+ const state = store.settle(id, { status: 'cancelled', result })
212
+ await record(history, meta, req, state)
213
+ return { state, recorded: true }
214
+ }
215
+ const state = store.settle(id, { status: 'completed', result })
216
+ await record(history, meta, req, state)
217
+ return { state, recorded: true }
218
+ } catch (err) {
219
+ if (isAbortError(err)) {
220
+ const state = store.settle(id, { status: 'cancelled' })
221
+ await record(history, meta, req, state).catch(() => {})
222
+ return { state, recorded: true }
223
+ }
224
+ const message = String((err as Error)?.message || err)
225
+ const state = store.settle(id, { status: 'failed', error: message })
226
+ await record(history, meta, req, state).catch(() => {})
227
+ return { state, recorded: true }
228
+ }
229
+
230
+ function fail(kind: 'needs_auth'): { state: JobState; recorded: boolean } {
231
+ const state = store.settle(id, {
232
+ status: 'failed',
233
+ error: kind === 'needs_auth' ? 'Huaqiu EDA login required' : 'generation failed',
234
+ result: { status: kind },
235
+ })
236
+ void record(history, meta, req, state).catch(() => {})
237
+ return { state, recorded: true }
238
+ }
239
+ }
240
+
241
+ /** Build + append a history entry for a terminal job state. */
242
+ async function record(
243
+ history: HistoryStore,
244
+ meta: JobMeta,
245
+ req: StartJobRequest,
246
+ state: JobState,
247
+ ): Promise<void> {
248
+ const kind: 'symbol' | 'footprint' = state.kind === 'symbol' ? 'symbol' : 'footprint'
249
+ const status: HistoryEntry['status'] = state.status === 'completed' ? 'generated' : state.status === 'cancelled' ? 'cancelled' : 'failed'
250
+ const result = state.result as Record<string, unknown> | undefined
251
+ const artifact = result?.artifact && typeof result.artifact === 'object'
252
+ ? result.artifact as { id?: unknown; filename?: unknown; size?: unknown }
253
+ : null
254
+ const entry: HistoryEntry = {
255
+ id: newHistoryId(),
256
+ kind,
257
+ createdAt: state.updatedAt ?? state.createdAt,
258
+ status,
259
+ input: {
260
+ ...(meta.imageId ? { imageId: meta.imageId } : {}),
261
+ ...(req.input.instruction ? { instruction: req.input.instruction } : {}),
262
+ ...(req.input.packageType ? { packageType: req.input.packageType } : {}),
263
+ ...(req.input.dimensions && Object.keys(req.input.dimensions).length > 0 ? { dimensions: req.input.dimensions } : {}),
264
+ },
265
+ ...(req.input.edited && Object.keys(req.input.edited).length > 0 ? { edited: req.input.edited } : {}),
266
+ ...(status === 'generated' && artifact?.id
267
+ ? {
268
+ result: {
269
+ artifactId: String(artifact.id),
270
+ filename: typeof artifact.filename === 'string' ? artifact.filename : (result?.filename as string | undefined) ?? `${kind}.kicad_${kind === 'symbol' ? 'sym' : 'mod'}`,
271
+ ...(typeof result?.fileUrl === 'string' ? { fileUrl: result.fileUrl } : {}),
272
+ ...(typeof artifact.size === 'number' ? { size: artifact.size } : {}),
273
+ },
274
+ }
275
+ : {}),
276
+ ...(status === 'failed' && state.error ? { error: state.error } : {}),
277
+ }
278
+ await history.append(entry)
279
+ }