Files
ai-novel-studio/server/utils/jobs.ts
T
jocay 3c9b65d982 AI 小说工坊:主编 + 作家多模型协作写作应用
- Nuxt 4 + Nuxt UI 4 + AI SDK 7 + Drizzle/SQLite
- 主编规划大纲与章节简报、选派作家;作家流式写入章节文件并实时推送前端
- 逐章确认模式(默认)与自动模式;摘要与故事记忆
- 酒馆角色卡(PNG/JSON,V1-V3)与世界书导入
- 自定义最高优先级系统提示词、审稿/修改/多版本、任务日志与提示词检视
- 模拟 LLM 服务与端到端测试脚本

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 02:04:40 +08:00

119 lines
3.9 KiB
TypeScript

import { eq } from 'drizzle-orm'
import type { JobInfo, JobType, ProjectEvent } from '#shared/types'
type JobRow = typeof tables.jobs.$inferSelect
export function toJobInfo(row: JobRow): JobInfo {
const { prompt: _p, projectId: _pid, ...rest } = row
return rest
}
/** 一次 AI 调用(或一组调用)的记录:持久化到数据库,并实时推送给前端 */
export class Job {
readonly id = newId()
private row: JobRow
constructor(readonly projectId: string, type: JobType, opts: { chapterId?: string | null, agentName?: string, modelLabel?: string } = {}) {
const db = useDb()
this.row = db.insert(tables.jobs).values({
id: this.id,
projectId,
type,
chapterId: opts.chapterId ?? null,
agentName: opts.agentName ?? '',
modelLabel: opts.modelLabel ?? ''
}).returning().get()
this.emit()
}
private update(patch: Partial<JobRow>) {
this.row = useDb().update(tables.jobs).set(patch).where(eq(tables.jobs.id, this.id)).returning().get()!
this.emit()
}
private emit() {
emitProject(this.projectId, { type: 'job', job: toJobInfo(this.row) })
}
log(message: string) {
const line = `[${new Date().toLocaleTimeString('zh-CN', { hour12: false })}] ${message}`
this.update({ log: this.row.log ? `${this.row.log}\n${line}` : line })
}
setPrompt(system: string, prompt: string) {
this.update({ prompt: { system, prompt } })
}
addUsage(usage: { inputTokens?: number, outputTokens?: number } | undefined) {
if (!usage) return
this.update({
inputTokens: this.row.inputTokens + (usage.inputTokens ?? 0),
outputTokens: this.row.outputTokens + (usage.outputTokens ?? 0)
})
}
done() {
this.update({ status: 'done', finishedAt: new Date() })
}
fail(err: unknown) {
const cancelled = isAbort(err)
this.update({ status: cancelled ? 'cancelled' : 'error', error: cancelled ? '' : describeError(err), finishedAt: new Date() })
}
}
export function isAbort(err: unknown) {
const e = err as { name?: string } | undefined
return e?.name === 'AbortError' || e?.name === 'ResponseAborted' || (err instanceof Error && /aborted|abort/i.test(err.message) && /signal|user/i.test(err.message))
}
/* ---------------- 项目级互斥运行 ---------------- */
interface ActiveRun {
label: string
controller: AbortController
startedAt: number
}
const activeRuns = new Map<string, ActiveRun>()
export function getActiveRun(projectId: string) {
const run = activeRuns.get(projectId)
return run ? { label: run.label, startedAt: run.startedAt } : null
}
/**
* 每个项目同一时间只允许一个生成任务(保证章节连贯、避免状态竞争)。
* 任务在后台执行,接口立即返回;进度通过 SSE 推送。
*/
export function runExclusive(projectId: string, label: string, fn: (signal: AbortSignal) => Promise<void>) {
if (activeRuns.has(projectId)) {
throw createError({ statusCode: 409, message: `当前项目正在执行「${activeRuns.get(projectId)!.label}」,请等待完成或先停止` })
}
const controller = new AbortController()
activeRuns.set(projectId, { label, controller, startedAt: Date.now() })
emitProject(projectId, { type: 'pipeline', running: true, message: label })
fn(controller.signal)
.catch((err) => {
if (!isAbort(err)) {
console.error(`[run:${label}]`, err)
emitProject(projectId, { type: 'log', level: 'error', message: `${label}失败:${describeError(err)}` })
}
})
.finally(() => {
activeRuns.delete(projectId)
emitProject(projectId, { type: 'pipeline', running: false })
})
}
export function stopRun(projectId: string) {
const run = activeRuns.get(projectId)
if (!run) return false
run.controller.abort()
return true
}
export function emitAll(projectId: string, ...events: ProjectEvent[]) {
for (const e of events) emitProject(projectId, e)
}