引入二级大纲功能,新增父子节点关系及子节点排序,同时调整API与UI以支持嵌套大纲管理

This commit is contained in:
2026-03-22 16:53:33 +08:00
parent 20c759150a
commit 098ff4e16d
33 changed files with 2516 additions and 381 deletions

View File

@@ -33,6 +33,22 @@ export async function clearMessages(novelId?: number): Promise<void> {
await http.delete('/chat/messages', { params })
}
export interface CompressResult {
compressed: boolean
reason?: string
error?: string
removed_count?: number
kept_count?: number
summary_length?: number
}
export async function compressContext(novelId?: number): Promise<CompressResult> {
const params: Record<string, unknown> = {}
if (novelId !== undefined) params.novel_id = novelId
const { data } = await http.post<CompressResult>('/chat/compress', null, { params })
return data
}
// ── 流式聊天 ──
export interface TokenUsage {
@@ -59,6 +75,7 @@ export interface StreamChatOptions {
novelId?: number
pageContext?: string
toolsEnabled?: boolean
autoApproveTools?: boolean
// 工具审批续传
pendingToolCalls?: PendingToolCallData[]
assistantText?: string
@@ -67,15 +84,16 @@ export interface StreamChatOptions {
onError: (error: string) => void
onDone: (usage?: TokenUsage, pending?: boolean) => void
onToolCallsPending?: (calls: PendingToolCallData[], assistantText: string) => void
onToolCallsAuto?: (calls: PendingToolCallData[]) => void
onToolResult?: (info: ToolResultInfo) => void
signal?: AbortSignal
}
export async function streamChat(options: StreamChatOptions): Promise<void> {
const {
messages, novelId, pageContext, toolsEnabled,
messages, novelId, pageContext, toolsEnabled, autoApproveTools,
pendingToolCalls, assistantText,
onChunk, onError, onDone, onToolCallsPending, onToolResult,
onChunk, onError, onDone, onToolCallsPending, onToolCallsAuto, onToolResult,
signal,
} = options
@@ -84,6 +102,7 @@ export async function streamChat(options: StreamChatOptions): Promise<void> {
novel_id: novelId ?? null,
page_context: pageContext ?? null,
tools_enabled: toolsEnabled ?? true,
auto_approve_tools: autoApproveTools ?? false,
pending_tool_calls: pendingToolCalls ?? null,
assistant_text: assistantText ?? null,
})
@@ -133,6 +152,9 @@ export async function streamChat(options: StreamChatOptions): Promise<void> {
if (event.tool_calls_pending && onToolCallsPending) {
onToolCallsPending(event.tool_calls_pending, event.assistant_text ?? '')
}
if (event.tool_calls_auto && onToolCallsAuto) {
onToolCallsAuto(event.tool_calls_auto)
}
if (event.tool_result && onToolResult) {
onToolResult(event.tool_result)
}

View File

@@ -31,3 +31,8 @@ export async function reorderOutlines(novelId: number, orderedIds: number[]): Pr
const { data } = await http.put<OutlineList>(`${base(novelId)}/reorder`, { ordered_ids: orderedIds })
return data
}
export async function reorderChildren(novelId: number, outlineId: number, orderedIds: number[]): Promise<Outline> {
const { data } = await http.put<Outline>(`${base(novelId)}/${outlineId}/children/reorder`, { ordered_ids: orderedIds })
return data
}

View File

@@ -1,13 +1,15 @@
<script setup lang="ts">
import { ref, computed, nextTick, watch, onMounted } from 'vue'
import { marked } from 'marked'
import { useChatAgent } from '@/composables/useChatAgent'
import { useToast } from '@/composables/useToast'
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<{
const props = defineProps<{
open: boolean
}>()
@@ -16,13 +18,14 @@ const emit = defineEmits<{
}>()
const {
entries, isStreaming, streamingContent, lastUsage,
toolsEnabled, currentNovelId,
entries, isStreaming, isCompressing, streamingContent, lastUsage,
toolsEnabled, autoApproveTools, currentNovelId,
loadHistory, sendMessage, approveToolCalls, skipToolCalls,
stopStreaming, clearChat,
stopStreaming, clearChat, compressChat,
} = useChatAgent()
const input = ref('')
const toast = useToast()
const messagesRef = ref<HTMLElement>()
const showConfig = ref(false)
@@ -54,11 +57,27 @@ const hasPendingTools = computed(() =>
entries.value.some(e => isToolCallGroup(e) && e.status === 'pending')
)
async function handleCompress() {
const result = await compressChat()
if (result.success) {
toast.success(result.message)
} else {
toast.info(result.message)
}
}
function renderMarkdown(text: string): string {
return marked.parse(text) as string
}
function scrollToBottom() {
// 双重 nextTick第一次等 v-if 渲染面板 DOM第二次等内容渲染完成
nextTick(() => {
if (messagesRef.value) {
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
}
nextTick(() => {
if (messagesRef.value) {
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
}
})
})
}
@@ -84,8 +103,11 @@ function handleSkip(group: ToolCallGroup) {
skipToolCalls(group)
}
// 自动滚动
// 自动滚动:消息变化时、面板打开时
watch([entries, streamingContent], scrollToBottom)
watch(() => props.open, (isOpen) => {
if (isOpen) scrollToBottom()
})
onMounted(() => {
loadHistory()
@@ -106,6 +128,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
class="icon-btn"
:class="{ 'icon-btn--compressing': isCompressing }"
:title="isCompressing ? '正在压缩上下文...' : '压缩上下文(总结旧消息,释放空间)'"
:disabled="isCompressing || isStreaming"
@click="handleCompress"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M4 2v4l4-2-4-2zM12 14v-4l-4 2 4 2zM2 8h12" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button
v-if="currentNovelId"
class="icon-btn"
@@ -117,6 +150,22 @@ onMounted(() => {
<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
v-if="currentNovelId && toolsEnabled"
class="icon-btn"
:class="{ 'icon-btn--active': autoApproveTools }"
:title="autoApproveTools ? '工具自动执行(点击切换为需审批)' : '工具需审批(点击切换为自动执行)'"
@click="autoApproveTools = !autoApproveTools"
>
<!-- 自动执行闪电图标需审批盾牌图标 -->
<svg v-if="autoApproveTools" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8.5 1L3.5 9H7.5L7 15L12.5 7H8.5L8.5 1Z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<svg v-else width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8 1.5L2.5 4V7.5C2.5 11 5 13.5 8 14.5C11 13.5 13.5 11 13.5 7.5V4L8 1.5Z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M6 8L7.5 9.5L10 6.5" 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" />
@@ -158,6 +207,19 @@ onMounted(() => {
@approve="handleApprove(entry as ToolCallGroup)"
@skip="handleSkip(entry as ToolCallGroup)"
/>
<!-- 对话摘要压缩后的历史总结 -->
<div
v-else-if="entry.role === 'assistant' && entry.content.startsWith('[对话摘要]')"
class="context-summary"
>
<div class="summary-header">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path d="M4 2v4l4-2-4-2zM12 14v-4l-4 2 4 2zM2 8h12" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span>上下文摘要</span>
</div>
<div class="summary-content" v-html="renderMarkdown(entry.content.replace('[对话摘要]\n', ''))" />
</div>
<!-- 普通消息 -->
<ChatMessageComp
v-else
@@ -279,6 +341,21 @@ onMounted(() => {
color: var(--ink-900);
}
.icon-btn--compressing {
animation: pulse 1.2s ease-in-out infinite;
color: #d4a017;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.icon-btn:disabled {
opacity: 0.3;
cursor: default;
}
.icon-btn--active {
color: var(--bamboo);
background: rgba(91, 140, 90, 0.1);
@@ -327,6 +404,34 @@ onMounted(() => {
font-family: var(--font-mono, monospace);
}
/* 上下文摘要卡片 */
.context-summary {
margin-bottom: var(--space-md);
padding: var(--space-sm) var(--space-md);
background: linear-gradient(135deg, rgba(212, 160, 23, 0.06), rgba(212, 160, 23, 0.02));
border: 1px dashed rgba(212, 160, 23, 0.3);
border-radius: var(--radius-md);
font-size: 0.82rem;
color: var(--ink-500);
}
.summary-header {
display: flex;
align-items: center;
gap: var(--space-xs);
font-weight: 600;
color: #d4a017;
margin-bottom: var(--space-xs);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.summary-content :deep(p) { margin: 0 0 0.3em; }
.summary-content :deep(p:last-child) { margin-bottom: 0; }
.summary-content :deep(ul) { margin: 0.2em 0; padding-left: 1.3em; }
.summary-content :deep(li) { margin-bottom: 0.1em; }
/* 消息区 */
.panel-messages {
flex: 1;

View File

@@ -18,23 +18,48 @@ 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',
reorder_outlines: 'M3 4h7M3 8h7M3 12h7M12 3l2 2-2 2M12 9l2 2-2 2',
// 世界观
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',
// 角色
list_characters: 'M5.5 5a2.5 2.5 0 105 0 2.5 2.5 0 00-5 0zM3 13c0-2.5 2-4 5-4s5 1.5 5 4',
create_character: 'M5 5a2.5 2.5 0 105 0 2.5 2.5 0 00-5 0zM2 13c0-2.5 2-4 5-4M12 6v5M9.5 8.5h5',
update_character: 'M5 5a2.5 2.5 0 105 0 2.5 2.5 0 00-5 0zM3 13c0-2.5 2-4 5-4M10 10l2-2 2 2-2 2z',
delete_character: 'M5 5a2.5 2.5 0 105 0 2.5 2.5 0 00-5 0zM3 13c0-2.5 2-4 5-4M10 8l4 4M14 8l-4 4',
// 章节
list_chapters: 'M4 2h8v12H4zM6 5h4M6 8h4M6 11h2',
create_chapter: 'M3 2h7v12H3zM12 5v5M9.5 7.5h5',
update_chapter: 'M4 2h8v12H4zM7 7l2-2 2 2-2 2z',
delete_chapter: 'M4 2h8v12H4zM6 6l4 4M10 6l-4 4',
}
// 工具颜色主题
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)',
reorder_outlines: '#d4a017',
// 世界观
get_world_setting: 'var(--ink-500)',
save_world_setting: 'var(--bamboo)',
// 角色
list_characters: '#7c6bc4',
create_character: '#7c6bc4',
update_character: '#d4a017',
delete_character: 'var(--danger)',
// 章节
list_chapters: '#4a90d9',
create_chapter: '#4a90d9',
update_chapter: '#d4a017',
delete_chapter: 'var(--danger)',
}
function getIcon(name: string): string {
@@ -57,10 +82,28 @@ function formatArgs(call: ToolCallEntry): string {
return `#${args.outline_id}` + (args.summary ? `${args.summary}` : '')
case 'delete_outline':
return `#${args.outline_id}`
case 'reorder_outlines': {
const ids = args.ordered_ids as number[]
return ids ? `${ids.length} 个节点` + (args.parent_id ? ` (父节点 #${args.parent_id})` : '') : ''
}
case 'save_world_setting': {
const content = (args.content as string) ?? ''
return content.length > 80 ? content.slice(0, 80) + '…' : content
}
// 角色
case 'create_character':
return args.name as string ?? ''
case 'update_character':
return `#${args.character_id}` + (args.name ? `${args.name}` : '')
case 'delete_character':
return `#${args.character_id}`
// 章节
case 'create_chapter':
return args.title as string ?? ''
case 'update_chapter':
return `#${args.chapter_id}` + (args.title ? `${args.title}` : '')
case 'delete_chapter':
return `#${args.chapter_id}`
default:
return ''
}
@@ -71,7 +114,11 @@ function formatResult(call: ToolCallEntry): string {
if (!call.result) return ''
try {
const data = call.parsedResult ?? JSON.parse(call.result)
// 通用错误检查
if ((data as { error?: string }).error) return `失败: ${(data as { error: string }).error}`
switch (call.name) {
// 大纲
case 'list_outlines': {
const items = (data as { outlines: { id: number; summary: string }[] }).outlines
if (!items?.length) return '暂无大纲'
@@ -80,11 +127,12 @@ function formatResult(call: ToolCallEntry): string {
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 'reorder_outlines':
return `已调整 ${(data as { count: number }).count} 个节点的排序`
// 世界观
case 'get_world_setting': {
const content = (data as { content: string }).content
if (!content) return '暂无世界观设定'
@@ -92,6 +140,30 @@ function formatResult(call: ToolCallEntry): string {
}
case 'save_world_setting':
return `已保存 (${(data as { content_length: number }).content_length} 字)`
// 角色
case 'list_characters': {
const chars = (data as { characters: { id: number; name: string }[] }).characters
if (!chars?.length) return '暂无角色'
return chars.map((c: { id: number; name: string }) => `${c.name}`).join('\n')
}
case 'create_character':
return `已创建: ${(data as { name: string }).name}`
case 'update_character':
return `已更新: ${(data as { name: string }).name}`
case 'delete_character':
return '已删除'
// 章节
case 'list_chapters': {
const chs = (data as { chapters: { id: number; title: string }[] }).chapters
if (!chs?.length) return '暂无章节'
return chs.map((c: { id: number; title: string }) => `${c.title}`).join('\n')
}
case 'create_chapter':
return `已创建: ${(data as { title: string }).title}`
case 'update_chapter':
return `已更新: ${(data as { title: string }).title}`
case 'delete_chapter':
return '已删除'
default:
return call.result.slice(0, 100)
}

View File

@@ -0,0 +1,183 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const props = defineProps<{
novelId: number
novelTitle: string
/** 当前页面的辅助信息,如 "共 5 个节点" */
subtitle?: string
}>()
const route = useRoute()
const NAV_ITEMS = [
{ name: 'novel-outlines', label: '大纲', icon: 'M3 4h10M3 8h10M3 12h6' },
{ name: 'novel-chapters', label: '章节', icon: 'M4 2h8v12H4zM6 5h4M6 8h4' },
{ name: 'novel-characters', label: '角色', icon: 'M5.5 5a2.5 2.5 0 105 0 2.5 2.5 0 00-5 0zM3 13c0-2.5 2-4 5-4s5 1.5 5 4' },
{ name: 'novel-world-setting', label: '世界观', icon: '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' },
]
const currentRoute = computed(() => route.name as string)
</script>
<template>
<div class="sub-nav">
<!-- 第一行返回 + 标题 + 右侧操作 -->
<div class="sub-nav-top">
<RouterLink
:to="{ name: 'novel-detail', params: { id: novelId } }"
class="back-link"
>
<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>
<span class="back-title">{{ novelTitle }}</span>
</RouterLink>
<div class="sub-nav-actions">
<slot name="actions" />
</div>
</div>
<!-- 第二行导航标签 + 辅助信息 -->
<div class="sub-nav-bar">
<nav class="nav-tabs">
<RouterLink
v-for="item in NAV_ITEMS"
:key="item.name"
:to="{ name: item.name, params: { id: novelId } }"
class="nav-tab"
:class="{ 'nav-tab--active': currentRoute === item.name }"
>
<svg class="nav-tab-icon" width="14" height="14" viewBox="0 0 16 16" fill="none">
<path :d="item.icon" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" fill="none" />
</svg>
<span>{{ item.label }}</span>
</RouterLink>
</nav>
<span v-if="subtitle" class="nav-subtitle">{{ subtitle }}</span>
</div>
</div>
</template>
<style scoped>
.sub-nav {
margin-bottom: var(--space-xl);
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
}
/* ── 顶部行:返回 + 操作按钮 ── */
.sub-nav-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-md);
}
.back-link {
display: inline-flex;
align-items: center;
gap: 6px;
text-decoration: none;
color: var(--ink-400);
transition: color var(--duration-fast) ease;
min-width: 0;
}
.back-link:hover {
color: var(--vermilion);
}
.back-link svg {
flex-shrink: 0;
transition: transform var(--duration-fast) ease;
}
.back-link:hover svg {
transform: translateX(-2px);
}
.back-title {
font-family: var(--font-display);
font-size: 1.35rem;
font-weight: 700;
color: var(--ink-900);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color var(--duration-fast) ease;
}
.back-link:hover .back-title {
color: var(--vermilion);
}
.sub-nav-actions {
flex-shrink: 0;
}
/* ── 导航标签栏 ── */
.sub-nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
border-bottom: 1px solid var(--paper-200);
padding-bottom: 0;
}
.nav-tabs {
display: flex;
gap: 2px;
}
.nav-tab {
display: inline-flex;
align-items: center;
gap: 5px;
padding: var(--space-sm) var(--space-md);
font-size: 0.88rem;
font-weight: 500;
color: var(--ink-400);
text-decoration: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px; /* 与底部边线重叠 */
transition: all var(--duration-fast) ease;
position: relative;
}
.nav-tab:hover {
color: var(--ink-700);
background: var(--paper-100);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
.nav-tab--active {
color: var(--vermilion);
border-bottom-color: var(--vermilion);
font-weight: 600;
}
.nav-tab--active:hover {
color: var(--vermilion);
background: transparent;
}
.nav-tab-icon {
flex-shrink: 0;
opacity: 0.7;
}
.nav-tab--active .nav-tab-icon {
opacity: 1;
}
.nav-subtitle {
font-size: 0.8rem;
color: var(--ink-300);
white-space: nowrap;
padding-bottom: var(--space-sm);
}
</style>

View File

@@ -0,0 +1,187 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Outline } from '@/types/outline'
import OutlineChildNode from './OutlineChildNode.vue'
const props = defineProps<{
children: Outline[]
expanded: boolean
}>()
const emit = defineEmits<{
addChild: []
editChild: [child: Outline]
deleteChild: [child: Outline]
reorderChildren: [children: Outline[]]
}>()
// 子节点拖拽排序
const dragIndex = ref<number | null>(null)
const dragOverIndex = ref<number | null>(null)
function onDragStart(index: number) {
dragIndex.value = index
}
function onDragOver(index: number) {
if (dragIndex.value === null || dragIndex.value === index) return
dragOverIndex.value = index
}
function onDragEnd() {
if (dragIndex.value !== null && dragOverIndex.value !== null && dragIndex.value !== dragOverIndex.value) {
const items = [...props.children]
const [moved] = items.splice(dragIndex.value, 1)
items.splice(dragOverIndex.value, 0, moved)
emit('reorderChildren', items)
}
dragIndex.value = null
dragOverIndex.value = null
}
</script>
<template>
<div class="child-list-wrapper" :class="{ 'child-list-wrapper--open': expanded }">
<div class="child-list-inner">
<div class="child-list">
<!-- 竹青竖线 -->
<div class="child-line" />
<!-- 子节点卡片 -->
<div
v-for="(child, index) in children"
:key="child.id"
class="child-item"
:class="{
'child-item--dragging': dragIndex === index,
'child-item--drag-over': dragOverIndex === index,
}"
draggable="true"
@dragstart.stop="onDragStart(index)"
@dragover.prevent.stop="onDragOver(index)"
@dragend.stop="onDragEnd"
>
<OutlineChildNode
:child="child"
:index="index"
@edit="emit('editChild', child)"
@delete="emit('deleteChild', child)"
/>
</div>
<!-- 添加子节点按钮 -->
<div class="child-add-wrapper">
<button class="child-add-btn" @click.stop="emit('addChild')">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M7 3v8M3 7h8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
<span>添加子节点</span>
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* 展开容器grid-template-rows 过渡 */
.child-list-wrapper {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows var(--duration-normal) var(--ease-out-smooth);
overflow: hidden;
}
.child-list-wrapper--open {
grid-template-rows: 1fr;
}
.child-list-inner {
min-height: 0;
overflow: hidden;
}
.child-list {
position: relative;
padding: var(--space-md) 0 var(--space-sm) var(--space-xl);
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
/* 竹青竖线 */
.child-line {
position: absolute;
left: 10px;
top: 0;
bottom: var(--space-md);
width: 2px;
background: linear-gradient(
to bottom,
var(--bamboo),
rgba(91, 140, 90, 0.3)
);
border-radius: 1px;
}
/* 子节点项 */
.child-item {
position: relative;
transition: opacity var(--duration-fast) ease;
}
.child-item--dragging {
opacity: 0.4;
}
.child-item--drag-over::before {
content: '';
position: absolute;
top: -4px;
left: 0;
right: 0;
height: 2px;
background: var(--bamboo);
border-radius: 1px;
z-index: 5;
}
/* 添加子节点按钮 */
.child-add-wrapper {
padding-left: var(--space-sm);
}
.child-add-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
background: none;
border: 1px dashed rgba(91, 140, 90, 0.4);
border-radius: var(--radius-sm);
color: var(--bamboo);
font-size: 0.76rem;
font-family: var(--font-display);
cursor: pointer;
transition: all var(--duration-fast) ease;
opacity: 0.7;
}
.child-add-btn:hover {
opacity: 1;
border-color: var(--bamboo);
background: rgba(91, 140, 90, 0.06);
transform: translateY(-1px);
}
/* 响应式 */
@media (max-width: 700px) {
.child-list {
padding-left: 36px;
}
.child-line {
left: 28px;
}
}
</style>

View File

@@ -0,0 +1,237 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Outline } from '@/types/outline'
defineProps<{
child: Outline
index: number
}>()
const emit = defineEmits<{
edit: []
delete: []
}>()
const expanded = ref(false)
function toggle() {
expanded.value = !expanded.value
}
</script>
<template>
<div
class="child-node"
:class="{ 'child-node--expanded': expanded }"
:style="{ '--child-delay': `${index * 80}ms` }"
@click="toggle"
>
<!-- 左侧竹青圆点 -->
<div class="child-dot" />
<div class="child-content">
<!-- 摘要 -->
<h4 class="child-summary">{{ child.summary }}</h4>
<!-- 详情可展开 -->
<div class="child-detail-wrapper" :class="{ 'child-detail-wrapper--open': expanded }">
<div class="child-detail-inner">
<p v-if="child.detail" class="child-detail">{{ child.detail }}</p>
<p v-else class="child-detail child-detail--empty">暂无详情</p>
<!-- 操作按钮 -->
<div class="child-actions" @click.stop>
<button class="child-action-btn child-action-btn--edit" @click="emit('edit')">
<svg width="12" height="12" viewBox="0 0 14 14" fill="none">
<path d="M10.5 1.5l2 2-8 8H2.5v-2l8-8z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
编辑
</button>
<button class="child-action-btn child-action-btn--delete" @click="emit('delete')">
<svg width="12" height="12" 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>
</div>
<!-- 展开箭头 -->
<div class="child-expand-hint">
<svg
class="child-chevron"
:class="{ 'child-chevron--open': expanded }"
width="10"
height="10"
viewBox="0 0 12 12"
fill="none"
>
<path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
</div>
</div>
</template>
<style scoped>
.child-node {
position: relative;
display: flex;
align-items: flex-start;
gap: var(--space-md);
padding: var(--space-md) var(--space-lg);
background: var(--paper-100);
border: 1px solid var(--paper-200);
border-radius: var(--radius-md);
cursor: pointer;
transition:
transform var(--duration-fast) ease,
box-shadow var(--duration-fast) ease,
border-color var(--duration-fast) ease;
animation: childFadeInUp var(--duration-normal) var(--ease-out-smooth) both;
animation-delay: var(--child-delay);
}
.child-node:hover {
transform: translateY(-2px);
box-shadow: 0 2px 12px rgba(91, 140, 90, 0.1);
border-color: var(--bamboo);
}
.child-node--expanded {
border-color: var(--bamboo);
border-left-width: 3px;
background: var(--paper-50);
box-shadow: 0 2px 12px rgba(91, 140, 90, 0.08);
}
/* 左侧竹青圆点 */
.child-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--bamboo);
flex-shrink: 0;
margin-top: 6px;
opacity: 0.7;
transition: all var(--duration-fast) ease;
}
.child-node:hover .child-dot,
.child-node--expanded .child-dot {
opacity: 1;
transform: scale(1.2);
}
/* 内容区 */
.child-content {
flex: 1;
min-width: 0;
}
.child-summary {
font-family: var(--font-display);
font-size: 0.9rem;
font-weight: 600;
color: var(--ink-800);
line-height: 1.5;
}
/* 详情展开区 */
.child-detail-wrapper {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows var(--duration-normal) var(--ease-out-smooth);
overflow: hidden;
}
.child-detail-wrapper--open {
grid-template-rows: 1fr;
}
.child-detail-inner {
min-height: 0;
overflow: hidden;
}
.child-detail {
margin-top: var(--space-sm);
padding-top: var(--space-sm);
border-top: 1px dashed rgba(91, 140, 90, 0.25);
font-size: 0.82rem;
color: var(--ink-500);
line-height: 1.7;
}
.child-detail--empty {
font-style: italic;
color: var(--ink-300);
}
/* 操作按钮 */
.child-actions {
display: flex;
gap: var(--space-xs);
margin-top: var(--space-sm);
}
.child-action-btn {
display: flex;
align-items: center;
gap: 3px;
padding: 2px 8px;
background: none;
border: 1px solid var(--paper-200);
border-radius: var(--radius-sm);
font-size: 0.72rem;
cursor: pointer;
transition: all var(--duration-fast) ease;
font-family: var(--font-body);
}
.child-action-btn--edit {
color: var(--bamboo);
}
.child-action-btn--edit:hover {
background: rgba(91, 140, 90, 0.08);
border-color: var(--bamboo);
}
.child-action-btn--delete {
color: var(--danger);
}
.child-action-btn--delete:hover {
background: var(--danger-ghost);
border-color: var(--danger);
}
/* 展开箭头 */
.child-expand-hint {
display: flex;
justify-content: center;
margin-top: 4px;
color: var(--ink-300);
}
.child-chevron {
transition: transform var(--duration-normal) var(--ease-out-back);
}
.child-chevron--open {
transform: rotate(180deg);
}
@keyframes childFadeInUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -10,6 +10,7 @@ const props = defineProps<{
show: boolean
outline: Outline | null
saving: boolean
parentId?: number | null
}>()
const emit = defineEmits<{
@@ -21,7 +22,11 @@ const summary = ref('')
const detail = ref('')
const isEdit = computed(() => !!props.outline)
const title = computed(() => isEdit.value ? '编辑大纲' : '新建大纲')
const isChild = computed(() => !!props.parentId)
const title = computed(() => {
if (isEdit.value) return isChild.value ? '编辑子节点' : '编辑大纲'
return isChild.value ? '新建子节点' : '新建大纲'
})
const detailLength = computed(() => detail.value.length)
const detailHint = computed(() => {
@@ -42,17 +47,24 @@ watch(() => props.show, (val) => {
function handleSubmit() {
if (!canSubmit.value) return
emit('submit', {
const data: OutlineCreate | OutlineUpdate = {
summary: summary.value.trim(),
detail: detail.value.trim(),
})
}
// 新建子节点时附带 parent_id
if (!isEdit.value && props.parentId) {
(data as OutlineCreate).parent_id = props.parentId
}
emit('submit', data)
}
</script>
<template>
<BaseModal :show="show" @close="emit('close')">
<form class="outline-form" @submit.prevent="handleSubmit">
<h2 class="form-title">{{ title }}</h2>
<h2 class="form-title" :class="{ 'form-title--child': isChild }">
{{ title }}
</h2>
<BaseInput
v-model="summary"
@@ -98,6 +110,10 @@ function handleSubmit() {
color: var(--ink-900);
}
.form-title--child {
color: var(--bamboo);
}
.detail-field {
position: relative;
}

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed, watch } from 'vue'
import type { Outline } from '@/types/outline'
import OutlineChildList from './OutlineChildList.vue'
const props = defineProps<{
outline: Outline
@@ -10,77 +11,146 @@ const props = defineProps<{
const emit = defineEmits<{
edit: []
delete: []
addChild: []
editChild: [child: Outline]
deleteChild: [child: Outline]
reorderChildren: [children: Outline[]]
}>()
const expanded = ref(false)
const childrenExpanded = ref(false)
const childCount = computed(() => props.outline.children?.length ?? 0)
const hasChildren = computed(() => childCount.value > 0)
// 子节点从无变有时自动展开(如 AI 创建了子节点)
watch(hasChildren, (val, oldVal) => {
if (val && !oldVal) childrenExpanded.value = true
})
function toggle() {
expanded.value = !expanded.value
}
function toggleChildren(e: Event) {
e.stopPropagation()
childrenExpanded.value = !childrenExpanded.value
}
</script>
<template>
<div
class="outline-node"
:class="{ 'outline-node--expanded': expanded }"
@click="toggle"
>
<!-- 拖拽手柄 -->
<div class="node-drag-handle" @click.stop>
<svg width="12" height="16" viewBox="0 0 12 16" fill="none">
<circle cx="3" cy="2" r="1.5" fill="currentColor" />
<circle cx="9" cy="2" r="1.5" fill="currentColor" />
<circle cx="3" cy="8" r="1.5" fill="currentColor" />
<circle cx="9" cy="8" r="1.5" fill="currentColor" />
<circle cx="3" cy="14" r="1.5" fill="currentColor" />
<circle cx="9" cy="14" r="1.5" fill="currentColor" />
</svg>
</div>
<div class="outline-node-wrapper">
<div
class="outline-node"
:class="{
'outline-node--expanded': expanded,
'outline-node--has-children': hasChildren,
'outline-node--children-open': childrenExpanded,
}"
@click="toggle"
>
<!-- 拖拽手柄 -->
<div class="node-drag-handle" @click.stop>
<svg width="12" height="16" viewBox="0 0 12 16" fill="none">
<circle cx="3" cy="2" r="1.5" fill="currentColor" />
<circle cx="9" cy="2" r="1.5" fill="currentColor" />
<circle cx="3" cy="8" r="1.5" fill="currentColor" />
<circle cx="9" cy="8" r="1.5" fill="currentColor" />
<circle cx="3" cy="14" r="1.5" fill="currentColor" />
<circle cx="9" cy="14" r="1.5" fill="currentColor" />
</svg>
</div>
<!-- 概要 -->
<h3 class="node-summary">{{ outline.summary }}</h3>
<!-- 子节点数量角标 -->
<div v-if="hasChildren" class="child-badge" @click="toggleChildren">
<span class="child-badge-count">{{ childCount }}</span>
</div>
<!-- 详情可展开 -->
<div class="node-detail-wrapper" :class="{ 'node-detail-wrapper--open': expanded }">
<div class="node-detail-inner">
<p v-if="outline.detail" class="node-detail">{{ outline.detail }}</p>
<p v-else class="node-detail node-detail--empty">暂无详情点击编辑添加</p>
<!-- 概要 -->
<div class="node-summary-row">
<h3 class="node-summary">{{ outline.summary }}</h3>
<!-- 子节点展开/折叠切换 -->
<button
v-if="hasChildren"
class="children-toggle"
:class="{ 'children-toggle--open': childrenExpanded }"
:title="childrenExpanded ? '折叠子节点' : '展开子节点'"
@click="toggleChildren"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M4.5 3l5 4-5 4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span class="children-toggle-label">{{ childCount }} 个子节点</span>
</button>
</div>
<!-- 操作按钮 -->
<div class="node-actions" @click.stop>
<button class="action-btn action-btn--edit" @click="emit('edit')">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M10.5 1.5l2 2-8 8H2.5v-2l8-8z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
编辑
</button>
<button class="action-btn action-btn--delete" @click="emit('delete')">
<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 class="node-detail-wrapper" :class="{ 'node-detail-wrapper--open': expanded }">
<div class="node-detail-inner">
<p v-if="outline.detail" class="node-detail">{{ outline.detail }}</p>
<p v-else class="node-detail node-detail--empty">暂无详情点击编辑添加</p>
<!-- 操作按钮 -->
<div class="node-actions" @click.stop>
<button class="action-btn action-btn--edit" @click="emit('edit')">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M10.5 1.5l2 2-8 8H2.5v-2l8-8z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
编辑
</button>
<button class="action-btn action-btn--child" @click="emit('addChild')">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M7 3v8M3 7h8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
子节点
</button>
<button class="action-btn action-btn--delete" @click="emit('delete')">
<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>
</div>
<!-- 展开提示 -->
<div class="node-expand-hint">
<svg
class="expand-chevron"
:class="{ 'expand-chevron--open': expanded }"
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
>
<path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
</div>
<!-- 展开提示 -->
<div class="node-expand-hint">
<svg
class="expand-chevron"
:class="{ 'expand-chevron--open': expanded }"
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
>
<path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<!-- 子节点展开引导三角 -->
<div v-if="childrenExpanded" class="children-arrow" />
<!-- 子节点区域 -->
<OutlineChildList
v-if="hasChildren || childrenExpanded"
:children="outline.children || []"
:expanded="childrenExpanded"
@add-child="emit('addChild')"
@edit-child="(child) => emit('editChild', child)"
@delete-child="(child) => emit('deleteChild', child)"
@reorder-children="(children) => emit('reorderChildren', children)"
/>
</div>
</template>
<style scoped>
.outline-node-wrapper {
display: flex;
flex-direction: column;
}
.outline-node {
position: relative;
background: var(--paper-50);
@@ -107,6 +177,33 @@ function toggle() {
border-left-width: 3px;
}
.outline-node--children-open {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
/* 子节点展开引导三角 */
.children-arrow {
width: 12px;
height: 8px;
margin: 0 auto;
position: relative;
z-index: 2;
}
.children-arrow::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 8px solid var(--bamboo);
opacity: 0.6;
}
/* 拖拽手柄 */
.node-drag-handle {
position: absolute;
@@ -127,6 +224,44 @@ function toggle() {
cursor: grabbing;
}
/* 子节点数量角标 */
.child-badge {
position: absolute;
top: -6px;
right: var(--space-xl);
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
background: var(--bamboo);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform var(--duration-fast) var(--ease-out-back);
z-index: 4;
box-shadow: 0 1px 4px rgba(91, 140, 90, 0.3);
}
.child-badge:hover {
transform: scale(1.15);
}
.child-badge-count {
font-family: var(--font-display);
font-size: 0.68rem;
font-weight: 700;
color: white;
line-height: 1;
}
/* 概要行 */
.node-summary-row {
display: flex;
flex-direction: column;
gap: 4px;
}
/* 概要标题 */
.node-summary {
font-family: var(--font-display);
@@ -137,6 +272,41 @@ function toggle() {
padding-right: var(--space-lg);
}
/* 子节点展开/折叠切换 */
.children-toggle {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 8px;
background: rgba(91, 140, 90, 0.06);
border: 1px solid rgba(91, 140, 90, 0.2);
border-radius: 12px;
color: var(--bamboo);
font-size: 0.72rem;
font-family: var(--font-display);
cursor: pointer;
transition: all var(--duration-fast) ease;
width: fit-content;
}
.children-toggle:hover {
background: rgba(91, 140, 90, 0.12);
border-color: var(--bamboo);
}
.children-toggle svg {
transition: transform var(--duration-normal) var(--ease-out-back);
flex-shrink: 0;
}
.children-toggle--open svg {
transform: rotate(90deg);
}
.children-toggle-label {
white-space: nowrap;
}
/* 详情展开区 */
.node-detail-wrapper {
display: grid;
@@ -199,6 +369,15 @@ function toggle() {
border-color: var(--bamboo);
}
.action-btn--child {
color: var(--bamboo);
}
.action-btn--child:hover {
background: rgba(91, 140, 90, 0.08);
border-color: var(--bamboo);
}
.action-btn--delete {
color: var(--danger);
}

View File

@@ -12,6 +12,10 @@ const emit = defineEmits<{
delete: [outline: Outline]
reorder: [outlines: Outline[]]
add: []
addChild: [parent: Outline]
editChild: [child: Outline]
deleteChild: [child: Outline]
reorderChildren: [parentId: number, children: Outline[]]
}>()
// 拖拽排序
@@ -69,13 +73,17 @@ function onDragEnd() {
<!-- 连接横线 -->
<div class="timeline-bridge" />
<!-- 节点卡片 -->
<!-- 节点卡片含子节点 -->
<div class="timeline-card">
<OutlineNode
:outline="outline"
:index="index"
@edit="emit('edit', outline)"
@delete="emit('delete', outline)"
@add-child="emit('addChild', outline)"
@edit-child="(child) => emit('editChild', child)"
@delete-child="(child) => emit('deleteChild', child)"
@reorder-children="(children) => emit('reorderChildren', outline.id, children)"
/>
</div>
</div>

View File

@@ -1,18 +1,108 @@
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { streamChat, fetchMessages, clearMessages as apiClearMessages } from '@/api/chat'
import type { ChatMessage, ChatEntry, ToolCallGroup, ToolCallEntry } from '@/types/chat'
import { streamChat, fetchMessages, clearMessages as apiClearMessages, compressContext } from '@/api/chat'
import type { ChatMessage, ChatEntry, ToolCallGroup } from '@/types/chat'
import type { TokenUsage, PendingToolCallData } from '@/api/chat'
// 中文标签 → 英文工具名(用于解析历史消息中的工具调用日志)
const LABEL_TO_TOOL_NAME: Record<string, string> = {
'查看小说信息': 'get_novel_info',
'更新小说信息': 'update_novel_info',
'查询大纲': 'list_outlines',
'创建大纲': 'create_outline',
'更新大纲': 'update_outline',
'删除大纲': 'delete_outline',
'调整大纲排序': 'reorder_outlines',
'查询世界观': 'get_world_setting',
'保存世界观': 'save_world_setting',
'查询角色': 'list_characters',
'创建角色': 'create_character',
'更新角色': 'update_character',
'删除角色': 'delete_character',
'查询章节': 'list_chapters',
'创建章节': 'create_chapter',
'更新章节': 'update_chapter',
'删除章节': 'delete_chapter',
}
/**
* 解析历史 assistant 消息中的工具调用日志,拆分为 ToolCallGroup + 纯文本消息
* 格式:[以下操作已执行]\n[工具调用: 标签] 参数: {...} → 结果: {...}\n\n回复文本
*/
function parseHistoryMessage(msg: ChatMessage): ChatEntry[] {
const content = msg.content
if (!content.startsWith('[以下操作已执行]')) {
return [msg]
}
const entries: ChatEntry[] = []
// 用双换行分隔工具调用块和后续回复
const firstBlankLine = content.indexOf('\n\n')
const toolSection = firstBlankLine >= 0 ? content.substring(0, firstBlankLine) : content
const replyText = firstBlankLine >= 0 ? content.substring(firstBlankLine + 2).trim() : ''
// 解析工具调用行
const toolLines = toolSection.split('\n').slice(1) // 跳过 [以下操作已执行]
const calls = toolLines
.map(line => {
const match = line.match(/^\[工具调用: (.+?)\] 参数: (.+?) → 结果: (.+)$/)
if (!match) return null
const label = match[1]
const name = LABEL_TO_TOOL_NAME[label] || label
let args: Record<string, unknown> = {}
try { args = JSON.parse(match[2]) } catch { /* ok */ }
const resultStr = match[3]
let parsedResult: unknown = undefined
try { parsedResult = JSON.parse(resultStr) } catch { /* ok */ }
return {
id: `hist-${idCounter++}`,
name,
label,
arguments: args,
result: resultStr,
parsedResult,
}
})
.filter(Boolean) as ToolCallGroup['calls']
if (calls.length > 0) {
entries.push({
id: idCounter++,
type: 'tool_calls',
calls,
status: 'done',
assistantText: '',
} as ToolCallGroup)
}
if (replyText) {
entries.push({
...msg,
content: replyText,
})
}
return entries
}
// 全局状态(跨组件共享)
const entries = ref<ChatEntry[]>([])
const isStreaming = ref(false)
const streamingContent = ref('')
const lastUsage = ref<TokenUsage | null>(null)
const toolsEnabled = ref(true)
const autoApproveTools = ref(false)
const isCompressing = ref(false)
let abortController: AbortController | null = null
let idCounter = Date.now()
// 上下文窗口大小(默认 128k后续可从配置读取
const DEFAULT_CONTEXT_WINDOW = 128000
// 自动压缩阈值
const AUTO_COMPRESS_THRESHOLD = 0.85
// 当前已加载历史的小说 ID用于检测切换
let loadedNovelId: number | undefined | null = null
// 路由名称 → 页面上下文描述
const PAGE_LABELS: Record<string, string> = {
'novel-detail': '小说详情页',
@@ -42,14 +132,40 @@ export function useChatAgent() {
)
async function loadHistory(novelId?: number) {
const targetId = novelId ?? currentNovelId.value
try {
const list = await fetchMessages(novelId ?? currentNovelId.value)
entries.value = list.items as ChatEntry[]
const list = await fetchMessages(targetId)
// 解析 assistant 消息中的工具调用日志,还原为 ToolCallGroup 卡片
const parsed: ChatEntry[] = []
for (const msg of list.items) {
if (msg.role === 'assistant') {
parsed.push(...parseHistoryMessage(msg))
} else {
parsed.push(msg)
}
}
entries.value = parsed
loadedNovelId = targetId
lastUsage.value = null
} catch {
// 静默
}
}
// 监听小说切换,自动重新加载对应小说的聊天历史
watch(currentNovelId, (newId) => {
if (newId !== loadedNovelId) {
// 如果正在流式输出,先中断
if (isStreaming.value && abortController) {
abortController.abort()
abortController = null
isStreaming.value = false
streamingContent.value = ''
}
loadHistory(newId)
}
})
async function sendMessage(content: string) {
if (isStreaming.value || !content.trim()) return
const novelId = currentNovelId.value
@@ -111,6 +227,7 @@ export function useChatAgent() {
novelId,
pageContext: pageContext.value,
toolsEnabled: toolsEnabled.value,
autoApproveTools: autoApproveTools.value,
pendingToolCalls,
assistantText,
signal: abortController.signal,
@@ -144,10 +261,28 @@ export function useChatAgent() {
entries.value.push(group)
},
onToolCallsAuto(calls: PendingToolCallData[]) {
// 自动执行模式:直接创建工具调用组,状态为 executing
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: 'executing',
assistantText: '',
}
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'
(e): e is ToolCallGroup => 'type' in e && e.type === 'tool_calls' && (e.status === 'executing' || e.status === 'done')
)
if (group) {
const call = group.calls.find(c => c.name === info.name)
@@ -160,6 +295,10 @@ export function useChatAgent() {
group.status = 'done'
}
}
// 通知页面组件刷新数据
window.dispatchEvent(new CustomEvent('tool-executed', {
detail: { name: info.name },
}))
},
onError(error) {
@@ -177,8 +316,6 @@ export function useChatAgent() {
lastUsage.value = usage
}
if (pending) {
// 工具调用待审批,保持 isStreaming 状态让 UI 知道对话还没结束
// 但不再显示加载指示
isStreaming.value = false
return
}
@@ -195,6 +332,14 @@ export function useChatAgent() {
streamingContent.value = ''
isStreaming.value = false
abortController = null
// 自动压缩检测:超过 85% 上下文窗口时触发
if (usage) {
const total = usage.total_tokens || (usage.prompt_tokens + usage.completion_tokens)
if (total > DEFAULT_CONTEXT_WINDOW * AUTO_COMPRESS_THRESHOLD) {
compressChat()
}
}
},
})
}
@@ -217,12 +362,41 @@ export function useChatAgent() {
lastUsage.value = null
}
/** 压缩上下文:总结旧消息,保留最近两轮对话 */
async function compressChat(): Promise<{ success: boolean; message: string }> {
if (isCompressing.value || isStreaming.value) {
return { success: false, message: '请等待当前操作完成' }
}
isCompressing.value = true
try {
const result = await compressContext(currentNovelId.value)
if (result.error) {
return { success: false, message: result.error }
}
if (!result.compressed) {
return { success: false, message: result.reason || '无需压缩' }
}
// 压缩成功,重新加载历史
await loadHistory()
return {
success: true,
message: `已压缩 ${result.removed_count} 条消息为摘要`,
}
} catch {
return { success: false, message: '压缩请求失败' }
} finally {
isCompressing.value = false
}
}
return {
entries,
isStreaming,
isCompressing,
streamingContent,
lastUsage,
toolsEnabled,
autoApproveTools,
currentNovelId,
pageContext,
loadHistory,
@@ -231,5 +405,6 @@ export function useChatAgent() {
skipToolCalls,
stopStreaming,
clearChat,
compressChat,
}
}

View File

@@ -0,0 +1,23 @@
import { onMounted, onUnmounted } from 'vue'
/**
* 监听 AI 工具执行事件,当匹配的工具完成时自动刷新数据
* @param toolNames 需要监听的工具名列表
* @param refresh 刷新回调
*/
export function useToolRefresh(toolNames: string[], refresh: () => void) {
function handler(e: Event) {
const name = (e as CustomEvent).detail?.name
if (toolNames.includes(name)) {
refresh()
}
}
onMounted(() => {
window.addEventListener('tool-executed', handler)
})
onUnmounted(() => {
window.removeEventListener('tool-executed', handler)
})
}

View File

@@ -23,25 +23,32 @@ const router = createRouter({
name: 'novel-edit',
component: () => import('@/views/NovelFormView.vue'),
},
// 工作区:大纲/章节/角色/世界观共享父布局(导航栏持久化)
{
path: '/novels/:id/outlines',
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'),
path: '/novels/:id',
component: () => import('@/views/NovelWorkspace.vue'),
children: [
{
path: 'outlines',
name: 'novel-outlines',
component: () => import('@/views/OutlineView.vue'),
},
{
path: 'chapters',
name: 'novel-chapters',
component: () => import('@/views/ChapterView.vue'),
},
{
path: 'characters',
name: 'novel-characters',
component: () => import('@/views/CharacterView.vue'),
},
{
path: 'world-setting',
name: 'novel-world-setting',
component: () => import('@/views/WorldSettingView.vue'),
},
],
},
],
})

View File

@@ -1,9 +1,11 @@
export interface Outline {
id: number
novel_id: number
parent_id: number | null
sort_order: number
summary: string
detail: string
children: Outline[]
created_at: string
updated_at: string
}
@@ -11,6 +13,7 @@ export interface Outline {
export interface OutlineCreate {
summary: string
detail?: string
parent_id?: number
}
export interface OutlineUpdate {

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ref, inject, onMounted, computed, watchEffect } 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 { useToolRefresh } from '@/composables/useToolRefresh'
import type { Novel } from '@/types/novel'
import type { Chapter } from '@/types/chapter'
import BaseButton from '@/components/ui/BaseButton.vue'
@@ -17,8 +17,9 @@ const route = useRoute()
const router = useRouter()
const toast = useToast()
const novelId = Number(route.params.id)
const novel = ref<Novel | null>(null)
const novelId = inject<number>('workspace-novel-id', Number(route.params.id))
const novel = inject<import('vue').Ref<Novel | null>>('workspace-novel', ref(null))
const subtitle = inject<import('vue').Ref<string>>('workspace-subtitle', ref(''))
const chapters = ref<Chapter[]>([])
const loading = ref(true)
@@ -40,15 +41,19 @@ const deleting = ref(false)
const totalChapters = computed(() => chapters.value.length)
watchEffect(() => {
subtitle.value = `${totalChapters.value}`
})
async function load() {
loading.value = true
try {
const [n, list] = await Promise.all([
fetchNovel(novelId),
fetchChapters(novelId),
])
novel.value = n
const list = await fetchChapters(novelId)
chapters.value = list.items
// 默认展开最后一章
if (chapters.value.length > 0 && !activeChapter.value) {
activeChapter.value = chapters.value[chapters.value.length - 1]
}
} catch {
toast.error('加载失败')
router.push({ name: 'home' })
@@ -57,6 +62,9 @@ async function load() {
}
}
// AI 工具执行后自动刷新章节数据
useToolRefresh(['list_chapters', 'create_chapter', 'update_chapter', 'delete_chapter'], load)
function openCreate() {
editingChapter.value = null
showForm.value = true
@@ -155,19 +163,12 @@ 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>
<div v-if="!loading" class="chapter-view">
<Teleport to="#workspace-actions">
<BaseButton v-if="totalChapters > 0" variant="primary" @click="openCreate">
新建章节
</BaseButton>
</div>
</Teleport>
<template v-if="totalChapters > 0">
<!-- 双栏布局 -->
@@ -231,36 +232,7 @@ onMounted(load)
<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);
/* max-width 由父 NovelWorkspace 控制 */
}
/* 双栏布局 */

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ref, inject, onMounted, computed, watchEffect } 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 { useToolRefresh } from '@/composables/useToolRefresh'
import type { Novel } from '@/types/novel'
import type { Character, CharacterCreate, CharacterUpdate } from '@/types/character'
import BaseButton from '@/components/ui/BaseButton.vue'
@@ -16,8 +16,9 @@ const route = useRoute()
const router = useRouter()
const toast = useToast()
const novelId = Number(route.params.id)
const novel = ref<Novel | null>(null)
const novelId = inject<number>('workspace-novel-id', Number(route.params.id))
const novel = inject<import('vue').Ref<Novel | null>>('workspace-novel', ref(null))
const subtitle = inject<import('vue').Ref<string>>('workspace-subtitle', ref(''))
const characters = ref<Character[]>([])
const loading = ref(true)
@@ -33,14 +34,14 @@ const deleting = ref(false)
const totalCharacters = computed(() => characters.value.length)
watchEffect(() => {
subtitle.value = `${totalCharacters.value} 个角色`
})
async function load() {
loading.value = true
try {
const [n, list] = await Promise.all([
fetchNovel(novelId),
fetchCharacters(novelId),
])
novel.value = n
const list = await fetchCharacters(novelId)
characters.value = list.items
} catch {
toast.error('加载失败')
@@ -50,6 +51,9 @@ async function load() {
}
}
// AI 工具执行后自动刷新角色数据
useToolRefresh(['list_characters', 'create_character', 'update_character', 'delete_character'], load)
function openCreate() {
editingCharacter.value = null
showForm.value = true
@@ -105,19 +109,12 @@ 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>
<div v-if="!loading" class="character-view">
<Teleport to="#workspace-actions">
<BaseButton v-if="totalCharacters > 0" variant="primary" @click="openCreate">
添加角色
</BaseButton>
</div>
</Teleport>
<CharacterGrid
v-if="totalCharacters > 0"
@@ -157,36 +154,7 @@ onMounted(load)
<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);
/* max-width 由父 NovelWorkspace 控制 */
}
/* Loading */

View File

@@ -3,6 +3,7 @@ import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchNovel, deleteNovel } from '@/api/novels'
import { useToast } from '@/composables/useToast'
import { useToolRefresh } from '@/composables/useToolRefresh'
import type { Novel } from '@/types/novel'
import BaseButton from '@/components/ui/BaseButton.vue'
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
@@ -18,6 +19,9 @@ const deleting = ref(false)
const novelId = Number(route.params.id)
// AI 工具执行后自动刷新小说详情
useToolRefresh(['get_novel_info', 'update_novel_info'], load)
async function load() {
loading.value = true
try {

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { ref, onMounted, provide } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchNovel } from '@/api/novels'
import { useToast } from '@/composables/useToast'
import type { Novel } from '@/types/novel'
import NovelSubNav from '@/components/novel/NovelSubNav.vue'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const novelId = Number(route.params.id)
const novel = ref<Novel | null>(null)
const loading = ref(true)
/** 子页面可设置的副标题(如 "共 5 个节点" */
const subtitle = ref('')
async function load() {
loading.value = true
try {
novel.value = await fetchNovel(novelId)
} catch {
toast.error('作品不存在')
router.push({ name: 'home' })
} finally {
loading.value = false
}
}
// 提供给子路由
provide('workspace-novel', novel)
provide('workspace-novel-id', novelId)
provide('workspace-subtitle', subtitle)
onMounted(load)
</script>
<template>
<div v-if="!loading && novel" class="workspace">
<NovelSubNav
:novel-id="novelId"
:novel-title="novel.title"
:subtitle="subtitle"
>
<template #actions>
<!-- 子页面通过 Teleport 注入操作按钮 -->
<div id="workspace-actions" />
</template>
</NovelSubNav>
<div class="workspace-content">
<RouterView />
</div>
</div>
<div v-else-if="loading" class="workspace-loading">
<div class="loading-pulse" style="height: 1.5rem; width: 30%; margin-bottom: var(--space-md)" />
<div class="loading-pulse" style="height: 1rem; width: 60%; margin-bottom: var(--space-2xl)" />
<div class="loading-pulse" style="height: 20rem; width: 100%" />
</div>
</template>
<style scoped>
.workspace {
max-width: 900px;
margin: 0 auto;
}
.workspace-content {
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
}
.workspace-loading {
max-width: 900px;
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>

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ref, inject, onMounted, computed, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchNovel } from '@/api/novels'
import { fetchOutlines, createOutline, updateOutline, deleteOutline, reorderOutlines } from '@/api/outlines'
import { fetchOutlines, createOutline, updateOutline, deleteOutline, reorderOutlines, reorderChildren as apiReorderChildren } from '@/api/outlines'
import { useToast } from '@/composables/useToast'
import { useToolRefresh } from '@/composables/useToolRefresh'
import type { Novel } from '@/types/novel'
import type { Outline, OutlineCreate, OutlineUpdate } from '@/types/outline'
import BaseButton from '@/components/ui/BaseButton.vue'
@@ -16,31 +16,35 @@ const route = useRoute()
const router = useRouter()
const toast = useToast()
const novelId = Number(route.params.id)
const novel = ref<Novel | null>(null)
const novelId = inject<number>('workspace-novel-id', Number(route.params.id))
const novel = inject<import('vue').Ref<Novel | null>>('workspace-novel', ref(null))
const subtitle = inject<import('vue').Ref<string>>('workspace-subtitle', ref(''))
const outlines = ref<Outline[]>([])
const loading = ref(true)
// 表单弹窗
const showForm = ref(false)
const editingOutline = ref<Outline | null>(null)
const formParentId = ref<number | null>(null)
const formSaving = ref(false)
// 删除确认
const showDeleteConfirm = ref(false)
const deletingOutline = ref<Outline | null>(null)
const deletingIsChild = ref(false)
const deleting = ref(false)
const totalOutlines = computed(() => outlines.value.length)
// 同步副标题到父布局
watchEffect(() => {
subtitle.value = `${totalOutlines.value} 个节点`
})
async function load() {
loading.value = true
try {
const [n, list] = await Promise.all([
fetchNovel(novelId),
fetchOutlines(novelId),
])
novel.value = n
const list = await fetchOutlines(novelId)
outlines.value = list.items
} catch {
toast.error('加载失败')
@@ -50,28 +54,54 @@ async function load() {
}
}
// AI 工具执行后自动刷新大纲数据
useToolRefresh(['list_outlines', 'create_outline', 'update_outline', 'delete_outline', 'reorder_outlines'], load)
// ── 顶级节点操作 ──
function openCreate() {
editingOutline.value = null
formParentId.value = null
showForm.value = true
}
function openEdit(outline: Outline) {
editingOutline.value = outline
formParentId.value = null
showForm.value = true
}
// ── 子节点操作 ──
function openCreateChild(parent: Outline) {
editingOutline.value = null
formParentId.value = parent.id
showForm.value = true
}
function openEditChild(child: Outline) {
editingOutline.value = child
formParentId.value = child.parent_id
showForm.value = true
}
// ── 统一表单提交 ──
async function handleFormSubmit(data: OutlineCreate | OutlineUpdate) {
formSaving.value = true
try {
if (editingOutline.value) {
// 编辑(顶级 or 子节点都一样)
const updated = await updateOutline(novelId, editingOutline.value.id, data as OutlineUpdate)
const idx = outlines.value.findIndex(o => o.id === updated.id)
if (idx !== -1) outlines.value[idx] = updated
toast.success('大纲已更新')
_replaceNode(updated)
toast.success('已更新')
} else {
const created = await createOutline(novelId, data as OutlineCreate)
outlines.value.push(created)
toast.success('大纲已创建')
// 新建
await createOutline(novelId, data as OutlineCreate)
// 重新加载以获取完整嵌套结构
const list = await fetchOutlines(novelId)
outlines.value = list.items
toast.success(formParentId.value ? '子节点已创建' : '大纲已创建')
}
showForm.value = false
} catch {
@@ -81,8 +111,38 @@ async function handleFormSubmit(data: OutlineCreate | OutlineUpdate) {
}
}
/** 在 outlines 树中替换更新后的节点 */
function _replaceNode(updated: Outline) {
// 尝试在顶级查找
const topIdx = outlines.value.findIndex(o => o.id === updated.id)
if (topIdx !== -1) {
// 保留 children
updated.children = outlines.value[topIdx].children || []
outlines.value[topIdx] = updated
return
}
// 在子节点中查找
for (const parent of outlines.value) {
if (!parent.children) continue
const childIdx = parent.children.findIndex(c => c.id === updated.id)
if (childIdx !== -1) {
parent.children[childIdx] = updated
return
}
}
}
// ── 删除 ──
function confirmDelete(outline: Outline) {
deletingOutline.value = outline
deletingIsChild.value = false
showDeleteConfirm.value = true
}
function confirmDeleteChild(child: Outline) {
deletingOutline.value = child
deletingIsChild.value = true
showDeleteConfirm.value = true
}
@@ -91,8 +151,20 @@ async function handleDelete() {
deleting.value = true
try {
await deleteOutline(novelId, deletingOutline.value.id)
outlines.value = outlines.value.filter(o => o.id !== deletingOutline.value!.id)
toast.success('大纲已删除')
if (deletingIsChild.value) {
// 从父节点的 children 中移除
for (const parent of outlines.value) {
if (!parent.children) continue
const idx = parent.children.findIndex(c => c.id === deletingOutline.value!.id)
if (idx !== -1) {
parent.children.splice(idx, 1)
break
}
}
} else {
outlines.value = outlines.value.filter(o => o.id !== deletingOutline.value!.id)
}
toast.success('已删除')
showDeleteConfirm.value = false
} catch {
toast.error('删除失败')
@@ -101,6 +173,8 @@ async function handleDelete() {
}
}
// ── 排序 ──
async function handleReorder(reordered: Outline[]) {
outlines.value = reordered
try {
@@ -112,23 +186,33 @@ async function handleReorder(reordered: Outline[]) {
}
}
async function handleChildReorder(parentId: number, reordered: Outline[]) {
// 乐观更新
const parent = outlines.value.find(o => o.id === parentId)
if (parent) {
parent.children = reordered
}
try {
await apiReorderChildren(novelId, parentId, reordered.map(c => c.id))
// 重新加载获取最新数据
const list = await fetchOutlines(novelId)
outlines.value = list.items
} catch {
toast.error('子节点排序保存失败')
await load()
}
}
onMounted(load)
</script>
<template>
<div v-if="!loading && novel" class="outline-view">
<div class="outline-header">
<BaseButton variant="ghost" @click="router.push({ name: 'novel-detail', params: { id: novelId } })">
&larr; 返回
</BaseButton>
<div class="header-center">
<h1 class="outline-title">{{ novel.title }}</h1>
<p class="outline-subtitle">故事大纲 · {{ totalOutlines }} 个节点</p>
</div>
<div v-if="!loading" class="outline-view">
<Teleport to="#workspace-actions">
<BaseButton v-if="totalOutlines > 0" variant="primary" @click="openCreate">
添加节点
</BaseButton>
</div>
</Teleport>
<OutlineTimeline
v-if="totalOutlines > 0"
@@ -137,6 +221,10 @@ onMounted(load)
@delete="confirmDelete"
@reorder="handleReorder"
@add="openCreate"
@add-child="openCreateChild"
@edit-child="openEditChild"
@delete-child="confirmDeleteChild"
@reorder-children="handleChildReorder"
/>
<OutlineEmptyState v-else @create="openCreate" />
@@ -144,6 +232,7 @@ onMounted(load)
:show="showForm"
:outline="editingOutline"
:saving="formSaving"
:parent-id="formParentId"
@submit="handleFormSubmit"
@close="showForm = false"
/>
@@ -151,7 +240,9 @@ onMounted(load)
<ConfirmDialog
:show="showDeleteConfirm"
title="确认删除"
:message="`确定要删除「${deletingOutline?.summary}」吗?`"
:message="deletingIsChild
? `确定要删除子节点「${deletingOutline?.summary}」吗?`
: `确定要删除「${deletingOutline?.summary}」吗?${(deletingOutline?.children?.length ?? 0) > 0 ? '其下所有子节点也将被删除。' : ''}`"
confirm-text="删除"
:loading="deleting"
@confirm="handleDelete"
@@ -171,37 +262,10 @@ onMounted(load)
<style scoped>
.outline-view {
max-width: 900px;
margin: 0 auto;
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
/* max-width 由父 NovelWorkspace 控制 */
}
.outline-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;
}
.outline-title {
font-family: var(--font-display);
font-size: 1.8rem;
font-weight: 700;
color: var(--ink-900);
line-height: 1.3;
}
.outline-subtitle {
font-size: 0.85rem;
color: var(--ink-400);
margin-top: var(--space-xs);
}
/* header 已由 NovelSubNav 处理 */
/* Loading */
.outline-loading {

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ref, inject, 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 { useToolRefresh } from '@/composables/useToolRefresh'
import type { Novel } from '@/types/novel'
import BaseButton from '@/components/ui/BaseButton.vue'
import BaseTextarea from '@/components/ui/BaseTextarea.vue'
@@ -12,8 +12,9 @@ const route = useRoute()
const router = useRouter()
const toast = useToast()
const novelId = Number(route.params.id)
const novel = ref<Novel | null>(null)
const novelId = inject<number>('workspace-novel-id', Number(route.params.id))
const novel = inject<import('vue').Ref<Novel | null>>('workspace-novel', ref(null))
const subtitle = inject<import('vue').Ref<string>>('workspace-subtitle', ref(''))
const content = ref('')
const loading = ref(true)
const saving = ref(false)
@@ -26,14 +27,16 @@ const contentHint = computed(() => {
return `${contentLength.value}`
})
// 世界观页不需要副标题
subtitle.value = ''
// AI 工具执行后自动刷新世界观数据
useToolRefresh(['get_world_setting', 'save_world_setting'], load)
async function load() {
loading.value = true
try {
const [n, ws] = await Promise.all([
fetchNovel(novelId),
fetchWorldSetting(novelId),
])
novel.value = n
const ws = await fetchWorldSetting(novelId)
content.value = ws.content
} catch {
toast.error('加载失败')
@@ -64,15 +67,8 @@ 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>
<div v-if="!loading" class="world-view">
<Teleport to="#workspace-actions">
<BaseButton
variant="primary"
:loading="saving"
@@ -81,7 +77,7 @@ onMounted(load)
>
保存
</BaseButton>
</div>
</Teleport>
<div class="editor-container">
<div class="editor-hint">
@@ -124,33 +120,6 @@ onMounted(load)
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);