All checks were successful
Deploy Noval to K3s / deploy (push) Successful in 47s
- **文件管理**: - 支持文件上传、解析和删除。 - 限制支持格式(txt, md, docx, pdf),最大 10MB。 - 提供前端上传区、文件列表及后端解析服务。 - **技能管理**: - 新增技能的创建、更新及删除功能。 - 支持分配自定义提示词和工具权限。 - 提供技能列表及分组工具选择交互界面。 同时调整相关 API 和前端组件,优化协作体验。
429 lines
13 KiB
Vue
429 lines
13 KiB
Vue
<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',
|
||
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',
|
||
// 文件
|
||
list_files: 'M13.5 7.5l-5.8 5.8a3.2 3.2 0 01-4.5-4.5l5.8-5.8a2.1 2.1 0 013 3L6.2 11.8',
|
||
read_file: 'M4 2h5l3 3v9H4V2zM9 2v3h3M6 8h4M6 10h4M6 12h2',
|
||
}
|
||
|
||
// 工具颜色主题
|
||
const TOOL_COLORS: Record<string, string> = {
|
||
// 大纲
|
||
list_outlines: 'var(--bamboo)',
|
||
create_outline: 'var(--bamboo)',
|
||
update_outline: '#d4a017',
|
||
delete_outline: '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)',
|
||
// 文件
|
||
list_files: '#e8833a',
|
||
read_file: '#e8833a',
|
||
}
|
||
|
||
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 '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}`
|
||
// 文件
|
||
case 'read_file':
|
||
return `#${args.file_id}` + (args.offset ? ` (偏移 ${args.offset})` : '')
|
||
default:
|
||
return ''
|
||
}
|
||
}
|
||
|
||
/** 格式化工具结果 */
|
||
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 '暂无大纲'
|
||
return items.map((o: { id: number; summary: string }) => `• ${o.summary}`).join('\n')
|
||
}
|
||
case 'create_outline':
|
||
return `已创建: ${(data as { summary: string }).summary}`
|
||
case 'update_outline':
|
||
return `已更新: ${(data as { summary: string }).summary}`
|
||
case 'delete_outline':
|
||
return '已删除'
|
||
case 'reorder_outlines':
|
||
return `已调整 ${(data as { count: number }).count} 个节点的排序`
|
||
// 世界观
|
||
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} 字)`
|
||
// 角色
|
||
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 '已删除'
|
||
// 文件
|
||
case 'list_files': {
|
||
const files = (data as { files: { id: number; filename: string; text_length: number }[] }).files
|
||
if (!files?.length) return '暂无文件'
|
||
return files.map((f: { id: number; filename: string; text_length: number }) => `• ${f.filename} (${f.text_length} 字)`).join('\n')
|
||
}
|
||
case 'read_file': {
|
||
const d = data as { filename: string; total_length: number; offset: number; limit: number; has_more: boolean }
|
||
return `${d.filename} [${d.offset}–${d.offset + d.limit}] / ${d.total_length} 字${d.has_more ? '(还有更多)' : ''}`
|
||
}
|
||
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>
|