@downcity/city 0.2.94 → 0.2.101

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.
@@ -9,21 +9,23 @@
9
9
  * 路由(City 自动生成):
10
10
  * - POST /v1/ai/text — 文本生成
11
11
  * - POST /v1/ai/stream — 流式生成
12
- * - POST /v1/ai/image — 图片生成
13
12
  * - POST /v1/ai/video — 视频生成
13
+ * - POST /v1/ai/image/create — 创建图片生成任务
14
+ * - POST /v1/ai/image/result — 查询图片生成任务
15
+ * - POST /v1/ai/image/persist — 后台持久化图片结果(admin)
14
16
  * - POST /v1/ai/chat/completions — OpenAI 兼容端点
15
17
  * - GET /v1/ai/models — 模型列表
16
18
  */
17
19
  import { Service } from "../service.js";
18
- import { httpError, randomSecret } from "../../utils/helpers.js";
20
+ import { httpError } from "../../utils/helpers.js";
19
21
  import { sqliteAsyncJobs } from "../async-job/schema.js";
20
22
  import { normalizeAIUsage } from "./helpers.js";
21
- /** AIService 直接暴露的 SDK 通路模态列表。 */
22
- const MODALITIES = ["text", "stream", "image", "video", "tts", "asr"];
23
+ /** AIService 直接暴露的 SDK 通路模态列表。图片只通过 image/create + image/result 暴露。 */
24
+ const MODALITIES = ["text", "stream", "video", "tts", "asr"];
23
25
  /** 用户侧默认以 text 模态排序模型 */
24
26
  const DEFAULT_MODEL_MODE = "text";
25
- /** 图片任务轮询建议间隔 */
26
- const IMAGE_JOB_POLL_AFTER_MS = 2_000;
27
+ /** 图片任务的内部 action 列表。 */
28
+ const IMAGE_ACTION_MODES = ["image_create", "image_persist"];
27
29
  /** 图片生成任务在通用 async_jobs 表中的类型。 */
28
30
  const IMAGE_GENERATE_JOB_TYPE = "ai.image.generate";
29
31
  /**
@@ -56,15 +58,6 @@ function isProviderChargedOutput(value) {
56
58
  function isPromiseLike(value) {
57
59
  return Boolean(value && typeof value === "object" && "then" in value && typeof value.then === "function");
58
60
  }
59
- /**
60
- * 读取必填字符串。
61
- */
62
- function readRequiredString(value, label) {
63
- const text = readOptionalString(value);
64
- if (!text)
65
- throw httpError(422, `${label} is required`);
66
- return text;
67
- }
68
61
  /**
69
62
  * 读取可选字符串。
70
63
  */
@@ -72,57 +65,71 @@ function readOptionalString(value) {
72
65
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
73
66
  }
74
67
  /**
75
- * 归一化图片任务输出。
68
+ * 读取可空字符串字段。
76
69
  */
77
- async function normalizeImageJobOutput(output) {
78
- if (!isResponse(output))
79
- return output;
80
- const text = await output.text();
81
- if (!output.ok) {
82
- throw httpError(output.status, text || output.statusText || "image generation failed");
83
- }
84
- return text ? JSON.parse(text) : {};
70
+ function readNullableString(value) {
71
+ return typeof value === "string" ? value : null;
85
72
  }
86
73
  /**
87
- * 解析已持久化的图片任务结果。
74
+ * 读取任务状态。
88
75
  */
89
- function parseImageJobResult(raw) {
90
- try {
91
- return JSON.parse(raw);
92
- }
93
- catch {
94
- return undefined;
95
- }
76
+ function readJobStatus(value) {
77
+ return value === "queued" || value === "running" || value === "succeeded" || value === "failed"
78
+ ? value
79
+ : "failed";
96
80
  }
97
81
  /**
98
- * 解析可恢复任务状态。
82
+ * 安全解析 JSON 对象。
99
83
  */
100
- function parseImageJobStepState(raw) {
101
- if (!raw)
102
- return undefined;
84
+ function parseRecordJson(value) {
85
+ if (typeof value !== "string" || !value.trim())
86
+ return {};
103
87
  try {
104
- const parsed = JSON.parse(raw);
105
- return parsed && typeof parsed === "object" && !Array.isArray(parsed)
106
- ? parsed
107
- : undefined;
88
+ const parsed = JSON.parse(value);
89
+ return isRecord(parsed) ? parsed : {};
108
90
  }
109
91
  catch {
110
- return undefined;
92
+ return {};
111
93
  }
112
94
  }
113
95
  /**
114
- * 解析已持久化的图片任务输入。
96
+ * 安全解析 UIMessage。
115
97
  */
116
- function parseImageJobInput(raw) {
117
- try {
118
- const parsed = JSON.parse(raw);
119
- return parsed && typeof parsed === "object" && !Array.isArray(parsed)
120
- ? parsed
121
- : {};
122
- }
123
- catch {
124
- return {};
98
+ function parseImageMessage(value) {
99
+ const record = parseRecordJson(value);
100
+ return record.role === "assistant" && Array.isArray(record.parts)
101
+ ? record
102
+ : undefined;
103
+ }
104
+ /**
105
+ * 把 TableApi 普通行转成图片任务记录。
106
+ */
107
+ function rowToAsyncJobRecord(row) {
108
+ return {
109
+ job_id: String(row.job_id ?? ""),
110
+ job_type: String(row.job_type ?? ""),
111
+ status: readJobStatus(row.status),
112
+ input_json: String(row.input_json ?? "{}"),
113
+ state_json: readNullableString(row.state_json),
114
+ result_json: readNullableString(row.result_json),
115
+ error: readNullableString(row.error),
116
+ message: readNullableString(row.message),
117
+ city_id: readNullableString(row.city_id),
118
+ user_id: readNullableString(row.user_id),
119
+ service_id: readNullableString(row.service_id),
120
+ model_id: readNullableString(row.model_id),
121
+ created_at: String(row.created_at ?? ""),
122
+ updated_at: String(row.updated_at ?? ""),
123
+ };
124
+ }
125
+ /**
126
+ * 保留已知 HTTP 错误状态,其它异常统一包装成上游错误。
127
+ */
128
+ function imageActionError(error, fallback_message) {
129
+ if (error instanceof Error && typeof error.statusCode === "number") {
130
+ return error;
125
131
  }
132
+ return httpError(502, error instanceof Error ? error.message : fallback_message);
126
133
  }
127
134
  /**
128
135
  * 从输出对象中读取 provider usage。
@@ -161,54 +168,60 @@ function countImageOutputs(output) {
161
168
  return count > 0 ? count : undefined;
162
169
  }
163
170
  /**
164
- * 判断 provider 是否返回了图片任务推进结果。
171
+ * 判断 provider 是否返回了图片任务创建结果。
165
172
  */
166
- function isImageJobStepResult(value) {
173
+ function isImageProviderCreateResult(value) {
167
174
  if (!value || typeof value !== "object")
168
175
  return false;
169
- const status = value.status;
170
- return status === "running" || status === "succeeded" || status === "failed";
176
+ const record = value;
177
+ return typeof record.job_id === "string" &&
178
+ Boolean(record.job_id.trim()) &&
179
+ isImageJobStatus(record.status);
171
180
  }
172
181
  /**
173
- * 把类型化任务记录转成 TableApi 使用的普通行。
182
+ * 判断 provider 是否返回了图片任务查询结果。
174
183
  */
175
- function recordToRow(record) {
176
- return { ...record };
177
- }
178
- /**
179
- * TableApi 普通行转成图片任务记录。
180
- */
181
- function rowToAsyncJobRecord(row) {
182
- return {
183
- job_id: String(row.job_id ?? ""),
184
- job_type: String(row.job_type ?? ""),
185
- status: readJobStatus(row.status),
186
- input_json: String(row.input_json ?? "{}"),
187
- state_json: readNullableString(row.state_json),
188
- result_json: readNullableString(row.result_json),
189
- error: readNullableString(row.error),
190
- message: readNullableString(row.message),
191
- city_id: readNullableString(row.city_id),
192
- user_id: readNullableString(row.user_id),
193
- service_id: readNullableString(row.service_id),
194
- model_id: readNullableString(row.model_id),
195
- created_at: String(row.created_at ?? ""),
196
- updated_at: String(row.updated_at ?? ""),
197
- };
184
+ function isImageProviderResult(value) {
185
+ if (!value || typeof value !== "object")
186
+ return false;
187
+ const record = value;
188
+ if (typeof record.job_id !== "string" || !record.job_id.trim())
189
+ return false;
190
+ if (!isImageJobStatus(record.status))
191
+ return false;
192
+ if (record.status === "succeeded") {
193
+ const result = isRecord(record.result) ? record.result : undefined;
194
+ if (!result || result.role !== "assistant" || !Array.isArray(result.parts))
195
+ return false;
196
+ }
197
+ return true;
198
198
  }
199
199
  /**
200
- * 读取任务状态。
200
+ * 判断 provider 是否返回了图片持久化结果。
201
201
  */
202
- function readJobStatus(value) {
203
- return value === "queued" || value === "running" || value === "succeeded" || value === "failed"
204
- ? value
205
- : "failed";
202
+ function isImageProviderPersistResult(value) {
203
+ if (!value || typeof value !== "object")
204
+ return false;
205
+ const record = value;
206
+ if (typeof record.job_id !== "string" || !record.job_id.trim())
207
+ return false;
208
+ if (!isImageJobStatus(record.status))
209
+ return false;
210
+ if (record.status === "succeeded") {
211
+ const result = isRecord(record.result) ? record.result : undefined;
212
+ if (!result || result.role !== "assistant" || !Array.isArray(result.parts))
213
+ return false;
214
+ }
215
+ return true;
206
216
  }
207
217
  /**
208
- * 读取可空字符串字段。
218
+ * 判断图片任务状态。
209
219
  */
210
- function readNullableString(value) {
211
- return typeof value === "string" ? value : null;
220
+ function isImageJobStatus(value) {
221
+ return value === "queued" ||
222
+ value === "running" ||
223
+ value === "succeeded" ||
224
+ value === "failed";
212
225
  }
213
226
  export class AIService extends Service {
214
227
  /** 模型注册表 */
@@ -233,6 +246,9 @@ export class AIService extends Service {
233
246
  this.action("image/result", async (ctx) => this.readImageJob(ctx), {
234
247
  auth: ["user", "admin"],
235
248
  });
249
+ this.action("image/persist", async (ctx) => this.persistImageJob(ctx), {
250
+ auth: ["admin"],
251
+ });
236
252
  // OpenAI 兼容端点
237
253
  this.action("chat/completions", async (ctx) => this.handleChatCompletions(ctx), {
238
254
  auth: ["user", "admin"],
@@ -295,6 +311,17 @@ export class AIService extends Service {
295
311
  throw httpError(422, `No model registered`);
296
312
  }
297
313
  getAction(model, mode) {
314
+ if (mode === "image" || IMAGE_ACTION_MODES.includes(mode)) {
315
+ const has_image_actions = Boolean(model.actions.image_create && model.actions.image_persist);
316
+ if (!has_image_actions)
317
+ return undefined;
318
+ return mode === "image"
319
+ ? model.actions.image_create
320
+ : model.actions[mode];
321
+ }
322
+ if (mode === "image_result") {
323
+ return model.actions.image_result;
324
+ }
298
325
  return model.actions[(mode ?? "text")];
299
326
  }
300
327
  resolveOpenAIAction(model) {
@@ -370,9 +397,12 @@ export class AIService extends Service {
370
397
  }
371
398
  getModelModalities(model) {
372
399
  const modalities = Object.keys(model.actions).filter((key) => model.actions[key] !== undefined);
400
+ if (modalities.includes("image_create") && modalities.includes("image_persist")) {
401
+ modalities.push("image");
402
+ }
373
403
  if (!modalities.includes("openai") && this.resolveOpenAIAction(model))
374
404
  modalities.push("openai");
375
- return modalities;
405
+ return modalities.filter((mode) => mode !== "image_create" && mode !== "image_persist" && mode !== "image_result");
376
406
  }
377
407
  getModelEnvRequirements(model) {
378
408
  const requirements = model.env
@@ -415,211 +445,173 @@ export class AIService extends Service {
415
445
  }
416
446
  // ========== 图片任务通路 ==========
417
447
  async createImageJob(ctx) {
418
- const resolved = this.resolve({ model: ctx.input.model, mode: "image" }, ctx.env);
419
- const job_id = `img_${randomSecret(12)}`;
448
+ const resolved = this.resolve({ model: ctx.input.model, mode: "image_create" }, ctx.env);
420
449
  this.attachResolvedModel(ctx, resolved.model, "image/create");
421
- await this.handleCharge(ctx, resolved.model?.bill?.(ctx, { job_id }), false);
422
- const now = new Date().toISOString();
423
- const record = {
424
- job_id,
425
- job_type: IMAGE_GENERATE_JOB_TYPE,
426
- status: "queued",
427
- input_json: JSON.stringify(ctx.input),
428
- state_json: null,
429
- result_json: null,
430
- error: null,
431
- message: "queued",
432
- city_id: ctx.city?.city_id ?? readOptionalString(ctx.input.city_id),
433
- user_id: ctx.user?.user_id ?? null,
434
- service_id: "ai",
435
- model_id: resolved.model?.id ?? null,
436
- created_at: now,
437
- updated_at: now,
438
- };
439
- await this.asyncJobTable(ctx).insert(recordToRow(record));
440
- const job_ctx = {
441
- ...ctx,
442
- input: { ...ctx.input },
443
- locals: {},
444
- output: undefined,
445
- error: undefined,
446
- };
447
- if (resolved.model?.actions.image_job) {
448
- let advanced;
449
- try {
450
- advanced = await this.advanceImageJob(job_ctx, record);
451
- }
452
- catch (error) {
453
- await this.updateImageJob(ctx, job_id, {
454
- status: "failed",
455
- error: error instanceof Error ? error.message : String(error),
456
- message: "failed",
457
- updated_at: new Date().toISOString(),
458
- });
459
- throw httpError(502, error instanceof Error ? error.message : "image_job action failed");
450
+ try {
451
+ const created = await resolved.action(ctx);
452
+ if (!isImageProviderCreateResult(created)) {
453
+ throw httpError(500, "image_create action returned invalid result");
460
454
  }
461
- return {
462
- job_id,
463
- status: advanced.status,
464
- poll_after_ms: IMAGE_JOB_POLL_AFTER_MS,
465
- };
455
+ await this.insertImageJob(ctx, created);
456
+ return created;
466
457
  }
467
- else {
468
- const promise = this.runImageJob(job_id, job_ctx);
469
- if (ctx.waitUntil)
470
- ctx.waitUntil(promise);
471
- else
472
- void promise;
458
+ catch (error) {
459
+ throw imageActionError(error, "image_create action failed");
473
460
  }
474
- return {
475
- job_id,
476
- status: "queued",
477
- poll_after_ms: IMAGE_JOB_POLL_AFTER_MS,
478
- };
479
461
  }
480
462
  async readImageJob(ctx) {
481
- const job_id = readRequiredString(ctx.input.job_id, "job_id");
482
- const record = await this.getImageJob(ctx, job_id);
483
- if (!record)
484
- throw httpError(404, `Image job not found: ${job_id}`);
485
- this.ensureImageJobAccess(ctx, record);
486
- if (record.status === "queued" || record.status === "running") {
487
- const advanced = await this.advanceImageJob(ctx, record);
488
- return this.toImageJobResult(advanced);
489
- }
490
- return this.toImageJobResult(record);
491
- }
492
- async advanceImageJob(ctx, record) {
493
- const input = parseImageJobInput(record.input_json);
494
- const resolved = this.resolve({ model: input.model, mode: "image" }, ctx.env);
495
- const step_action = resolved.model?.actions.image_job;
496
- if (!step_action)
497
- return record;
498
- this.attachResolvedModel(ctx, resolved.model, "image");
499
- const step_ctx = {
500
- ...ctx,
501
- input,
502
- locals: {
503
- ...ctx.locals,
504
- image_job: {
505
- job_id: record.job_id,
506
- status: record.status,
507
- state: parseImageJobStepState(record.state_json),
508
- },
509
- },
510
- };
511
- const output = await step_action(step_ctx);
512
- if (!isImageJobStepResult(output)) {
513
- throw httpError(500, "image_job action returned invalid result");
514
- }
515
- const updated_at = new Date().toISOString();
516
- if (output.status === "succeeded") {
517
- if (!output.result)
518
- throw httpError(500, "image_job action succeeded without result");
519
- await this.updateImageJob(ctx, record.job_id, {
520
- status: "succeeded",
521
- state_json: null,
522
- result_json: JSON.stringify(output.result),
523
- error: null,
524
- message: output.message ?? "succeeded",
525
- updated_at,
526
- });
527
- }
528
- else if (output.status === "failed") {
529
- await this.updateImageJob(ctx, record.job_id, {
530
- status: "failed",
531
- error: output.error ?? output.message ?? "image generation failed",
532
- message: output.message ?? "failed",
533
- updated_at,
534
- });
463
+ const job = await this.requireImageJob(ctx);
464
+ try {
465
+ const model_id = readOptionalString(ctx.input.model);
466
+ const model = model_id ? this.modelMap.get(model_id) : undefined;
467
+ if (model?.actions.image_result) {
468
+ this.attachResolvedModel(ctx, model, "image/result");
469
+ this.attachImageJobContext(ctx, job);
470
+ const output = await model.actions.image_result(ctx);
471
+ if (!isImageProviderResult(output)) {
472
+ throw httpError(500, "image_result action returned invalid result");
473
+ }
474
+ return output;
475
+ }
476
+ if (job.model_id && this.modelMap.has(job.model_id)) {
477
+ this.attachResolvedModel(ctx, this.modelMap.get(job.model_id), "image/result");
478
+ }
479
+ const output = this.imageJobToResult(job);
480
+ if (!isImageProviderResult(output)) {
481
+ throw httpError(500, "image_result action returned invalid result");
482
+ }
483
+ return output;
535
484
  }
536
- else {
537
- await this.updateImageJob(ctx, record.job_id, {
538
- status: "running",
539
- state_json: output.state ? JSON.stringify(output.state) : record.state_json,
540
- error: null,
541
- message: output.message ?? "running",
542
- updated_at,
543
- });
485
+ catch (error) {
486
+ throw imageActionError(error, "image_result action failed");
544
487
  }
545
- const next = await this.getImageJob(ctx, record.job_id);
546
- return next ?? record;
547
488
  }
548
- async runImageJob(job_id, ctx) {
489
+ /**
490
+ * 持久化图片任务结果,并在持久化成功后触发计费。
491
+ *
492
+ * 关键点(中文)
493
+ * - 这个方法供后台 worker / queue / cron 调用,不是前端轮询 API。
494
+ * - AIService 使用内置 async_jobs 持久化任务,Provider 只负责把上游结果转成固定返回协议。
495
+ */
496
+ async persistImageJob(ctx) {
497
+ const job = await this.requireImageJob(ctx);
498
+ if (!ctx.input.model && job.model_id) {
499
+ ctx.input = { ...ctx.input, model: job.model_id };
500
+ }
501
+ const resolved = this.resolve({ model: ctx.input.model, mode: "image_persist" }, ctx.env);
502
+ this.attachResolvedModel(ctx, resolved.model, "image/persist");
503
+ this.attachImageJobContext(ctx, job);
504
+ const started_at = Date.now();
549
505
  try {
550
- await this.updateImageJob(ctx, job_id, {
551
- status: "running",
552
- message: "running",
553
- error: null,
554
- updated_at: new Date().toISOString(),
555
- });
556
- const resolved = this.resolve({ model: ctx.input.model, mode: "image" }, ctx.env);
557
- this.attachResolvedModel(ctx, resolved.model, "image");
558
506
  const output = await resolved.action(ctx);
559
- const result = await normalizeImageJobOutput(output);
560
- await this.updateImageJob(ctx, job_id, {
561
- status: "succeeded",
562
- state_json: null,
563
- result_json: JSON.stringify(result),
564
- error: null,
565
- message: "succeeded",
566
- updated_at: new Date().toISOString(),
567
- });
507
+ if (!isImageProviderPersistResult(output)) {
508
+ throw httpError(500, "image_persist action returned invalid result");
509
+ }
510
+ if (output.status === "succeeded") {
511
+ this.attachOutputMetering(ctx, output.result, "image", started_at);
512
+ await this.updateImageJobFromPersist(ctx, job, output);
513
+ const charge = resolved.model?.bill?.(ctx, output);
514
+ await this.handleCharge(ctx, charge, isPromiseLike(charge));
515
+ }
516
+ else {
517
+ await this.updateImageJobFromPersist(ctx, job, output);
518
+ }
519
+ return output;
568
520
  }
569
521
  catch (error) {
570
- await this.updateImageJob(ctx, job_id, {
571
- status: "failed",
572
- error: error instanceof Error ? error.message : String(error),
573
- message: "failed",
574
- updated_at: new Date().toISOString(),
575
- });
522
+ throw imageActionError(error, "image_persist action failed");
576
523
  }
577
524
  }
578
- asyncJobTable(ctx) {
525
+ /**
526
+ * 写入图片任务。
527
+ */
528
+ async insertImageJob(ctx, created) {
579
529
  const table = ctx.db.async_jobs;
580
530
  if (!table)
581
- throw httpError(500, "Async job table is not initialized");
582
- return table;
583
- }
584
- async getImageJob(ctx, job_id) {
585
- const rows = await this.asyncJobTable(ctx).select({
586
- job_id,
531
+ throw httpError(500, "AI async_jobs table is not initialized");
532
+ const now = new Date().toISOString();
533
+ await table.insert({
534
+ job_id: created.job_id,
587
535
  job_type: IMAGE_GENERATE_JOB_TYPE,
536
+ status: created.status,
537
+ input_json: JSON.stringify(ctx.input ?? {}),
538
+ state_json: JSON.stringify(created.metadata ?? {}),
539
+ result_json: null,
540
+ error: created.error ?? null,
541
+ message: created.message ?? null,
542
+ city_id: ctx.city?.city_id ?? null,
543
+ user_id: ctx.user?.user_id ?? null,
544
+ service_id: "ai",
545
+ model_id: ctx.metering?.model_id ?? null,
546
+ created_at: now,
547
+ updated_at: now,
588
548
  });
589
- return rows[0] ? rowToAsyncJobRecord(rows[0]) : undefined;
590
549
  }
591
- async updateImageJob(ctx, job_id, values) {
592
- await this.asyncJobTable(ctx).update({
593
- where: {
594
- job_id,
595
- job_type: IMAGE_GENERATE_JOB_TYPE,
596
- },
597
- values: recordToRow(values),
598
- });
550
+ /**
551
+ * 读取图片任务。
552
+ */
553
+ async requireImageJob(ctx) {
554
+ const table = ctx.db.async_jobs;
555
+ if (!table)
556
+ throw httpError(500, "AI async_jobs table is not initialized");
557
+ const job_id = readOptionalString(ctx.input.job_id);
558
+ if (!job_id)
559
+ throw httpError(422, "job_id is required");
560
+ const rows = await table.select({ job_id, job_type: IMAGE_GENERATE_JOB_TYPE });
561
+ const row = rows[0];
562
+ if (!row)
563
+ throw httpError(404, `Image job not found: ${job_id}`);
564
+ return rowToAsyncJobRecord(row);
599
565
  }
600
- ensureImageJobAccess(ctx, record) {
601
- if (ctx.identity?.kind !== "user")
602
- return;
603
- if (record.city_id && ctx.city?.city_id && record.city_id !== ctx.city.city_id) {
604
- throw httpError(404, `Image job not found: ${record.job_id}`);
605
- }
606
- if (record.user_id && ctx.user?.user_id && record.user_id !== ctx.user.user_id) {
607
- throw httpError(404, `Image job not found: ${record.job_id}`);
608
- }
566
+ /**
567
+ * async_jobs 记录注入 Provider 可读取的上下文。
568
+ */
569
+ attachImageJobContext(ctx, job) {
570
+ const image_job = {
571
+ record: job,
572
+ input: parseRecordJson(job.input_json),
573
+ state: parseRecordJson(job.state_json),
574
+ };
575
+ ctx.locals.ai_image_job = image_job;
576
+ ctx.input = {
577
+ ...parseRecordJson(job.input_json),
578
+ ...ctx.input,
579
+ job_id: job.job_id,
580
+ };
609
581
  }
610
- toImageJobResult(record) {
611
- const result = record.status === "succeeded" && record.result_json
612
- ? parseImageJobResult(record.result_json)
613
- : undefined;
582
+ /**
583
+ * 将图片任务记录转成默认 result 返回。
584
+ */
585
+ imageJobToResult(job) {
614
586
  return {
615
- job_id: record.job_id,
616
- status: record.status,
617
- result,
618
- error: record.error ?? undefined,
619
- message: record.message ?? undefined,
620
- poll_after_ms: IMAGE_JOB_POLL_AFTER_MS,
587
+ job_id: job.job_id,
588
+ status: job.status,
589
+ result: job.status === "succeeded" ? parseImageMessage(job.result_json) : undefined,
590
+ error: job.error ?? undefined,
591
+ message: job.message ?? undefined,
592
+ poll_after_ms: job.status === "running" || job.status === "queued" ? 2_000 : undefined,
593
+ metadata: parseRecordJson(job.state_json),
621
594
  };
622
595
  }
596
+ /**
597
+ * 按 persist 输出更新图片任务。
598
+ */
599
+ async updateImageJobFromPersist(ctx, job, output) {
600
+ const table = ctx.db.async_jobs;
601
+ if (!table)
602
+ throw httpError(500, "AI async_jobs table is not initialized");
603
+ await table.update({
604
+ where: { job_id: job.job_id, job_type: IMAGE_GENERATE_JOB_TYPE },
605
+ values: {
606
+ status: output.status,
607
+ state_json: JSON.stringify(output.metadata ?? parseRecordJson(job.state_json)),
608
+ result_json: output.result ? JSON.stringify(output.result) : job.result_json ?? null,
609
+ error: output.error ?? null,
610
+ message: output.message ?? null,
611
+ updated_at: new Date().toISOString(),
612
+ },
613
+ });
614
+ }
623
615
  // ========== OpenAI 兼容通路 ==========
624
616
  async handleChatCompletions(ctx) {
625
617
  const body = ctx.input;
@@ -716,11 +708,12 @@ export class AIService extends Service {
716
708
  .then(async (line) => {
717
709
  if (!line || line.amount_microcredits <= 0)
718
710
  return;
719
- if (!ctx.user?.user_id)
711
+ const user_id = line.user_id ?? ctx.user?.user_id;
712
+ if (!user_id)
720
713
  return;
721
714
  await this.balance?.charge({
722
- user_id: ctx.user.user_id,
723
715
  ...line,
716
+ user_id,
724
717
  });
725
718
  });
726
719
  if (!defer) {