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