新增文件管理与技能管理功能:
All checks were successful
Deploy Noval to K3s / deploy (push) Successful in 47s

- **文件管理**:
  - 支持文件上传、解析和删除。
  - 限制支持格式(txt, md, docx, pdf),最大 10MB。
  - 提供前端上传区、文件列表及后端解析服务。

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

同时调整相关 API 和前端组件,优化协作体验。
This commit is contained in:
2026-03-22 23:50:14 +08:00
parent 7d5f4640b3
commit b39377e487
21 changed files with 1913 additions and 45 deletions

View File

@@ -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
View 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}`)
}

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

View 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">支持 txtmddocxpdf最大 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>

View File

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

View File

@@ -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)
}

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

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

View File

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