@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/LICENSE +21 -0
- package/lib/index.d.mts +205 -0
- package/lib/index.mjs +636 -0
- package/lib/standalone.mjs +754 -0
- package/package.json +47 -0
- package/src/backend.ts +33 -0
- package/src/history.ts +135 -0
- package/src/index.ts +48 -0
- package/src/jobs.ts +279 -0
- package/src/routes.ts +227 -0
- package/src/standalone.ts +172 -0
- package/src/types.ts +101 -0
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
|
+
}
|