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,8 @@
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
colors: {
|
||||
primary: 'rose',
|
||||
neutral: 'stone'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<UApp :toaster="{ position: 'top-right', duration: 4000 }">
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</UApp>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
@import "tailwindcss";
|
||||
@import "@nuxt/ui";
|
||||
|
||||
@theme static {
|
||||
--font-sans: "Inter", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
--font-serif: "Noto Serif SC", "Source Han Serif SC", "Songti SC", "SimSun", serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
--ui-primary: var(--ui-color-primary-600);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--ui-primary: var(--ui-color-primary-400);
|
||||
}
|
||||
|
||||
/* 小说正文排版 */
|
||||
.novel-text {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.0625rem;
|
||||
line-height: 2;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.novel-body {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.95;
|
||||
letter-spacing: 0.02em;
|
||||
word-break: break-word;
|
||||
}
|
||||
.novel-body p {
|
||||
text-indent: 2em;
|
||||
margin: 0 0 0.6em;
|
||||
}
|
||||
.novel-cursor {
|
||||
display: inline-block;
|
||||
width: 0.5em;
|
||||
height: 1.1em;
|
||||
margin-inline-start: 2px;
|
||||
vertical-align: text-bottom;
|
||||
background: var(--ui-primary);
|
||||
animation: novel-blink 1s steps(2) infinite;
|
||||
}
|
||||
@keyframes novel-blink { 50% { opacity: 0; } }
|
||||
|
||||
.prose-brief h1, .prose-brief h2, .prose-brief h3 {
|
||||
font-weight: 600;
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.prose-brief ul { list-style: disc; padding-left: 1.25rem; }
|
||||
.prose-brief ol { list-style: decimal; padding-left: 1.25rem; }
|
||||
.prose-brief p { margin: 0.25rem 0; }
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import { ROLE_LABEL } from '#shared/types'
|
||||
|
||||
const props = defineProps<{ agent: AgentItem, builtin: string }>()
|
||||
const store = useProjectStore()
|
||||
const api = useApi()
|
||||
|
||||
const form = reactive({
|
||||
name: props.agent.name,
|
||||
modelId: props.agent.modelId,
|
||||
systemPrompt: props.agent.systemPrompt,
|
||||
styleNotes: props.agent.styleNotes,
|
||||
enabled: props.agent.enabled
|
||||
})
|
||||
watch(() => props.agent, (a) => {
|
||||
Object.assign(form, { name: a.name, modelId: a.modelId, systemPrompt: a.systemPrompt, styleNotes: a.styleNotes, enabled: a.enabled })
|
||||
})
|
||||
|
||||
const dirty = computed(() => form.name !== props.agent.name || form.modelId !== props.agent.modelId || form.systemPrompt !== props.agent.systemPrompt || form.styleNotes !== props.agent.styleNotes || form.enabled !== props.agent.enabled)
|
||||
const saving = ref(false)
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await api.put(`/api/agents/${props.agent.id}`, { ...form })
|
||||
await store.refreshAgents()
|
||||
api.toast.add({ title: `${form.name} 已保存`, color: 'success' })
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
async function remove() {
|
||||
if (!window.confirm(`删除作家「${props.agent.name}」?`)) return
|
||||
await api.del(`/api/agents/${props.agent.id}`)
|
||||
await store.refreshAgents()
|
||||
}
|
||||
|
||||
const modelItems = computed(() => store.models.value.map(m => ({ label: `${m.providerName} / ${m.label}`, value: m.id })))
|
||||
const showBuiltin = ref(false)
|
||||
|
||||
const ICON = { editor: 'i-lucide-crown', writer: 'i-lucide-pen-tool', summarizer: 'i-lucide-notebook-pen' } as const
|
||||
const DESC = {
|
||||
editor: '负责规划章节大纲、为每章撰写简报、选派作家、审稿。建议使用推理能力强的模型(如 Claude)。',
|
||||
writer: '根据主编的简报写出章节正文。可以创建多位风格不同的作家,使用不同模型。',
|
||||
summarizer: '章节确认后提炼摘要并更新人物状态、伏笔等故事记忆。可以用便宜快速的模型;未配置时使用主编的模型。'
|
||||
} as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard :class="form.enabled ? '' : 'opacity-60'">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon :name="ICON[agent.role]" class="size-5 text-primary" />
|
||||
<UInput v-model="form.name" variant="ghost" class="font-semibold max-w-56" />
|
||||
<UBadge variant="subtle" size="sm">
|
||||
{{ ROLE_LABEL[agent.role] }}
|
||||
</UBadge>
|
||||
<UBadge v-if="agent.createdBy === 'editor'" color="info" variant="subtle" size="sm">
|
||||
主编创建
|
||||
</UBadge>
|
||||
<div class="ms-auto flex items-center gap-2">
|
||||
<USwitch v-if="agent.role === 'writer'" v-model="form.enabled" size="sm" label="启用" />
|
||||
<UButton v-if="agent.role === 'writer'" icon="i-lucide-trash-2" size="xs" color="error" variant="ghost" @click="remove" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<p class="text-xs text-muted">
|
||||
{{ DESC[agent.role] }}
|
||||
</p>
|
||||
<div class="grid sm:grid-cols-2 gap-4">
|
||||
<UFormField label="模型" required>
|
||||
<USelect
|
||||
:model-value="form.modelId ?? undefined"
|
||||
:items="modelItems"
|
||||
placeholder="选择模型"
|
||||
@update:model-value="(v: string) => (form.modelId = v)"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField v-if="agent.role === 'writer'" label="擅长风格" hint="主编选派作家时参考">
|
||||
<UInput v-model="form.styleNotes" placeholder="例如:细腻的情感描写、快节奏动作戏" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
<UFormField label="自定义系统提示词" hint="最高优先级,与内置提示词冲突时以此为准">
|
||||
<UTextarea
|
||||
v-model="form.systemPrompt"
|
||||
autoresize
|
||||
:rows="4"
|
||||
:maxrows="20"
|
||||
class="w-full"
|
||||
:placeholder="agent.role === 'writer' ? '例如:你的文字冷峻克制,擅长用白描和短句营造张力;对话不加多余的动作提示……' : agent.role === 'editor' ? '例如:每章必须有一个明确的小高潮;感情线推进要克制……' : '例如:摘要要保留关键对白原句……'"
|
||||
/>
|
||||
</UFormField>
|
||||
<div>
|
||||
<UButton :label="showBuiltin ? '收起内置提示词' : '查看内置提示词'" :icon="showBuiltin ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" size="xs" color="neutral" variant="link" class="px-0" @click="showBuiltin = !showBuiltin" />
|
||||
<pre v-if="showBuiltin" class="mt-2 text-xs whitespace-pre-wrap rounded-md bg-elevated p-3 text-muted">{{ builtin }}</pre>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<UButton label="保存" icon="i-lucide-save" size="sm" :loading="saving" :disabled="!dirty" @click="save" />
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
/** 酒馆角色卡导入:上传 → 预览解析结果 → 选择映射方式 → 导入 */
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
const emit = defineEmits<{ imported: [] }>()
|
||||
const store = useProjectStore()
|
||||
const api = useApi()
|
||||
|
||||
const file = ref<File | null>(null)
|
||||
const preview = ref<CardPreview | null>(null)
|
||||
const parsing = ref(false)
|
||||
const importing = ref(false)
|
||||
const parseError = ref('')
|
||||
const avatarUrl = ref('')
|
||||
const opts = reactive({
|
||||
scenarioToWorld: true,
|
||||
importLore: true,
|
||||
firstMesAsSample: true,
|
||||
systemPromptToNotes: false,
|
||||
userName: '',
|
||||
identity: ''
|
||||
})
|
||||
|
||||
watch(open, (v) => {
|
||||
if (!v) return
|
||||
file.value = null
|
||||
preview.value = null
|
||||
parseError.value = ''
|
||||
opts.userName = store.project.value?.userName ?? ''
|
||||
opts.identity = ''
|
||||
})
|
||||
|
||||
watch(file, async (f) => {
|
||||
preview.value = null
|
||||
parseError.value = ''
|
||||
if (avatarUrl.value) URL.revokeObjectURL(avatarUrl.value)
|
||||
avatarUrl.value = f && f.type.startsWith('image/') ? URL.createObjectURL(f) : ''
|
||||
if (!f) return
|
||||
parsing.value = true
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('file', f)
|
||||
preview.value = await $fetch<CardPreview>(`/api/projects/${store.projectId}/cards/preview`, { method: 'POST', body: form })
|
||||
}
|
||||
catch (err) {
|
||||
parseError.value = apiErrorMessage(err, '解析失败')
|
||||
}
|
||||
finally {
|
||||
parsing.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function doImport() {
|
||||
if (!file.value) return
|
||||
importing.value = true
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('file', file.value)
|
||||
form.append('options', JSON.stringify(opts))
|
||||
const res = await api.upload<{ name: string, loreCount: number }>(`/api/projects/${store.projectId}/cards/import`, form)
|
||||
api.toast.add({ title: `已导入角色「${res.name}」`, description: res.loreCount ? `同时导入 ${res.loreCount} 条世界书条目` : undefined, color: 'success', icon: 'i-lucide-user-check' })
|
||||
open.value = false
|
||||
await store.refreshProject()
|
||||
emit('imported')
|
||||
}
|
||||
finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const preview160 = (s: string) => (s.length > 160 ? `${s.slice(0, 160)}…` : s)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal v-model:open="open" title="导入酒馆角色卡" description="支持 SillyTavern 的 PNG 角色卡(V1/V2/V3)和 JSON 文件" :ui="{ content: 'max-w-3xl' }">
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<UFileUpload
|
||||
v-model="file"
|
||||
accept="image/png,application/json,.json,.png"
|
||||
label="拖入角色卡文件,或点击选择"
|
||||
description="请使用原始 PNG 文件:截图或经聊天软件压缩的图片会丢失角色数据"
|
||||
icon="i-lucide-id-card"
|
||||
layout="list"
|
||||
class="w-full"
|
||||
:ui="{ base: 'min-h-28' }"
|
||||
/>
|
||||
|
||||
<div v-if="parsing" class="flex items-center gap-2 text-sm text-muted">
|
||||
<UIcon name="i-lucide-loader-circle" class="animate-spin" />正在解析…
|
||||
</div>
|
||||
<UAlert v-if="parseError" color="error" variant="subtle" icon="i-lucide-circle-alert" :description="parseError" />
|
||||
|
||||
<template v-if="preview">
|
||||
<div class="flex gap-4 rounded-lg border border-default p-4">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="w-24 h-32 object-cover rounded-md shrink-0" alt="">
|
||||
<div class="min-w-0 space-y-1.5 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-lg font-semibold">{{ preview.name }}</span>
|
||||
<UBadge variant="subtle" size="sm">
|
||||
{{ preview.spec.toUpperCase() }}
|
||||
</UBadge>
|
||||
<UBadge v-for="t in preview.tags.slice(0, 5)" :key="t" variant="outline" color="neutral" size="sm">
|
||||
{{ t }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<p v-if="preview.description" class="text-muted whitespace-pre-wrap">
|
||||
<b class="text-default">描述</b> {{ preview160(preview.description) }}
|
||||
</p>
|
||||
<p v-if="preview.personality" class="text-muted">
|
||||
<b class="text-default">性格</b> {{ preview160(preview.personality) }}
|
||||
</p>
|
||||
<p v-if="preview.scenario" class="text-muted">
|
||||
<b class="text-default">场景</b> {{ preview160(preview.scenario) }}
|
||||
</p>
|
||||
<p v-if="preview.firstMes" class="text-muted">
|
||||
<b class="text-default">开场白</b> {{ preview160(preview.firstMes) }}
|
||||
</p>
|
||||
<p v-if="preview.world" class="text-muted">
|
||||
<b class="text-default">关联世界书</b> {{ preview.world }}
|
||||
</p>
|
||||
<p class="text-muted">
|
||||
<b class="text-default">内置世界书</b> {{ preview.lore.length }} 条
|
||||
<template v-if="preview.lore.length">
|
||||
(常驻 {{ preview.lore.filter(l => l.constant).length }} 条)
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-2 gap-3">
|
||||
<UFormField label="角色身份" hint="可选">
|
||||
<UInput v-model="opts.identity" placeholder="例如:女主角" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="{{user}} 替换为">
|
||||
<UInput v-model="opts.userName" placeholder="主角" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
<div class="grid sm:grid-cols-2 gap-3">
|
||||
<UCheckbox v-model="opts.scenarioToWorld" label="场景(scenario)追加到故事背景" :disabled="!preview.scenario" />
|
||||
<UCheckbox v-model="opts.importLore" :label="`导入内置世界书(${preview.lore.length} 条)`" :disabled="!preview.lore.length" />
|
||||
<UCheckbox v-model="opts.firstMesAsSample" label="开场白作为文风/说话示例" :disabled="!preview.firstMes" />
|
||||
<UCheckbox v-model="opts.systemPromptToNotes" label="卡片系统提示词写入角色备注" :disabled="!preview.systemPrompt && !preview.postHistoryInstructions" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer="{ close }">
|
||||
<div class="flex justify-end gap-2 w-full">
|
||||
<UButton label="取消" color="neutral" variant="outline" @click="close" />
|
||||
<UButton label="导入" icon="i-lucide-download" :loading="importing" :disabled="!preview" @click="doImport" />
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -0,0 +1,404 @@
|
||||
<script setup lang="ts">
|
||||
import type { TabsItem } from '@nuxt/ui'
|
||||
import { CHAPTER_STATUS_COLOR, CHAPTER_STATUS_LABEL } from '#shared/types'
|
||||
|
||||
const props = defineProps<{ chapterId: string }>()
|
||||
const store = useProjectStore()
|
||||
const api = useApi()
|
||||
const { running, live, chapterRev, agents } = store
|
||||
|
||||
const chapter = ref<ChapterDetail | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
chapter.value = await api.get<ChapterDetail>(`/api/chapters/${props.chapterId}`)
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
load()
|
||||
watch(() => chapterRev[props.chapterId], () => { if (!editing.value && !editingBrief.value) load() })
|
||||
|
||||
const writers = computed(() => agents.value.filter(a => a.role === 'writer'))
|
||||
const writerItems = computed(() => writers.value.map(w => ({ label: w.name + (w.createdBy === 'editor' ? '(主编创建)' : ''), value: w.id })))
|
||||
const currentWriter = computed(() => agents.value.find(a => a.id === chapter.value?.writerAgentId))
|
||||
|
||||
const isWriting = computed(() => chapter.value?.status === 'writing')
|
||||
const liveText = computed(() => live[props.chapterId]?.text ?? '')
|
||||
const displayText = computed(() => (isWriting.value ? liveText.value : chapter.value?.content ?? ''))
|
||||
const activeVersion = computed(() => chapter.value?.versions.find(v => v.id === chapter.value?.activeVersionId))
|
||||
|
||||
/* ---------- 标题 ---------- */
|
||||
const titleDraft = ref('')
|
||||
watch(() => chapter.value?.title, v => (titleDraft.value = v ?? ''), { immediate: true })
|
||||
async function saveTitle() {
|
||||
if (!chapter.value || titleDraft.value === chapter.value.title) return
|
||||
await api.put(`/api/chapters/${props.chapterId}`, { title: titleDraft.value })
|
||||
}
|
||||
|
||||
/* ---------- 作家指派 ---------- */
|
||||
async function assignWriter(id: string) {
|
||||
await api.put(`/api/chapters/${props.chapterId}`, { writerAgentId: id, writerLocked: true })
|
||||
}
|
||||
async function unlockWriter() {
|
||||
await api.put(`/api/chapters/${props.chapterId}`, { writerLocked: false })
|
||||
}
|
||||
|
||||
/* ---------- 正文:实时滚动 ---------- */
|
||||
const textBox = ref<HTMLElement | null>(null)
|
||||
const followTail = ref(true)
|
||||
function onScroll() {
|
||||
const el = textBox.value
|
||||
if (!el) return
|
||||
followTail.value = el.scrollHeight - el.scrollTop - el.clientHeight < 80
|
||||
}
|
||||
watch(liveText, async () => {
|
||||
if (!isWriting.value || !followTail.value) return
|
||||
await nextTick()
|
||||
textBox.value?.scrollTo({ top: textBox.value.scrollHeight })
|
||||
})
|
||||
|
||||
/* ---------- 正文:手动编辑 ---------- */
|
||||
const editing = ref(false)
|
||||
const contentDraft = ref('')
|
||||
function startEdit() {
|
||||
contentDraft.value = chapter.value?.content ?? ''
|
||||
editing.value = true
|
||||
}
|
||||
async function saveContent() {
|
||||
if (!chapter.value?.activeVersionId) return
|
||||
await api.put(`/api/versions/${chapter.value.activeVersionId}`, { content: contentDraft.value })
|
||||
editing.value = false
|
||||
await load()
|
||||
api.toast.add({ title: '已保存到文件', color: 'success', icon: 'i-lucide-save' })
|
||||
}
|
||||
|
||||
/* ---------- 简报 ---------- */
|
||||
const editingBrief = ref(false)
|
||||
const briefDraft = ref('')
|
||||
function startEditBrief() {
|
||||
briefDraft.value = chapter.value?.brief || chapter.value?.synopsis || ''
|
||||
editingBrief.value = true
|
||||
}
|
||||
async function saveBrief() {
|
||||
await api.put(`/api/chapters/${props.chapterId}`, { brief: briefDraft.value })
|
||||
editingBrief.value = false
|
||||
await load()
|
||||
}
|
||||
const synopsisDraft = ref('')
|
||||
watch(() => chapter.value?.synopsis, v => (synopsisDraft.value = v ?? ''), { immediate: true })
|
||||
async function saveSynopsis() {
|
||||
if (synopsisDraft.value === chapter.value?.synopsis) return
|
||||
await api.put(`/api/chapters/${props.chapterId}`, { synopsis: synopsisDraft.value })
|
||||
}
|
||||
|
||||
/* ---------- 动作 ---------- */
|
||||
const act = (path: string, body?: unknown) => api.post(`/api/chapters/${props.chapterId}/${path}`, body)
|
||||
const genBrief = () => act('brief')
|
||||
const write = () => act('write', {})
|
||||
const approve = (next: boolean) => act('approve', { continue: next })
|
||||
const review = async () => { await act('review'); tab.value = 'review' }
|
||||
const reopen = () => act('reopen')
|
||||
|
||||
const reviseOpen = ref(false)
|
||||
const reviseForm = reactive({ feedback: '', mode: 'revise' as 'revise' | 'rewrite', writerAgentId: '' })
|
||||
function openRevise(useReview = false) {
|
||||
reviseForm.feedback = useReview ? (chapter.value?.reviewNotes ?? '') : ''
|
||||
reviseForm.mode = chapter.value?.activeVersionId ? 'revise' : 'rewrite'
|
||||
reviseForm.writerAgentId = chapter.value?.writerAgentId ?? writers.value[0]?.id ?? ''
|
||||
reviseOpen.value = true
|
||||
}
|
||||
async function submitRevise() {
|
||||
await act('write', { feedback: reviseForm.feedback, revise: reviseForm.mode === 'revise', writerAgentId: reviseForm.writerAgentId || null })
|
||||
reviseOpen.value = false
|
||||
tab.value = 'text'
|
||||
}
|
||||
|
||||
async function activate(versionId: string) {
|
||||
await act('activate', { versionId })
|
||||
tab.value = 'text'
|
||||
}
|
||||
const viewing = ref<{ version: number, content: string } | null>(null)
|
||||
async function viewVersion(id: string) {
|
||||
const v = await api.get<{ version: number, content: string }>(`/api/versions/${id}`)
|
||||
viewing.value = v
|
||||
}
|
||||
|
||||
const deleteOpen = ref(false)
|
||||
async function deleteChapter() {
|
||||
await api.del(`/api/chapters/${props.chapterId}`)
|
||||
deleteOpen.value = false
|
||||
await store.refreshChapters()
|
||||
}
|
||||
|
||||
/* ---------- 摘要 ---------- */
|
||||
const summaryDraft = ref('')
|
||||
watch(() => chapter.value?.summary, v => (summaryDraft.value = v ?? ''), { immediate: true })
|
||||
async function saveSummary() {
|
||||
await api.put(`/api/chapters/${props.chapterId}`, { summary: summaryDraft.value })
|
||||
api.toast.add({ title: '摘要已保存', color: 'success' })
|
||||
}
|
||||
|
||||
const tab = ref('text')
|
||||
const tabs = computed<TabsItem[]>(() => [
|
||||
{ label: '正文', value: 'text', icon: 'i-lucide-file-text' },
|
||||
{ label: '简报', value: 'brief', icon: 'i-lucide-clipboard-list', badge: chapter.value?.brief ? undefined : { label: '空', color: 'neutral', variant: 'subtle' } },
|
||||
{ label: `版本 (${chapter.value?.versions.length ?? 0})`, value: 'versions', icon: 'i-lucide-history' },
|
||||
{ label: '审稿与摘要', value: 'review', icon: 'i-lucide-message-square-text' }
|
||||
])
|
||||
|
||||
// 没有正文时默认显示简报
|
||||
watch(() => chapter.value?.id, () => {
|
||||
if (chapter.value && !chapter.value.activeVersionId && chapter.value.status !== 'writing') tab.value = 'brief'
|
||||
}, { immediate: true })
|
||||
watch(isWriting, (v) => { if (v) tab.value = 'text' })
|
||||
|
||||
const VERSION_STATUS: Record<string, { label: string, color: 'success' | 'warning' | 'error' | 'neutral' }> = {
|
||||
done: { label: '完成', color: 'success' },
|
||||
writing: { label: '写作中', color: 'warning' },
|
||||
error: { label: '出错', color: 'error' },
|
||||
cancelled: { label: '已取消', color: 'neutral' }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="flex justify-center py-20">
|
||||
<UIcon name="i-lucide-loader-circle" class="size-6 animate-spin text-muted" />
|
||||
</div>
|
||||
<div v-else-if="chapter" class="flex flex-col gap-4 h-full">
|
||||
<!-- 头部 -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="text-muted font-medium shrink-0">第{{ chapter.index }}章</span>
|
||||
<UInput
|
||||
v-model="titleDraft"
|
||||
variant="ghost"
|
||||
placeholder="章节标题"
|
||||
class="flex-1 min-w-48"
|
||||
:ui="{ base: 'text-lg font-semibold' }"
|
||||
@blur="saveTitle"
|
||||
@keydown.enter="($event.target as HTMLInputElement).blur()"
|
||||
/>
|
||||
<UBadge :color="CHAPTER_STATUS_COLOR[chapter.status]" variant="subtle">
|
||||
{{ CHAPTER_STATUS_LABEL[chapter.status] }}
|
||||
</UBadge>
|
||||
<div class="flex items-center gap-1">
|
||||
<USelect
|
||||
:model-value="chapter.writerAgentId ?? undefined"
|
||||
:items="writerItems"
|
||||
placeholder="由主编指派作家"
|
||||
size="sm"
|
||||
class="w-48"
|
||||
icon="i-lucide-pen-tool"
|
||||
:disabled="isWriting"
|
||||
@update:model-value="assignWriter"
|
||||
/>
|
||||
<UTooltip v-if="chapter.writerLocked" text="已锁定作家(主编不会更换)。点击交给主编决定">
|
||||
<UButton icon="i-lucide-lock" size="sm" color="neutral" variant="ghost" @click="unlockWriter" />
|
||||
</UTooltip>
|
||||
</div>
|
||||
<UDropdownMenu :items="[[{ label: '删除本章', icon: 'i-lucide-trash-2', color: 'error', onSelect: () => (deleteOpen = true) }]]">
|
||||
<UButton icon="i-lucide-ellipsis-vertical" size="sm" color="neutral" variant="ghost" />
|
||||
</UDropdownMenu>
|
||||
</div>
|
||||
|
||||
<!-- 动作条 -->
|
||||
<div class="flex flex-wrap items-center gap-2 rounded-lg bg-elevated/50 border border-default px-3 py-2">
|
||||
<template v-if="isWriting">
|
||||
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin text-warning" />
|
||||
<span class="text-sm">
|
||||
{{ currentWriter?.name || '作家' }} 正在写作,已写 {{ liveText.replace(/\s/g, '').length }} 字,同步写入
|
||||
<code class="text-xs">{{ chapter.versions[0]?.filePath }}</code>
|
||||
</span>
|
||||
<UButton class="ms-auto" icon="i-lucide-square" label="停止" size="sm" color="error" variant="soft" @click="store.stop()" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="chapter.status === 'written'">
|
||||
<span class="text-sm text-muted me-auto">本章已写完,请阅读后确认。确认后会生成摘要、更新故事记忆。</span>
|
||||
<UButton icon="i-lucide-check-check" label="确认并继续下一章" size="sm" :disabled="running" @click="approve(true)" />
|
||||
<UButton icon="i-lucide-check" label="仅确认" size="sm" variant="soft" :disabled="running" @click="approve(false)" />
|
||||
<UButton icon="i-lucide-scan-search" label="主编审稿" size="sm" color="neutral" variant="outline" :disabled="running" @click="review" />
|
||||
<UButton icon="i-lucide-wand-sparkles" label="修改 / 重写" size="sm" color="neutral" variant="outline" :disabled="running" @click="openRevise()" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="chapter.status === 'approved'">
|
||||
<UIcon name="i-lucide-circle-check" class="size-4 text-success" />
|
||||
<span class="text-sm text-muted me-auto">本章已确认</span>
|
||||
<UButton icon="i-lucide-wand-sparkles" label="修改 / 重写" size="sm" color="neutral" variant="outline" :disabled="running" @click="openRevise()" />
|
||||
<UButton icon="i-lucide-undo-2" label="撤销确认" size="sm" color="neutral" variant="ghost" :disabled="running" @click="reopen" />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<span class="text-sm text-muted me-auto">
|
||||
{{ chapter.brief ? '简报已就绪,可以开始写作,也可以先修改简报。' : '还没有简报:可以让主编生成,也可以手动填写。' }}
|
||||
</span>
|
||||
<UButton icon="i-lucide-sparkles" :label="chapter.brief ? '主编重写简报' : '主编生成简报'" size="sm" :variant="chapter.brief ? 'soft' : 'solid'" :disabled="running" @click="genBrief" />
|
||||
<UButton icon="i-lucide-pen-line" label="开始写作" size="sm" :variant="chapter.brief ? 'solid' : 'soft'" :disabled="running || (!chapter.brief && !chapter.synopsis)" @click="write" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<UTabs v-model="tab" :items="tabs" :content="false" variant="link" class="w-full" />
|
||||
|
||||
<!-- 正文 -->
|
||||
<div v-if="tab === 'text'" class="flex-1 min-h-0 flex flex-col gap-2">
|
||||
<div v-if="activeVersion && !isWriting" class="flex items-center gap-2 text-xs text-muted">
|
||||
<span>v{{ activeVersion.version }} · {{ activeVersion.writerName }} · {{ activeVersion.modelLabel }} · {{ activeVersion.wordCount }} 字</span>
|
||||
<code class="truncate">{{ activeVersion.filePath }}</code>
|
||||
<div class="ms-auto flex gap-1">
|
||||
<template v-if="editing">
|
||||
<UButton label="取消" size="xs" color="neutral" variant="ghost" @click="editing = false" />
|
||||
<UButton label="保存" icon="i-lucide-save" size="xs" @click="saveContent" />
|
||||
</template>
|
||||
<UButton v-else label="编辑正文" icon="i-lucide-pencil" size="xs" color="neutral" variant="outline" :disabled="running" @click="startEdit" />
|
||||
</div>
|
||||
</div>
|
||||
<UTextarea v-if="editing" v-model="contentDraft" autoresize :rows="20" :maxrows="0" class="w-full" :ui="{ base: 'novel-text' }" />
|
||||
<div
|
||||
v-else-if="displayText"
|
||||
ref="textBox"
|
||||
class="overflow-y-auto rounded-lg border border-default bg-default px-8 py-6 max-h-[calc(100vh-18rem)]"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<NovelText :text="displayText" :writing="isWriting" class="max-w-3xl mx-auto" />
|
||||
</div>
|
||||
<div v-else class="text-center text-muted py-16 text-sm">
|
||||
{{ isWriting ? '等待模型输出…' : '还没有正文' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 简报 -->
|
||||
<div v-else-if="tab === 'brief'" class="space-y-4">
|
||||
<UFormField label="章节梗概" hint="来自大纲规划,可修改">
|
||||
<UTextarea v-model="synopsisDraft" autoresize :rows="2" class="w-full" @blur="saveSynopsis" />
|
||||
</UFormField>
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-medium">
|
||||
章节简报
|
||||
</h3>
|
||||
<span class="text-xs text-muted">主编写给作家的执行指令,作家会严格按此写作</span>
|
||||
<div class="ms-auto flex gap-1">
|
||||
<template v-if="editingBrief">
|
||||
<UButton label="取消" size="xs" color="neutral" variant="ghost" @click="editingBrief = false" />
|
||||
<UButton label="保存简报" icon="i-lucide-save" size="xs" @click="saveBrief" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<UButton label="编辑" icon="i-lucide-pencil" size="xs" color="neutral" variant="outline" :disabled="isWriting" @click="startEditBrief" />
|
||||
<UButton :label="chapter.brief ? '主编重写' : '主编生成'" icon="i-lucide-sparkles" size="xs" variant="soft" :disabled="running" @click="genBrief" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<UTextarea v-if="editingBrief" v-model="briefDraft" autoresize :rows="14" :maxrows="0" class="w-full font-mono text-sm" placeholder="支持 Markdown。写清楚本章目标、情节要点、出场人物、场景、基调、结尾钩子等。" />
|
||||
<div v-else-if="chapter.brief" class="prose-brief text-sm rounded-lg border border-default px-5 py-4" v-html="renderMarkdown(chapter.brief)" />
|
||||
<div v-else class="text-sm text-muted rounded-lg border border-dashed border-default px-5 py-8 text-center">
|
||||
暂无简报
|
||||
</div>
|
||||
<UAlert v-if="chapter.editorNotes" icon="i-lucide-message-circle" color="neutral" variant="subtle" title="主编说明" :description="chapter.editorNotes" :ui="{ description: 'whitespace-pre-wrap' }" />
|
||||
</div>
|
||||
|
||||
<!-- 版本 -->
|
||||
<div v-else-if="tab === 'versions'" class="space-y-2">
|
||||
<div v-if="!chapter.versions.length" class="text-sm text-muted py-8 text-center">
|
||||
还没有版本
|
||||
</div>
|
||||
<div v-for="v in chapter.versions" :key="v.id" class="flex flex-wrap items-center gap-3 rounded-lg border border-default px-4 py-3" :class="v.id === chapter.activeVersionId ? 'ring-1 ring-primary' : ''">
|
||||
<span class="font-medium">v{{ v.version }}</span>
|
||||
<UBadge :color="VERSION_STATUS[v.status]?.color" variant="subtle" size="sm">
|
||||
{{ VERSION_STATUS[v.status]?.label }}
|
||||
</UBadge>
|
||||
<UBadge v-if="v.id === chapter.activeVersionId" color="primary" variant="soft" size="sm">
|
||||
当前
|
||||
</UBadge>
|
||||
<span class="text-sm">{{ v.writerName }}</span>
|
||||
<span class="text-xs text-muted">{{ v.modelLabel }} · {{ v.wordCount }} 字 · {{ formatTime(v.createdAt) }}</span>
|
||||
<div class="ms-auto flex gap-1">
|
||||
<UButton label="查看" size="xs" color="neutral" variant="ghost" @click="viewVersion(v.id)" />
|
||||
<UButton v-if="v.id !== chapter.activeVersionId && v.status !== 'writing'" label="设为当前" size="xs" variant="soft" :disabled="running" @click="activate(v.id)" />
|
||||
</div>
|
||||
<p v-if="v.feedback" class="w-full text-xs text-muted whitespace-pre-wrap line-clamp-3">
|
||||
修改意见:{{ v.feedback }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 审稿与摘要 -->
|
||||
<div v-else class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-medium">
|
||||
主编审稿意见
|
||||
</h3>
|
||||
<div class="ms-auto flex gap-1">
|
||||
<UButton v-if="chapter.reviewNotes" label="按审稿意见修改" icon="i-lucide-wand-sparkles" size="xs" variant="soft" :disabled="running" @click="openRevise(true)" />
|
||||
<UButton label="主编审稿" icon="i-lucide-scan-search" size="xs" color="neutral" variant="outline" :disabled="running || !chapter.activeVersionId" @click="review" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="chapter.reviewNotes" class="prose-brief text-sm rounded-lg border border-default px-5 py-4" v-html="renderMarkdown(chapter.reviewNotes)" />
|
||||
<p v-else class="text-sm text-muted">
|
||||
暂无审稿意见
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-medium">
|
||||
本章摘要
|
||||
</h3>
|
||||
<span class="text-xs text-muted">确认章节时自动生成;主编和后续作家只会看到摘要而不是全文</span>
|
||||
<UButton class="ms-auto" label="保存" icon="i-lucide-save" size="xs" variant="soft" :disabled="summaryDraft === chapter.summary" @click="saveSummary" />
|
||||
</div>
|
||||
<UTextarea v-model="summaryDraft" autoresize :rows="6" class="w-full" placeholder="确认章节后自动生成" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 修改 / 重写 -->
|
||||
<UModal v-model:open="reviseOpen" title="修改 / 重写本章" :ui="{ content: 'max-w-2xl' }">
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<UFormField label="方式">
|
||||
<URadioGroup
|
||||
v-model="reviseForm.mode"
|
||||
orientation="horizontal"
|
||||
:items="[
|
||||
{ label: '在当前版本上修改', value: 'revise', disabled: !chapter?.activeVersionId },
|
||||
{ label: '按简报重新写一版', value: 'rewrite' }
|
||||
]"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="执笔作家">
|
||||
<USelect v-model="reviseForm.writerAgentId" :items="writerItems" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="修改意见" :hint="reviseForm.mode === 'revise' ? '必填' : '可选'">
|
||||
<UTextarea v-model="reviseForm.feedback" autoresize :rows="6" :maxrows="16" class="w-full" placeholder="例如:节奏太快,第二场对话要更有张力;结尾不要点破悬念……" />
|
||||
</UFormField>
|
||||
<p class="text-xs text-muted">
|
||||
新内容会保存为新版本,旧版本仍可在「版本」中查看和恢复。
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer="{ close }">
|
||||
<div class="flex justify-end gap-2 w-full">
|
||||
<UButton label="取消" color="neutral" variant="outline" @click="close" />
|
||||
<UButton label="开始" icon="i-lucide-play" :disabled="running || (reviseForm.mode === 'revise' && !reviseForm.feedback.trim())" @click="submitRevise" />
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<USlideover :open="!!viewing" :title="viewing ? `版本 v${viewing.version}` : ''" :ui="{ content: 'max-w-3xl' }" @update:open="v => !v && (viewing = null)">
|
||||
<template #body>
|
||||
<NovelText :text="viewing?.content ?? ''" />
|
||||
</template>
|
||||
</USlideover>
|
||||
|
||||
<UModal v-model:open="deleteOpen" title="删除章节">
|
||||
<template #body>
|
||||
<p>确定删除第{{ chapter?.index }}章吗?之后的章节序号会前移。已生成的文件会保留在磁盘上。</p>
|
||||
</template>
|
||||
<template #footer="{ close }">
|
||||
<div class="flex justify-end gap-2 w-full">
|
||||
<UButton label="取消" color="neutral" variant="outline" @click="close" />
|
||||
<UButton label="删除" color="error" @click="deleteChapter" />
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
/** 小说正文排版:按段落渲染,首行缩进 */
|
||||
const props = defineProps<{ text: string, writing?: boolean }>()
|
||||
const paragraphs = computed(() => props.text.split(/\n+/).map(s => s.trim()).filter(Boolean))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="novel-body">
|
||||
<p v-for="(p, i) in paragraphs" :key="i">
|
||||
{{ p }}<span v-if="writing && i === paragraphs.length - 1" class="novel-cursor" />
|
||||
</p>
|
||||
<p v-if="writing && !paragraphs.length">
|
||||
<span class="novel-cursor" />
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
/** 流水线控制:继续写作 / 停止 / 模式切换 */
|
||||
const store = useProjectStore()
|
||||
const { project, running, runningLabel, connected } = store
|
||||
|
||||
const settings = computed(() => project.value!.settings)
|
||||
const pendingChapter = computed(() => store.chapters.value.find(c => c.status === 'written'))
|
||||
|
||||
async function toggleAuto(v: boolean) {
|
||||
await store.saveProject({ settings: { ...settings.value, autoMode: v } })
|
||||
}
|
||||
|
||||
const starting = ref(false)
|
||||
async function run() {
|
||||
starting.value = true
|
||||
try { await store.run() }
|
||||
finally { starting.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<UTooltip :text="connected ? '实时连接正常' : '实时连接断开,正在重连…'">
|
||||
<span class="size-2 rounded-full" :class="connected ? 'bg-success' : 'bg-error animate-pulse'" />
|
||||
</UTooltip>
|
||||
<USwitch
|
||||
:model-value="settings.autoMode"
|
||||
label="自动模式"
|
||||
:disabled="running"
|
||||
size="sm"
|
||||
@update:model-value="toggleAuto"
|
||||
/>
|
||||
<template v-if="running">
|
||||
<UBadge color="warning" variant="subtle" icon="i-lucide-loader-circle" :ui="{ leadingIcon: 'animate-spin' }" class="max-w-64 truncate">
|
||||
{{ runningLabel || '运行中' }}
|
||||
</UBadge>
|
||||
<UButton icon="i-lucide-square" label="停止" color="error" variant="soft" size="sm" @click="store.stop()" />
|
||||
</template>
|
||||
<UTooltip v-else :text="pendingChapter ? `第${pendingChapter.index}章待确认,请先在章节中确认` : (settings.autoMode ? `自动连续写作,最多 ${settings.autoChapterLimit} 章` : '主编规划/写简报 → 作家写下一章,写完后暂停等你确认')">
|
||||
<UButton
|
||||
icon="i-lucide-play"
|
||||
:label="settings.autoMode ? '开始自动写作' : '继续下一章'"
|
||||
size="sm"
|
||||
:loading="starting"
|
||||
:disabled="!!pendingChapter && !settings.autoMode"
|
||||
@click="run"
|
||||
/>
|
||||
</UTooltip>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import type { NavigationMenuItem } from '@nuxt/ui'
|
||||
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => (route.params.id as string | undefined) ?? null)
|
||||
|
||||
const { data: projectTitle } = useAsyncData(
|
||||
'layout-project-title',
|
||||
async () => (projectId.value ? (await $fetch<ProjectDetail>(`/api/projects/${projectId.value}`)).title : ''),
|
||||
{ watch: [projectId], default: () => '' }
|
||||
)
|
||||
|
||||
const mainItems = computed<NavigationMenuItem[]>(() => [
|
||||
{ label: '我的作品', icon: 'i-lucide-library', to: '/', exact: true },
|
||||
{ label: '模型与 API', icon: 'i-lucide-plug-zap', to: '/settings' }
|
||||
])
|
||||
|
||||
const projectItems = computed<NavigationMenuItem[]>(() => {
|
||||
if (!projectId.value) return []
|
||||
const base = `/projects/${projectId.value}`
|
||||
return [
|
||||
{ label: '写作台', icon: 'i-lucide-pen-line', to: base, exact: true },
|
||||
{ label: '故事设定', icon: 'i-lucide-book-open', to: `${base}/setting` },
|
||||
{ label: '角色与世界书', icon: 'i-lucide-users', to: `${base}/characters` },
|
||||
{ label: 'AI 团队', icon: 'i-lucide-bot', to: `${base}/team` },
|
||||
{ label: '故事记忆', icon: 'i-lucide-brain', to: `${base}/memory` },
|
||||
{ label: '任务日志', icon: 'i-lucide-list-checks', to: `${base}/jobs` }
|
||||
]
|
||||
})
|
||||
|
||||
const colorMode = useColorMode()
|
||||
const isDark = computed({
|
||||
get: () => colorMode.value === 'dark',
|
||||
set: v => (colorMode.preference = v ? 'dark' : 'light')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardGroup unit="rem">
|
||||
<UDashboardSidebar collapsible resizable :default-size="15" :ui="{ footer: 'border-t border-default' }">
|
||||
<template #header="{ collapsed }">
|
||||
<NuxtLink to="/" class="flex items-center gap-2 font-semibold">
|
||||
<UIcon name="i-lucide-feather" class="size-5 text-primary shrink-0" />
|
||||
<span v-if="!collapsed" class="truncate">AI 小说工坊</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<template #default="{ collapsed }">
|
||||
<UNavigationMenu :collapsed="collapsed" :items="mainItems" orientation="vertical" />
|
||||
|
||||
<template v-if="projectId">
|
||||
<USeparator class="my-1" />
|
||||
<div v-if="!collapsed" class="px-2.5 text-xs text-muted truncate" :title="projectTitle">
|
||||
{{ projectTitle || '加载中…' }}
|
||||
</div>
|
||||
<UNavigationMenu :collapsed="collapsed" :items="projectItems" orientation="vertical" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #footer="{ collapsed }">
|
||||
<UButton
|
||||
:icon="isDark ? 'i-lucide-moon' : 'i-lucide-sun'"
|
||||
:label="collapsed ? undefined : (isDark ? '深色模式' : '浅色模式')"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
:block="collapsed"
|
||||
@click="isDark = !isDark"
|
||||
/>
|
||||
</template>
|
||||
</UDashboardSidebar>
|
||||
|
||||
<slot />
|
||||
</UDashboardGroup>
|
||||
</template>
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
const api = useApi()
|
||||
const { data: projects, refresh } = await useAsyncData('projects', () => api.get<ProjectListItem[]>('/api/projects'), { default: () => [] })
|
||||
const { data: models } = await useAsyncData('models-count', () => api.get<ModelItem[]>('/api/models'), { default: () => [] })
|
||||
|
||||
const createOpen = ref(false)
|
||||
const form = reactive({ title: '', description: '' })
|
||||
const creating = ref(false)
|
||||
|
||||
async function create() {
|
||||
if (!form.title.trim()) return
|
||||
creating.value = true
|
||||
try {
|
||||
const { id } = await api.post<{ id: string }>('/api/projects', form)
|
||||
createOpen.value = false
|
||||
form.title = ''
|
||||
form.description = ''
|
||||
await navigateTo(`/projects/${id}/setting`)
|
||||
}
|
||||
finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleting = ref<ProjectListItem | null>(null)
|
||||
async function confirmDelete() {
|
||||
if (!deleting.value) return
|
||||
await api.del(`/api/projects/${deleting.value.id}`)
|
||||
deleting.value = null
|
||||
await refresh()
|
||||
}
|
||||
|
||||
function formatTime(t: string | number | Date) {
|
||||
return new Date(t).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardPanel id="projects">
|
||||
<template #header>
|
||||
<UDashboardNavbar title="我的作品">
|
||||
<template #right>
|
||||
<UButton icon="i-lucide-plus" label="新建作品" @click="createOpen = true" />
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<UAlert
|
||||
v-if="!models.length"
|
||||
color="warning"
|
||||
variant="subtle"
|
||||
icon="i-lucide-plug-zap"
|
||||
title="还没有配置模型"
|
||||
description="先去「模型与 API」添加一个 API 提供方和至少一个模型,主编和作家才能开始工作。"
|
||||
:actions="[{ label: '去配置', to: '/settings', color: 'warning', variant: 'solid' }]"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<div v-if="!projects.length" class="flex flex-col items-center justify-center py-24 text-center gap-3">
|
||||
<UIcon name="i-lucide-book-plus" class="size-12 text-muted" />
|
||||
<p class="text-muted">还没有作品,开始创作你的第一部小说吧</p>
|
||||
<UButton icon="i-lucide-plus" label="新建作品" @click="createOpen = true" />
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<UCard v-for="p in projects" :key="p.id" class="hover:ring-primary/50 transition" :ui="{ body: 'space-y-3' }">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<NuxtLink :to="`/projects/${p.id}`" class="font-semibold text-lg hover:text-primary line-clamp-1">
|
||||
{{ p.title }}
|
||||
</NuxtLink>
|
||||
<UDropdownMenu :items="[[{ label: '删除作品', icon: 'i-lucide-trash-2', color: 'error', onSelect: () => (deleting = p) }]]">
|
||||
<UButton icon="i-lucide-ellipsis" color="neutral" variant="ghost" size="sm" />
|
||||
</UDropdownMenu>
|
||||
</div>
|
||||
<p class="text-sm text-muted line-clamp-2 min-h-10">
|
||||
{{ p.description || '暂无简介' }}
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<UBadge variant="subtle" color="neutral">
|
||||
{{ p.stats.approved }}/{{ p.stats.total }} 章
|
||||
</UBadge>
|
||||
<UBadge variant="subtle" color="neutral">
|
||||
{{ (p.stats.words / 10000).toFixed(1) }} 万字
|
||||
</UBadge>
|
||||
<UBadge v-if="p.running" variant="subtle" color="warning" icon="i-lucide-loader-circle">
|
||||
{{ p.running.label }}
|
||||
</UBadge>
|
||||
<span class="ms-auto text-muted">{{ formatTime(p.updatedAt) }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<UButton :to="`/projects/${p.id}`" label="写作台" icon="i-lucide-pen-line" size="sm" />
|
||||
<UButton :to="`/projects/${p.id}/setting`" label="设定" icon="i-lucide-book-open" size="sm" color="neutral" variant="outline" />
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
|
||||
<UModal v-model:open="createOpen" title="新建作品">
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<UFormField label="作品名" required>
|
||||
<UInput v-model="form.title" placeholder="例如:雨季来临之前" class="w-full" autofocus @keydown.enter="create" />
|
||||
</UFormField>
|
||||
<UFormField label="简介" hint="可选">
|
||||
<UTextarea v-model="form.description" :rows="3" autoresize class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer="{ close }">
|
||||
<div class="flex justify-end gap-2 w-full">
|
||||
<UButton label="取消" color="neutral" variant="outline" @click="close" />
|
||||
<UButton label="创建" :loading="creating" :disabled="!form.title.trim()" @click="create" />
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<UModal :open="!!deleting" title="删除作品" @update:open="v => !v && (deleting = null)">
|
||||
<template #body>
|
||||
<p>确定要删除《{{ deleting?.title }}》吗?所有章节、设定和生成的文件都会被永久删除,无法恢复。</p>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2 w-full">
|
||||
<UButton label="取消" color="neutral" variant="outline" @click="deleting = null" />
|
||||
<UButton label="永久删除" color="error" @click="confirmDelete" />
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const projectId = route.params.id as string
|
||||
const store = createProjectStore(projectId)
|
||||
provide(projectStoreKey, store)
|
||||
|
||||
const error = ref('')
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await store.load()
|
||||
store.connect()
|
||||
}
|
||||
catch (e) {
|
||||
error.value = apiErrorMessage(e, '加载失败')
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => store.disconnect())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardPanel v-if="error" id="project-error">
|
||||
<template #body>
|
||||
<div class="flex flex-col items-center py-24 gap-3">
|
||||
<UIcon name="i-lucide-circle-alert" class="size-10 text-error" />
|
||||
<p>{{ error }}</p>
|
||||
<UButton to="/" label="返回作品列表" />
|
||||
</div>
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
<div v-else-if="!store.project.value" class="flex flex-1 items-center justify-center">
|
||||
<UIcon name="i-lucide-loader-circle" class="size-8 animate-spin text-muted" />
|
||||
</div>
|
||||
<NuxtPage v-else />
|
||||
</template>
|
||||
@@ -0,0 +1,230 @@
|
||||
<script setup lang="ts">
|
||||
const store = useProjectStore()
|
||||
const api = useApi()
|
||||
const pid = store.projectId
|
||||
|
||||
const { data: characters, refresh: refreshChars } = await useAsyncData(`chars-${pid}`, () => api.get<CharacterItem[]>(`/api/projects/${pid}/characters`), { default: () => [] })
|
||||
const { data: lore, refresh: refreshLore } = await useAsyncData(`lore-${pid}`, () => api.get<LoreItem[]>(`/api/projects/${pid}/lore`), { default: () => [] })
|
||||
|
||||
const importOpen = ref(false)
|
||||
async function onImported() {
|
||||
await Promise.all([refreshChars(), refreshLore()])
|
||||
}
|
||||
|
||||
/* ---------- 角色编辑 ---------- */
|
||||
const charOpen = ref(false)
|
||||
const charForm = reactive({ id: '', name: '', identity: '', description: '', personality: '', speechExamples: '', notes: '', avatar: '' })
|
||||
function openChar(c?: CharacterItem) {
|
||||
Object.assign(charForm, c
|
||||
? { id: c.id, name: c.name, identity: c.identity, description: c.description, personality: c.personality, speechExamples: c.speechExamples, notes: c.notes, avatar: c.avatar }
|
||||
: { id: '', name: '', identity: '', description: '', personality: '', speechExamples: '', notes: '', avatar: '' })
|
||||
charOpen.value = true
|
||||
}
|
||||
async function saveChar() {
|
||||
const { id, avatar: _a, ...body } = charForm
|
||||
if (id) await api.put(`/api/characters/${id}`, body)
|
||||
else await api.post(`/api/projects/${pid}/characters`, { ...body, sortOrder: characters.value.length })
|
||||
charOpen.value = false
|
||||
await refreshChars()
|
||||
}
|
||||
async function deleteChar() {
|
||||
if (!charForm.id || !window.confirm(`删除角色「${charForm.name}」?`)) return
|
||||
await api.del(`/api/characters/${charForm.id}`)
|
||||
charOpen.value = false
|
||||
await refreshChars()
|
||||
}
|
||||
const avatarSrc = (c: { avatar: string }) => (c.avatar ? `/api/avatars/${pid}/${c.avatar}` : undefined)
|
||||
|
||||
/* ---------- 世界书 ---------- */
|
||||
const loreOpen = ref(false)
|
||||
const loreForm = reactive({ id: '', title: '', keysText: '', content: '', constant: false, enabled: true })
|
||||
function openLore(l?: LoreItem) {
|
||||
Object.assign(loreForm, l
|
||||
? { id: l.id, title: l.title, keysText: l.keys.join(', '), content: l.content, constant: l.constant, enabled: l.enabled }
|
||||
: { id: '', title: '', keysText: '', content: '', constant: false, enabled: true })
|
||||
loreOpen.value = true
|
||||
}
|
||||
async function saveLore() {
|
||||
const body = {
|
||||
title: loreForm.title,
|
||||
keys: loreForm.keysText.split(/[,,\n]/).map(s => s.trim()).filter(Boolean),
|
||||
content: loreForm.content,
|
||||
constant: loreForm.constant,
|
||||
enabled: loreForm.enabled
|
||||
}
|
||||
if (loreForm.id) await api.put(`/api/lore/${loreForm.id}`, body)
|
||||
else await api.post(`/api/projects/${pid}/lore`, body)
|
||||
loreOpen.value = false
|
||||
await refreshLore()
|
||||
}
|
||||
async function toggleLore(l: LoreItem, enabled: boolean) {
|
||||
await api.put(`/api/lore/${l.id}`, { enabled })
|
||||
l.enabled = enabled
|
||||
}
|
||||
async function deleteLore(l: LoreItem) {
|
||||
if (!window.confirm(`删除世界书条目「${l.title}」?`)) return
|
||||
await api.del(`/api/lore/${l.id}`)
|
||||
await refreshLore()
|
||||
}
|
||||
|
||||
const tab = ref('characters')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardPanel id="project-characters">
|
||||
<template #header>
|
||||
<UDashboardNavbar title="角色与世界书">
|
||||
<template #right>
|
||||
<UButton icon="i-lucide-id-card" label="导入角色卡" variant="soft" @click="importOpen = true" />
|
||||
<UButton v-if="tab === 'characters'" icon="i-lucide-user-plus" label="新建角色" @click="openChar()" />
|
||||
<UButton v-else icon="i-lucide-plus" label="新建条目" @click="openLore()" />
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<UTabs
|
||||
v-model="tab"
|
||||
:items="[
|
||||
{ label: `角色 (${characters.length})`, value: 'characters', icon: 'i-lucide-users' },
|
||||
{ label: `世界书 (${lore.length})`, value: 'lore', icon: 'i-lucide-scroll-text' }
|
||||
]"
|
||||
:content="false"
|
||||
variant="link"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<template v-if="tab === 'characters'">
|
||||
<div v-if="!characters.length" class="flex flex-col items-center py-20 gap-3 text-center">
|
||||
<UIcon name="i-lucide-users" class="size-12 text-muted" />
|
||||
<p class="text-muted">
|
||||
还没有角色。可以手动新建,也可以直接导入酒馆角色卡(PNG/JSON)。
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<UButton icon="i-lucide-id-card" label="导入角色卡" variant="soft" @click="importOpen = true" />
|
||||
<UButton icon="i-lucide-user-plus" label="新建角色" @click="openChar()" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
|
||||
<UCard v-for="c in characters" :key="c.id" class="cursor-pointer hover:ring-primary/50 transition" @click="openChar(c)">
|
||||
<div class="flex gap-4">
|
||||
<UAvatar :src="avatarSrc(c)" :alt="c.name" size="3xl" class="shrink-0" />
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold">{{ c.name }}</span>
|
||||
<UBadge v-if="c.identity" variant="subtle" size="sm">
|
||||
{{ c.identity }}
|
||||
</UBadge>
|
||||
<UBadge v-if="c.sourceCard" variant="outline" color="neutral" size="sm">
|
||||
角色卡
|
||||
</UBadge>
|
||||
</div>
|
||||
<p class="text-sm text-muted line-clamp-4 whitespace-pre-wrap">
|
||||
{{ c.description || c.personality || '暂无描述' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="text-sm text-muted mb-3">
|
||||
常驻条目每次都会提供给 AI;其他条目只有当章节简报、梗概或大纲中出现关键词时才会注入。关键词支持 <code>/正则/</code> 写法。
|
||||
</p>
|
||||
<div v-if="!lore.length" class="text-center text-muted py-16">
|
||||
暂无世界书条目
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div v-for="l in lore" :key="l.id" class="rounded-lg border border-default px-4 py-3 flex gap-3 items-start" :class="l.enabled ? '' : 'opacity-50'">
|
||||
<USwitch :model-value="l.enabled" size="sm" class="mt-0.5" @update:model-value="v => toggleLore(l, v)" />
|
||||
<div class="min-w-0 flex-1 cursor-pointer" @click="openLore(l)">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium">{{ l.title || '未命名条目' }}</span>
|
||||
<UBadge v-if="l.constant" color="primary" variant="subtle" size="sm">
|
||||
常驻
|
||||
</UBadge>
|
||||
<UBadge v-if="l.source === 'card'" color="neutral" variant="outline" size="sm">
|
||||
角色卡
|
||||
</UBadge>
|
||||
<UBadge v-for="k in l.keys.slice(0, 8)" :key="k" color="neutral" variant="soft" size="sm">
|
||||
{{ k }}
|
||||
</UBadge>
|
||||
<span v-if="l.keys.length > 8" class="text-xs text-muted">+{{ l.keys.length - 8 }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-muted line-clamp-2 mt-1 whitespace-pre-wrap">
|
||||
{{ l.content }}
|
||||
</p>
|
||||
</div>
|
||||
<UButton icon="i-lucide-trash-2" size="xs" color="error" variant="ghost" @click="deleteLore(l)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
|
||||
<CardImportModal v-model:open="importOpen" @imported="onImported" />
|
||||
|
||||
<USlideover v-model:open="charOpen" :title="charForm.id ? `编辑角色:${charForm.name}` : '新建角色'" :ui="{ content: 'max-w-2xl' }">
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<div class="flex gap-4 items-start">
|
||||
<UAvatar :src="avatarSrc(charForm)" :alt="charForm.name || '?'" size="3xl" />
|
||||
<div class="grid grid-cols-2 gap-3 flex-1">
|
||||
<UFormField label="姓名" required>
|
||||
<UInput v-model="charForm.name" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="身份">
|
||||
<UInput v-model="charForm.identity" placeholder="例如:女主角 / 反派" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
<UFormField label="人物设定" hint="外貌、背景、经历、人际关系等">
|
||||
<UTextarea v-model="charForm.description" autoresize :rows="8" :maxrows="24" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="性格">
|
||||
<UTextarea v-model="charForm.personality" autoresize :rows="3" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="说话风格 / 示例" hint="对白示例能显著提升人物辨识度">
|
||||
<UTextarea v-model="charForm.speechExamples" autoresize :rows="5" :maxrows="16" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="备注">
|
||||
<UTextarea v-model="charForm.notes" autoresize :rows="2" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex gap-2 w-full">
|
||||
<UButton v-if="charForm.id" label="删除" icon="i-lucide-trash-2" color="error" variant="ghost" @click="deleteChar" />
|
||||
<UButton class="ms-auto" label="取消" color="neutral" variant="outline" @click="charOpen = false" />
|
||||
<UButton label="保存" :disabled="!charForm.name.trim()" @click="saveChar" />
|
||||
</div>
|
||||
</template>
|
||||
</USlideover>
|
||||
|
||||
<UModal v-model:open="loreOpen" :title="loreForm.id ? '编辑世界书条目' : '新建世界书条目'" :ui="{ content: 'max-w-2xl' }">
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<UFormField label="标题">
|
||||
<UInput v-model="loreForm.title" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="关键词" hint="用逗号分隔;常驻条目可不填">
|
||||
<UInput v-model="loreForm.keysText" placeholder="例如:青云宗, 掌门, /剑[阵法]/" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="内容">
|
||||
<UTextarea v-model="loreForm.content" autoresize :rows="8" :maxrows="24" class="w-full" />
|
||||
</UFormField>
|
||||
<div class="flex gap-6">
|
||||
<UCheckbox v-model="loreForm.constant" label="常驻(总是注入)" />
|
||||
<UCheckbox v-model="loreForm.enabled" label="启用" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer="{ close }">
|
||||
<div class="flex justify-end gap-2 w-full">
|
||||
<UButton label="取消" color="neutral" variant="outline" @click="close" />
|
||||
<UButton label="保存" :disabled="!loreForm.content.trim()" @click="saveLore" />
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import { CHAPTER_STATUS_COLOR, CHAPTER_STATUS_LABEL } from '#shared/types'
|
||||
|
||||
const store = useProjectStore()
|
||||
const api = useApi()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { chapters, running, live } = store
|
||||
|
||||
const selectedId = computed<string | null>({
|
||||
get: () => (route.query.c as string) || chapters.value.find(c => c.status !== 'approved')?.id || chapters.value.at(-1)?.id || null,
|
||||
set: v => router.replace({ query: { ...route.query, c: v ?? undefined } })
|
||||
})
|
||||
|
||||
// 写作开始时自动切换到正在写的章节
|
||||
watch(() => chapters.value.find(c => c.status === 'writing')?.id, (id) => {
|
||||
if (id) selectedId.value = id
|
||||
})
|
||||
|
||||
const totalWords = computed(() => chapters.value.reduce((s, c) => s + c.wordCount, 0))
|
||||
const approvedCount = computed(() => chapters.value.filter(c => c.status === 'approved').length)
|
||||
|
||||
/* 规划大纲 */
|
||||
const planOpen = ref(false)
|
||||
const planForm = reactive({ count: 10, fromIndex: null as number | null })
|
||||
async function plan() {
|
||||
await api.post(`/api/projects/${store.projectId}/plan`, { count: planForm.count, ...(planForm.fromIndex ? { fromIndex: planForm.fromIndex } : {}) })
|
||||
planOpen.value = false
|
||||
}
|
||||
|
||||
/* 新增章节 */
|
||||
async function addChapter() {
|
||||
const row = await api.post<{ id: string }>(`/api/projects/${store.projectId}/chapters`, { title: '' })
|
||||
await store.refreshChapters()
|
||||
selectedId.value = row.id
|
||||
}
|
||||
|
||||
const exportUrl = (format: string, scope: string) => `/api/projects/${store.projectId}/export?format=${format}&scope=${scope}`
|
||||
const exportItems = [
|
||||
[{ label: '导出全部章节', type: 'label' as const }, { label: 'TXT', icon: 'i-lucide-file-text', to: exportUrl('txt', 'all'), external: true, target: '_blank' }, { label: 'Markdown', icon: 'i-lucide-file-code', to: exportUrl('md', 'all'), external: true, target: '_blank' }],
|
||||
[{ label: '仅导出已确认章节', type: 'label' as const }, { label: 'TXT', icon: 'i-lucide-file-text', to: exportUrl('txt', 'approved'), external: true, target: '_blank' }, { label: 'Markdown', icon: 'i-lucide-file-code', to: exportUrl('md', 'approved'), external: true, target: '_blank' }]
|
||||
]
|
||||
|
||||
function liveWords(chapterId: string) {
|
||||
const t = live[chapterId]?.text
|
||||
return t ? t.replace(/\s/g, '').length : 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardPanel id="chapter-list" :default-size="22" :min-size="16" :max-size="35" resizable>
|
||||
<template #header>
|
||||
<UDashboardNavbar title="章节">
|
||||
<template #right>
|
||||
<UTooltip text="让主编规划章节大纲">
|
||||
<UButton icon="i-lucide-list-tree" size="sm" color="neutral" variant="ghost" :disabled="running" @click="planOpen = true" />
|
||||
</UTooltip>
|
||||
<UTooltip text="手动新增章节">
|
||||
<UButton icon="i-lucide-plus" size="sm" color="neutral" variant="ghost" @click="addChapter" />
|
||||
</UTooltip>
|
||||
<UDropdownMenu :items="exportItems">
|
||||
<UButton icon="i-lucide-download" size="sm" color="neutral" variant="ghost" />
|
||||
</UDropdownMenu>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="text-xs text-muted flex gap-3 px-1 -mt-1 mb-2">
|
||||
<span>{{ approvedCount }}/{{ chapters.length }} 章已确认</span>
|
||||
<span>{{ formatWords(totalWords) }}</span>
|
||||
</div>
|
||||
<div v-if="!chapters.length" class="text-sm text-muted text-center py-10 space-y-3">
|
||||
<p>还没有章节</p>
|
||||
<UButton label="让主编规划大纲" icon="i-lucide-list-tree" size="sm" :disabled="running" @click="planOpen = true" />
|
||||
<p class="text-xs">
|
||||
或直接点击右上角「继续下一章」,主编会自动规划
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<button
|
||||
v-for="c in chapters"
|
||||
:key="c.id"
|
||||
class="text-left rounded-md px-2.5 py-2 transition border border-transparent"
|
||||
:class="c.id === selectedId ? 'bg-elevated border-default' : 'hover:bg-elevated/50'"
|
||||
@click="selectedId = c.id"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted tabular-nums w-10 shrink-0">第{{ c.index }}章</span>
|
||||
<span class="truncate text-sm font-medium flex-1">{{ c.title || '(未命名)' }}</span>
|
||||
<UIcon v-if="c.status === 'writing'" name="i-lucide-loader-circle" class="size-3.5 animate-spin text-warning" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1 ps-12">
|
||||
<UBadge :color="CHAPTER_STATUS_COLOR[c.status]" variant="subtle" size="sm">
|
||||
{{ CHAPTER_STATUS_LABEL[c.status] }}
|
||||
</UBadge>
|
||||
<span v-if="c.status === 'writing'" class="text-xs text-muted">{{ liveWords(c.id) }} 字…</span>
|
||||
<span v-else-if="c.wordCount" class="text-xs text-muted">{{ c.wordCount }} 字</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
|
||||
<UDashboardPanel id="chapter-main">
|
||||
<template #header>
|
||||
<UDashboardNavbar :title="store.project.value?.title">
|
||||
<template #right>
|
||||
<RunControls />
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
</template>
|
||||
<template #body>
|
||||
<ChapterWorkspace v-if="selectedId" :key="selectedId" :chapter-id="selectedId" />
|
||||
<div v-else class="flex flex-col items-center justify-center py-24 gap-4 text-center">
|
||||
<UIcon name="i-lucide-feather" class="size-12 text-muted" />
|
||||
<div class="space-y-1">
|
||||
<p class="font-medium">
|
||||
准备开始
|
||||
</p>
|
||||
<p class="text-sm text-muted max-w-md">
|
||||
确认已经填写「故事设定」、添加了角色,并在「AI 团队」中为主编和作家分配了模型。然后点击右上角「继续下一章」,主编会先规划章节,再为第一章写简报并交给作家。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
|
||||
<UModal v-model:open="planOpen" title="主编规划章节大纲">
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-muted">
|
||||
主编会根据故事设定、大纲和已完成章节的摘要,规划后续章节的标题和梗概。已开写的章节不会被改动。
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<UFormField label="规划章节数">
|
||||
<UInputNumber v-model="planForm.count" :min="1" :max="100" />
|
||||
</UFormField>
|
||||
<UFormField label="起始章节" hint="留空=接在已写章节之后">
|
||||
<UInputNumber v-model="planForm.fromIndex" :min="1" placeholder="自动" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer="{ close }">
|
||||
<div class="flex justify-end gap-2 w-full">
|
||||
<UButton label="取消" color="neutral" variant="outline" @click="close" />
|
||||
<UButton label="开始规划" icon="i-lucide-sparkles" @click="plan" />
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { JOB_TYPE_LABEL, type JobInfo } from '#shared/types'
|
||||
|
||||
const store = useProjectStore()
|
||||
const api = useApi()
|
||||
const { jobs, chapters } = store
|
||||
|
||||
onMounted(() => store.refreshJobs())
|
||||
|
||||
const STATUS: Record<string, { label: string, color: 'warning' | 'success' | 'error' | 'neutral', icon: string, cls: string }> = {
|
||||
running: { label: '进行中', color: 'warning', icon: 'i-lucide-loader-circle', cls: 'text-warning animate-spin' },
|
||||
done: { label: '完成', color: 'success', icon: 'i-lucide-circle-check', cls: 'text-success' },
|
||||
error: { label: '出错', color: 'error', icon: 'i-lucide-circle-x', cls: 'text-error' },
|
||||
cancelled: { label: '已取消', color: 'neutral', icon: 'i-lucide-circle-slash', cls: 'text-muted' }
|
||||
}
|
||||
|
||||
const totals = computed(() => jobs.value.reduce((s, j) => ({ input: s.input + j.inputTokens, output: s.output + j.outputTokens }), { input: 0, output: 0 }))
|
||||
|
||||
function chapterLabel(id: string | null) {
|
||||
if (!id) return ''
|
||||
const c = chapters.value.find(x => x.id === id)
|
||||
return c ? `第${c.index}章` : ''
|
||||
}
|
||||
function duration(j: JobInfo) {
|
||||
if (!j.finishedAt) return ''
|
||||
const s = Math.round((new Date(j.finishedAt).getTime() - new Date(j.createdAt).getTime()) / 1000)
|
||||
return s >= 60 ? `${Math.floor(s / 60)}分${s % 60}秒` : `${s}秒`
|
||||
}
|
||||
|
||||
const detail = ref<JobDetail | null>(null)
|
||||
async function openDetail(j: JobInfo) {
|
||||
detail.value = await api.get<JobDetail>(`/api/jobs/${j.id}`)
|
||||
}
|
||||
// 正在查看的任务有更新时刷新
|
||||
watch(jobs, async () => {
|
||||
if (!detail.value) return
|
||||
const j = jobs.value.find(x => x.id === detail.value!.id)
|
||||
if (j && (j.status !== detail.value.status || j.log !== detail.value.log)) detail.value = await api.get<JobDetail>(`/api/jobs/${j.id}`)
|
||||
}, { deep: true })
|
||||
|
||||
async function copy(text: string) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
api.toast.add({ title: '已复制', color: 'success' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardPanel id="project-jobs">
|
||||
<template #header>
|
||||
<UDashboardNavbar title="任务日志">
|
||||
<template #right>
|
||||
<span class="text-xs text-muted">最近 {{ jobs.length }} 个任务 · 输入 {{ totals.input.toLocaleString() }} / 输出 {{ totals.output.toLocaleString() }} tokens</span>
|
||||
<UButton icon="i-lucide-refresh-cw" size="sm" color="neutral" variant="ghost" @click="store.refreshJobs()" />
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="!jobs.length" class="text-center text-muted py-20">
|
||||
暂无任务
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<button
|
||||
v-for="j in jobs"
|
||||
:key="j.id"
|
||||
class="w-full text-left flex flex-wrap items-center gap-3 rounded-md border border-default px-3 py-2 hover:bg-elevated/50"
|
||||
@click="openDetail(j)"
|
||||
>
|
||||
<UIcon :name="STATUS[j.status]!.icon" class="size-4 shrink-0" :class="STATUS[j.status]!.cls" />
|
||||
<span class="text-xs text-muted tabular-nums w-28 shrink-0">{{ formatTime(j.createdAt) }}</span>
|
||||
<UBadge variant="subtle" size="sm" color="neutral">
|
||||
{{ JOB_TYPE_LABEL[j.type] }}
|
||||
</UBadge>
|
||||
<span class="text-sm">{{ chapterLabel(j.chapterId) }}</span>
|
||||
<span class="text-sm font-medium">{{ j.agentName }}</span>
|
||||
<span class="text-xs text-muted truncate max-w-64">{{ j.modelLabel }}</span>
|
||||
<span class="ms-auto text-xs text-muted tabular-nums">
|
||||
<template v-if="j.inputTokens || j.outputTokens">{{ j.inputTokens.toLocaleString() }} → {{ j.outputTokens.toLocaleString() }} tokens · </template>{{ duration(j) }}
|
||||
</span>
|
||||
<p v-if="j.error" class="w-full text-xs text-error line-clamp-2 ps-7">
|
||||
{{ j.error }}
|
||||
</p>
|
||||
<p v-else-if="j.log" class="w-full text-xs text-muted truncate ps-7">
|
||||
{{ j.log.split('\n').at(-1) }}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
|
||||
<USlideover :open="!!detail" :title="detail ? `${JOB_TYPE_LABEL[detail.type]} · ${detail.agentName}` : ''" :description="detail?.modelLabel" :ui="{ content: 'max-w-3xl' }" @update:open="v => !v && (detail = null)">
|
||||
<template #body>
|
||||
<div v-if="detail" class="space-y-5">
|
||||
<UAlert v-if="detail.error" color="error" variant="subtle" icon="i-lucide-circle-x" title="错误" :description="detail.error" :ui="{ description: 'whitespace-pre-wrap break-all' }" />
|
||||
<div>
|
||||
<h3 class="font-medium mb-1">
|
||||
日志
|
||||
</h3>
|
||||
<pre class="text-xs whitespace-pre-wrap rounded-md bg-elevated p-3">{{ detail.log || '(无)' }}</pre>
|
||||
</div>
|
||||
<template v-if="detail.prompt">
|
||||
<div>
|
||||
<div class="flex items-center mb-1">
|
||||
<h3 class="font-medium">
|
||||
系统提示词(System)
|
||||
</h3>
|
||||
<UButton icon="i-lucide-copy" size="xs" color="neutral" variant="ghost" class="ms-auto" @click="copy(detail.prompt.system)" />
|
||||
</div>
|
||||
<pre class="text-xs whitespace-pre-wrap rounded-md bg-elevated p-3 max-h-96 overflow-y-auto">{{ detail.prompt.system }}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center mb-1">
|
||||
<h3 class="font-medium">
|
||||
用户提示词(Prompt)
|
||||
</h3>
|
||||
<span class="text-xs text-muted ms-2">{{ detail.prompt.prompt.length.toLocaleString() }} 字符</span>
|
||||
<UButton icon="i-lucide-copy" size="xs" color="neutral" variant="ghost" class="ms-auto" @click="copy(detail.prompt.prompt)" />
|
||||
</div>
|
||||
<pre class="text-xs whitespace-pre-wrap rounded-md bg-elevated p-3 max-h-[32rem] overflow-y-auto">{{ detail.prompt.prompt }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</USlideover>
|
||||
</template>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import type { ProjectMemory } from '#shared/types'
|
||||
|
||||
const store = useProjectStore()
|
||||
const api = useApi()
|
||||
const { chapters } = store
|
||||
|
||||
const mem = reactive<ProjectMemory>(structuredClone(toRaw(store.project.value!.memory)))
|
||||
const snapshot = ref(JSON.stringify(mem))
|
||||
const dirty = computed(() => JSON.stringify(mem) !== snapshot.value)
|
||||
|
||||
// 确认章节后服务端会更新记忆;没有本地修改时同步最新数据
|
||||
watch(() => store.project.value?.memory, (m) => {
|
||||
if (m && !dirty.value) {
|
||||
Object.assign(mem, structuredClone(toRaw(m)))
|
||||
snapshot.value = JSON.stringify(mem)
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
async function save() {
|
||||
await store.saveProject({ memory: JSON.parse(JSON.stringify(mem)) })
|
||||
snapshot.value = JSON.stringify(mem)
|
||||
api.toast.add({ title: '故事记忆已保存', color: 'success' })
|
||||
}
|
||||
|
||||
const openForeshadowing = computed(() => mem.foreshadowing.filter(f => f.status === 'open'))
|
||||
const resolvedForeshadowing = computed(() => mem.foreshadowing.filter(f => f.status === 'resolved'))
|
||||
|
||||
function addState() { mem.characterStates.push({ name: '', state: '' }) }
|
||||
function addForeshadowing() { mem.foreshadowing.push({ text: '', status: 'open', chapter: chapters.value.filter(c => c.status === 'approved').at(-1)?.index ?? 0 }) }
|
||||
function removeItem<T>(arr: T[], item: T) {
|
||||
const i = arr.indexOf(item)
|
||||
if (i >= 0) arr.splice(i, 1)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardPanel id="project-memory">
|
||||
<template #header>
|
||||
<UDashboardNavbar title="故事记忆">
|
||||
<template #right>
|
||||
<span v-if="dirty" class="text-xs text-warning">有未保存的修改</span>
|
||||
<UButton icon="i-lucide-save" label="保存" :disabled="!dirty" @click="save" />
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="max-w-5xl mx-auto w-full space-y-8 pb-16">
|
||||
<p class="text-sm text-muted">
|
||||
每章确认后,摘要员会自动更新这里的内容。主编和作家写作时会参考这些信息来保持人物状态与情节的连贯。你可以随时手动修正。
|
||||
</p>
|
||||
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="font-semibold">
|
||||
人物当前状态
|
||||
</h2>
|
||||
<UButton icon="i-lucide-plus" size="xs" variant="soft" label="添加" class="ms-auto" @click="addState" />
|
||||
</div>
|
||||
<div v-if="!mem.characterStates.length" class="text-sm text-muted">
|
||||
暂无
|
||||
</div>
|
||||
<div v-for="(s, i) in mem.characterStates" :key="i" class="flex gap-2 items-start">
|
||||
<UInput v-model="s.name" placeholder="人物" class="w-32 shrink-0" />
|
||||
<UTextarea v-model="s.state" autoresize :rows="1" class="flex-1" />
|
||||
<UButton icon="i-lucide-x" size="sm" color="neutral" variant="ghost" @click="removeItem(mem.characterStates, s)" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="font-semibold">
|
||||
伏笔
|
||||
</h2>
|
||||
<UBadge variant="subtle" size="sm">
|
||||
未回收 {{ openForeshadowing.length }}
|
||||
</UBadge>
|
||||
<UBadge variant="subtle" color="success" size="sm">
|
||||
已回收 {{ resolvedForeshadowing.length }}
|
||||
</UBadge>
|
||||
<UButton icon="i-lucide-plus" size="xs" variant="soft" label="添加" class="ms-auto" @click="addForeshadowing" />
|
||||
</div>
|
||||
<div v-for="(f, i) in mem.foreshadowing" :key="i" class="flex gap-2 items-center">
|
||||
<UBadge :color="f.status === 'open' ? 'warning' : 'success'" variant="subtle" class="shrink-0 cursor-pointer" @click="f.status = f.status === 'open' ? 'resolved' : 'open'">
|
||||
{{ f.status === 'open' ? '未回收' : `已回收${f.resolvedChapter ? `·第${f.resolvedChapter}章` : ''}` }}
|
||||
</UBadge>
|
||||
<span class="text-xs text-muted shrink-0 w-16">第{{ f.chapter }}章</span>
|
||||
<UInput v-model="f.text" class="flex-1" />
|
||||
<UButton icon="i-lucide-x" size="sm" color="neutral" variant="ghost" @click="removeItem(mem.foreshadowing, f)" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-semibold">
|
||||
时间线
|
||||
</h2>
|
||||
<div v-if="!mem.timeline.length" class="text-sm text-muted">
|
||||
暂无
|
||||
</div>
|
||||
<ol class="relative border-s border-default ms-2 space-y-2">
|
||||
<li v-for="(t, i) in mem.timeline" :key="i" class="ms-4 flex gap-2 items-center">
|
||||
<span class="absolute -start-1.5 size-3 rounded-full bg-primary/70" />
|
||||
<span class="text-xs text-muted w-14 shrink-0">第{{ t.chapter }}章</span>
|
||||
<UInput v-model="t.event" variant="ghost" class="flex-1" />
|
||||
<UButton icon="i-lucide-x" size="xs" color="neutral" variant="ghost" @click="removeItem(mem.timeline, t)" />
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-semibold">
|
||||
章节摘要一览
|
||||
</h2>
|
||||
<div v-for="c in chapters.filter(c => c.status === 'approved')" :key="c.id" class="text-sm">
|
||||
<NuxtLink :to="`/projects/${store.projectId}?c=${c.id}`" class="font-medium hover:text-primary">
|
||||
第{{ c.index }}章 {{ c.title }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<p class="text-xs text-muted">
|
||||
点击章节可在写作台的「审稿与摘要」中查看和编辑摘要。
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
</template>
|
||||
@@ -0,0 +1,155 @@
|
||||
<script setup lang="ts">
|
||||
import type { ProjectRequirements, ProjectSettings } from '#shared/types'
|
||||
|
||||
const store = useProjectStore()
|
||||
const api = useApi()
|
||||
const p = store.project
|
||||
|
||||
const draft = reactive({
|
||||
title: p.value!.title,
|
||||
description: p.value!.description,
|
||||
worldSetting: p.value!.worldSetting,
|
||||
storyOutline: p.value!.storyOutline,
|
||||
userName: p.value!.userName,
|
||||
requirements: { ...p.value!.requirements } as ProjectRequirements,
|
||||
settings: { ...p.value!.settings } as ProjectSettings
|
||||
})
|
||||
|
||||
const snapshot = ref(JSON.stringify(draft))
|
||||
const dirty = computed(() => JSON.stringify(draft) !== snapshot.value)
|
||||
const saving = ref(false)
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await store.saveProject(JSON.parse(JSON.stringify(draft)))
|
||||
snapshot.value = JSON.stringify(draft)
|
||||
api.toast.add({ title: '设定已保存', color: 'success', icon: 'i-lucide-save' })
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+S 保存
|
||||
defineShortcuts({ meta_s: { usingInput: true, handler: () => { if (dirty.value) save() } } })
|
||||
|
||||
onBeforeRouteLeave(() => {
|
||||
if (dirty.value && !window.confirm('有未保存的修改,确定离开吗?')) return false
|
||||
})
|
||||
|
||||
const POV_OPTIONS = ['第一人称', '第三人称有限视角', '第三人称全知视角', '第二人称', '多视角轮换']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardPanel id="project-setting">
|
||||
<template #header>
|
||||
<UDashboardNavbar title="故事设定">
|
||||
<template #right>
|
||||
<span v-if="dirty" class="text-xs text-warning">有未保存的修改</span>
|
||||
<UButton icon="i-lucide-save" label="保存" :loading="saving" :disabled="!dirty" @click="save" />
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="max-w-4xl mx-auto w-full space-y-8 pb-16">
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-base font-semibold flex items-center gap-2">
|
||||
<UIcon name="i-lucide-book" class="text-primary" />基本信息
|
||||
</h2>
|
||||
<div class="grid sm:grid-cols-2 gap-4">
|
||||
<UFormField label="作品名" required>
|
||||
<UInput v-model="draft.title" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="主角名 / {{user}}" hint="导入角色卡时用于替换 {{user}}">
|
||||
<UInput v-model="draft.userName" placeholder="例如:陈默" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
<UFormField label="简介">
|
||||
<UTextarea v-model="draft.description" autoresize :rows="2" class="w-full" />
|
||||
</UFormField>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-base font-semibold flex items-center gap-2">
|
||||
<UIcon name="i-lucide-globe" class="text-primary" />故事背景 / 世界观
|
||||
</h2>
|
||||
<UTextarea
|
||||
v-model="draft.worldSetting"
|
||||
autoresize
|
||||
:rows="8"
|
||||
:maxrows="30"
|
||||
class="w-full"
|
||||
placeholder="时代、地点、社会环境、力量体系、重要组织、基调……越具体,AI 写得越贴合。导入角色卡时,卡片的场景描述会追加到这里。"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-base font-semibold flex items-center gap-2">
|
||||
<UIcon name="i-lucide-route" class="text-primary" />故事大纲
|
||||
</h2>
|
||||
<UTextarea
|
||||
v-model="draft.storyOutline"
|
||||
autoresize
|
||||
:rows="10"
|
||||
:maxrows="40"
|
||||
class="w-full"
|
||||
placeholder="整个故事的主线:起因、发展、高潮、结局,以及主要的转折点和人物弧光。主编会据此拆分章节。"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-base font-semibold flex items-center gap-2">
|
||||
<UIcon name="i-lucide-pen-tool" class="text-primary" />写作要求 <span class="text-xs font-normal text-muted">(可选)</span>
|
||||
</h2>
|
||||
<div class="grid sm:grid-cols-3 gap-4">
|
||||
<UFormField label="语言">
|
||||
<UInput v-model="draft.requirements.language" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="叙事视角">
|
||||
<UInputMenu v-model="draft.requirements.pov" :items="POV_OPTIONS" create-item class="w-full" @create="(v: string) => (draft.requirements.pov = v)" />
|
||||
</UFormField>
|
||||
<UFormField label="每章字数">
|
||||
<UInputNumber v-model="draft.requirements.wordsPerChapter" :min="500" :max="50000" :step="500" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
<UFormField label="文风">
|
||||
<UInput v-model="draft.requirements.style" placeholder="例如:细腻克制的现实主义,带一点潮湿的南方气息" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="叙述方式">
|
||||
<UTextarea v-model="draft.requirements.narration" autoresize :rows="2" placeholder="例如:过去时;对白占比约三成;多用环境烘托情绪;少用心理独白" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="其他要求">
|
||||
<UTextarea v-model="draft.requirements.extra" autoresize :rows="2" placeholder="例如:避免现代网络用语;不要出现“仿佛”“不禁”等滥用词" class="w-full" />
|
||||
</UFormField>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-base font-semibold flex items-center gap-2">
|
||||
<UIcon name="i-lucide-settings-2" class="text-primary" />写作流程
|
||||
</h2>
|
||||
<div class="grid sm:grid-cols-2 gap-4">
|
||||
<USwitch v-model="draft.settings.autoMode" label="自动模式" description="章节写完后自动确认并继续下一章;关闭时每章写完都会停下来等你确认" />
|
||||
<USwitch v-model="draft.settings.pauseAfterBrief" label="简报后暂停" description="主编写完简报后先暂停,你确认或修改简报后再开始写作(仅逐章模式)" />
|
||||
<USwitch v-model="draft.settings.allowEditorCreateWriters" label="允许主编创建作家" description="主编可以用已配置的模型创建新的作家来写特定风格的章节" />
|
||||
</div>
|
||||
<div class="grid sm:grid-cols-4 gap-4">
|
||||
<UFormField label="上一章衔接字数" hint="注入作家">
|
||||
<UInputNumber v-model="draft.settings.prevTailChars" :min="0" :max="20000" :step="500" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="详细前情章数">
|
||||
<UInputNumber v-model="draft.settings.recentSummaryCount" :min="0" :max="50" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="截断自动续写次数">
|
||||
<UInputNumber v-model="draft.settings.maxContinuations" :min="0" :max="10" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="自动模式章节上限">
|
||||
<UInputNumber v-model="draft.settings.autoChapterLimit" :min="1" :max="200" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
</template>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
const store = useProjectStore()
|
||||
const api = useApi()
|
||||
const { agents, models } = store
|
||||
|
||||
const { data: builtin } = await useAsyncData('builtin-prompts', () => api.get<{ editor: string, writer: string, summarizer: string }>('/api/prompts/builtin'), {
|
||||
default: () => ({ editor: '', writer: '', summarizer: '' })
|
||||
})
|
||||
|
||||
onMounted(() => store.refreshModels())
|
||||
|
||||
const editor = computed(() => agents.value.find(a => a.role === 'editor'))
|
||||
const summarizer = computed(() => agents.value.find(a => a.role === 'summarizer'))
|
||||
const writers = computed(() => agents.value.filter(a => a.role === 'writer'))
|
||||
|
||||
async function addWriter() {
|
||||
await api.post(`/api/projects/${store.projectId}/agents`, {
|
||||
role: 'writer',
|
||||
name: `作家${writers.value.length + 1}`,
|
||||
modelId: models.value[0]?.id ?? null
|
||||
})
|
||||
await store.refreshAgents()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardPanel id="project-team">
|
||||
<template #header>
|
||||
<UDashboardNavbar title="AI 团队">
|
||||
<template #right>
|
||||
<UButton icon="i-lucide-user-plus" label="添加作家" @click="addWriter" />
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="max-w-4xl mx-auto w-full space-y-6 pb-16">
|
||||
<UAlert
|
||||
v-if="!models.length"
|
||||
color="warning"
|
||||
variant="subtle"
|
||||
icon="i-lucide-plug-zap"
|
||||
title="还没有可用模型"
|
||||
description="请先在「模型与 API」中添加模型。"
|
||||
:actions="[{ label: '去配置', to: '/settings', color: 'warning', variant: 'solid' }]"
|
||||
/>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-semibold">
|
||||
编辑部
|
||||
</h2>
|
||||
<AgentCard v-if="editor" :agent="editor" :builtin="builtin.editor" />
|
||||
<AgentCard v-if="summarizer" :agent="summarizer" :builtin="builtin.summarizer" />
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="font-semibold">
|
||||
作家团队
|
||||
</h2>
|
||||
<span class="text-xs text-muted">主编会根据「擅长风格」为每章选派作家;你也可以在写作台为某章手动指定作家</span>
|
||||
</div>
|
||||
<AgentCard v-for="w in writers" :key="w.id" :agent="w" :builtin="builtin.writer" />
|
||||
<div v-if="!writers.length" class="text-sm text-muted text-center py-8">
|
||||
还没有作家
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
</template>
|
||||
@@ -0,0 +1,310 @@
|
||||
<script setup lang="ts">
|
||||
import type { ProviderType } from '#shared/types'
|
||||
|
||||
const api = useApi()
|
||||
const { data: providers, refresh: refreshProviders } = await useAsyncData('providers', () => api.get<ProviderItem[]>('/api/providers'), { default: () => [] })
|
||||
const { data: models, refresh: refreshModels } = await useAsyncData('models', () => api.get<ModelItem[]>('/api/models'), { default: () => [] })
|
||||
|
||||
const TYPE_LABEL: Record<ProviderType, string> = {
|
||||
'openai-compatible': 'OpenAI 兼容',
|
||||
'anthropic': 'Anthropic (Claude)',
|
||||
'google': 'Google (Gemini)'
|
||||
}
|
||||
const typeItems = Object.entries(TYPE_LABEL).map(([value, label]) => ({ value, label }))
|
||||
|
||||
const PRESETS: { label: string, name: string, type: ProviderType, baseUrl: string }[] = [
|
||||
{ label: 'Anthropic Claude', name: 'Claude', type: 'anthropic', baseUrl: '' },
|
||||
{ label: 'Google Gemini', name: 'Gemini', type: 'google', baseUrl: '' },
|
||||
{ label: 'OpenAI', name: 'OpenAI', type: 'openai-compatible', baseUrl: 'https://api.openai.com/v1' },
|
||||
{ label: 'DeepSeek', name: 'DeepSeek', type: 'openai-compatible', baseUrl: 'https://api.deepseek.com/v1' },
|
||||
{ label: 'OpenRouter', name: 'OpenRouter', type: 'openai-compatible', baseUrl: 'https://openrouter.ai/api/v1' },
|
||||
{ label: '硅基流动', name: '硅基流动', type: 'openai-compatible', baseUrl: 'https://api.siliconflow.cn/v1' },
|
||||
{ label: 'Ollama(本地)', name: 'Ollama', type: 'openai-compatible', baseUrl: 'http://localhost:11434/v1' }
|
||||
]
|
||||
|
||||
const DEFAULT_URL: Record<ProviderType, string> = {
|
||||
'openai-compatible': 'https://api.openai.com/v1',
|
||||
'anthropic': 'https://api.anthropic.com/v1',
|
||||
'google': 'https://generativelanguage.googleapis.com/v1beta'
|
||||
}
|
||||
|
||||
/* ---------- 提供方 ---------- */
|
||||
const providerOpen = ref(false)
|
||||
const providerForm = reactive({ id: '', name: '', type: 'openai-compatible' as ProviderType, baseUrl: '', apiKey: '', hasKey: false })
|
||||
const savingProvider = ref(false)
|
||||
|
||||
function openProvider(p?: ProviderItem) {
|
||||
Object.assign(providerForm, p
|
||||
? { id: p.id, name: p.name, type: p.type, baseUrl: p.baseUrl, apiKey: '', hasKey: p.hasKey }
|
||||
: { id: '', name: '', type: 'openai-compatible', baseUrl: '', apiKey: '', hasKey: false })
|
||||
providerOpen.value = true
|
||||
}
|
||||
|
||||
function applyPreset(label: string) {
|
||||
const p = PRESETS.find(x => x.label === label)
|
||||
if (!p) return
|
||||
providerForm.name = p.name
|
||||
providerForm.type = p.type
|
||||
providerForm.baseUrl = p.baseUrl
|
||||
}
|
||||
|
||||
async function saveProvider() {
|
||||
savingProvider.value = true
|
||||
try {
|
||||
const body = { name: providerForm.name, type: providerForm.type, baseUrl: providerForm.baseUrl, apiKey: providerForm.apiKey }
|
||||
if (providerForm.id) await api.put(`/api/providers/${providerForm.id}`, body)
|
||||
else await api.post('/api/providers', body)
|
||||
providerOpen.value = false
|
||||
await refreshProviders()
|
||||
}
|
||||
finally {
|
||||
savingProvider.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deletingProvider = ref<ProviderItem | null>(null)
|
||||
async function deleteProvider() {
|
||||
await api.del(`/api/providers/${deletingProvider.value!.id}`)
|
||||
deletingProvider.value = null
|
||||
await Promise.all([refreshProviders(), refreshModels()])
|
||||
}
|
||||
|
||||
/* ---------- 模型 ---------- */
|
||||
const modelOpen = ref(false)
|
||||
const modelForm = reactive({
|
||||
id: '', providerId: '', label: '', modelId: '',
|
||||
temperature: null as number | null, topP: null as number | null, maxOutputTokens: null as number | null
|
||||
})
|
||||
const remoteModels = ref<string[]>([])
|
||||
const loadingRemote = ref(false)
|
||||
const savingModel = ref(false)
|
||||
|
||||
function openModel(providerId: string, m?: ModelItem) {
|
||||
Object.assign(modelForm, m
|
||||
? { id: m.id, providerId, label: m.label, modelId: m.modelId, temperature: m.temperature, topP: m.topP, maxOutputTokens: m.maxOutputTokens }
|
||||
: { id: '', providerId, label: '', modelId: '', temperature: null, topP: null, maxOutputTokens: null })
|
||||
remoteModels.value = []
|
||||
modelOpen.value = true
|
||||
}
|
||||
|
||||
async function fetchRemote() {
|
||||
loadingRemote.value = true
|
||||
try {
|
||||
remoteModels.value = await api.get<string[]>(`/api/providers/${modelForm.providerId}/remote-models`)
|
||||
if (!remoteModels.value.length) api.toast.add({ title: '没有获取到模型', color: 'warning' })
|
||||
}
|
||||
finally {
|
||||
loadingRemote.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveModel() {
|
||||
savingModel.value = true
|
||||
try {
|
||||
const body = {
|
||||
providerId: modelForm.providerId,
|
||||
label: modelForm.label.trim() || modelForm.modelId.trim(),
|
||||
modelId: modelForm.modelId.trim(),
|
||||
temperature: modelForm.temperature,
|
||||
topP: modelForm.topP,
|
||||
maxOutputTokens: modelForm.maxOutputTokens
|
||||
}
|
||||
if (modelForm.id) await api.put(`/api/models/${modelForm.id}`, body)
|
||||
else await api.post('/api/models', body)
|
||||
modelOpen.value = false
|
||||
await refreshModels()
|
||||
}
|
||||
finally {
|
||||
savingModel.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteModel(m: ModelItem) {
|
||||
await api.del(`/api/models/${m.id}`)
|
||||
await refreshModels()
|
||||
}
|
||||
|
||||
const testing = ref<string | null>(null)
|
||||
async function testModel(m: ModelItem) {
|
||||
testing.value = m.id
|
||||
try {
|
||||
const res = await api.post<{ ok: boolean, text?: string, error?: string, ms: number }>(`/api/models/${m.id}/test`)
|
||||
if (res.ok) api.toast.add({ title: `${m.label} 连接成功`, description: `${res.ms} ms:${res.text}`, color: 'success', icon: 'i-lucide-circle-check' })
|
||||
else api.toast.add({ title: `${m.label} 连接失败`, description: res.error, color: 'error', duration: 10000 })
|
||||
}
|
||||
finally {
|
||||
testing.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const modelsOf = (providerId: string) => models.value.filter(m => m.providerId === providerId)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardPanel id="settings">
|
||||
<template #header>
|
||||
<UDashboardNavbar title="模型与 API">
|
||||
<template #right>
|
||||
<UButton icon="i-lucide-plus" label="添加 API 提供方" @click="openProvider()" />
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<p class="text-sm text-muted mb-4">
|
||||
API Key 使用 AES-256-GCM 加密后保存在本地 <code>data/app.db</code>。添加提供方后,在其下添加具体模型,然后就可以在作品的「AI 团队」中把模型分配给主编和作家。
|
||||
</p>
|
||||
|
||||
<div v-if="!providers.length" class="flex flex-col items-center py-20 gap-3 text-center">
|
||||
<UIcon name="i-lucide-plug" class="size-12 text-muted" />
|
||||
<p class="text-muted">还没有 API 提供方</p>
|
||||
<UButton icon="i-lucide-plus" label="添加 API 提供方" @click="openProvider()" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<UCard v-for="p in providers" :key="p.id">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon name="i-lucide-server" class="size-5 text-primary" />
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold">
|
||||
{{ p.name }}
|
||||
<UBadge variant="subtle" color="neutral" size="sm" class="ms-2">
|
||||
{{ TYPE_LABEL[p.type] }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<div class="text-xs text-muted truncate">
|
||||
{{ p.baseUrl || DEFAULT_URL[p.type] }} · Key:{{ p.apiKeyMasked || '未设置' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ms-auto flex gap-1">
|
||||
<UButton icon="i-lucide-plus" label="添加模型" size="sm" variant="soft" @click="openModel(p.id)" />
|
||||
<UButton icon="i-lucide-pencil" size="sm" color="neutral" variant="ghost" @click="openProvider(p)" />
|
||||
<UButton icon="i-lucide-trash-2" size="sm" color="error" variant="ghost" @click="deletingProvider = p" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="!modelsOf(p.id).length" class="text-sm text-muted">
|
||||
还没有模型,点击「添加模型」。
|
||||
</div>
|
||||
<div v-else class="divide-y divide-default">
|
||||
<div v-for="m in modelsOf(p.id)" :key="m.id" class="flex flex-wrap items-center gap-3 py-2">
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium">
|
||||
{{ m.label }}
|
||||
</div>
|
||||
<div class="text-xs text-muted">
|
||||
{{ m.modelId }}
|
||||
<template v-if="m.temperature != null">
|
||||
· 温度 {{ m.temperature }}
|
||||
</template>
|
||||
<template v-if="m.topP != null">
|
||||
· top_p {{ m.topP }}
|
||||
</template>
|
||||
<template v-if="m.maxOutputTokens">
|
||||
· 最大输出 {{ m.maxOutputTokens }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ms-auto flex gap-1">
|
||||
<UButton icon="i-lucide-activity" label="测试" size="xs" color="neutral" variant="outline" :loading="testing === m.id" @click="testModel(m)" />
|
||||
<UButton icon="i-lucide-pencil" size="xs" color="neutral" variant="ghost" @click="openModel(p.id, m)" />
|
||||
<UButton icon="i-lucide-trash-2" size="xs" color="error" variant="ghost" @click="deleteModel(m)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
|
||||
<!-- 提供方编辑 -->
|
||||
<UModal v-model:open="providerOpen" :title="providerForm.id ? '编辑 API 提供方' : '添加 API 提供方'">
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<UFormField v-if="!providerForm.id" label="快速填写">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<UButton v-for="pr in PRESETS" :key="pr.label" :label="pr.label" size="xs" color="neutral" variant="outline" @click="applyPreset(pr.label)" />
|
||||
</div>
|
||||
</UFormField>
|
||||
<UFormField label="名称" required>
|
||||
<UInput v-model="providerForm.name" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="接口类型" required>
|
||||
<USelect v-model="providerForm.type" :items="typeItems" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Base URL" :hint="`留空使用默认:${DEFAULT_URL[providerForm.type]}`">
|
||||
<UInput v-model="providerForm.baseUrl" :placeholder="DEFAULT_URL[providerForm.type]" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="API Key" :hint="providerForm.hasKey ? '留空则保留原有 Key' : ''">
|
||||
<UInput v-model="providerForm.apiKey" type="password" autocomplete="off" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer="{ close }">
|
||||
<div class="flex justify-end gap-2 w-full">
|
||||
<UButton label="取消" color="neutral" variant="outline" @click="close" />
|
||||
<UButton label="保存" :loading="savingProvider" :disabled="!providerForm.name" @click="saveProvider" />
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<!-- 模型编辑 -->
|
||||
<UModal v-model:open="modelOpen" :title="modelForm.id ? '编辑模型' : '添加模型'">
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<UFormField label="模型 ID" required hint="与 API 中的 model 参数一致">
|
||||
<div class="flex gap-2">
|
||||
<UInput v-model="modelForm.modelId" placeholder="例如 claude-opus-5-5 / gemini-3-pro" class="flex-1" />
|
||||
<UButton icon="i-lucide-download" label="获取列表" color="neutral" variant="outline" :loading="loadingRemote" @click="fetchRemote" />
|
||||
</div>
|
||||
</UFormField>
|
||||
<UFormField v-if="remoteModels.length" label="从列表选择">
|
||||
<USelectMenu
|
||||
:items="remoteModels"
|
||||
:model-value="remoteModels.includes(modelForm.modelId) ? modelForm.modelId : undefined"
|
||||
placeholder="搜索模型…"
|
||||
class="w-full"
|
||||
@update:model-value="(v: string) => (modelForm.modelId = v)"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="显示名称" hint="可选">
|
||||
<UInput v-model="modelForm.label" :placeholder="modelForm.modelId" class="w-full" />
|
||||
</UFormField>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<UFormField label="温度" hint="留空=默认">
|
||||
<UInputNumber v-model="modelForm.temperature" :min="0" :max="2" :step="0.1" placeholder="默认" />
|
||||
</UFormField>
|
||||
<UFormField label="top_p">
|
||||
<UInputNumber v-model="modelForm.topP" :min="0" :max="1" :step="0.05" placeholder="默认" />
|
||||
</UFormField>
|
||||
<UFormField label="最大输出 tokens">
|
||||
<UInputNumber v-model="modelForm.maxOutputTokens" :min="256" :step="1024" placeholder="默认" />
|
||||
</UFormField>
|
||||
</div>
|
||||
<p class="text-xs text-muted">
|
||||
写长章节时建议把「最大输出 tokens」调大(例如 16000 以上);如果输出仍被截断,系统会自动续写。
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer="{ close }">
|
||||
<div class="flex justify-end gap-2 w-full">
|
||||
<UButton label="取消" color="neutral" variant="outline" @click="close" />
|
||||
<UButton label="保存" :loading="savingModel" :disabled="!modelForm.modelId.trim()" @click="saveModel" />
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<UModal :open="!!deletingProvider" title="删除 API 提供方" @update:open="v => !v && (deletingProvider = null)">
|
||||
<template #body>
|
||||
<p>删除「{{ deletingProvider?.name }}」会同时删除它下面的所有模型,已分配这些模型的 AI 成员需要重新选择模型。</p>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2 w-full">
|
||||
<UButton label="取消" color="neutral" variant="outline" @click="deletingProvider = null" />
|
||||
<UButton label="删除" color="error" @click="deleteProvider" />
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { marked } from 'marked'
|
||||
|
||||
/** 渲染 Markdown(先转义 HTML,避免模型输出的 HTML 被直接执行) */
|
||||
export function renderMarkdown(md: string) {
|
||||
if (!md) return ''
|
||||
const escaped = md.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
return marked.parse(escaped, { async: false, breaks: true, gfm: true }) as string
|
||||
}
|
||||
|
||||
export function formatTime(t: string | number | Date | null | undefined) {
|
||||
if (!t) return ''
|
||||
return new Date(t).toLocaleString('zh-CN', { hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
|
||||
export function formatWords(n: number) {
|
||||
return n >= 10000 ? `${(n / 10000).toFixed(1)} 万字` : `${n} 字`
|
||||
}
|
||||
|
||||
/** 取出接口错误信息 */
|
||||
export function apiErrorMessage(err: unknown, fallback = '请求失败') {
|
||||
const e = err as { data?: { message?: string, statusMessage?: string }, message?: string }
|
||||
return e.data?.message || e.data?.statusMessage || e.message || fallback
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { InternalApi } from 'nitropack/types'
|
||||
|
||||
/** 从服务端路由推导出的接口返回类型(已序列化,日期为字符串) */
|
||||
export type Api<P extends keyof InternalApi, M extends keyof InternalApi[P] = 'get' & keyof InternalApi[P]> = InternalApi[P][M]
|
||||
|
||||
export type ProjectDetail = Api<'/api/projects/:id'>
|
||||
export type ProjectListItem = Api<'/api/projects'>[number]
|
||||
export type ChapterListItem = Api<'/api/projects/:id/chapters'>[number]
|
||||
export type ChapterDetail = Api<'/api/chapters/:id'>
|
||||
export type ChapterVersion = ChapterDetail['versions'][number]
|
||||
export type AgentItem = Api<'/api/projects/:id/agents'>[number]
|
||||
export type CharacterItem = Api<'/api/projects/:id/characters'>[number]
|
||||
export type LoreItem = Api<'/api/projects/:id/lore'>[number]
|
||||
export type ModelItem = Api<'/api/models'>[number]
|
||||
export type ProviderItem = Api<'/api/providers'>[number]
|
||||
export type JobDetail = Api<'/api/jobs/:id'>
|
||||
export type CardPreview = Api<'/api/projects/:id/cards/preview', 'post'>
|
||||
Reference in New Issue
Block a user