Files
NovalRedot/frontend/src/components/chat/ChatToolCalls.vue
ROOG b39377e487
All checks were successful
Deploy Noval to K3s / deploy (push) Successful in 47s
新增文件管理与技能管理功能:
- **文件管理**:
  - 支持文件上传、解析和删除。
  - 限制支持格式(txt, md, docx, pdf),最大 10MB。
  - 提供前端上传区、文件列表及后端解析服务。

- **技能管理**:
  - 新增技能的创建、更新及删除功能。
  - 支持分配自定义提示词和工具权限。
  - 提供技能列表及分组工具选择交互界面。

同时调整相关 API 和前端组件,优化协作体验。
2026-03-22 23:50:14 +08:00

429 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>