@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/lib/index.mjs
ADDED
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
//#region src/history.ts
|
|
5
|
+
/**
|
|
6
|
+
* `@huaqiu/component-gen-server` — history store.
|
|
7
|
+
*
|
|
8
|
+
* Plain filesystem (no SQLite): `<dir>/history.json` + `<dir>/inputs/<id>`.
|
|
9
|
+
* History is user-level (not project-level). Entries are appended by the job
|
|
10
|
+
* runner on terminal states; input thumbnails are stored by the routes layer
|
|
11
|
+
* at POST /jobs time. `imageId` in an entry's `input` points into `inputs/`.
|
|
12
|
+
*/
|
|
13
|
+
const INPUT_DIR = "inputs";
|
|
14
|
+
function readJsonFile(path, fallback) {
|
|
15
|
+
try {
|
|
16
|
+
if (!existsSync(path)) return fallback;
|
|
17
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
18
|
+
} catch {
|
|
19
|
+
return fallback;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function writeJsonFile(path, value) {
|
|
23
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
24
|
+
writeFileSync(path, JSON.stringify(value, null, 2), "utf8");
|
|
25
|
+
}
|
|
26
|
+
/** `data:image/...;base64,....` → { mime, bytes } | null. */
|
|
27
|
+
function parseDataUrl(dataUrl) {
|
|
28
|
+
const m = /^data:([^;,]+);base64,(.+)$/s.exec(dataUrl);
|
|
29
|
+
if (!m) return null;
|
|
30
|
+
try {
|
|
31
|
+
return {
|
|
32
|
+
mime: m[1],
|
|
33
|
+
bytes: Buffer.from(m[2], "base64")
|
|
34
|
+
};
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
var HistoryStore = class {
|
|
40
|
+
dir;
|
|
41
|
+
file;
|
|
42
|
+
entries = [];
|
|
43
|
+
constructor(dir) {
|
|
44
|
+
this.dir = dir;
|
|
45
|
+
this.file = join(dir, "history.json");
|
|
46
|
+
this.entries = readJsonFile(this.file, []);
|
|
47
|
+
}
|
|
48
|
+
/** All entries, newest first. */
|
|
49
|
+
sorted() {
|
|
50
|
+
return [...this.entries].sort((a, b) => a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0);
|
|
51
|
+
}
|
|
52
|
+
async append(entry) {
|
|
53
|
+
this.entries = [entry, ...this.entries.filter((e) => e.id !== entry.id)];
|
|
54
|
+
writeJsonFile(this.file, this.entries);
|
|
55
|
+
return entry;
|
|
56
|
+
}
|
|
57
|
+
async list(query) {
|
|
58
|
+
const limit = Math.max(1, Math.min(100, query.limit ?? 20));
|
|
59
|
+
const sorted = this.sorted();
|
|
60
|
+
const start = query.cursor ? sorted.findIndex((e) => e.id === query.cursor) + 1 : 0;
|
|
61
|
+
const slice = start < 0 ? [] : sorted.slice(start, start + limit);
|
|
62
|
+
return {
|
|
63
|
+
entries: slice,
|
|
64
|
+
nextCursor: start + slice.length < sorted.length ? slice[slice.length - 1]?.id ?? null : null
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
async get(id) {
|
|
68
|
+
return this.entries.find((e) => e.id === id) ?? null;
|
|
69
|
+
}
|
|
70
|
+
async patch(id, patch) {
|
|
71
|
+
const idx = this.entries.findIndex((e) => e.id === id);
|
|
72
|
+
if (idx < 0) return null;
|
|
73
|
+
const next = {
|
|
74
|
+
...this.entries[idx],
|
|
75
|
+
...patch.status !== void 0 ? { status: patch.status } : {},
|
|
76
|
+
...patch.error !== void 0 ? { error: patch.error } : {},
|
|
77
|
+
...patch.edited !== void 0 ? { edited: patch.edited } : {},
|
|
78
|
+
...patch.result !== void 0 ? { result: patch.result } : {}
|
|
79
|
+
};
|
|
80
|
+
this.entries[idx] = next;
|
|
81
|
+
writeJsonFile(this.file, this.entries);
|
|
82
|
+
return next;
|
|
83
|
+
}
|
|
84
|
+
async delete(id) {
|
|
85
|
+
const entry = this.entries.find((e) => e.id === id);
|
|
86
|
+
this.entries = this.entries.filter((e) => e.id !== id);
|
|
87
|
+
writeJsonFile(this.file, this.entries);
|
|
88
|
+
if (entry?.input?.imageId) {
|
|
89
|
+
try {
|
|
90
|
+
unlinkSync(join(this.dir, INPUT_DIR, entry.input.imageId));
|
|
91
|
+
} catch {}
|
|
92
|
+
try {
|
|
93
|
+
unlinkSync(join(this.dir, INPUT_DIR, `${entry.input.imageId}.mime`));
|
|
94
|
+
} catch {}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async saveImage(imageId, dataUrl) {
|
|
98
|
+
const parsed = parseDataUrl(dataUrl);
|
|
99
|
+
if (!parsed) throw new Error("component-gen: invalid image data URL");
|
|
100
|
+
const dir = join(this.dir, INPUT_DIR);
|
|
101
|
+
mkdirSync(dir, { recursive: true });
|
|
102
|
+
writeFileSync(join(dir, imageId), parsed.bytes);
|
|
103
|
+
writeFileSync(join(dir, `${imageId}.mime`), parsed.mime, "utf8");
|
|
104
|
+
}
|
|
105
|
+
async readImage(imageId) {
|
|
106
|
+
const dir = join(this.dir, INPUT_DIR);
|
|
107
|
+
const path = join(dir, imageId);
|
|
108
|
+
if (!existsSync(path)) return null;
|
|
109
|
+
let mime = "image/png";
|
|
110
|
+
try {
|
|
111
|
+
const sidecar = readFileSync(join(dir, `${imageId}.mime`), "utf8").trim();
|
|
112
|
+
if (sidecar) mime = sidecar;
|
|
113
|
+
} catch {}
|
|
114
|
+
return {
|
|
115
|
+
bytes: readFileSync(path),
|
|
116
|
+
mime
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
function newHistoryId() {
|
|
121
|
+
return `hst_${randomUUID().slice(0, 18)}`;
|
|
122
|
+
}
|
|
123
|
+
function newImageId() {
|
|
124
|
+
return `img_${randomUUID().slice(0, 18)}`;
|
|
125
|
+
}
|
|
126
|
+
//#endregion
|
|
127
|
+
//#region src/jobs.ts
|
|
128
|
+
var JobStore = class {
|
|
129
|
+
jobs = /* @__PURE__ */ new Map();
|
|
130
|
+
listeners = /* @__PURE__ */ new Map();
|
|
131
|
+
create(req, meta) {
|
|
132
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
133
|
+
const id = `job_${randomUUID().slice(0, 18)}`;
|
|
134
|
+
const state = {
|
|
135
|
+
id,
|
|
136
|
+
kind: req.kind,
|
|
137
|
+
status: "queued",
|
|
138
|
+
createdAt: now,
|
|
139
|
+
updatedAt: now
|
|
140
|
+
};
|
|
141
|
+
this.jobs.set(id, {
|
|
142
|
+
state,
|
|
143
|
+
controller: new AbortController()
|
|
144
|
+
});
|
|
145
|
+
return state;
|
|
146
|
+
}
|
|
147
|
+
get(id) {
|
|
148
|
+
return this.jobs.get(id)?.state;
|
|
149
|
+
}
|
|
150
|
+
abort(id) {
|
|
151
|
+
const rec = this.jobs.get(id);
|
|
152
|
+
if (!rec) return false;
|
|
153
|
+
rec.controller.abort();
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
signal(id) {
|
|
157
|
+
return this.jobs.get(id)?.controller.signal;
|
|
158
|
+
}
|
|
159
|
+
subscribe(id, cb) {
|
|
160
|
+
if (!this.jobs.has(id)) return null;
|
|
161
|
+
let set = this.listeners.get(id);
|
|
162
|
+
if (!set) {
|
|
163
|
+
set = /* @__PURE__ */ new Set();
|
|
164
|
+
this.listeners.set(id, set);
|
|
165
|
+
}
|
|
166
|
+
set.add(cb);
|
|
167
|
+
return () => {
|
|
168
|
+
set?.delete(cb);
|
|
169
|
+
if (set && set.size === 0) this.listeners.delete(id);
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
emit(id, event) {
|
|
173
|
+
const set = this.listeners.get(id);
|
|
174
|
+
if (!set) return;
|
|
175
|
+
for (const cb of [...set]) try {
|
|
176
|
+
cb(event);
|
|
177
|
+
} catch {}
|
|
178
|
+
}
|
|
179
|
+
/** Update job state (public — the runner writes progress/status). */
|
|
180
|
+
update(id, patch, event) {
|
|
181
|
+
const rec = this.jobs.get(id);
|
|
182
|
+
if (!rec) return patch;
|
|
183
|
+
rec.state = {
|
|
184
|
+
...rec.state,
|
|
185
|
+
...patch,
|
|
186
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
187
|
+
};
|
|
188
|
+
if (event) this.emit(id, event);
|
|
189
|
+
return rec.state;
|
|
190
|
+
}
|
|
191
|
+
/** Update + emit the canonical event for a terminal state. */
|
|
192
|
+
settle(id, patch) {
|
|
193
|
+
const state = this.update(id, patch);
|
|
194
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
195
|
+
if (state.status === "completed") this.emit(id, {
|
|
196
|
+
type: "completed",
|
|
197
|
+
job: state,
|
|
198
|
+
at: now
|
|
199
|
+
});
|
|
200
|
+
else if (state.status === "failed") this.emit(id, {
|
|
201
|
+
type: "failed",
|
|
202
|
+
error: state.error ?? "generation failed",
|
|
203
|
+
result: state.result,
|
|
204
|
+
at: now
|
|
205
|
+
});
|
|
206
|
+
else if (state.status === "cancelled") this.emit(id, {
|
|
207
|
+
type: "cancelled",
|
|
208
|
+
at: now
|
|
209
|
+
});
|
|
210
|
+
else if (state.status === "needs_confirmation") this.emit(id, {
|
|
211
|
+
type: "needs_confirmation",
|
|
212
|
+
dimensions: state.dimensions ?? {},
|
|
213
|
+
pkgType: state.pkgType ?? null,
|
|
214
|
+
fileName: state.fileName ?? null,
|
|
215
|
+
at: now
|
|
216
|
+
});
|
|
217
|
+
return state;
|
|
218
|
+
}
|
|
219
|
+
remove(id) {
|
|
220
|
+
this.jobs.delete(id);
|
|
221
|
+
this.listeners.delete(id);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
function isAbortError(err) {
|
|
225
|
+
return err instanceof Error && (err.name === "AbortError" || /abort/i.test(err.message));
|
|
226
|
+
}
|
|
227
|
+
/** Map the tool-body `needs_auth` outcome to a job failure with the marker. */
|
|
228
|
+
function isNeedsAuth(result) {
|
|
229
|
+
return result?.status === "needs_auth";
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Run one generation to a terminal state. Returns the final JobState.
|
|
233
|
+
* History recording happens here so entry and state cannot drift.
|
|
234
|
+
*/
|
|
235
|
+
async function runGeneration(store, backend, history, id, req, meta, onProgress) {
|
|
236
|
+
if (!store.get(id)) return {
|
|
237
|
+
state: {
|
|
238
|
+
id,
|
|
239
|
+
kind: req.kind,
|
|
240
|
+
status: "failed",
|
|
241
|
+
error: "job not found",
|
|
242
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
243
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
244
|
+
},
|
|
245
|
+
recorded: false
|
|
246
|
+
};
|
|
247
|
+
const signal = store.signal(id);
|
|
248
|
+
const progress = (message) => {
|
|
249
|
+
store.update(id, {
|
|
250
|
+
status: "running",
|
|
251
|
+
progress: message
|
|
252
|
+
});
|
|
253
|
+
onProgress?.(message);
|
|
254
|
+
};
|
|
255
|
+
const exec = { signal };
|
|
256
|
+
try {
|
|
257
|
+
if (req.kind === "symbol") {
|
|
258
|
+
progress("正在生成 Symbol…");
|
|
259
|
+
const result = await backend.generateSymbol({
|
|
260
|
+
imageDataUrl: req.input.imageDataUrl ?? "",
|
|
261
|
+
instruction: req.input.instruction
|
|
262
|
+
}, exec);
|
|
263
|
+
if (isNeedsAuth(result)) return fail("needs_auth");
|
|
264
|
+
const state = store.settle(id, {
|
|
265
|
+
status: "completed",
|
|
266
|
+
result
|
|
267
|
+
});
|
|
268
|
+
await record(history, meta, req, state);
|
|
269
|
+
return {
|
|
270
|
+
state,
|
|
271
|
+
recorded: true
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
if (req.kind === "extract-footprint") {
|
|
275
|
+
progress("正在提取封装尺寸…");
|
|
276
|
+
const result = await backend.extractFootprint({
|
|
277
|
+
imageDataUrl: req.input.imageDataUrl ?? "",
|
|
278
|
+
packageType: req.input.packageType,
|
|
279
|
+
instruction: req.input.instruction
|
|
280
|
+
}, exec);
|
|
281
|
+
if (isNeedsAuth(result)) return fail("needs_auth");
|
|
282
|
+
if (result.status === "needs_confirmation") {
|
|
283
|
+
const dims = result.dimensions && typeof result.dimensions === "object" ? result.dimensions : {};
|
|
284
|
+
const pkg = typeof result.pkgType === "string" ? result.pkgType : req.input.packageType ?? null;
|
|
285
|
+
const fileName = typeof result.fileName === "string" ? result.fileName : null;
|
|
286
|
+
return {
|
|
287
|
+
state: store.settle(id, {
|
|
288
|
+
status: "needs_confirmation",
|
|
289
|
+
dimensions: dims,
|
|
290
|
+
pkgType: pkg,
|
|
291
|
+
fileName
|
|
292
|
+
}),
|
|
293
|
+
recorded: false
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
if (result.status === "cancelled") {
|
|
297
|
+
const state = store.settle(id, {
|
|
298
|
+
status: "cancelled",
|
|
299
|
+
result
|
|
300
|
+
});
|
|
301
|
+
await record(history, meta, req, state);
|
|
302
|
+
return {
|
|
303
|
+
state,
|
|
304
|
+
recorded: true
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
const state = store.settle(id, {
|
|
308
|
+
status: "completed",
|
|
309
|
+
result
|
|
310
|
+
});
|
|
311
|
+
await record(history, meta, req, state);
|
|
312
|
+
return {
|
|
313
|
+
state,
|
|
314
|
+
recorded: true
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
progress("正在生成封装…");
|
|
318
|
+
const result = await backend.generateFootprint({
|
|
319
|
+
packageType: req.input.packageType ?? "",
|
|
320
|
+
fileName: req.input.fileName,
|
|
321
|
+
dimensions: req.input.dimensions ?? {}
|
|
322
|
+
}, exec);
|
|
323
|
+
if (isNeedsAuth(result)) return fail("needs_auth");
|
|
324
|
+
if (result.status === "cancelled") {
|
|
325
|
+
const state = store.settle(id, {
|
|
326
|
+
status: "cancelled",
|
|
327
|
+
result
|
|
328
|
+
});
|
|
329
|
+
await record(history, meta, req, state);
|
|
330
|
+
return {
|
|
331
|
+
state,
|
|
332
|
+
recorded: true
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
const state = store.settle(id, {
|
|
336
|
+
status: "completed",
|
|
337
|
+
result
|
|
338
|
+
});
|
|
339
|
+
await record(history, meta, req, state);
|
|
340
|
+
return {
|
|
341
|
+
state,
|
|
342
|
+
recorded: true
|
|
343
|
+
};
|
|
344
|
+
} catch (err) {
|
|
345
|
+
if (isAbortError(err)) {
|
|
346
|
+
const state = store.settle(id, { status: "cancelled" });
|
|
347
|
+
await record(history, meta, req, state).catch(() => {});
|
|
348
|
+
return {
|
|
349
|
+
state,
|
|
350
|
+
recorded: true
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
const message = String(err?.message || err);
|
|
354
|
+
const state = store.settle(id, {
|
|
355
|
+
status: "failed",
|
|
356
|
+
error: message
|
|
357
|
+
});
|
|
358
|
+
await record(history, meta, req, state).catch(() => {});
|
|
359
|
+
return {
|
|
360
|
+
state,
|
|
361
|
+
recorded: true
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
function fail(kind) {
|
|
365
|
+
const state = store.settle(id, {
|
|
366
|
+
status: "failed",
|
|
367
|
+
error: kind === "needs_auth" ? "Huaqiu EDA login required" : "generation failed",
|
|
368
|
+
result: { status: kind }
|
|
369
|
+
});
|
|
370
|
+
record(history, meta, req, state).catch(() => {});
|
|
371
|
+
return {
|
|
372
|
+
state,
|
|
373
|
+
recorded: true
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/** Build + append a history entry for a terminal job state. */
|
|
378
|
+
async function record(history, meta, req, state) {
|
|
379
|
+
const kind = state.kind === "symbol" ? "symbol" : "footprint";
|
|
380
|
+
const status = state.status === "completed" ? "generated" : state.status === "cancelled" ? "cancelled" : "failed";
|
|
381
|
+
const result = state.result;
|
|
382
|
+
const artifact = result?.artifact && typeof result.artifact === "object" ? result.artifact : null;
|
|
383
|
+
const entry = {
|
|
384
|
+
id: newHistoryId(),
|
|
385
|
+
kind,
|
|
386
|
+
createdAt: state.updatedAt ?? state.createdAt,
|
|
387
|
+
status,
|
|
388
|
+
input: {
|
|
389
|
+
...meta.imageId ? { imageId: meta.imageId } : {},
|
|
390
|
+
...req.input.instruction ? { instruction: req.input.instruction } : {},
|
|
391
|
+
...req.input.packageType ? { packageType: req.input.packageType } : {},
|
|
392
|
+
...req.input.dimensions && Object.keys(req.input.dimensions).length > 0 ? { dimensions: req.input.dimensions } : {}
|
|
393
|
+
},
|
|
394
|
+
...req.input.edited && Object.keys(req.input.edited).length > 0 ? { edited: req.input.edited } : {},
|
|
395
|
+
...status === "generated" && artifact?.id ? { result: {
|
|
396
|
+
artifactId: String(artifact.id),
|
|
397
|
+
filename: typeof artifact.filename === "string" ? artifact.filename : result?.filename ?? `${kind}.kicad_${kind === "symbol" ? "sym" : "mod"}`,
|
|
398
|
+
...typeof result?.fileUrl === "string" ? { fileUrl: result.fileUrl } : {},
|
|
399
|
+
...typeof artifact.size === "number" ? { size: artifact.size } : {}
|
|
400
|
+
} } : {},
|
|
401
|
+
...status === "failed" && state.error ? { error: state.error } : {}
|
|
402
|
+
};
|
|
403
|
+
await history.append(entry);
|
|
404
|
+
}
|
|
405
|
+
//#endregion
|
|
406
|
+
//#region src/types.ts
|
|
407
|
+
const COMPONENT_GEN_ROUTE_PREFIX = "/api/v1/huaqiu/component-gen";
|
|
408
|
+
const MAX_IMAGE_BYTES = 4194304;
|
|
409
|
+
//#endregion
|
|
410
|
+
//#region src/routes.ts
|
|
411
|
+
function sendJson(res, status, body) {
|
|
412
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
413
|
+
res.end(JSON.stringify(body));
|
|
414
|
+
}
|
|
415
|
+
function readBody(req) {
|
|
416
|
+
return new Promise((resolve, reject) => {
|
|
417
|
+
const chunks = [];
|
|
418
|
+
req.on("data", (c) => chunks.push(c));
|
|
419
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
420
|
+
req.on("error", reject);
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
function pathnameOf(url) {
|
|
424
|
+
const u = url ?? "";
|
|
425
|
+
const q = u.indexOf("?");
|
|
426
|
+
return (q >= 0 ? u.slice(0, q) : u).replace(/\/+$/, "");
|
|
427
|
+
}
|
|
428
|
+
function jsonBodyOf(text) {
|
|
429
|
+
return JSON.parse(text || "{}");
|
|
430
|
+
}
|
|
431
|
+
/** Write one SSE frame and flush. */
|
|
432
|
+
function sse(res, event, payload) {
|
|
433
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
434
|
+
}
|
|
435
|
+
function isFinal(status) {
|
|
436
|
+
return status === "needs_confirmation" || status === "completed" || status === "failed" || status === "cancelled";
|
|
437
|
+
}
|
|
438
|
+
/** Map a current job state to its replay SSE event (or null when queued/running). */
|
|
439
|
+
function replayEventOf(state) {
|
|
440
|
+
if (state.status === "needs_confirmation") return {
|
|
441
|
+
type: "needs_confirmation",
|
|
442
|
+
dimensions: state.dimensions ?? {},
|
|
443
|
+
pkgType: state.pkgType ?? null,
|
|
444
|
+
fileName: state.fileName ?? null,
|
|
445
|
+
at: state.updatedAt
|
|
446
|
+
};
|
|
447
|
+
if (state.status === "completed") return {
|
|
448
|
+
type: "completed",
|
|
449
|
+
job: state,
|
|
450
|
+
at: state.updatedAt
|
|
451
|
+
};
|
|
452
|
+
if (state.status === "failed") return {
|
|
453
|
+
type: "failed",
|
|
454
|
+
error: state.error ?? "generation failed",
|
|
455
|
+
result: state.result,
|
|
456
|
+
at: state.updatedAt
|
|
457
|
+
};
|
|
458
|
+
if (state.status === "cancelled") return {
|
|
459
|
+
type: "cancelled",
|
|
460
|
+
at: state.updatedAt
|
|
461
|
+
};
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
function createComponentGenHandler(deps) {
|
|
465
|
+
const store = new JobStore();
|
|
466
|
+
return async (req, res) => {
|
|
467
|
+
const raw = pathnameOf(req.url);
|
|
468
|
+
if (!raw.startsWith("/api/v1/huaqiu/component-gen")) {
|
|
469
|
+
sendJson(res, 404, { error: "not found" });
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const path = raw.slice(28) || "/";
|
|
473
|
+
const method = req.method ?? "GET";
|
|
474
|
+
try {
|
|
475
|
+
if (method === "GET" && path === "/config") {
|
|
476
|
+
sendJson(res, 200, {
|
|
477
|
+
hostMode: deps.hostMode === true,
|
|
478
|
+
capabilities: {
|
|
479
|
+
symbol: true,
|
|
480
|
+
footprint: true
|
|
481
|
+
},
|
|
482
|
+
limits: { imageBytes: MAX_IMAGE_BYTES }
|
|
483
|
+
});
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (method === "POST" && path === "/jobs") {
|
|
487
|
+
const body = jsonBodyOf(await readBody(req));
|
|
488
|
+
if (!body || body.kind !== "symbol" && body.kind !== "extract-footprint" && body.kind !== "generate-footprint") {
|
|
489
|
+
sendJson(res, 400, { error: "invalid job kind (expected symbol | extract-footprint | generate-footprint)" });
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const input = body.input ?? {};
|
|
493
|
+
if (input.imageDataUrl && input.imageDataUrl.length > 4194304) {
|
|
494
|
+
sendJson(res, 413, {
|
|
495
|
+
error: "image too large",
|
|
496
|
+
detail: `max ${MAX_IMAGE_BYTES} bytes`
|
|
497
|
+
});
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const meta = {};
|
|
501
|
+
if (input.imageDataUrl) {
|
|
502
|
+
const imageId = newImageId();
|
|
503
|
+
await deps.history.saveImage(imageId, input.imageDataUrl);
|
|
504
|
+
meta.imageId = imageId;
|
|
505
|
+
}
|
|
506
|
+
const state = store.create({
|
|
507
|
+
kind: body.kind,
|
|
508
|
+
input
|
|
509
|
+
}, meta);
|
|
510
|
+
runGeneration(store, deps.backend, deps.history, state.id, {
|
|
511
|
+
kind: body.kind,
|
|
512
|
+
input
|
|
513
|
+
}, meta).catch((err) => {
|
|
514
|
+
console.warn("[component-gen] background run failed", String(err?.message || err));
|
|
515
|
+
});
|
|
516
|
+
res.writeHead(202, { "content-type": "application/json; charset=utf-8" });
|
|
517
|
+
res.end(JSON.stringify({ jobId: state.id }));
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
const jobGet = /^\/jobs\/([^/]+)$/.exec(path);
|
|
521
|
+
if (method === "GET" && jobGet) {
|
|
522
|
+
const state = store.get(jobGet[1]);
|
|
523
|
+
if (!state) {
|
|
524
|
+
sendJson(res, 404, { error: "job not found" });
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
sendJson(res, 200, state);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
const jobEvents = /^\/jobs\/([^/]+)\/events$/.exec(path);
|
|
531
|
+
if (method === "GET" && jobEvents) {
|
|
532
|
+
const id = jobEvents[1];
|
|
533
|
+
const state = store.get(id);
|
|
534
|
+
if (!state) {
|
|
535
|
+
sendJson(res, 404, { error: "job not found" });
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
res.writeHead(200, {
|
|
539
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
540
|
+
"cache-control": "no-cache",
|
|
541
|
+
connection: "keep-alive"
|
|
542
|
+
});
|
|
543
|
+
res.write(": ok\n\n");
|
|
544
|
+
const replay = replayEventOf(state);
|
|
545
|
+
if (replay) sse(res, replay.type, replay);
|
|
546
|
+
if (isFinal(state.status)) {
|
|
547
|
+
res.end();
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
const unsub = store.subscribe(id, (e) => {
|
|
551
|
+
sse(res, e.type, e);
|
|
552
|
+
if (e.type === "needs_confirmation" || e.type === "completed" || e.type === "failed" || e.type === "cancelled") {
|
|
553
|
+
unsub?.();
|
|
554
|
+
res.end();
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
req.on("close", () => unsub?.());
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
const jobDel = /^\/jobs\/([^/]+)$/.exec(path);
|
|
561
|
+
if (method === "DELETE" && jobDel) {
|
|
562
|
+
const ok = store.abort(jobDel[1]);
|
|
563
|
+
sendJson(res, ok ? 200 : 404, { ok });
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (method === "GET" && path === "/history") {
|
|
567
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
568
|
+
const query = {
|
|
569
|
+
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : void 0,
|
|
570
|
+
cursor: url.searchParams.get("cursor")
|
|
571
|
+
};
|
|
572
|
+
sendJson(res, 200, await deps.history.list(query));
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const histImage = /^\/history\/([^/]+)\/image$/.exec(path);
|
|
576
|
+
if (method === "GET" && histImage) {
|
|
577
|
+
const img = await deps.history.readImage(histImage[1]);
|
|
578
|
+
if (!img) {
|
|
579
|
+
sendJson(res, 404, { error: "image not found" });
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
res.writeHead(200, {
|
|
583
|
+
"content-type": img.mime,
|
|
584
|
+
"cache-control": "public, max-age=3600"
|
|
585
|
+
});
|
|
586
|
+
res.end(Buffer.from(img.bytes));
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const histGet = /^\/history\/([^/]+)$/.exec(path);
|
|
590
|
+
if (method === "GET" && histGet) {
|
|
591
|
+
const entry = await deps.history.get(histGet[1]);
|
|
592
|
+
if (!entry) {
|
|
593
|
+
sendJson(res, 404, { error: "history not found" });
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
sendJson(res, 200, entry);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
const histPatch = /^\/history\/([^/]+)$/.exec(path);
|
|
600
|
+
if (method === "PATCH" && histPatch) {
|
|
601
|
+
const patch = jsonBodyOf(await readBody(req));
|
|
602
|
+
const entry = await deps.history.patch(histPatch[1], patch);
|
|
603
|
+
if (!entry) {
|
|
604
|
+
sendJson(res, 404, { error: "history not found" });
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
sendJson(res, 200, entry);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
const histDel = /^\/history\/([^/]+)$/.exec(path);
|
|
611
|
+
if (method === "DELETE" && histDel) {
|
|
612
|
+
await deps.history.delete(histDel[1]);
|
|
613
|
+
sendJson(res, 200, { ok: true });
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
sendJson(res, 404, { error: "not found" });
|
|
617
|
+
} catch (err) {
|
|
618
|
+
sendJson(res, 500, {
|
|
619
|
+
error: "internal error",
|
|
620
|
+
detail: String(err)
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
//#endregion
|
|
626
|
+
//#region src/index.ts
|
|
627
|
+
/** Build the DSH `webServer.register(...)` route object. */
|
|
628
|
+
function createComponentGenRoutes(deps) {
|
|
629
|
+
return {
|
|
630
|
+
kind: "prefix",
|
|
631
|
+
path: COMPONENT_GEN_ROUTE_PREFIX,
|
|
632
|
+
handler: createComponentGenHandler(deps)
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
//#endregion
|
|
636
|
+
export { COMPONENT_GEN_ROUTE_PREFIX, HistoryStore, JobStore, MAX_IMAGE_BYTES, createComponentGenHandler, createComponentGenRoutes, newHistoryId, newImageId, parseDataUrl, runGeneration };
|