Back to Cherry Studio

异步绘图迁移到 JobManager

v2-refactor-temp/docs/ai/async-painting-via-jobs.md

2.0.06.9 KB
Original Source

异步绘图迁移到 JobManager

更新日期:2026-05-31 范围:src/renderer/aiCore/provider/custom/*(图像 transport)、src/main/data/services/PaintingService.tssrc/main/core/job/* 目的:记录「aiCore 迁移到 main 之后,异步绘图改用 Job & Scheduler 实现」的设计结论与落地映射。这是迁移后的目标设计,不是当前要实现的代码——前提见文末。

结论

JobManager + 一个 painting.generate JobHandler,不用 SchedulerService。

docs/references/job-and-scheduler/overview.md 区分得很清楚:

  • SchedulerService 管「什么时候触发」(cron / interval / once,时间驱动)。
  • JobManager 管「任务生命周期」(持久化 + 6 态机 + 重试 + 并发 + 重启恢复)。

异步绘图是按需触发的后台长任务,没有 cron,所以归 JobManager。scheduler 那半边在绘图里用不上。

为什么契合

docs/references/job-and-scheduler/handler-authoring.md§2 "Remote-poll pattern (cross-restart hand-off)" 就是异步绘图的形状:submit 到 vendor → 持久化 providerTaskId → 轮询到完成。文档明确点出了那个最关键的坑:

CRITICAL: await — without persistence the restart-recovery will re-submit the remote job, wasting user quota and producing parallel external tasks.

现有 transport 与该模式 1:1 对应,恢复所需的钩子已经存在:

  • dashscope/dashscopeTransport.ts:submit() 返回 { taskId },并在拿到 task id 时回调 onSubmitTaskId?.(taskId)(:404);poll(taskId, { signal })(:408)。
  • ppio/ppioTransport.tsaihubmix/aihubmixFlux.ts:同样是 submit → { taskId } + poll(taskId)

onSubmitTaskId 这个回调本来就是为「跨重启把 task id 交给上层持久化」而存在的——正好喂给 §2 的 ctx.patchMetadata({ providerTaskId })

JobManager 在本仓已有成熟先例(KnowledgeServicefileProcessing/tasks/*JobHandler),不是新基建。

落地映射(迁移到 main 之后)

  1. JobRegistry 增加类型:

    ts
    declare module '@main/core/job/jobRegistry' {
      interface JobRegistry {
        'painting.generate': PaintingGeneratePayload
      }
    }
    
  2. PaintingService(main,已存在)拥有它:在 onInitregisterHandler('painting.generate', paintingGenerateJobHandler)(注册时机见 handler-authoring「Registration timing」——必须 onInit,不能 onAllReady),并暴露一个 IPC(如 Painting_Generate)→ 内部 jobManager.enqueue('painting.generate', payload)。Handler 放 src/main/data/services/.../tasks/PaintingGenerateJobHandler.ts(handler-authoring §6 约定)。

  3. Handler.execute = §2 remote-poll:

    ts
    async execute(ctx) {
      let taskId = ctx.metadata.providerTaskId as string | undefined
      if (!taskId) {
        const r = await transport.submit(input, { signal: ctx.signal })
        if (r.imageUrls) return finalize(r.imageUrls)          // 同步 vendor:一次返图,无 taskId
        taskId = r.taskId!
        await ctx.patchMetadata({ providerTaskId: taskId })     // CRITICAL: poll 之前先持久化
      }
      while (!ctx.signal.aborted) {
        const urls = await transport.poll(taskId, { signal: ctx.signal })
        if (urls.length) return finalize(urls)
        ctx.reportProgress(/* … */)
        await sleep(POLL_INTERVAL_MS, { signal: ctx.signal })   // 必须 signal-aware
      }
      throw new Error('AbortError: cancelled')
    }
    

    finalize 在 main 写 FileEntry + file_ref(sourceType='painting'),与已落地的 file_ref / orphan-sweep 一致。

  4. 渲染端只读观察(overview「Renderer-side consumers」规则):useJob(jobId) / useJobProgress(jobId)。渲染端直接 enqueue / cancel,只通过 PaintingService 的 IPC 发起。这替掉了现在渲染端的轮询循环和 painting.generation.${id} 共享 cache。

同步 vendor 也走同一个 job

dmxapi(openai-flat 族)/ ovms / silicon / aihubmix(非 async)/ openai 等是一次请求返图(submit() 返回 imageUrls 而非 taskId)。同一个 painting.generate handler 按返回的是 taskId? 还是 imageUrls? 分叉即可(见上 execute 草图),无需第二种 job 类型。这样并发、重试、可观测性对同步 vendor 同样生效,模型统一。

收益(相比现在渲染端 submit/poll)

能力现状(渲染端)用 Job 之后
跨重启续跑重启即丢,task id 没了持久化 providerTaskId,recovery: 'retry' 续轮询而非重交(省配额、不产生并行外部任务)—— 杀手锏
并发控制per-queue + 全局 globalMaxConcurrency
重试 / backoff内建,JOB_HANDLER_THREW / JOB_HANDLER_TIMEOUT 可重试
取消paintingAbortControllerStore(渲染端 Map)Job cancelRequestedctx.signal,transport 已吃 AbortSignal
进度 / 可观测性临时 cache持久化 JobSnapshot + reportProgress

设计要点 / 坑

  • 幂等、防重交:providerTaskId 必须在第一次 poll 之前 await 持久化(§2 CRITICAL)。enqueue 带 idempotency key,防双击产生两个 vendor 任务。
  • recovery 策略:异步 vendor 用 retry(续跑);execute 重入时必须先读 ctx.metadata.providerTaskId 决定续轮询而非重交。
  • 轮询循环:while (!ctx.signal.aborted) + sleep(N, { signal }),禁止 while (true) / 无 signal 的 sleep(否则取消会被拖延 N ms,见 §2 anti-pattern)。
  • 文件产出:在 main 写 FileEntry + file_ref,对齐现有 file_ref / orphan-sweep。

可退役项

迁移后这些渲染端构件可以删掉:

  • paintingAbortControllerStore(取消改走 Job cancel)。
  • painting.generation.${id} 共享 cache(状态改走 useJob / useJobProgress)。
  • 渲染端的轮询循环(移入 handler.execute)。

前提与未决

  • 依赖 transport 真的搬到 main。当前 src/renderer/aiCore/provider/custom/* 的 transport 仍在渲染端(src/main/aiCore 已存在,迁移有脚手架,但 transport 尚未过去)。transport 能从 main 调用之前,这个 JobHandler 跑不起来。
  • 落地时可复用已建的 boundary 测试 harness(src/renderer/aiCore/provider/custom/__tests__/boundary/)给 PaintingGenerateJobHandler 补 request/response 契约测试。

参考