新增角色管理、世界观设定、章节管理和AI工具调用功能

- 角色管理:CRUD API + 卡片网格UI,每个角色约200字描述
- 世界观设定:单篇长文编辑器,建议500字以上
- 章节管理:CRUD + 排序 API,左右分栏编辑器,单章5000字上限
- AI工具调用:支持工具审批流程和结构化工具调用
- 小说详情页新增章节、角色、世界观入口按钮

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 13:58:17 +08:00
parent 580dc8be52
commit 20c759150a
34 changed files with 3495 additions and 127 deletions

View File

@@ -0,0 +1,33 @@
import { http } from './client'
import type { Chapter, ChapterCreate, ChapterUpdate, ChapterList } from '@/types/chapter'
const base = (novelId: number) => `/novels/${novelId}/chapters`
export async function fetchChapters(novelId: number): Promise<ChapterList> {
const { data } = await http.get<ChapterList>(base(novelId))
return data
}
export async function fetchChapter(novelId: number, chapterId: number): Promise<Chapter> {
const { data } = await http.get<Chapter>(`${base(novelId)}/${chapterId}`)
return data
}
export async function createChapter(novelId: number, payload: ChapterCreate): Promise<Chapter> {
const { data } = await http.post<Chapter>(base(novelId), payload)
return data
}
export async function updateChapter(novelId: number, chapterId: number, payload: ChapterUpdate): Promise<Chapter> {
const { data } = await http.patch<Chapter>(`${base(novelId)}/${chapterId}`, payload)
return data
}
export async function deleteChapter(novelId: number, chapterId: number): Promise<void> {
await http.delete(`${base(novelId)}/${chapterId}`)
}
export async function reorderChapters(novelId: number, orderedIds: number[]): Promise<ChapterList> {
const { data } = await http.put<ChapterList>(`${base(novelId)}/reorder`, { ordered_ids: orderedIds })
return data
}

View File

@@ -0,0 +1,23 @@
import { http } from './client'
import type { Character, CharacterCreate, CharacterUpdate, CharacterList } from '@/types/character'
const base = (novelId: number) => `/novels/${novelId}/characters`
export async function fetchCharacters(novelId: number): Promise<CharacterList> {
const { data } = await http.get<CharacterList>(base(novelId))
return data
}
export async function createCharacter(novelId: number, payload: CharacterCreate): Promise<Character> {
const { data } = await http.post<Character>(base(novelId), payload)
return data
}
export async function updateCharacter(novelId: number, characterId: number, payload: CharacterUpdate): Promise<Character> {
const { data } = await http.patch<Character>(`${base(novelId)}/${characterId}`, payload)
return data
}
export async function deleteCharacter(novelId: number, characterId: number): Promise<void> {
await http.delete(`${base(novelId)}/${characterId}`)
}

View File

@@ -1,5 +1,5 @@
import { http } from './client'
import type { AIConfig, AIConfigSave, ChatMessageList } from '@/types/chat'
import type { AIConfig, AIConfigSave, ChatMessageList, ToolCallEntry } from '@/types/chat'
// ── AI配置 ──
@@ -41,27 +41,51 @@ export interface TokenUsage {
total_tokens: number
}
export interface ContextMessage {
role: string
content: string
export interface ToolResultInfo {
name: string
label: string
result: string
}
export interface PendingToolCallData {
id: string
name: string
label: string
arguments: Record<string, unknown>
}
export interface StreamChatOptions {
messages: { role: string; content: string }[]
novelId?: number
pageContext?: string
toolsEnabled?: boolean
// 工具审批续传
pendingToolCalls?: PendingToolCallData[]
assistantText?: string
// 回调
onChunk: (text: string) => void
onError: (error: string) => void
onDone: (usage?: TokenUsage) => void
onContext?: (context: ContextMessage[]) => void
onDone: (usage?: TokenUsage, pending?: boolean) => void
onToolCallsPending?: (calls: PendingToolCallData[], assistantText: string) => void
onToolResult?: (info: ToolResultInfo) => void
signal?: AbortSignal
}
export async function streamChat(options: StreamChatOptions): Promise<void> {
const { messages, novelId, onChunk, onError, onDone, onContext, signal } = options
const {
messages, novelId, pageContext, toolsEnabled,
pendingToolCalls, assistantText,
onChunk, onError, onDone, onToolCallsPending, onToolResult,
signal,
} = options
const body = JSON.stringify({
messages,
novel_id: novelId ?? null,
page_context: pageContext ?? null,
tools_enabled: toolsEnabled ?? true,
pending_tool_calls: pendingToolCalls ?? null,
assistant_text: assistantText ?? null,
})
const resp = await fetch('/api/chat/stream', {
@@ -95,7 +119,7 @@ export async function streamChat(options: StreamChatOptions): Promise<void> {
try {
const event = JSON.parse(line.slice(6))
if (event.done) {
onDone(event.usage ?? undefined)
onDone(event.usage ?? undefined, event.pending ?? false)
return
}
if (event.error) {
@@ -103,12 +127,15 @@ export async function streamChat(options: StreamChatOptions): Promise<void> {
onDone()
return
}
if (event.context && onContext) {
onContext(event.context)
}
if (event.content) {
onChunk(event.content)
}
if (event.tool_calls_pending && onToolCallsPending) {
onToolCallsPending(event.tool_calls_pending, event.assistant_text ?? '')
}
if (event.tool_result && onToolResult) {
onToolResult(event.tool_result)
}
} catch {
// 忽略解析错误
}

View File

@@ -0,0 +1,14 @@
import { http } from './client'
import type { WorldSetting, WorldSettingUpdate } from '@/types/worldSetting'
const base = (novelId: number) => `/novels/${novelId}/world-setting`
export async function fetchWorldSetting(novelId: number): Promise<WorldSetting> {
const { data } = await http.get<WorldSetting>(base(novelId))
return data
}
export async function saveWorldSetting(novelId: number, payload: WorldSettingUpdate): Promise<WorldSetting> {
const { data } = await http.put<WorldSetting>(base(novelId), payload)
return data
}

View File

@@ -0,0 +1,193 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import type { Chapter } from '@/types/chapter'
import BaseButton from '@/components/ui/BaseButton.vue'
import BaseTextarea from '@/components/ui/BaseTextarea.vue'
const props = defineProps<{
chapter: Chapter
saving: boolean
}>()
const emit = defineEmits<{
save: [data: { title: string; content: string }]
back: []
}>()
const title = ref('')
const content = ref('')
const hasChanges = ref(false)
const contentLength = computed(() => content.value.length)
const contentHint = computed(() => {
if (contentLength.value === 0) return '上限 5000 字'
return `${contentLength.value} / 5000 字`
})
watch(() => props.chapter, (ch) => {
title.value = ch.title
content.value = ch.content
hasChanges.value = false
}, { immediate: true })
function onInput() {
hasChanges.value = true
}
function handleSave() {
emit('save', {
title: title.value.trim(),
content: content.value,
})
hasChanges.value = false
}
</script>
<template>
<div class="chapter-editor">
<div class="editor-header">
<button class="back-btn" @click="emit('back')">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M10 3L5 8l5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
返回列表
</button>
<div class="editor-title-wrap">
<input
v-model="title"
class="editor-title-input"
placeholder="章节标题"
maxlength="200"
@input="onInput"
/>
</div>
<div class="editor-actions">
<span v-if="hasChanges" class="unsaved-hint">未保存</span>
<BaseButton
variant="primary"
size="small"
:loading="saving"
:disabled="!hasChanges || !title.trim()"
@click="handleSave"
>
保存
</BaseButton>
</div>
</div>
<div class="editor-body">
<BaseTextarea
v-model="content"
placeholder="在此书写章节内容..."
:rows="20"
:maxlength="5000"
:max-height="800"
@input="onInput"
/>
<div class="editor-footer">
<span class="word-count" :class="{ 'word-count--near': contentLength > 4500 }">
{{ contentHint }}
</span>
</div>
</div>
</div>
</template>
<style scoped>
.chapter-editor {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.editor-header {
display: flex;
align-items: center;
gap: var(--space-md);
padding-bottom: var(--space-lg);
border-bottom: 1px solid var(--paper-200);
margin-bottom: var(--space-lg);
}
.back-btn {
display: flex;
align-items: center;
gap: var(--space-xs);
border: none;
background: none;
color: var(--ink-400);
font-size: 0.85rem;
cursor: pointer;
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
transition: all var(--duration-fast) ease;
flex-shrink: 0;
}
.back-btn:hover {
color: var(--ink-700);
background: var(--paper-100);
}
.editor-title-wrap {
flex: 1;
min-width: 0;
}
.editor-title-input {
width: 100%;
border: none;
background: none;
font-family: var(--font-display);
font-size: 1.3rem;
font-weight: 700;
color: var(--ink-900);
outline: none;
padding: var(--space-xs) 0;
border-bottom: 2px solid transparent;
transition: border-color var(--duration-fast) ease;
}
.editor-title-input:focus {
border-bottom-color: var(--vermilion);
}
.editor-title-input::placeholder {
color: var(--ink-200);
}
.editor-actions {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-shrink: 0;
}
.unsaved-hint {
font-size: 0.75rem;
color: var(--vermilion);
font-style: italic;
}
.editor-body {
flex: 1;
display: flex;
flex-direction: column;
}
.editor-footer {
display: flex;
justify-content: flex-end;
margin-top: var(--space-sm);
}
.word-count {
font-size: 0.75rem;
color: var(--ink-300);
}
.word-count--near {
color: var(--vermilion);
}
</style>

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import BaseButton from '@/components/ui/BaseButton.vue'
defineEmits<{
create: []
}>()
</script>
<template>
<div class="empty-state">
<!-- 书页SVG -->
<svg class="book-svg" viewBox="0 0 200 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- 打开的书 -->
<path class="book-left" d="M30 40 Q100 35 100 45 L100 150 Q100 140 30 145 Z" fill="#faf8f5" stroke="#e8e0d4" stroke-width="1" />
<path class="book-right" d="M170 40 Q100 35 100 45 L100 150 Q100 140 170 145 Z" fill="#faf8f5" stroke="#e8e0d4" stroke-width="1" />
<!-- 书脊 -->
<line x1="100" y1="38" x2="100" y2="150" stroke="#d9cebf" stroke-width="1.5" />
<!-- 左页文字线 -->
<line class="text-line tl-1" x1="45" y1="65" x2="90" y2="65" stroke="#e8e0d4" stroke-width="0.8" stroke-dasharray="3 2" />
<line class="text-line tl-2" x1="45" y1="78" x2="85" y2="78" stroke="#e8e0d4" stroke-width="0.8" stroke-dasharray="3 2" />
<line class="text-line tl-3" x1="45" y1="91" x2="88" y2="91" stroke="#e8e0d4" stroke-width="0.8" stroke-dasharray="3 2" />
<line class="text-line tl-4" x1="45" y1="104" x2="80" y2="104" stroke="#e8e0d4" stroke-width="0.8" stroke-dasharray="3 2" />
<!-- 右页空白等待书写 -->
<text class="page-hint" x="135" y="95" text-anchor="middle" font-size="11" fill="#d9cebf">?</text>
<!-- 翻页效果 -->
<path class="page-flip" d="M100 45 Q115 42 130 50 L125 140 Q110 135 100 150 Z" fill="#f5f0ea" stroke="#e8e0d4" stroke-width="0.8" opacity="0.5" />
</svg>
<h3 class="empty-title">开始书写你的故事</h3>
<p class="empty-desc">还没有章节创建第一章来展开你的叙事</p>
<BaseButton variant="primary" @click="$emit('create')">
创建第一章
</BaseButton>
</div>
</template>
<style scoped>
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-3xl) var(--space-xl);
animation: fadeInUp var(--duration-slow) var(--ease-out-smooth) both;
}
.book-svg {
width: 200px;
height: 180px;
margin-bottom: var(--space-xl);
}
.book-left {
animation: fadeIn 0.6s ease 0.2s both;
}
.book-right {
animation: fadeIn 0.6s ease 0.4s both;
}
.page-flip {
animation: fadeIn 0.6s ease 0.6s both;
}
.text-line {
opacity: 0;
animation: fadeIn 0.4s ease both;
}
.tl-1 { animation-delay: 0.6s; }
.tl-2 { animation-delay: 0.8s; }
.tl-3 { animation-delay: 1.0s; }
.tl-4 { animation-delay: 1.2s; }
.page-hint {
animation: fadeIn 0.4s ease 1.4s both;
}
.empty-title {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 700;
color: var(--ink-900);
margin-bottom: var(--space-sm);
}
.empty-desc {
font-size: 0.9rem;
color: var(--ink-400);
margin-bottom: var(--space-xl);
text-align: center;
}
</style>

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import type { Chapter } from '@/types/chapter'
import BaseModal from '@/components/ui/BaseModal.vue'
import BaseInput from '@/components/ui/BaseInput.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
const props = defineProps<{
show: boolean
chapter: Chapter | null
saving: boolean
}>()
const emit = defineEmits<{
submit: [data: { title: string }]
close: []
}>()
const title = ref('')
const isEdit = computed(() => !!props.chapter)
const modalTitle = computed(() => isEdit.value ? '重命名章节' : '新建章节')
const canSubmit = computed(() => title.value.trim().length > 0 && !props.saving)
watch(() => props.show, (val) => {
if (val) {
title.value = props.chapter?.title ?? ''
}
})
function handleSubmit() {
if (!canSubmit.value) return
emit('submit', { title: title.value.trim() })
}
</script>
<template>
<BaseModal :show="show" @close="emit('close')">
<form class="chapter-form" @submit.prevent="handleSubmit">
<h2 class="form-title">{{ modalTitle }}</h2>
<BaseInput
v-model="title"
label="章节标题"
:maxlength="200"
required
/>
<div class="form-actions">
<BaseButton variant="secondary" type="button" @click="emit('close')">取消</BaseButton>
<BaseButton variant="primary" type="submit" :loading="saving" :disabled="!canSubmit">
{{ isEdit ? '保存' : '创建' }}
</BaseButton>
</div>
</form>
</BaseModal>
</template>
<style scoped>
.chapter-form {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.form-title {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 700;
color: var(--ink-900);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
padding-top: var(--space-sm);
}
</style>

View File

@@ -0,0 +1,172 @@
<script setup lang="ts">
import type { Chapter } from '@/types/chapter'
const props = defineProps<{
chapter: Chapter
index: number
active: boolean
}>()
defineEmits<{
select: [chapter: Chapter]
edit: [chapter: Chapter]
delete: [chapter: Chapter]
}>()
const wordCount = computed(() => props.chapter.content.length)
import { computed } from 'vue'
</script>
<template>
<div
class="chapter-item"
:class="{ 'chapter-item--active': active }"
draggable="true"
@click="$emit('select', chapter)"
>
<div class="item-drag-handle" title="拖拽排序">
<svg width="10" height="14" viewBox="0 0 10 14" fill="none">
<circle cx="3" cy="2" r="1.2" fill="currentColor" />
<circle cx="7" cy="2" r="1.2" fill="currentColor" />
<circle cx="3" cy="7" r="1.2" fill="currentColor" />
<circle cx="7" cy="7" r="1.2" fill="currentColor" />
<circle cx="3" cy="12" r="1.2" fill="currentColor" />
<circle cx="7" cy="12" r="1.2" fill="currentColor" />
</svg>
</div>
<span class="item-order">{{ index + 1 }}</span>
<div class="item-info">
<span class="item-title">{{ chapter.title }}</span>
<span class="item-meta">{{ wordCount }} </span>
</div>
<div class="item-actions">
<button class="action-btn" title="重命名" @click.stop="$emit('edit', chapter)">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M10.5 1.5l2 2-8 8H2.5v-2z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button class="action-btn action-btn--danger" title="删除" @click.stop="$emit('delete', chapter)">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M2 4h10M5 4V2.5h4V4M3.5 4v8h7V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</div>
</template>
<style scoped>
.chapter-item {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-md) var(--space-lg);
background: var(--paper-50);
border: 1px solid var(--paper-200);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out-smooth);
}
.chapter-item:hover {
border-color: var(--paper-300);
box-shadow: 0 2px 8px rgba(26, 26, 46, 0.06);
}
.chapter-item--active {
border-color: var(--vermilion);
background: #fef8f6;
}
.item-drag-handle {
color: var(--ink-200);
cursor: grab;
padding: var(--space-xs);
opacity: 0;
transition: opacity var(--duration-fast) ease;
}
.chapter-item:hover .item-drag-handle {
opacity: 1;
}
.item-order {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--paper-100);
color: var(--ink-400);
font-size: 0.75rem;
font-weight: 600;
flex-shrink: 0;
}
.chapter-item--active .item-order {
background: var(--vermilion);
color: white;
}
.item-info {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-sm);
}
.item-title {
font-size: 0.95rem;
color: var(--ink-800);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-meta {
font-size: 0.75rem;
color: var(--ink-300);
flex-shrink: 0;
}
.item-actions {
display: flex;
gap: var(--space-xs);
opacity: 0;
transition: opacity var(--duration-fast) ease;
}
.chapter-item:hover .item-actions {
opacity: 1;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--radius-sm);
background: var(--paper-100);
color: var(--ink-400);
cursor: pointer;
transition: all var(--duration-fast) ease;
}
.action-btn:hover {
background: var(--paper-200);
color: var(--ink-700);
}
.action-btn--danger:hover {
background: #fef2f2;
color: var(--vermilion);
}
</style>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Chapter } from '@/types/chapter'
import ChapterItem from './ChapterItem.vue'
const props = defineProps<{
chapters: Chapter[]
activeId: number | null
}>()
const emit = defineEmits<{
select: [chapter: Chapter]
edit: [chapter: Chapter]
delete: [chapter: Chapter]
reorder: [chapters: Chapter[]]
}>()
const dragIndex = ref(-1)
const dragOverIndex = ref(-1)
function onDragStart(e: DragEvent, index: number) {
dragIndex.value = index
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
}
}
function onDragOver(e: DragEvent, index: number) {
e.preventDefault()
dragOverIndex.value = index
}
function onDragEnd() {
if (dragIndex.value !== -1 && dragOverIndex.value !== -1 && dragIndex.value !== dragOverIndex.value) {
const reordered = [...props.chapters]
const [moved] = reordered.splice(dragIndex.value, 1)
reordered.splice(dragOverIndex.value, 0, moved)
emit('reorder', reordered)
}
dragIndex.value = -1
dragOverIndex.value = -1
}
</script>
<template>
<div class="chapter-list">
<div
v-for="(chapter, index) in chapters"
:key="chapter.id"
class="chapter-list-item"
:class="{
'chapter-list-item--drag-over': dragOverIndex === index && dragIndex !== index,
}"
@dragstart="onDragStart($event, index)"
@dragover="onDragOver($event, index)"
@dragend="onDragEnd"
>
<ChapterItem
:chapter="chapter"
:index="index"
:active="chapter.id === activeId"
@select="emit('select', $event)"
@edit="emit('edit', $event)"
@delete="emit('delete', $event)"
/>
</div>
</div>
</template>
<style scoped>
.chapter-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.chapter-list-item {
position: relative;
}
.chapter-list-item--drag-over::before {
content: '';
position: absolute;
top: -3px;
left: 0;
right: 0;
height: 2px;
background: var(--vermilion);
border-radius: 1px;
}
</style>

View File

@@ -0,0 +1,106 @@
<script setup lang="ts">
import type { Character } from '@/types/character'
defineProps<{
character: Character
}>()
defineEmits<{
edit: [character: Character]
delete: [character: Character]
}>()
</script>
<template>
<div class="character-card">
<div class="card-header">
<h3 class="card-name">{{ character.name }}</h3>
<div class="card-actions">
<button class="action-btn" title="编辑" @click="$emit('edit', character)">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M10.5 1.5l2 2-8 8H2.5v-2z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button class="action-btn action-btn--danger" title="删除" @click="$emit('delete', character)">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M2 4h10M5 4V2.5h4V4M3.5 4v8h7V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</div>
<p class="card-desc">{{ character.description || '暂无描述' }}</p>
</div>
</template>
<style scoped>
.character-card {
background: var(--paper-50);
border: 1px solid var(--paper-200);
border-radius: var(--radius-md);
padding: var(--space-lg);
transition: transform var(--duration-fast) var(--ease-out-smooth),
box-shadow var(--duration-fast) var(--ease-out-smooth);
}
.character-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(26, 26, 46, 0.08);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-sm);
}
.card-name {
font-family: var(--font-display);
font-size: 1.1rem;
font-weight: 700;
color: var(--ink-900);
}
.card-actions {
display: flex;
gap: var(--space-xs);
opacity: 0;
transition: opacity var(--duration-fast) ease;
}
.character-card:hover .card-actions {
opacity: 1;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--radius-sm);
background: var(--paper-100);
color: var(--ink-400);
cursor: pointer;
transition: all var(--duration-fast) ease;
}
.action-btn:hover {
background: var(--paper-200);
color: var(--ink-700);
}
.action-btn--danger:hover {
background: #fef2f2;
color: var(--vermilion);
}
.card-desc {
font-size: 0.9rem;
color: var(--ink-500);
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
</style>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
import BaseButton from '@/components/ui/BaseButton.vue'
defineEmits<{
create: []
}>()
</script>
<template>
<div class="empty-state">
<!-- 人物剪影SVG -->
<svg class="person-svg" viewBox="0 0 200 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- 中间人物 -->
<circle class="head head-center" cx="100" cy="50" r="18" fill="#e8e0d4" stroke="#9090a8" stroke-width="1" />
<path class="body body-center" d="M75 90 Q100 75 125 90 L120 140 H80 Z" fill="#e8e0d4" stroke="#9090a8" stroke-width="1" />
<!-- 左侧人物 -->
<circle class="head head-left" cx="45" cy="65" r="14" fill="#f0ebe4" stroke="#b0b0c0" stroke-width="0.8" opacity="0.6" />
<path class="body body-left" d="M28 95 Q45 83 62 95 L59 135 H31 Z" fill="#f0ebe4" stroke="#b0b0c0" stroke-width="0.8" opacity="0.6" />
<!-- 右侧人物 -->
<circle class="head head-right" cx="155" cy="65" r="14" fill="#f0ebe4" stroke="#b0b0c0" stroke-width="0.8" opacity="0.6" />
<path class="body body-right" d="M138 95 Q155 83 172 95 L169 135 H141 Z" fill="#f0ebe4" stroke="#b0b0c0" stroke-width="0.8" opacity="0.6" />
<!-- 连接线 -->
<line class="connect-line" x1="65" y1="80" x2="82" y2="70" stroke="#d9cebf" stroke-width="1" stroke-dasharray="3 3" />
<line class="connect-line" x1="135" y1="80" x2="118" y2="70" stroke="#d9cebf" stroke-width="1" stroke-dasharray="3 3" />
</svg>
<h3 class="empty-title">塑造你的角色</h3>
<p class="empty-desc">还没有角色创建第一个来丰富你的故事世界</p>
<BaseButton variant="primary" @click="$emit('create')">
创建第一个角色
</BaseButton>
</div>
</template>
<style scoped>
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-3xl) var(--space-xl);
animation: fadeInUp var(--duration-slow) var(--ease-out-smooth) both;
}
.person-svg {
width: 200px;
height: 180px;
margin-bottom: var(--space-xl);
}
.head-center {
animation: fadeIn 0.6s ease 0.2s both;
}
.body-center {
animation: fadeIn 0.6s ease 0.4s both;
}
.head-left, .body-left {
animation: fadeIn 0.6s ease 0.6s both;
}
.head-right, .body-right {
animation: fadeIn 0.6s ease 0.8s both;
}
.connect-line {
animation: fadeIn 0.4s ease 1.0s both;
}
.empty-title {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 700;
color: var(--ink-900);
margin-bottom: var(--space-sm);
}
.empty-desc {
font-size: 0.9rem;
color: var(--ink-400);
margin-bottom: var(--space-xl);
text-align: center;
}
</style>

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import type { Character, CharacterCreate, CharacterUpdate } from '@/types/character'
import BaseModal from '@/components/ui/BaseModal.vue'
import BaseInput from '@/components/ui/BaseInput.vue'
import BaseTextarea from '@/components/ui/BaseTextarea.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
const props = defineProps<{
show: boolean
character: Character | null
saving: boolean
}>()
const emit = defineEmits<{
submit: [data: CharacterCreate | CharacterUpdate]
close: []
}>()
const name = ref('')
const description = ref('')
const isEdit = computed(() => !!props.character)
const title = computed(() => isEdit.value ? '编辑角色' : '新建角色')
const descLength = computed(() => description.value.length)
const descHint = computed(() => {
if (descLength.value === 0) return '建议约 200 字'
if (descLength.value < 100) return `${descLength.value} 字,建议至少 100 字`
if (descLength.value <= 300) return `${descLength.value}`
return `${descLength.value} 字,建议不超过 300 字`
})
const canSubmit = computed(() => name.value.trim().length > 0 && !props.saving)
watch(() => props.show, (val) => {
if (val) {
name.value = props.character?.name ?? ''
description.value = props.character?.description ?? ''
}
})
function handleSubmit() {
if (!canSubmit.value) return
emit('submit', {
name: name.value.trim(),
description: description.value.trim(),
})
}
</script>
<template>
<BaseModal :show="show" @close="emit('close')">
<form class="character-form" @submit.prevent="handleSubmit">
<h2 class="form-title">{{ title }}</h2>
<BaseInput
v-model="name"
label="角色名称"
:maxlength="100"
required
/>
<div class="desc-field">
<BaseTextarea
v-model="description"
label="角色描述"
:rows="6"
:maxlength="1000"
:max-height="300"
/>
<span class="desc-counter" :class="{ 'desc-counter--warn': descLength > 300 }">
{{ descHint }}
</span>
</div>
<div class="form-actions">
<BaseButton variant="secondary" type="button" @click="emit('close')">取消</BaseButton>
<BaseButton variant="primary" type="submit" :loading="saving" :disabled="!canSubmit">
{{ isEdit ? '保存' : '创建' }}
</BaseButton>
</div>
</form>
</BaseModal>
</template>
<style scoped>
.character-form {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.form-title {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 700;
color: var(--ink-900);
}
.desc-field {
position: relative;
}
.desc-counter {
display: block;
text-align: right;
font-size: 0.75rem;
color: var(--ink-300);
margin-top: var(--space-xs);
}
.desc-counter--warn {
color: var(--vermilion);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
padding-top: var(--space-sm);
}
</style>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { Character } from '@/types/character'
import CharacterCard from './CharacterCard.vue'
defineProps<{
characters: Character[]
}>()
defineEmits<{
edit: [character: Character]
delete: [character: Character]
}>()
</script>
<template>
<div class="character-grid">
<CharacterCard
v-for="c in characters"
:key="c.id"
:character="c"
@edit="$emit('edit', $event)"
@delete="$emit('delete', $event)"
/>
</div>
</template>
<style scoped>
.character-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-lg);
}
</style>

View File

@@ -1,7 +1,10 @@
<script setup lang="ts">
import { ref, computed, nextTick, watch, onMounted } from 'vue'
import { useChatAgent } from '@/composables/useChatAgent'
import { isToolCallGroup } from '@/types/chat'
import type { ToolCallGroup } from '@/types/chat'
import ChatMessageComp from './ChatMessage.vue'
import ChatToolCalls from './ChatToolCalls.vue'
import ChatConfigModal from './ChatConfigModal.vue'
defineProps<{
@@ -12,28 +15,20 @@ const emit = defineEmits<{
close: []
}>()
const { messages, isStreaming, streamingContent, lastUsage, loadHistory, sendMessage, stopStreaming, clearChat } = useChatAgent()
const {
entries, isStreaming, streamingContent, lastUsage,
toolsEnabled, currentNovelId,
loadHistory, sendMessage, approveToolCalls, skipToolCalls,
stopStreaming, clearChat,
} = useChatAgent()
const input = ref('')
const messagesRef = ref<HTMLElement>()
const showConfig = ref(false)
// 常见模型的上下文窗口大小
const MODEL_CONTEXT: Record<string, number> = {
'gpt-4o': 128000,
'gpt-4o-mini': 128000,
'gpt-4-turbo': 128000,
'gpt-4': 8192,
'gpt-3.5-turbo': 16385,
'claude-sonnet-4-20250514': 200000,
'claude-opus-4-20250514': 200000,
'claude-haiku-4-5-20251001': 200000,
'claude-3-5-sonnet-20241022': 200000,
'claude-3-opus-20240229': 200000,
}
// Token用量
const DEFAULT_CONTEXT = 128000
// Token用量
const totalUsed = computed(() => {
if (!lastUsage.value) return 0
const u = lastUsage.value
@@ -54,6 +49,11 @@ const usageLabel = computed(() => {
return `${total.toLocaleString()} / ${contextLimit.value.toLocaleString()} tokens输入 ${u.prompt_tokens.toLocaleString()} · 输出 ${u.completion_tokens.toLocaleString()}`
})
// 是否有待审批的工具调用
const hasPendingTools = computed(() =>
entries.value.some(e => isToolCallGroup(e) && e.status === 'pending')
)
function scrollToBottom() {
nextTick(() => {
if (messagesRef.value) {
@@ -64,7 +64,7 @@ function scrollToBottom() {
async function handleSend() {
const text = input.value.trim()
if (!text || isStreaming.value) return
if (!text || isStreaming.value || hasPendingTools.value) return
input.value = ''
await sendMessage(text)
}
@@ -76,8 +76,16 @@ function handleKeydown(e: KeyboardEvent) {
}
}
async function handleApprove(group: ToolCallGroup) {
await approveToolCalls(group)
}
function handleSkip(group: ToolCallGroup) {
skipToolCalls(group)
}
// 自动滚动
watch([messages, streamingContent], scrollToBottom)
watch([entries, streamingContent], scrollToBottom)
onMounted(() => {
loadHistory()
@@ -87,7 +95,7 @@ onMounted(() => {
<template>
<Teleport to="body">
<Transition name="panel">
<div v-if="open" class="chat-overlay" @click.self="emit('close')">
<div v-if="open" class="chat-overlay">
<div class="chat-panel">
<!-- 头部 -->
<div class="panel-header">
@@ -98,6 +106,17 @@ onMounted(() => {
<path d="M2 4h12M5 4V2.5h6V4M3.5 4v9.5h9V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button
v-if="currentNovelId"
class="icon-btn"
:class="{ 'icon-btn--active': toolsEnabled }"
:title="toolsEnabled ? '工具已启用(点击关闭)' : '工具已关闭(点击开启)'"
@click="toolsEnabled = !toolsEnabled"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M9.5 1.5L13.5 5.5L6.5 12.5H2.5V8.5L9.5 1.5Z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button class="icon-btn" title="AI配置" @click="showConfig = true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="2.5" stroke="currentColor" stroke-width="1.2" />
@@ -126,17 +145,26 @@ onMounted(() => {
<!-- 消息区 -->
<div ref="messagesRef" class="panel-messages">
<div v-if="messages.length === 0 && !isStreaming" class="empty-chat">
<div v-if="entries.length === 0 && !isStreaming" class="empty-chat">
<p class="empty-hint">有什么可以帮你的</p>
<p class="empty-sub">关于小说创作的任何问题都可以问我</p>
</div>
<ChatMessageComp
v-for="msg in messages"
:key="msg.id"
:role="msg.role"
:content="msg.content"
/>
<template v-for="entry in entries" :key="entry.id">
<!-- 工具调用组 -->
<ChatToolCalls
v-if="isToolCallGroup(entry)"
:group="entry"
@approve="handleApprove(entry as ToolCallGroup)"
@skip="handleSkip(entry as ToolCallGroup)"
/>
<!-- 普通消息 -->
<ChatMessageComp
v-else
:role="entry.role"
:content="entry.content"
/>
</template>
<!-- 流式消息 -->
<ChatMessageComp
@@ -159,13 +187,13 @@ onMounted(() => {
class="input-field"
placeholder="输入消息..."
rows="1"
:disabled="isStreaming"
:disabled="isStreaming || hasPendingTools"
@keydown="handleKeydown"
/>
<button
v-if="!isStreaming"
class="send-btn"
:disabled="!input.trim()"
:disabled="!input.trim() || hasPendingTools"
@click="handleSend"
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
@@ -194,6 +222,7 @@ onMounted(() => {
z-index: 1000;
display: flex;
justify-content: flex-end;
pointer-events: none;
}
/* 面板 */
@@ -206,6 +235,7 @@ onMounted(() => {
display: flex;
flex-direction: column;
box-shadow: -8px 0 30px rgba(26, 26, 46, 0.1);
pointer-events: auto;
}
/* 头部 */
@@ -249,6 +279,16 @@ onMounted(() => {
color: var(--ink-900);
}
.icon-btn--active {
color: var(--bamboo);
background: rgba(91, 140, 90, 0.1);
}
.icon-btn--active:hover {
color: var(--bamboo);
background: rgba(91, 140, 90, 0.18);
}
/* Token用量进度条 */
.usage-bar {
padding: var(--space-sm) var(--space-lg);

View File

@@ -0,0 +1,337 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ToolCallGroup, ToolCallEntry } from '@/types/chat'
const props = defineProps<{
group: ToolCallGroup
}>()
const emit = defineEmits<{
approve: []
skip: []
}>()
const isPending = computed(() => props.group.status === 'pending')
const isExecuting = computed(() => props.group.status === 'executing')
const isDone = computed(() => props.group.status === 'done')
const isSkipped = computed(() => props.group.status === 'skipped')
// 工具图标SVG path
const TOOL_ICONS: Record<string, string> = {
list_outlines: 'M3 4h10M3 8h10M3 12h6',
create_outline: 'M8 3v10M3 8h10',
update_outline: 'M3 12l2-2 4 4-2 2H3v-4zM9 6l2-2 4 4-2 2-4-4z',
delete_outline: 'M3 5h10M5 5V3h6v2M4 5v8h8V5',
get_world_setting: 'M8 1a7 7 0 100 14A7 7 0 008 1zM1 8h14M8 1c2 2 3 4.5 3 7s-1 5-3 7M8 1c-2 2-3 4.5-3 7s1 5 3 7',
save_world_setting: 'M8 1a7 7 0 100 14A7 7 0 008 1zM4 8l3 3 5-5',
}
// 工具颜色主题
const TOOL_COLORS: Record<string, string> = {
list_outlines: 'var(--bamboo)',
create_outline: 'var(--bamboo)',
update_outline: '#d4a017',
delete_outline: 'var(--danger)',
delete_world_setting: 'var(--danger)',
get_world_setting: 'var(--ink-500)',
save_world_setting: 'var(--bamboo)',
}
function getIcon(name: string): string {
return TOOL_ICONS[name] ?? 'M8 3v10M3 8h10'
}
function getColor(name: string): string {
return TOOL_COLORS[name] ?? 'var(--ink-400)'
}
/** 格式化参数为可读文本 */
function formatArgs(call: ToolCallEntry): string {
const args = call.arguments
if (!args || Object.keys(args).length === 0) return ''
switch (call.name) {
case 'create_outline':
return args.summary as string ?? ''
case 'update_outline':
return `#${args.outline_id}` + (args.summary ? `${args.summary}` : '')
case 'delete_outline':
return `#${args.outline_id}`
case 'save_world_setting': {
const content = (args.content as string) ?? ''
return content.length > 80 ? content.slice(0, 80) + '…' : content
}
default:
return ''
}
}
/** 格式化工具结果 */
function formatResult(call: ToolCallEntry): string {
if (!call.result) return ''
try {
const data = call.parsedResult ?? JSON.parse(call.result)
switch (call.name) {
case 'list_outlines': {
const items = (data as { outlines: { id: number; summary: string }[] }).outlines
if (!items?.length) return '暂无大纲'
return items.map((o: { id: number; summary: string }) => `${o.summary}`).join('\n')
}
case 'create_outline':
return `已创建: ${(data as { summary: string }).summary}`
case 'update_outline':
if ((data as { error?: string }).error) return `失败: ${(data as { error: string }).error}`
return `已更新: ${(data as { summary: string }).summary}`
case 'delete_outline':
if ((data as { error?: string }).error) return `失败: ${(data as { error: string }).error}`
return '已删除'
case 'get_world_setting': {
const content = (data as { content: string }).content
if (!content) return '暂无世界观设定'
return content.length > 120 ? content.slice(0, 120) + '…' : content
}
case 'save_world_setting':
return `已保存 (${(data as { content_length: number }).content_length} 字)`
default:
return call.result.slice(0, 100)
}
} catch {
return call.result.slice(0, 100)
}
}
</script>
<template>
<div class="tool-calls-card">
<div class="tool-calls-header">
<span class="tool-calls-title">工具调用</span>
<span v-if="isPending" class="tool-calls-badge badge--pending">待确认</span>
<span v-else-if="isExecuting" class="tool-calls-badge badge--executing">执行中</span>
<span v-else-if="isDone" class="tool-calls-badge badge--done">已完成</span>
<span v-else-if="isSkipped" class="tool-calls-badge badge--skipped">已跳过</span>
</div>
<!-- 每个工具调用 -->
<div
v-for="call in group.calls"
:key="call.id"
class="tool-item"
:class="{ 'tool-item--danger': call.name.includes('delete') }"
>
<div class="tool-item-header">
<svg class="tool-item-icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path :d="getIcon(call.name)" :stroke="getColor(call.name)" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" fill="none" />
</svg>
<span class="tool-item-label" :style="{ color: getColor(call.name) }">{{ call.label }}</span>
</div>
<!-- 参数 -->
<div v-if="formatArgs(call)" class="tool-item-args">{{ formatArgs(call) }}</div>
<!-- 结果 -->
<div v-if="call.result" class="tool-item-result">
<pre class="result-text">{{ formatResult(call) }}</pre>
</div>
</div>
<!-- 审批按钮 -->
<div v-if="isPending" class="tool-actions">
<button class="tool-btn tool-btn--approve" @click="emit('approve')">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M3 7l3 3 5-5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
执行
</button>
<button class="tool-btn tool-btn--skip" @click="emit('skip')">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M10 4L4 10M4 4l6 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
跳过
</button>
</div>
<!-- 执行中指示 -->
<div v-if="isExecuting" class="tool-executing">
<span class="dot" /><span class="dot" /><span class="dot" />
</div>
</div>
</template>
<style scoped>
.tool-calls-card {
background: var(--paper-50);
border: 1px solid var(--paper-200);
border-radius: var(--radius-md);
padding: var(--space-sm) var(--space-md);
margin-bottom: var(--space-md);
animation: fadeInUp var(--duration-fast) var(--ease-out-smooth) both;
}
.tool-calls-header {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
}
.tool-calls-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--ink-400);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.tool-calls-badge {
font-size: 0.68rem;
padding: 1px 6px;
border-radius: 8px;
font-weight: 500;
}
.badge--pending {
background: rgba(212, 160, 23, 0.12);
color: #b8860b;
}
.badge--executing {
background: rgba(91, 140, 90, 0.12);
color: var(--bamboo);
animation: toolPulse 1.5s ease-in-out infinite;
}
.badge--done {
background: rgba(91, 140, 90, 0.12);
color: var(--bamboo);
}
.badge--skipped {
background: var(--paper-100);
color: var(--ink-300);
}
.tool-item {
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
background: var(--paper-100);
margin-bottom: var(--space-xs);
}
.tool-item--danger {
background: rgba(220, 53, 69, 0.04);
}
.tool-item-header {
display: flex;
align-items: center;
gap: 6px;
}
.tool-item-icon {
flex-shrink: 0;
}
.tool-item-label {
font-size: 0.82rem;
font-weight: 600;
}
.tool-item-args {
font-size: 0.78rem;
color: var(--ink-500);
margin-top: 2px;
padding-left: 22px;
word-break: break-word;
}
.tool-item-result {
margin-top: 4px;
padding-left: 22px;
}
.result-text {
font-size: 0.75rem;
color: var(--ink-600);
background: var(--paper-50);
border: 1px solid var(--paper-200);
border-radius: var(--radius-sm);
padding: var(--space-xs) var(--space-sm);
margin: 0;
white-space: pre-wrap;
word-break: break-word;
max-height: 120px;
overflow-y: auto;
font-family: var(--font-body);
}
/* 审批按钮 */
.tool-actions {
display: flex;
gap: var(--space-sm);
margin-top: var(--space-sm);
justify-content: flex-end;
}
.tool-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: var(--radius-sm);
border: 1px solid;
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
transition: all var(--duration-fast) ease;
}
.tool-btn--approve {
background: var(--bamboo);
border-color: var(--bamboo);
color: white;
}
.tool-btn--approve:hover {
opacity: 0.85;
}
.tool-btn--skip {
background: var(--paper-50);
border-color: var(--paper-300);
color: var(--ink-500);
}
.tool-btn--skip:hover {
background: var(--paper-100);
color: var(--ink-700);
}
/* 执行中 */
.tool-executing {
display: flex;
gap: 4px;
justify-content: center;
padding: var(--space-xs) 0;
}
.tool-executing .dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--bamboo);
animation: typingBounce 1.4s ease-in-out infinite;
}
.tool-executing .dot:nth-child(2) { animation-delay: 0.2s; }
.tool-executing .dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes toolPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes typingBounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-4px); }
}
</style>

View File

@@ -1,30 +1,59 @@
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { streamChat, fetchMessages, clearMessages as apiClearMessages } from '@/api/chat'
import type { ChatMessage } from '@/types/chat'
import type { TokenUsage } from '@/api/chat'
import type { ChatMessage, ChatEntry, ToolCallGroup, ToolCallEntry } from '@/types/chat'
import type { TokenUsage, PendingToolCallData } from '@/api/chat'
// 全局状态(跨组件共享)
const messages = ref<ChatMessage[]>([])
const entries = ref<ChatEntry[]>([])
const isStreaming = ref(false)
const streamingContent = ref('')
const lastUsage = ref<TokenUsage | null>(null)
const toolsEnabled = ref(true)
let abortController: AbortController | null = null
let idCounter = Date.now()
// 路由名称 → 页面上下文描述
const PAGE_LABELS: Record<string, string> = {
'novel-detail': '小说详情页',
'novel-outlines': '大纲管理页',
'novel-chapters': '章节管理页',
'novel-characters': '角色管理页',
'novel-world-setting': '世界观设定页',
'novel-edit': '小说编辑页',
}
export function useChatAgent() {
const route = useRoute()
const currentNovelId = computed(() => {
const id = route.params.id
return id ? Number(id) : undefined
})
const pageContext = computed(() => {
const name = route.name as string
return PAGE_LABELS[name] ?? undefined
})
/** 仅普通消息(用于发送给 API */
const chatMessages = computed(() =>
entries.value.filter((e): e is ChatMessage => !('type' in e))
)
async function loadHistory(novelId?: number) {
try {
const list = await fetchMessages(novelId)
messages.value = list.items
const list = await fetchMessages(novelId ?? currentNovelId.value)
entries.value = list.items as ChatEntry[]
} catch {
// 静默失败
// 静默
}
}
async function sendMessage(content: string, novelId?: number) {
async function sendMessage(content: string) {
if (isStreaming.value || !content.trim()) return
const novelId = currentNovelId.value
// 添加用户消息到列表
const userMsg: ChatMessage = {
id: idCounter++,
novel_id: novelId ?? null,
@@ -32,49 +61,136 @@ export function useChatAgent() {
content: content.trim(),
created_at: new Date().toISOString(),
}
messages.value.push(userMsg)
entries.value.push(userMsg)
// 开始流式请求
await _doStreamRequest(novelId)
}
/** 用户批准工具调用 */
async function approveToolCalls(group: ToolCallGroup) {
group.status = 'executing'
const novelId = currentNovelId.value
await _doStreamRequest(novelId, group)
}
/** 用户跳过工具调用 */
function skipToolCalls(group: ToolCallGroup) {
group.status = 'skipped'
// 把 LLM 在工具调用前的文本作为 assistant 消息保存
if (group.assistantText) {
entries.value.push({
id: idCounter++,
novel_id: currentNovelId.value ?? null,
role: 'assistant',
content: group.assistantText,
created_at: new Date().toISOString(),
})
}
isStreaming.value = false
}
/** 核心:发起流式请求 */
async function _doStreamRequest(novelId?: number, approvedGroup?: ToolCallGroup) {
isStreaming.value = true
streamingContent.value = ''
abortController = new AbortController()
// 构建发送给API的消息列表取最近20条
const recentMessages = messages.value
.filter(m => m.role === 'user' || m.role === 'assistant')
// 构建消息列表(取最近 20 条)
const recentMessages = chatMessages.value
.slice(-20)
.map(m => ({ role: m.role, content: m.content }))
// 审批续传参数
const pendingToolCalls = approvedGroup
? approvedGroup.calls.map(c => ({ id: c.id, name: c.name, label: c.label, arguments: c.arguments }))
: undefined
const assistantText = approvedGroup?.assistantText
await streamChat({
messages: recentMessages,
novelId,
pageContext: pageContext.value,
toolsEnabled: toolsEnabled.value,
pendingToolCalls,
assistantText,
signal: abortController.signal,
onChunk(text) {
streamingContent.value += text
},
onToolCallsPending(calls: PendingToolCallData[], aText: string) {
// 先把当前 streaming 文本保存(如果有)
// LLM 在调用工具前可能输出了一些文本
if (streamingContent.value) {
// 不作为独立消息,因为它会在续传后被合并
// 这段文本已包含在 aText 中
}
streamingContent.value = ''
// 创建工具调用组
const group: ToolCallGroup = {
id: idCounter++,
type: 'tool_calls',
calls: calls.map(c => ({
id: c.id,
name: c.name,
label: c.label,
arguments: c.arguments,
})),
status: 'pending',
assistantText: aText,
}
entries.value.push(group)
},
onToolResult(info) {
// 找到正在执行的工具组,更新对应工具的结果
const group = [...entries.value].reverse().find(
(e): e is ToolCallGroup => 'type' in e && e.type === 'tool_calls' && e.status === 'executing'
)
if (group) {
const call = group.calls.find(c => c.name === info.name)
if (call) {
call.result = info.result
try { call.parsedResult = JSON.parse(info.result) } catch { /* ok */ }
}
// 所有工具都有结果 → 标记完成
if (group.calls.every(c => c.result)) {
group.status = 'done'
}
}
},
onError(error) {
const errMsg: ChatMessage = {
entries.value.push({
id: idCounter++,
novel_id: novelId ?? null,
role: 'assistant',
content: `⚠️ ${error}`,
created_at: new Date().toISOString(),
}
messages.value.push(errMsg)
})
},
onDone(usage) {
onDone(usage, pending) {
if (usage) {
lastUsage.value = usage
}
if (pending) {
// 工具调用待审批,保持 isStreaming 状态让 UI 知道对话还没结束
// 但不再显示加载指示
isStreaming.value = false
return
}
// 正常完成
if (streamingContent.value) {
const aiMsg: ChatMessage = {
entries.value.push({
id: idCounter++,
novel_id: novelId ?? null,
role: 'assistant',
content: streamingContent.value,
created_at: new Date().toISOString(),
}
messages.value.push(aiMsg)
})
}
streamingContent.value = ''
isStreaming.value = false
@@ -90,24 +206,29 @@ export function useChatAgent() {
}
}
async function clearChat(novelId?: number) {
async function clearChat() {
try {
await apiClearMessages(novelId)
await apiClearMessages(currentNovelId.value)
} catch {
// 静默
}
messages.value = []
entries.value = []
streamingContent.value = ''
lastUsage.value = null
}
return {
messages,
entries,
isStreaming,
streamingContent,
lastUsage,
toolsEnabled,
currentNovelId,
pageContext,
loadHistory,
sendMessage,
approveToolCalls,
skipToolCalls,
stopStreaming,
clearChat,
}

View File

@@ -28,6 +28,21 @@ const router = createRouter({
name: 'novel-outlines',
component: () => import('@/views/OutlineView.vue'),
},
{
path: '/novels/:id/chapters',
name: 'novel-chapters',
component: () => import('@/views/ChapterView.vue'),
},
{
path: '/novels/:id/characters',
name: 'novel-characters',
component: () => import('@/views/CharacterView.vue'),
},
{
path: '/novels/:id/world-setting',
name: 'novel-world-setting',
component: () => import('@/views/WorldSettingView.vue'),
},
],
})

View File

@@ -0,0 +1,29 @@
export interface Chapter {
id: number
novel_id: number
sort_order: number
title: string
content: string
created_at: string
updated_at: string
}
export interface ChapterCreate {
title: string
content?: string
}
export interface ChapterUpdate {
title?: string
content?: string
sort_order?: number
}
export interface ChapterList {
items: Chapter[]
total: number
}
export interface ChapterReorder {
ordered_ids: number[]
}

View File

@@ -0,0 +1,23 @@
export interface Character {
id: number
novel_id: number
name: string
description: string
created_at: string
updated_at: string
}
export interface CharacterCreate {
name: string
description?: string
}
export interface CharacterUpdate {
name?: string
description?: string
}
export interface CharacterList {
items: Character[]
total: number
}

View File

@@ -6,6 +6,31 @@ export interface ChatMessage {
created_at: string
}
/** 工具调用组(在对话中显示) */
export interface ToolCallGroup {
id: number
type: 'tool_calls'
calls: ToolCallEntry[]
status: 'pending' | 'executing' | 'done' | 'skipped'
assistantText: string // LLM 在调用工具前输出的文本
}
export interface ToolCallEntry {
id: string
name: string
label: string
arguments: Record<string, unknown>
result?: string
parsedResult?: unknown
}
/** 对话条目:普通消息或工具调用组 */
export type ChatEntry = ChatMessage | ToolCallGroup
export function isToolCallGroup(entry: ChatEntry): entry is ToolCallGroup {
return 'type' in entry && entry.type === 'tool_calls'
}
export interface ChatMessageList {
items: ChatMessage[]
total: number

View File

@@ -0,0 +1,11 @@
export interface WorldSetting {
id: number
novel_id: number
content: string
created_at: string
updated_at: string
}
export interface WorldSettingUpdate {
content: string
}

View File

@@ -0,0 +1,340 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchNovel } from '@/api/novels'
import { fetchChapters, createChapter, updateChapter, deleteChapter, reorderChapters } from '@/api/chapters'
import { useToast } from '@/composables/useToast'
import type { Novel } from '@/types/novel'
import type { Chapter } from '@/types/chapter'
import BaseButton from '@/components/ui/BaseButton.vue'
import ChapterList from '@/components/chapter/ChapterList.vue'
import ChapterEditor from '@/components/chapter/ChapterEditor.vue'
import ChapterEmptyState from '@/components/chapter/ChapterEmptyState.vue'
import ChapterFormModal from '@/components/chapter/ChapterFormModal.vue'
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const novelId = Number(route.params.id)
const novel = ref<Novel | null>(null)
const chapters = ref<Chapter[]>([])
const loading = ref(true)
// 当前选中的章节(用于编辑)
const activeChapter = ref<Chapter | null>(null)
// 表单弹窗(新建/重命名)
const showForm = ref(false)
const editingChapter = ref<Chapter | null>(null)
const formSaving = ref(false)
// 编辑器保存状态
const editorSaving = ref(false)
// 删除确认
const showDeleteConfirm = ref(false)
const deletingChapter = ref<Chapter | null>(null)
const deleting = ref(false)
const totalChapters = computed(() => chapters.value.length)
async function load() {
loading.value = true
try {
const [n, list] = await Promise.all([
fetchNovel(novelId),
fetchChapters(novelId),
])
novel.value = n
chapters.value = list.items
} catch {
toast.error('加载失败')
router.push({ name: 'home' })
} finally {
loading.value = false
}
}
function openCreate() {
editingChapter.value = null
showForm.value = true
}
function openRename(chapter: Chapter) {
editingChapter.value = chapter
showForm.value = true
}
function selectChapter(chapter: Chapter) {
activeChapter.value = chapter
}
function backToList() {
activeChapter.value = null
}
async function handleFormSubmit(data: { title: string }) {
formSaving.value = true
try {
if (editingChapter.value) {
const updated = await updateChapter(novelId, editingChapter.value.id, { title: data.title })
const idx = chapters.value.findIndex(c => c.id === updated.id)
if (idx !== -1) chapters.value[idx] = updated
if (activeChapter.value?.id === updated.id) activeChapter.value = updated
toast.success('章节已重命名')
} else {
const created = await createChapter(novelId, { title: data.title })
chapters.value.push(created)
activeChapter.value = created
toast.success('章节已创建')
}
showForm.value = false
} catch {
toast.error('保存失败')
} finally {
formSaving.value = false
}
}
async function handleEditorSave(data: { title: string; content: string }) {
if (!activeChapter.value) return
editorSaving.value = true
try {
const updated = await updateChapter(novelId, activeChapter.value.id, {
title: data.title,
content: data.content,
})
const idx = chapters.value.findIndex(c => c.id === updated.id)
if (idx !== -1) chapters.value[idx] = updated
activeChapter.value = updated
toast.success('章节已保存')
} catch {
toast.error('保存失败')
} finally {
editorSaving.value = false
}
}
function confirmDelete(chapter: Chapter) {
deletingChapter.value = chapter
showDeleteConfirm.value = true
}
async function handleDelete() {
if (!deletingChapter.value) return
deleting.value = true
try {
await deleteChapter(novelId, deletingChapter.value.id)
if (activeChapter.value?.id === deletingChapter.value.id) {
activeChapter.value = null
}
chapters.value = chapters.value.filter(c => c.id !== deletingChapter.value!.id)
toast.success('章节已删除')
showDeleteConfirm.value = false
} catch {
toast.error('删除失败')
} finally {
deleting.value = false
}
}
async function handleReorder(reordered: Chapter[]) {
chapters.value = reordered
try {
const result = await reorderChapters(novelId, reordered.map(c => c.id))
chapters.value = result.items
} catch {
toast.error('排序保存失败')
await load()
}
}
onMounted(load)
</script>
<template>
<div v-if="!loading && novel" class="chapter-view">
<div class="chapter-header">
<BaseButton variant="ghost" @click="router.push({ name: 'novel-detail', params: { id: novelId } })">
&larr; 返回
</BaseButton>
<div class="header-center">
<h1 class="chapter-title">{{ novel.title }}</h1>
<p class="chapter-subtitle">章节管理 · {{ totalChapters }} </p>
</div>
<BaseButton v-if="totalChapters > 0" variant="primary" @click="openCreate">
新建章节
</BaseButton>
</div>
<template v-if="totalChapters > 0">
<!-- 双栏布局 -->
<div class="chapter-layout">
<aside class="chapter-sidebar" :class="{ 'chapter-sidebar--hidden': activeChapter }">
<ChapterList
:chapters="chapters"
:active-id="activeChapter?.id ?? null"
@select="selectChapter"
@edit="openRename"
@delete="confirmDelete"
@reorder="handleReorder"
/>
</aside>
<main v-if="activeChapter" class="chapter-main">
<ChapterEditor
:chapter="activeChapter"
:saving="editorSaving"
@save="handleEditorSave"
@back="backToList"
/>
</main>
<div v-else class="chapter-placeholder">
<p>选择一个章节开始编辑</p>
</div>
</div>
</template>
<ChapterEmptyState v-else @create="openCreate" />
<ChapterFormModal
:show="showForm"
:chapter="editingChapter"
:saving="formSaving"
@submit="handleFormSubmit"
@close="showForm = false"
/>
<ConfirmDialog
:show="showDeleteConfirm"
title="确认删除"
:message="`确定要删除「${deletingChapter?.title}」吗?章节内容将无法恢复。`"
confirm-text="删除"
:loading="deleting"
@confirm="handleDelete"
@cancel="showDeleteConfirm = false"
/>
</div>
<div v-else-if="loading" class="chapter-loading">
<div class="loading-pulse" style="height: 2rem; width: 40%; margin: 0 auto var(--space-lg)" />
<div class="loading-pulse" style="height: 1rem; width: 25%; margin: 0 auto var(--space-2xl)" />
<div class="loading-layout">
<div class="loading-pulse" style="height: 300px; width: 280px" />
<div class="loading-pulse" style="height: 300px; flex: 1" />
</div>
</div>
</template>
<style scoped>
.chapter-view {
max-width: 1100px;
margin: 0 auto;
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
}
.chapter-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-md);
margin-bottom: var(--space-2xl);
}
.header-center {
flex: 1;
text-align: center;
}
.chapter-title {
font-family: var(--font-display);
font-size: 1.8rem;
font-weight: 700;
color: var(--ink-900);
line-height: 1.3;
}
.chapter-subtitle {
font-size: 0.85rem;
color: var(--ink-400);
margin-top: var(--space-xs);
}
/* 双栏布局 */
.chapter-layout {
display: flex;
gap: var(--space-xl);
min-height: 500px;
}
.chapter-sidebar {
width: 300px;
flex-shrink: 0;
}
.chapter-main {
flex: 1;
min-width: 0;
background: var(--paper-50);
border: 1px solid var(--paper-200);
border-radius: var(--radius-md);
padding: var(--space-xl);
}
.chapter-placeholder {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--paper-50);
border: 1px dashed var(--paper-300);
border-radius: var(--radius-md);
color: var(--ink-300);
font-size: 0.9rem;
}
/* Loading */
.chapter-loading {
max-width: 1100px;
margin: var(--space-3xl) auto;
}
.loading-layout {
display: flex;
gap: var(--space-xl);
}
.loading-pulse {
background: linear-gradient(
90deg,
var(--paper-100) 25%,
var(--paper-200) 50%,
var(--paper-100) 75%
);
background-size: 200% 100%;
border-radius: var(--radius-sm);
animation: shimmer 1.5s ease infinite;
}
/* 小屏适配:隐藏侧边栏,编辑器全屏 */
@media (max-width: 768px) {
.chapter-layout {
flex-direction: column;
}
.chapter-sidebar {
width: 100%;
}
.chapter-sidebar--hidden {
display: none;
}
.chapter-placeholder {
display: none;
}
}
</style>

View File

@@ -0,0 +1,215 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchNovel } from '@/api/novels'
import { fetchCharacters, createCharacter, updateCharacter, deleteCharacter } from '@/api/characters'
import { useToast } from '@/composables/useToast'
import type { Novel } from '@/types/novel'
import type { Character, CharacterCreate, CharacterUpdate } from '@/types/character'
import BaseButton from '@/components/ui/BaseButton.vue'
import CharacterGrid from '@/components/character/CharacterGrid.vue'
import CharacterEmptyState from '@/components/character/CharacterEmptyState.vue'
import CharacterFormModal from '@/components/character/CharacterFormModal.vue'
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const novelId = Number(route.params.id)
const novel = ref<Novel | null>(null)
const characters = ref<Character[]>([])
const loading = ref(true)
// 表单弹窗
const showForm = ref(false)
const editingCharacter = ref<Character | null>(null)
const formSaving = ref(false)
// 删除确认
const showDeleteConfirm = ref(false)
const deletingCharacter = ref<Character | null>(null)
const deleting = ref(false)
const totalCharacters = computed(() => characters.value.length)
async function load() {
loading.value = true
try {
const [n, list] = await Promise.all([
fetchNovel(novelId),
fetchCharacters(novelId),
])
novel.value = n
characters.value = list.items
} catch {
toast.error('加载失败')
router.push({ name: 'home' })
} finally {
loading.value = false
}
}
function openCreate() {
editingCharacter.value = null
showForm.value = true
}
function openEdit(character: Character) {
editingCharacter.value = character
showForm.value = true
}
async function handleFormSubmit(data: CharacterCreate | CharacterUpdate) {
formSaving.value = true
try {
if (editingCharacter.value) {
const updated = await updateCharacter(novelId, editingCharacter.value.id, data as CharacterUpdate)
const idx = characters.value.findIndex(c => c.id === updated.id)
if (idx !== -1) characters.value[idx] = updated
toast.success('角色已更新')
} else {
const created = await createCharacter(novelId, data as CharacterCreate)
characters.value.push(created)
toast.success('角色已创建')
}
showForm.value = false
} catch {
toast.error('保存失败')
} finally {
formSaving.value = false
}
}
function confirmDelete(character: Character) {
deletingCharacter.value = character
showDeleteConfirm.value = true
}
async function handleDelete() {
if (!deletingCharacter.value) return
deleting.value = true
try {
await deleteCharacter(novelId, deletingCharacter.value.id)
characters.value = characters.value.filter(c => c.id !== deletingCharacter.value!.id)
toast.success('角色已删除')
showDeleteConfirm.value = false
} catch {
toast.error('删除失败')
} finally {
deleting.value = false
}
}
onMounted(load)
</script>
<template>
<div v-if="!loading && novel" class="character-view">
<div class="character-header">
<BaseButton variant="ghost" @click="router.push({ name: 'novel-detail', params: { id: novelId } })">
&larr; 返回
</BaseButton>
<div class="header-center">
<h1 class="character-title">{{ novel.title }}</h1>
<p class="character-subtitle">角色管理 · {{ totalCharacters }} 个角色</p>
</div>
<BaseButton v-if="totalCharacters > 0" variant="primary" @click="openCreate">
添加角色
</BaseButton>
</div>
<CharacterGrid
v-if="totalCharacters > 0"
:characters="characters"
@edit="openEdit"
@delete="confirmDelete"
/>
<CharacterEmptyState v-else @create="openCreate" />
<CharacterFormModal
:show="showForm"
:character="editingCharacter"
:saving="formSaving"
@submit="handleFormSubmit"
@close="showForm = false"
/>
<ConfirmDialog
:show="showDeleteConfirm"
title="确认删除"
:message="`确定要删除角色「${deletingCharacter?.name}」吗?`"
confirm-text="删除"
:loading="deleting"
@confirm="handleDelete"
@cancel="showDeleteConfirm = false"
/>
</div>
<div v-else-if="loading" class="character-loading">
<div class="loading-pulse" style="height: 2rem; width: 40%; margin: 0 auto var(--space-lg)" />
<div class="loading-pulse" style="height: 1rem; width: 25%; margin: 0 auto var(--space-2xl)" />
<div class="loading-grid">
<div v-for="i in 4" :key="i" class="loading-pulse" style="height: 120px" />
</div>
</div>
</template>
<style scoped>
.character-view {
max-width: 900px;
margin: 0 auto;
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
}
.character-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-md);
margin-bottom: var(--space-2xl);
}
.header-center {
flex: 1;
text-align: center;
}
.character-title {
font-family: var(--font-display);
font-size: 1.8rem;
font-weight: 700;
color: var(--ink-900);
line-height: 1.3;
}
.character-subtitle {
font-size: 0.85rem;
color: var(--ink-400);
margin-top: var(--space-xs);
}
/* Loading */
.character-loading {
max-width: 900px;
margin: var(--space-3xl) auto;
}
.loading-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-lg);
}
.loading-pulse {
background: linear-gradient(
90deg,
var(--paper-100) 25%,
var(--paper-200) 50%,
var(--paper-100) 75%
);
background-size: 200% 100%;
border-radius: var(--radius-sm);
animation: shimmer 1.5s ease infinite;
}
</style>

View File

@@ -87,6 +87,15 @@ onMounted(load)
<RouterLink :to="{ name: 'novel-outlines', params: { id: novel.id } }">
<BaseButton variant="primary">大纲</BaseButton>
</RouterLink>
<RouterLink :to="{ name: 'novel-chapters', params: { id: novel.id } }">
<BaseButton variant="primary">章节</BaseButton>
</RouterLink>
<RouterLink :to="{ name: 'novel-characters', params: { id: novel.id } }">
<BaseButton variant="primary">角色</BaseButton>
</RouterLink>
<RouterLink :to="{ name: 'novel-world-setting', params: { id: novel.id } }">
<BaseButton variant="primary">世界观</BaseButton>
</RouterLink>
<RouterLink :to="{ name: 'novel-edit', params: { id: novel.id } }">
<BaseButton variant="secondary">编辑</BaseButton>
</RouterLink>

View File

@@ -0,0 +1,212 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchNovel } from '@/api/novels'
import { fetchWorldSetting, saveWorldSetting } from '@/api/worldSetting'
import { useToast } from '@/composables/useToast'
import type { Novel } from '@/types/novel'
import BaseButton from '@/components/ui/BaseButton.vue'
import BaseTextarea from '@/components/ui/BaseTextarea.vue'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const novelId = Number(route.params.id)
const novel = ref<Novel | null>(null)
const content = ref('')
const loading = ref(true)
const saving = ref(false)
const hasChanges = ref(false)
const contentLength = computed(() => content.value.length)
const contentHint = computed(() => {
if (contentLength.value === 0) return '建议至少 500 字'
if (contentLength.value < 500) return `${contentLength.value} 字,建议至少 500 字`
return `${contentLength.value}`
})
async function load() {
loading.value = true
try {
const [n, ws] = await Promise.all([
fetchNovel(novelId),
fetchWorldSetting(novelId),
])
novel.value = n
content.value = ws.content
} catch {
toast.error('加载失败')
router.push({ name: 'home' })
} finally {
loading.value = false
}
}
function onInput() {
hasChanges.value = true
}
async function handleSave() {
saving.value = true
try {
await saveWorldSetting(novelId, { content: content.value })
hasChanges.value = false
toast.success('世界观已保存')
} catch {
toast.error('保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<template>
<div v-if="!loading && novel" class="world-view">
<div class="world-header">
<BaseButton variant="ghost" @click="router.push({ name: 'novel-detail', params: { id: novelId } })">
&larr; 返回
</BaseButton>
<div class="header-center">
<h1 class="world-title">{{ novel.title }}</h1>
<p class="world-subtitle">世界观设定</p>
</div>
<BaseButton
variant="primary"
:loading="saving"
:disabled="!hasChanges"
@click="handleSave"
>
保存
</BaseButton>
</div>
<div class="editor-container">
<div class="editor-hint">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.2" />
<path d="M8 4v5M8 11v1" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
<span>描述故事发生的世界背景历史规则地理环境等设定</span>
</div>
<BaseTextarea
v-model="content"
placeholder="在此描述你的故事世界..."
:rows="16"
:maxlength="5000"
:max-height="600"
@input="onInput"
/>
<div class="editor-footer">
<span class="word-count" :class="{ 'word-count--low': contentLength < 500 && contentLength > 0 }">
{{ contentHint }}
</span>
<span v-if="hasChanges" class="unsaved-hint">未保存的更改</span>
</div>
</div>
</div>
<div v-else-if="loading" class="world-loading">
<div class="loading-pulse" style="height: 2rem; width: 40%; margin: 0 auto var(--space-lg)" />
<div class="loading-pulse" style="height: 1rem; width: 20%; margin: 0 auto var(--space-2xl)" />
<div class="loading-pulse" style="height: 300px; width: 100%" />
</div>
</template>
<style scoped>
.world-view {
max-width: 800px;
margin: 0 auto;
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
}
.world-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-md);
margin-bottom: var(--space-2xl);
}
.header-center {
flex: 1;
text-align: center;
}
.world-title {
font-family: var(--font-display);
font-size: 1.8rem;
font-weight: 700;
color: var(--ink-900);
line-height: 1.3;
}
.world-subtitle {
font-size: 0.85rem;
color: var(--ink-400);
margin-top: var(--space-xs);
}
.editor-container {
background: var(--paper-50);
border: 1px solid var(--paper-200);
border-radius: var(--radius-md);
padding: var(--space-xl);
}
.editor-hint {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 0.85rem;
color: var(--ink-400);
margin-bottom: var(--space-lg);
padding: var(--space-sm) var(--space-md);
background: var(--paper-100);
border-radius: var(--radius-sm);
}
.editor-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--space-sm);
}
.word-count {
font-size: 0.75rem;
color: var(--ink-300);
}
.word-count--low {
color: var(--vermilion);
}
.unsaved-hint {
font-size: 0.75rem;
color: var(--vermilion);
font-style: italic;
}
/* Loading */
.world-loading {
max-width: 800px;
margin: var(--space-3xl) auto;
}
.loading-pulse {
background: linear-gradient(
90deg,
var(--paper-100) 25%,
var(--paper-200) 50%,
var(--paper-100) 75%
);
background-size: 200% 100%;
border-radius: var(--radius-sm);
animation: shimmer 1.5s ease infinite;
}
</style>