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>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
type Method = 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
|
||||
// 使用宽松签名,返回类型由调用方通过泛型指定
|
||||
const rawFetch = $fetch as unknown as <T>(url: string, opts?: { method?: Method, body?: unknown }) => Promise<T>
|
||||
|
||||
/** 统一的接口调用:自动弹出错误提示 */
|
||||
export function useApi() {
|
||||
const toast = useToast()
|
||||
|
||||
async function call<T = unknown>(url: string, method: Method = 'GET', body?: unknown, opts: { silent?: boolean, title?: string } = {}): Promise<T> {
|
||||
try {
|
||||
return await rawFetch<T>(url, { method, body })
|
||||
}
|
||||
catch (err) {
|
||||
if (!opts.silent) toast.add({ title: opts.title ?? '操作失败', description: apiErrorMessage(err), color: 'error', icon: 'i-lucide-circle-alert' })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get: <T>(url: string) => call<T>(url, 'GET'),
|
||||
post: <T>(url: string, body?: unknown, opts?: { silent?: boolean }) => call<T>(url, 'POST', body ?? {}, opts),
|
||||
put: <T>(url: string, body?: unknown) => call<T>(url, 'PUT', body ?? {}),
|
||||
del: <T>(url: string) => call<T>(url, 'DELETE'),
|
||||
upload: <T>(url: string, form: FormData) => call<T>(url, 'POST', form, { title: '上传失败' }),
|
||||
toast
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user