Files
ai-novel-studio/app/composables/useProject.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

122 lines
4.8 KiB
TypeScript

import type { InjectionKey } from 'vue'
import type { JobInfo, ProjectEvent } from '#shared/types'
/** 项目工作区的共享状态:数据 + SSE 实时事件 */
export function createProjectStore(projectId: string) {
const api = useApi()
const project = ref<ProjectDetail | null>(null)
const chapters = ref<ChapterListItem[]>([])
const agents = ref<AgentItem[]>([])
const models = ref<ModelItem[]>([])
const jobs = ref<JobInfo[]>([])
const running = ref(false)
const runningLabel = ref('')
const connected = ref(false)
/** 正在写作的章节实时文本:chapterId → { versionId, text } */
const live = reactive<Record<string, { versionId: string, text: string }>>({})
/** 章节变更计数,章节详情组件据此刷新 */
const chapterRev = reactive<Record<string, number>>({})
const refreshProject = async () => { project.value = await api.get<ProjectDetail>(`/api/projects/${projectId}`) }
const refreshChapters = async () => { chapters.value = await api.get<ChapterListItem[]>(`/api/projects/${projectId}/chapters`) }
const refreshAgents = async () => { agents.value = await api.get<AgentItem[]>(`/api/projects/${projectId}/agents`) }
const refreshModels = async () => { models.value = await api.get<ModelItem[]>('/api/models') }
const refreshJobs = async () => { jobs.value = await api.get<JobInfo[]>(`/api/projects/${projectId}/jobs?limit=100`) }
let chapterTimer: ReturnType<typeof setTimeout> | null = null
const refreshChaptersSoon = () => {
if (chapterTimer) clearTimeout(chapterTimer)
chapterTimer = setTimeout(() => { refreshChapters().catch(() => {}) }, 150)
}
function handle(e: ProjectEvent) {
switch (e.type) {
case 'job': {
const i = jobs.value.findIndex(j => j.id === e.job.id)
if (i >= 0) jobs.value[i] = e.job
else jobs.value = [e.job, ...jobs.value].slice(0, 200)
if (e.job.status === 'error') {
api.toast.add({ title: `${e.job.agentName || '任务'}出错`, description: e.job.error.slice(0, 300), color: 'error', icon: 'i-lucide-circle-alert' })
}
break
}
case 'delta': {
const cur = live[e.chapterId]
if (!cur || cur.versionId !== e.versionId) live[e.chapterId] = { versionId: e.versionId, text: e.text }
else cur.text += e.text
break
}
case 'chapter':
chapterRev[e.chapterId] = (chapterRev[e.chapterId] ?? 0) + 1
refreshChaptersSoon()
break
case 'chapters':
refreshChaptersSoon()
break
case 'agents':
refreshAgents().catch(() => {})
break
case 'pipeline':
running.value = e.running
runningLabel.value = e.message ?? ''
if (!e.running) {
refreshProject().catch(() => {})
refreshChaptersSoon()
}
break
case 'log':
api.toast.add({ title: e.level === 'error' ? '出错了' : '提示', description: e.message.slice(0, 400), color: e.level === 'error' ? 'error' : e.level === 'warn' ? 'warning' : 'info' })
break
}
}
let source: EventSource | null = null
function connect() {
if (source) return
source = new EventSource(`/api/projects/${projectId}/events`)
source.onopen = () => {
connected.value = true
// 断线重连后补齐可能错过的状态
refreshChapters().catch(() => {})
refreshJobs().catch(() => {})
}
source.onerror = () => { connected.value = false }
source.onmessage = (msg) => {
try { handle(JSON.parse(msg.data) as ProjectEvent) }
catch (err) { console.error('bad event', err) }
}
}
function disconnect() {
source?.close()
source = null
connected.value = false
}
async function load() {
await Promise.all([refreshProject(), refreshChapters(), refreshAgents(), refreshModels(), refreshJobs()])
running.value = Boolean(project.value?.running)
runningLabel.value = project.value?.running?.label ?? ''
}
async function saveProject(patch: Record<string, unknown>) {
project.value = { ...project.value!, ...(await api.put<ProjectDetail>(`/api/projects/${projectId}`, patch)), running: project.value?.running ?? null }
}
async function run() { await api.post(`/api/projects/${projectId}/run`) }
async function stop() { await api.post(`/api/projects/${projectId}/stop`) }
return {
projectId, project, chapters, agents, models, jobs, running, runningLabel, connected, live, chapterRev,
load, connect, disconnect, refreshProject, refreshChapters, refreshAgents, refreshModels, refreshJobs, saveProject, run, stop
}
}
export type ProjectStore = ReturnType<typeof createProjectStore>
export const projectStoreKey: InjectionKey<ProjectStore> = Symbol('project-store')
export function useProjectStore() {
const store = inject(projectStoreKey)
if (!store) throw new Error('useProjectStore 必须在项目页面内使用')
return store
}