- **文件管理**: - 支持文件上传、解析和删除。 - 限制支持格式(txt, md, docx, pdf),最大 10MB。 - 提供前端上传区、文件列表及后端解析服务。 - **技能管理**: - 新增技能的创建、更新及删除功能。 - 支持分配自定义提示词和工具权限。 - 提供技能列表及分组工具选择交互界面。 同时调整相关 API 和前端组件,优化协作体验。
This commit is contained in:
@@ -76,6 +76,7 @@ export interface StreamChatOptions {
|
||||
pageContext?: string
|
||||
toolsEnabled?: boolean
|
||||
autoApproveTools?: boolean
|
||||
skillId?: number | null
|
||||
// 工具审批续传
|
||||
pendingToolCalls?: PendingToolCallData[]
|
||||
assistantText?: string
|
||||
@@ -91,7 +92,7 @@ export interface StreamChatOptions {
|
||||
|
||||
export async function streamChat(options: StreamChatOptions): Promise<void> {
|
||||
const {
|
||||
messages, novelId, pageContext, toolsEnabled, autoApproveTools,
|
||||
messages, novelId, pageContext, toolsEnabled, autoApproveTools, skillId,
|
||||
pendingToolCalls, assistantText,
|
||||
onChunk, onError, onDone, onToolCallsPending, onToolCallsAuto, onToolResult,
|
||||
signal,
|
||||
@@ -103,6 +104,7 @@ export async function streamChat(options: StreamChatOptions): Promise<void> {
|
||||
page_context: pageContext ?? null,
|
||||
tools_enabled: toolsEnabled ?? true,
|
||||
auto_approve_tools: autoApproveTools ?? false,
|
||||
skill_id: skillId ?? null,
|
||||
pending_tool_calls: pendingToolCalls ?? null,
|
||||
assistant_text: assistantText ?? null,
|
||||
})
|
||||
|
||||
39
frontend/src/api/files.ts
Normal file
39
frontend/src/api/files.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { http } from './client'
|
||||
|
||||
export interface NovelFile {
|
||||
id: number
|
||||
novel_id: number
|
||||
filename: string
|
||||
file_size: number
|
||||
mime_type: string
|
||||
text_length: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface FileList {
|
||||
items: NovelFile[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 上传文件 */
|
||||
export async function uploadFile(novelId: number, file: File): Promise<NovelFile> {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const { data } = await http.post<NovelFile>(
|
||||
`/novels/${novelId}/files`,
|
||||
form,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' }, timeout: 60000 },
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/** 列出文件 */
|
||||
export async function listFiles(novelId: number): Promise<FileList> {
|
||||
const { data } = await http.get<FileList>(`/novels/${novelId}/files`)
|
||||
return data
|
||||
}
|
||||
|
||||
/** 删除文件 */
|
||||
export async function deleteFile(novelId: number, fileId: number): Promise<void> {
|
||||
await http.delete(`/novels/${novelId}/files/${fileId}`)
|
||||
}
|
||||
68
frontend/src/api/skills.ts
Normal file
68
frontend/src/api/skills.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { http } from './client'
|
||||
|
||||
export interface Skill {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
system_prompt: string
|
||||
allowed_tools: string[]
|
||||
is_builtin: boolean
|
||||
novel_id: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface SkillList {
|
||||
items: Skill[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface ToolInfo {
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface SkillCreate {
|
||||
name: string
|
||||
description?: string
|
||||
system_prompt?: string
|
||||
allowed_tools?: string[]
|
||||
novel_id?: number | null
|
||||
}
|
||||
|
||||
export interface SkillUpdate {
|
||||
name?: string
|
||||
description?: string
|
||||
system_prompt?: string
|
||||
allowed_tools?: string[]
|
||||
}
|
||||
|
||||
/** 列出 Skill(全局 + 指定小说的) */
|
||||
export async function listSkills(novelId?: number): Promise<SkillList> {
|
||||
const params = novelId ? { novel_id: novelId } : {}
|
||||
const { data } = await http.get<SkillList>('/skills', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
/** 创建 Skill */
|
||||
export async function createSkill(skill: SkillCreate): Promise<Skill> {
|
||||
const { data } = await http.post<Skill>('/skills', skill)
|
||||
return data
|
||||
}
|
||||
|
||||
/** 更新 Skill */
|
||||
export async function updateSkill(id: number, skill: SkillUpdate): Promise<Skill> {
|
||||
const { data } = await http.patch<Skill>(`/skills/${id}`, skill)
|
||||
return data
|
||||
}
|
||||
|
||||
/** 删除 Skill */
|
||||
export async function deleteSkill(id: number): Promise<void> {
|
||||
await http.delete(`/skills/${id}`)
|
||||
}
|
||||
|
||||
/** 获取所有可用工具列表 */
|
||||
export async function getAvailableTools(): Promise<ToolInfo[]> {
|
||||
const { data } = await http.get<ToolInfo[]>('/skills/tools')
|
||||
return data
|
||||
}
|
||||
321
frontend/src/components/chat/ChatFiles.vue
Normal file
321
frontend/src/components/chat/ChatFiles.vue
Normal file
@@ -0,0 +1,321 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { listFiles, deleteFile, uploadFile } from '@/api/files'
|
||||
import type { NovelFile } from '@/api/files'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
novelId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
const files = ref<NovelFile[]>([])
|
||||
const loading = ref(false)
|
||||
const isUploading = ref(false)
|
||||
const deletingId = ref<number | null>(null)
|
||||
const fileInputRef = ref<HTMLInputElement>()
|
||||
|
||||
const ALLOWED_EXTS = ['.txt', '.md', '.docx', '.pdf']
|
||||
const MAX_SIZE = 10 * 1024 * 1024
|
||||
|
||||
// 打开时加载文件列表
|
||||
watch(() => props.show, async (val) => {
|
||||
if (val) {
|
||||
await loadFiles()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadFiles() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await listFiles(props.novelId)
|
||||
files.value = result.items
|
||||
} catch {
|
||||
toast.error('加载文件列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function triggerUpload() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
async function handleFileSelect(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (!file) return
|
||||
target.value = ''
|
||||
|
||||
const ext = '.' + file.name.split('.').pop()?.toLowerCase()
|
||||
if (!ALLOWED_EXTS.includes(ext)) {
|
||||
toast.error(`不支持的格式 ${ext},仅支持 ${ALLOWED_EXTS.join('、')}`)
|
||||
return
|
||||
}
|
||||
if (file.size > MAX_SIZE) {
|
||||
toast.error(`文件过大(${(file.size / 1024 / 1024).toFixed(1)}MB),最大 10MB`)
|
||||
return
|
||||
}
|
||||
|
||||
isUploading.value = true
|
||||
try {
|
||||
await uploadFile(props.novelId, file)
|
||||
toast.success(`已上传: ${file.name}`)
|
||||
await loadFiles()
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '上传失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(file: NovelFile) {
|
||||
deletingId.value = file.id
|
||||
try {
|
||||
await deleteFile(props.novelId, file.id)
|
||||
files.value = files.value.filter(f => f.id !== file.id)
|
||||
toast.success(`已删除: ${file.filename}`)
|
||||
} catch {
|
||||
toast.error('删除失败')
|
||||
} finally {
|
||||
deletingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 格式化文件大小 */
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/** 格式化日期 */
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** 文件类型图标颜色 */
|
||||
function getExtColor(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
switch (ext) {
|
||||
case 'txt': case 'md': return 'var(--bamboo)'
|
||||
case 'docx': return '#4a90d9'
|
||||
case 'pdf': return '#c73e1d'
|
||||
default: return 'var(--ink-400)'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal :show="show" title="文件工作区" @close="emit('close')">
|
||||
<div class="files-container">
|
||||
<!-- 上传区 -->
|
||||
<div class="upload-area" @click="triggerUpload">
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept=".txt,.md,.docx,.pdf"
|
||||
style="display: none"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
<div v-if="isUploading" class="upload-hint uploading">
|
||||
<svg class="spin" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="2" stroke-dasharray="40" stroke-dashoffset="10" stroke-linecap="round" />
|
||||
</svg>
|
||||
<span>上传中...</span>
|
||||
</div>
|
||||
<div v-else class="upload-hint">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M10 4v12M4 10h12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
<span>点击上传文件</span>
|
||||
<span class="upload-sub">支持 txt、md、docx、pdf,最大 10MB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<div v-if="loading" class="files-loading">加载中...</div>
|
||||
<div v-else-if="files.length === 0" class="files-empty">
|
||||
<p>还没有上传文件</p>
|
||||
<p class="files-empty-sub">上传小说或剧本后,AI 可以帮你分析内容</p>
|
||||
</div>
|
||||
<div v-else class="file-list">
|
||||
<div v-for="file in files" :key="file.id" class="file-item">
|
||||
<div class="file-icon" :style="{ color: getExtColor(file.filename) }">
|
||||
<svg width="18" height="18" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M4 2h5l3 3v9H4V2z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M9 2v3h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="file-info">
|
||||
<div class="file-name">{{ file.filename }}</div>
|
||||
<div class="file-meta">
|
||||
{{ formatSize(file.file_size) }} · {{ file.text_length.toLocaleString() }} 字 · {{ formatDate(file.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="file-delete"
|
||||
title="删除文件"
|
||||
:disabled="deletingId === file.id"
|
||||
@click.stop="handleDelete(file)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<BaseButton variant="ghost" @click="emit('close')">关闭</BaseButton>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.files-container {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* 上传区 */
|
||||
.upload-area {
|
||||
border: 2px dashed var(--paper-300);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-lg);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) ease;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.upload-area:hover {
|
||||
border-color: var(--vermilion);
|
||||
background: rgba(199, 62, 29, 0.03);
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
color: var(--ink-400);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.upload-hint.uploading {
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.upload-sub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-300);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 加载和空状态 */
|
||||
.files-loading,
|
||||
.files-empty {
|
||||
text-align: center;
|
||||
padding: var(--space-xl) 0;
|
||||
color: var(--ink-400);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.files-empty-sub {
|
||||
font-size: 0.78rem;
|
||||
color: var(--ink-300);
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
/* 文件列表 */
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--paper-100);
|
||||
border: 1px solid var(--paper-200);
|
||||
transition: background var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
background: var(--paper-50);
|
||||
border-color: var(--paper-300);
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-800);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
font-size: 0.72rem;
|
||||
color: var(--ink-400);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.file-delete {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--ink-300);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.file-delete:hover {
|
||||
color: var(--danger);
|
||||
background: rgba(199, 62, 29, 0.08);
|
||||
}
|
||||
|
||||
.file-delete:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -3,11 +3,15 @@ import { ref, computed, nextTick, watch, onMounted } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import { useChatAgent } from '@/composables/useChatAgent'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { uploadFile } from '@/api/files'
|
||||
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'
|
||||
import ChatFiles from './ChatFiles.vue'
|
||||
import SkillSelector from './SkillSelector.vue'
|
||||
import SkillManageModal from './SkillManageModal.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
@@ -19,7 +23,7 @@ const emit = defineEmits<{
|
||||
|
||||
const {
|
||||
entries, isStreaming, isCompressing, streamingContent, lastUsage,
|
||||
toolsEnabled, autoApproveTools, currentNovelId,
|
||||
toolsEnabled, autoApproveTools, activeSkillId, currentNovelId,
|
||||
loadHistory, sendMessage, approveToolCalls, skipToolCalls,
|
||||
stopStreaming, clearChat, compressChat,
|
||||
} = useChatAgent()
|
||||
@@ -28,6 +32,15 @@ const input = ref('')
|
||||
const toast = useToast()
|
||||
const messagesRef = ref<HTMLElement>()
|
||||
const showConfig = ref(false)
|
||||
const showFiles = ref(false)
|
||||
const showSkillManage = ref(false)
|
||||
const skillSelectorRef = ref<InstanceType<typeof SkillSelector>>()
|
||||
const fileInputRef = ref<HTMLInputElement>()
|
||||
const isUploading = ref(false)
|
||||
|
||||
// 允许的文件扩展名
|
||||
const ALLOWED_EXTS = ['.txt', '.md', '.docx', '.pdf']
|
||||
const MAX_SIZE = 10 * 1024 * 1024 // 10MB
|
||||
|
||||
// Token用量
|
||||
const DEFAULT_CONTEXT = 128000
|
||||
@@ -103,6 +116,46 @@ function handleSkip(group: ToolCallGroup) {
|
||||
skipToolCalls(group)
|
||||
}
|
||||
|
||||
// 文件上传
|
||||
function triggerFileInput() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
async function handleFileSelect(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (!file) return
|
||||
target.value = '' // 重置,允许重复上传同名文件
|
||||
|
||||
// 前端校验
|
||||
const ext = '.' + file.name.split('.').pop()?.toLowerCase()
|
||||
if (!ALLOWED_EXTS.includes(ext)) {
|
||||
toast.error(`不支持的格式 ${ext},仅支持 ${ALLOWED_EXTS.join('、')}`)
|
||||
return
|
||||
}
|
||||
if (file.size > MAX_SIZE) {
|
||||
toast.error(`文件过大(${(file.size / 1024 / 1024).toFixed(1)}MB),最大 10MB`)
|
||||
return
|
||||
}
|
||||
if (!currentNovelId.value) {
|
||||
toast.error('请先进入一部小说')
|
||||
return
|
||||
}
|
||||
|
||||
isUploading.value = true
|
||||
try {
|
||||
const result = await uploadFile(currentNovelId.value, file)
|
||||
toast.success(`文件已上传:${result.filename}(${result.text_length} 字)`)
|
||||
// 自动填入提示
|
||||
input.value = `我上传了文件「${result.filename}」,请帮我分析其中的内容。`
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '上传失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 自动滚动:消息变化时、面板打开时
|
||||
watch([entries, streamingContent], scrollToBottom)
|
||||
watch(() => props.open, (isOpen) => {
|
||||
@@ -139,6 +192,16 @@ onMounted(() => {
|
||||
<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"
|
||||
title="文件工作区"
|
||||
@click="showFiles = true"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="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" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="currentNovelId"
|
||||
class="icon-btn"
|
||||
@@ -166,6 +229,13 @@ onMounted(() => {
|
||||
<path d="M6 8L7.5 9.5L10 6.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<SkillSelector
|
||||
v-if="currentNovelId"
|
||||
ref="skillSelectorRef"
|
||||
v-model="activeSkillId"
|
||||
:novel-id="currentNovelId"
|
||||
@manage="showSkillManage = true"
|
||||
/>
|
||||
<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" />
|
||||
@@ -244,6 +314,26 @@ onMounted(() => {
|
||||
|
||||
<!-- 输入区 -->
|
||||
<div class="panel-input">
|
||||
<!-- 附件上传按钮 -->
|
||||
<button
|
||||
v-if="currentNovelId"
|
||||
class="icon-btn attach-btn"
|
||||
:class="{ 'icon-btn--uploading': isUploading }"
|
||||
:title="isUploading ? '上传中...' : '上传文件(txt/md/docx/pdf)'"
|
||||
:disabled="isStreaming || isUploading"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="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.8a1.1 1.1 0 01-1.5-1.5L10 5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept=".txt,.md,.docx,.pdf"
|
||||
style="display: none"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
<textarea
|
||||
v-model="input"
|
||||
class="input-field"
|
||||
@@ -274,6 +364,13 @@ onMounted(() => {
|
||||
</Teleport>
|
||||
|
||||
<ChatConfigModal :show="showConfig" @close="showConfig = false" />
|
||||
<ChatFiles v-if="currentNovelId" :show="showFiles" :novel-id="currentNovelId" @close="showFiles = false" />
|
||||
<SkillManageModal
|
||||
:show="showSkillManage"
|
||||
:novel-id="currentNovelId"
|
||||
@close="showSkillManage = false"
|
||||
@updated="skillSelectorRef?.loadSkills()"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -487,6 +584,17 @@ onMounted(() => {
|
||||
30% { transform: translateY(-6px); }
|
||||
}
|
||||
|
||||
/* 附件上传按钮 */
|
||||
.attach-btn {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.icon-btn--uploading {
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
/* 输入区 */
|
||||
.panel-input {
|
||||
display: flex;
|
||||
|
||||
@@ -37,6 +37,9 @@ const TOOL_ICONS: Record<string, string> = {
|
||||
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',
|
||||
}
|
||||
|
||||
// 工具颜色主题
|
||||
@@ -60,6 +63,9 @@ const TOOL_COLORS: Record<string, string> = {
|
||||
create_chapter: '#4a90d9',
|
||||
update_chapter: '#d4a017',
|
||||
delete_chapter: 'var(--danger)',
|
||||
// 文件
|
||||
list_files: '#e8833a',
|
||||
read_file: '#e8833a',
|
||||
}
|
||||
|
||||
function getIcon(name: string): string {
|
||||
@@ -104,6 +110,9 @@ function formatArgs(call: ToolCallEntry): string {
|
||||
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 ''
|
||||
}
|
||||
@@ -164,6 +173,16 @@ function formatResult(call: ToolCallEntry): string {
|
||||
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)
|
||||
}
|
||||
|
||||
506
frontend/src/components/chat/SkillManageModal.vue
Normal file
506
frontend/src/components/chat/SkillManageModal.vue
Normal file
@@ -0,0 +1,506 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { listSkills, createSkill, updateSkill, deleteSkill, getAvailableTools } from '@/api/skills'
|
||||
import type { Skill, ToolInfo } from '@/api/skills'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||
import BaseInput from '@/components/ui/BaseInput.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
novelId?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
updated: []
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
const skills = ref<Skill[]>([])
|
||||
const tools = ref<ToolInfo[]>([])
|
||||
const selectedId = ref<number | null>(null)
|
||||
const saving = ref(false)
|
||||
|
||||
// 编辑表单
|
||||
const formName = ref('')
|
||||
const formDesc = ref('')
|
||||
const formPrompt = ref('')
|
||||
const formTools = ref<string[]>([])
|
||||
const isNew = ref(false)
|
||||
|
||||
const selectedSkill = computed(() =>
|
||||
skills.value.find(s => s.id === selectedId.value) ?? null
|
||||
)
|
||||
|
||||
// 工具分组
|
||||
const TOOL_GROUPS: { label: string; tools: string[] }[] = [
|
||||
{ label: '小说信息', tools: ['get_novel_info', 'update_novel_info'] },
|
||||
{ label: '大纲管理', tools: ['list_outlines', 'create_outline', 'update_outline', 'delete_outline', 'reorder_outlines'] },
|
||||
{ label: '世界观', tools: ['get_world_setting', 'save_world_setting'] },
|
||||
{ label: '角色管理', tools: ['list_characters', 'create_character', 'update_character', 'delete_character'] },
|
||||
{ label: '章节管理', tools: ['list_chapters', 'create_chapter', 'update_chapter', 'delete_chapter'] },
|
||||
{ label: '文件管理', tools: ['list_files', 'read_file'] },
|
||||
]
|
||||
|
||||
function getToolDesc(name: string): string {
|
||||
return tools.value.find(t => t.name === name)?.description ?? name
|
||||
}
|
||||
|
||||
// 全选/取消
|
||||
const allToolsSelected = computed(() =>
|
||||
formTools.value.length === 0 || tools.value.every(t => formTools.value.includes(t.name))
|
||||
)
|
||||
|
||||
function toggleAllTools() {
|
||||
if (allToolsSelected.value && formTools.value.length > 0) {
|
||||
formTools.value = [] // 空数组 = 全部
|
||||
} else {
|
||||
formTools.value = tools.value.map(t => t.name)
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.show, async (val) => {
|
||||
if (val) {
|
||||
await Promise.all([loadSkills(), loadTools()])
|
||||
// 默认选中第一个
|
||||
if (skills.value.length > 0 && !selectedId.value) {
|
||||
selectSkill(skills.value[0])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function loadSkills() {
|
||||
try {
|
||||
const result = await listSkills(props.novelId)
|
||||
skills.value = result.items
|
||||
} catch { /* 静默 */ }
|
||||
}
|
||||
|
||||
async function loadTools() {
|
||||
try {
|
||||
tools.value = await getAvailableTools()
|
||||
} catch { /* 静默 */ }
|
||||
}
|
||||
|
||||
function selectSkill(skill: Skill) {
|
||||
selectedId.value = skill.id
|
||||
formName.value = skill.name
|
||||
formDesc.value = skill.description
|
||||
formPrompt.value = skill.system_prompt
|
||||
formTools.value = [...skill.allowed_tools]
|
||||
isNew.value = false
|
||||
}
|
||||
|
||||
function startNew() {
|
||||
selectedId.value = null
|
||||
formName.value = ''
|
||||
formDesc.value = ''
|
||||
formPrompt.value = ''
|
||||
formTools.value = []
|
||||
isNew.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!formName.value.trim()) {
|
||||
toast.error('请输入技能名称')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (isNew.value) {
|
||||
const created = await createSkill({
|
||||
name: formName.value.trim(),
|
||||
description: formDesc.value.trim(),
|
||||
system_prompt: formPrompt.value,
|
||||
allowed_tools: formTools.value,
|
||||
novel_id: props.novelId ?? null,
|
||||
})
|
||||
toast.success(`已创建:${created.name}`)
|
||||
await loadSkills()
|
||||
selectSkill(created)
|
||||
} else if (selectedId.value) {
|
||||
const updated = await updateSkill(selectedId.value, {
|
||||
name: formName.value.trim(),
|
||||
description: formDesc.value.trim(),
|
||||
system_prompt: formPrompt.value,
|
||||
allowed_tools: formTools.value,
|
||||
})
|
||||
toast.success(`已更新:${updated.name}`)
|
||||
await loadSkills()
|
||||
selectSkill(updated)
|
||||
}
|
||||
emit('updated')
|
||||
} catch {
|
||||
toast.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!selectedId.value) return
|
||||
try {
|
||||
await deleteSkill(selectedId.value)
|
||||
toast.success('已删除')
|
||||
selectedId.value = null
|
||||
await loadSkills()
|
||||
if (skills.value.length > 0) {
|
||||
selectSkill(skills.value[0])
|
||||
} else {
|
||||
isNew.value = true
|
||||
}
|
||||
emit('updated')
|
||||
} catch {
|
||||
toast.error('删除失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal :show="show" title="技能管理" @close="emit('close')" width="680px">
|
||||
<div class="skill-manage">
|
||||
<!-- 左栏:列表 -->
|
||||
<div class="skill-list-col">
|
||||
<div class="list-header">
|
||||
<span class="list-title">技能列表</span>
|
||||
<button class="add-btn" @click="startNew" title="新建技能">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="list-items">
|
||||
<div
|
||||
v-for="skill in skills"
|
||||
:key="skill.id"
|
||||
class="list-item"
|
||||
:class="{ 'list-item--active': selectedId === skill.id }"
|
||||
@click="selectSkill(skill)"
|
||||
>
|
||||
<span class="item-name">{{ skill.name }}</span>
|
||||
<span v-if="skill.is_builtin" class="item-badge">预置</span>
|
||||
</div>
|
||||
<div v-if="isNew" class="list-item list-item--active">
|
||||
<span class="item-name" style="color: var(--vermilion)">+ 新建技能</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右栏:编辑 -->
|
||||
<div class="skill-edit-col">
|
||||
<template v-if="selectedId || isNew">
|
||||
<BaseInput
|
||||
v-model="formName"
|
||||
label="名称"
|
||||
placeholder="技能名称"
|
||||
/>
|
||||
<BaseInput
|
||||
v-model="formDesc"
|
||||
label="描述"
|
||||
placeholder="简短描述(可选)"
|
||||
/>
|
||||
<div class="form-group">
|
||||
<label class="form-label">系统提示词</label>
|
||||
<textarea
|
||||
v-model="formPrompt"
|
||||
class="prompt-textarea"
|
||||
placeholder="定义 AI 在此技能下的行为、风格和专注领域..."
|
||||
rows="5"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="tools-header">
|
||||
<label class="form-label">可用工具</label>
|
||||
<button class="toggle-all" @click="toggleAllTools">
|
||||
{{ allToolsSelected ? '取消全选' : '全选' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="tool-groups">
|
||||
<div v-for="group in TOOL_GROUPS" :key="group.label" class="tool-group">
|
||||
<div class="group-label">{{ group.label }}</div>
|
||||
<div class="group-tools">
|
||||
<label
|
||||
v-for="toolName in group.tools"
|
||||
:key="toolName"
|
||||
class="tool-checkbox"
|
||||
:title="getToolDesc(toolName)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="toolName"
|
||||
v-model="formTools"
|
||||
/>
|
||||
<span>{{ toolName.replace(/_/g, ' ') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="tools-hint">不选择任何工具 = 使用全部工具</p>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="edit-empty">
|
||||
<p>选择一个技能进行编辑</p>
|
||||
<p>或点击 + 创建新技能</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="modal-footer">
|
||||
<BaseButton
|
||||
v-if="selectedId && !isNew"
|
||||
variant="ghost"
|
||||
class="delete-btn"
|
||||
@click="handleDelete"
|
||||
>
|
||||
删除
|
||||
</BaseButton>
|
||||
<div class="footer-right">
|
||||
<BaseButton variant="ghost" @click="emit('close')">取消</BaseButton>
|
||||
<BaseButton
|
||||
v-if="selectedId || isNew"
|
||||
:loading="saving"
|
||||
@click="handleSave"
|
||||
>
|
||||
{{ isNew ? '创建' : '保存' }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skill-manage {
|
||||
display: flex;
|
||||
gap: var(--space-lg);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
/* 左栏 */
|
||||
.skill-list-col {
|
||||
width: 180px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--paper-200);
|
||||
padding-right: var(--space-md);
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.list-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: 1px solid var(--paper-200);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--ink-400);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
border-color: var(--vermilion);
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.list-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background var(--duration-fast) ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.list-item:hover {
|
||||
background: var(--paper-100);
|
||||
}
|
||||
|
||||
.list-item--active {
|
||||
background: rgba(91, 140, 90, 0.1);
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 0.82rem;
|
||||
color: var(--ink-700);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-badge {
|
||||
font-size: 0.6rem;
|
||||
padding: 1px 4px;
|
||||
border-radius: 6px;
|
||||
background: rgba(91, 140, 90, 0.12);
|
||||
color: var(--bamboo);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 右栏 */
|
||||
.skill-edit-col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.edit-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--ink-300);
|
||||
font-size: 0.88rem;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-600);
|
||||
}
|
||||
|
||||
.prompt-textarea {
|
||||
width: 100%;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: 0.85rem;
|
||||
font-family: var(--font-body);
|
||||
color: var(--ink-900);
|
||||
background: var(--paper-100);
|
||||
border: 1px solid var(--paper-200);
|
||||
border-radius: var(--radius-md);
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
max-height: 200px;
|
||||
line-height: 1.5;
|
||||
transition: border-color var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.prompt-textarea:focus {
|
||||
border-color: var(--vermilion);
|
||||
}
|
||||
|
||||
/* 工具选择 */
|
||||
.tools-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.toggle-all {
|
||||
font-size: 0.72rem;
|
||||
color: var(--ink-400);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.toggle-all:hover {
|
||||
color: var(--ink-700);
|
||||
}
|
||||
|
||||
.tool-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
background: var(--paper-100);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--paper-200);
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tool-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.group-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-400);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.group-tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
}
|
||||
|
||||
.tool-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-600);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tool-checkbox input {
|
||||
accent-color: var(--bamboo);
|
||||
}
|
||||
|
||||
.tools-hint {
|
||||
font-size: 0.7rem;
|
||||
color: var(--ink-300);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 底部 */
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer-right {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
color: var(--danger) !important;
|
||||
}
|
||||
</style>
|
||||
254
frontend/src/components/chat/SkillSelector.vue
Normal file
254
frontend/src/components/chat/SkillSelector.vue
Normal file
@@ -0,0 +1,254 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { listSkills } from '@/api/skills'
|
||||
import type { Skill } from '@/api/skills'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: number | null
|
||||
novelId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number | null]
|
||||
manage: []
|
||||
}>()
|
||||
|
||||
const skills = ref<Skill[]>([])
|
||||
const open = ref(false)
|
||||
const dropdownRef = ref<HTMLElement>()
|
||||
|
||||
const activeSkill = ref<Skill | null>(null)
|
||||
|
||||
async function loadSkills() {
|
||||
try {
|
||||
const result = await listSkills(props.novelId)
|
||||
skills.value = result.items
|
||||
// 同步当前激活 Skill
|
||||
if (props.modelValue) {
|
||||
activeSkill.value = skills.value.find(s => s.id === props.modelValue) ?? null
|
||||
}
|
||||
} catch { /* 静默 */ }
|
||||
}
|
||||
|
||||
function selectSkill(skill: Skill | null) {
|
||||
activeSkill.value = skill
|
||||
emit('update:modelValue', skill?.id ?? null)
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (dropdownRef.value && !dropdownRef.value.contains(e.target as Node)) {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.novelId, loadSkills)
|
||||
watch(() => props.modelValue, (id) => {
|
||||
activeSkill.value = id ? skills.value.find(s => s.id === id) ?? null : null
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadSkills()
|
||||
document.addEventListener('click', handleClickOutside, true)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside, true)
|
||||
})
|
||||
|
||||
/** 外部调用:重新加载技能列表 */
|
||||
defineExpose({ loadSkills })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="dropdownRef" class="skill-selector">
|
||||
<button
|
||||
class="skill-trigger"
|
||||
:class="{ 'skill-trigger--active': activeSkill }"
|
||||
:title="activeSkill ? `当前技能:${activeSkill.name}` : '选择技能'"
|
||||
@click.stop="open = !open"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M8 1l2.5 5H14l-4 3.5 1.5 5.5L8 12l-3.5 3 1.5-5.5L2 6h3.5z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<span v-if="activeSkill" class="skill-name">{{ activeSkill.name }}</span>
|
||||
<span v-else class="skill-name">技能</span>
|
||||
<svg class="chevron" :class="{ 'chevron--open': open }" width="10" height="10" viewBox="0 0 10 10" fill="none">
|
||||
<path d="M2.5 4L5 6.5L7.5 4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<Transition name="dropdown">
|
||||
<div v-if="open" class="skill-dropdown">
|
||||
<!-- 无技能选项 -->
|
||||
<div
|
||||
class="skill-option"
|
||||
:class="{ 'skill-option--active': !activeSkill }"
|
||||
@click="selectSkill(null)"
|
||||
>
|
||||
<span class="option-name">无技能</span>
|
||||
<span class="option-desc">使用默认写作助手</span>
|
||||
</div>
|
||||
<div class="dropdown-divider" />
|
||||
<!-- 技能列表 -->
|
||||
<div
|
||||
v-for="skill in skills"
|
||||
:key="skill.id"
|
||||
class="skill-option"
|
||||
:class="{ 'skill-option--active': activeSkill?.id === skill.id }"
|
||||
@click="selectSkill(skill)"
|
||||
>
|
||||
<div class="option-header">
|
||||
<span class="option-name">{{ skill.name }}</span>
|
||||
<span v-if="skill.is_builtin" class="builtin-badge">预置</span>
|
||||
</div>
|
||||
<span class="option-desc">{{ skill.description }}</span>
|
||||
</div>
|
||||
<div class="dropdown-divider" />
|
||||
<!-- 管理入口 -->
|
||||
<div class="skill-option skill-option--manage" @click="emit('manage'); open = false">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="2.5" stroke="currentColor" stroke-width="1.2" />
|
||||
<path d="M8 1v2M8 13v2M1 8h2M13 8h2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
|
||||
</svg>
|
||||
<span>管理技能...</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skill-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.skill-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: none;
|
||||
border: 1px solid var(--paper-200);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--ink-500);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--font-body);
|
||||
transition: all var(--duration-fast) ease;
|
||||
height: 28px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.skill-trigger:hover {
|
||||
border-color: var(--ink-300);
|
||||
color: var(--ink-800);
|
||||
}
|
||||
|
||||
.skill-trigger--active {
|
||||
border-color: var(--bamboo);
|
||||
color: var(--bamboo);
|
||||
background: rgba(91, 140, 90, 0.06);
|
||||
}
|
||||
|
||||
.skill-name {
|
||||
max-width: 80px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
transition: transform var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.chevron--open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 下拉菜单 */
|
||||
.skill-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
width: 240px;
|
||||
background: var(--paper-50);
|
||||
border: 1px solid var(--paper-200);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 1100;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
height: 1px;
|
||||
background: var(--paper-200);
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.skill-option {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.skill-option:hover {
|
||||
background: var(--paper-100);
|
||||
}
|
||||
|
||||
.skill-option--active {
|
||||
background: rgba(91, 140, 90, 0.08);
|
||||
}
|
||||
|
||||
.skill-option--manage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--ink-400);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.skill-option--manage:hover {
|
||||
color: var(--ink-700);
|
||||
}
|
||||
|
||||
.option-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.option-name {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-800);
|
||||
}
|
||||
|
||||
.option-desc {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
color: var(--ink-400);
|
||||
margin-top: 2px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.builtin-badge {
|
||||
font-size: 0.65rem;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
background: rgba(91, 140, 90, 0.12);
|
||||
color: var(--bamboo);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 动画 */
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
transition: all var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
</style>
|
||||
@@ -23,6 +23,8 @@ const LABEL_TO_TOOL_NAME: Record<string, string> = {
|
||||
'创建章节': 'create_chapter',
|
||||
'更新章节': 'update_chapter',
|
||||
'删除章节': 'delete_chapter',
|
||||
'查询文件': 'list_files',
|
||||
'读取文件': 'read_file',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,6 +94,7 @@ const streamingContent = ref('')
|
||||
const lastUsage = ref<TokenUsage | null>(null)
|
||||
const toolsEnabled = ref(true)
|
||||
const autoApproveTools = ref(false)
|
||||
const activeSkillId = ref<number | null>(null)
|
||||
const isCompressing = ref(false)
|
||||
let abortController: AbortController | null = null
|
||||
let idCounter = Date.now()
|
||||
@@ -227,6 +230,7 @@ export function useChatAgent() {
|
||||
novelId,
|
||||
pageContext: pageContext.value,
|
||||
toolsEnabled: toolsEnabled.value,
|
||||
skillId: activeSkillId.value,
|
||||
autoApproveTools: autoApproveTools.value,
|
||||
pendingToolCalls,
|
||||
assistantText,
|
||||
@@ -285,7 +289,8 @@ export function useChatAgent() {
|
||||
(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)
|
||||
// 匹配第一个同名且还没有 result 的 call(处理多个相同工具调用的情况)
|
||||
const call = group.calls.find(c => c.name === info.name && !c.result)
|
||||
if (call) {
|
||||
call.result = info.result
|
||||
try { call.parsedResult = JSON.parse(info.result) } catch { /* ok */ }
|
||||
@@ -397,6 +402,7 @@ export function useChatAgent() {
|
||||
lastUsage,
|
||||
toolsEnabled,
|
||||
autoApproveTools,
|
||||
activeSkillId,
|
||||
currentNovelId,
|
||||
pageContext,
|
||||
loadHistory,
|
||||
|
||||
Reference in New Issue
Block a user