新增角色管理、世界观设定、章节管理和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:
337
frontend/src/components/chat/ChatToolCalls.vue
Normal file
337
frontend/src/components/chat/ChatToolCalls.vue
Normal 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>
|
||||
Reference in New Issue
Block a user