新增角色管理、世界观设定、章节管理和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,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>