- **子 Agent 支持**: - 新增 `dispatch_subagent` 工具,支持分配特定任务给子 Agent,由其独立操作。 - 前端新增 `ChatSubAgent` 组件,支持子 Agent 任务状态展示(运行中/已完成)、工具调用进度及输出预览等。 - **技能管理扩展**: - 新增 Skill 编辑器页面(`SkillEditorView.vue`),支持创建、编辑、删除技能。 - 完善技能工具权限分组选择功能,按类型灵活分配工具。 - **工具增强**: - 新增工具接口:`get_outline_detail`、`get_character_detail`、`get_chapter_detail`,用于获取详情内容。 - 优化部分工具返回数据,仅输出基础信息以减少上下文体积。 - 后端添加 `SPECIAL_TOOLS` 支持,增强 `dispatch_subagent` 的特殊处理能力。 同时优化部分前后端交互,提升代码维护性与可读性。
This commit is contained in:
@@ -70,6 +70,25 @@ export interface PendingToolCallData {
|
||||
arguments: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface SubAgentStartInfo {
|
||||
skill_id: number
|
||||
skill_name: string
|
||||
task: string
|
||||
subagent_id: string
|
||||
}
|
||||
|
||||
export interface SubAgentToolCallInfo {
|
||||
name: string
|
||||
label: string
|
||||
arguments: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface SubAgentToolResultInfo {
|
||||
name: string
|
||||
label: string
|
||||
result: string
|
||||
}
|
||||
|
||||
export interface StreamChatOptions {
|
||||
messages: { role: string; content: string }[]
|
||||
novelId?: number
|
||||
@@ -87,6 +106,12 @@ export interface StreamChatOptions {
|
||||
onToolCallsPending?: (calls: PendingToolCallData[], assistantText: string) => void
|
||||
onToolCallsAuto?: (calls: PendingToolCallData[]) => void
|
||||
onToolResult?: (info: ToolResultInfo) => void
|
||||
// 子 Agent 回调(subagentId 用于区分并行的多个子 Agent)
|
||||
onSubAgentStart?: (info: SubAgentStartInfo) => void
|
||||
onSubAgentChunk?: (text: string, subagentId: string) => void
|
||||
onSubAgentToolCall?: (info: SubAgentToolCallInfo, subagentId: string) => void
|
||||
onSubAgentToolResult?: (info: SubAgentToolResultInfo, subagentId: string) => void
|
||||
onSubAgentDone?: (result: string, subagentId: string) => void
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
@@ -95,6 +120,7 @@ export async function streamChat(options: StreamChatOptions): Promise<void> {
|
||||
messages, novelId, pageContext, toolsEnabled, autoApproveTools, skillId,
|
||||
pendingToolCalls, assistantText,
|
||||
onChunk, onError, onDone, onToolCallsPending, onToolCallsAuto, onToolResult,
|
||||
onSubAgentStart, onSubAgentChunk, onSubAgentToolCall, onSubAgentToolResult, onSubAgentDone,
|
||||
signal,
|
||||
} = options
|
||||
|
||||
@@ -160,6 +186,22 @@ export async function streamChat(options: StreamChatOptions): Promise<void> {
|
||||
if (event.tool_result && onToolResult) {
|
||||
onToolResult(event.tool_result)
|
||||
}
|
||||
// 子 Agent 事件(所有事件携带 subagent_id 用于区分并行的子 Agent)
|
||||
if (event.subagent_start && onSubAgentStart) {
|
||||
onSubAgentStart(event.subagent_start)
|
||||
}
|
||||
if (event.subagent_chunk && onSubAgentChunk) {
|
||||
onSubAgentChunk(event.subagent_chunk, event.subagent_id ?? '')
|
||||
}
|
||||
if (event.subagent_tool_call && onSubAgentToolCall) {
|
||||
onSubAgentToolCall(event.subagent_tool_call, event.subagent_id ?? '')
|
||||
}
|
||||
if (event.subagent_tool_result && onSubAgentToolResult) {
|
||||
onSubAgentToolResult(event.subagent_tool_result, event.subagent_id ?? '')
|
||||
}
|
||||
if (event.subagent_done !== undefined && onSubAgentDone) {
|
||||
onSubAgentDone(event.subagent_done, event.subagent_id ?? '')
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, watch, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
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 { listSkills } from '@/api/skills'
|
||||
import type { Skill } from '@/api/skills'
|
||||
import { isToolCallGroup, isSubAgentEntry } from '@/types/chat'
|
||||
import type { ToolCallGroup } from '@/types/chat'
|
||||
import ChatMessageComp from './ChatMessage.vue'
|
||||
import ChatToolCalls from './ChatToolCalls.vue'
|
||||
import ChatSubAgent from './ChatSubAgent.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
|
||||
@@ -28,16 +31,123 @@ const {
|
||||
stopStreaming, clearChat, compressChat,
|
||||
} = useChatAgent()
|
||||
|
||||
const router = useRouter()
|
||||
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 showSlashMenu = ref(false)
|
||||
const slashFilter = ref('')
|
||||
const slashSelectedIndex = ref(0)
|
||||
const cachedSkills = ref<Skill[]>([])
|
||||
|
||||
interface SlashCommand {
|
||||
id: string
|
||||
label: string
|
||||
desc: string
|
||||
icon: string
|
||||
type: 'action' | 'skill'
|
||||
skillId?: number
|
||||
}
|
||||
|
||||
// 内置命令
|
||||
const BUILTIN_COMMANDS: SlashCommand[] = [
|
||||
{ id: 'clear', label: '清空对话', desc: '清除当前对话历史', icon: 'M2 4h12M5 4V2.5h6V4M3.5 4v9.5h9V4', type: 'action' },
|
||||
{ id: 'compress', label: '压缩上下文', desc: '总结旧消息释放空间', icon: 'M4 2v4l4-2-4-2zM12 14v-4l-4 2 4 2zM2 8h12', type: 'action' },
|
||||
{ id: 'skills', label: '管理技能', desc: '打开技能管理页面', icon: 'M8 1l2.5 5H14l-4 3.5 1.5 5.5L8 12l-3.5 3 1.5-5.5L2 6h3.5z', type: 'action' },
|
||||
{ id: 'files', label: '文件工作区', desc: '管理上传的文件', icon: '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', type: 'action' },
|
||||
{ id: 'config', label: 'AI 配置', desc: '修改 AI 服务设置', icon: 'M8 8m-2.5 0a2.5 2.5 0 105 0 2.5 2.5 0 10-5 0M8 1v2M8 13v2M1 8h2M13 8h2', type: 'action' },
|
||||
]
|
||||
|
||||
const slashCommands = computed<SlashCommand[]>(() => {
|
||||
const skillCmds: SlashCommand[] = cachedSkills.value.map(s => ({
|
||||
id: `skill-${s.id}`,
|
||||
label: s.name,
|
||||
desc: s.description || '切换到此技能',
|
||||
icon: 'M8 1l2.5 5H14l-4 3.5 1.5 5.5L8 12l-3.5 3 1.5-5.5L2 6h3.5z',
|
||||
type: 'skill' as const,
|
||||
skillId: s.id,
|
||||
}))
|
||||
const all = [...BUILTIN_COMMANDS, ...skillCmds]
|
||||
if (!slashFilter.value) return all
|
||||
const q = slashFilter.value.toLowerCase()
|
||||
return all.filter(c => c.label.toLowerCase().includes(q) || c.id.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
function onInputChange() {
|
||||
const val = input.value
|
||||
if (val.startsWith('/')) {
|
||||
slashFilter.value = val.slice(1)
|
||||
showSlashMenu.value = true
|
||||
slashSelectedIndex.value = 0
|
||||
} else {
|
||||
showSlashMenu.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function executeCommand(cmd: SlashCommand) {
|
||||
showSlashMenu.value = false
|
||||
input.value = ''
|
||||
|
||||
if (cmd.type === 'skill') {
|
||||
activeSkillId.value = cmd.skillId ?? null
|
||||
skillSelectorRef.value?.loadSkills()
|
||||
toast.success(`已切换技能:${cmd.label}`)
|
||||
return
|
||||
}
|
||||
|
||||
switch (cmd.id) {
|
||||
case 'clear':
|
||||
clearChat()
|
||||
toast.success('对话已清空')
|
||||
break
|
||||
case 'compress':
|
||||
handleCompress()
|
||||
break
|
||||
case 'skills':
|
||||
router.push('/skills')
|
||||
break
|
||||
case 'files':
|
||||
if (currentNovelId.value) showFiles.value = true
|
||||
else toast.info('请先进入一部小说')
|
||||
break
|
||||
case 'config':
|
||||
showConfig.value = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function handleSlashKeydown(e: KeyboardEvent) {
|
||||
if (!showSlashMenu.value) return
|
||||
const cmds = slashCommands.value
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
slashSelectedIndex.value = Math.min(slashSelectedIndex.value + 1, cmds.length - 1)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
slashSelectedIndex.value = Math.max(slashSelectedIndex.value - 1, 0)
|
||||
} else if (e.key === 'Enter' && cmds.length > 0) {
|
||||
e.preventDefault()
|
||||
executeCommand(cmds[slashSelectedIndex.value])
|
||||
} else if (e.key === 'Escape') {
|
||||
showSlashMenu.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载技能列表用于斜杠命令
|
||||
async function loadCachedSkills() {
|
||||
try {
|
||||
const result = await listSkills(currentNovelId.value)
|
||||
cachedSkills.value = result.items
|
||||
} catch { /* 静默 */ }
|
||||
}
|
||||
|
||||
// 允许的文件扩展名
|
||||
const ALLOWED_EXTS = ['.txt', '.md', '.docx', '.pdf']
|
||||
const MAX_SIZE = 10 * 1024 * 1024 // 10MB
|
||||
@@ -102,6 +212,11 @@ async function handleSend() {
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
// 斜杠命令键盘导航
|
||||
if (showSlashMenu.value) {
|
||||
handleSlashKeydown(e)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
@@ -164,7 +279,11 @@ watch(() => props.open, (isOpen) => {
|
||||
|
||||
onMounted(() => {
|
||||
loadHistory()
|
||||
loadCachedSkills()
|
||||
})
|
||||
|
||||
// 小说切换时刷新技能缓存
|
||||
watch(currentNovelId, () => loadCachedSkills())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -234,7 +353,7 @@ onMounted(() => {
|
||||
ref="skillSelectorRef"
|
||||
v-model="activeSkillId"
|
||||
:novel-id="currentNovelId"
|
||||
@manage="showSkillManage = true"
|
||||
@manage="router.push('/skills')"
|
||||
/>
|
||||
<button class="icon-btn" title="AI配置" @click="showConfig = true">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
@@ -277,6 +396,11 @@ onMounted(() => {
|
||||
@approve="handleApprove(entry as ToolCallGroup)"
|
||||
@skip="handleSkip(entry as ToolCallGroup)"
|
||||
/>
|
||||
<!-- 子 Agent -->
|
||||
<ChatSubAgent
|
||||
v-else-if="isSubAgentEntry(entry)"
|
||||
:entry="entry"
|
||||
/>
|
||||
<!-- 对话摘要(压缩后的历史总结) -->
|
||||
<div
|
||||
v-else-if="entry.role === 'assistant' && entry.content.startsWith('[对话摘要]')"
|
||||
@@ -310,6 +434,14 @@ onMounted(() => {
|
||||
<div v-if="isStreaming && !streamingContent" class="typing-indicator">
|
||||
<span /><span /><span />
|
||||
</div>
|
||||
|
||||
<!-- 压缩中提示 -->
|
||||
<div v-if="isCompressing" class="compress-indicator">
|
||||
<svg class="compress-spinner" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<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>
|
||||
<span>正在压缩上下文,总结旧消息中...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入区 -->
|
||||
@@ -334,14 +466,43 @@ onMounted(() => {
|
||||
style="display: none"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
<textarea
|
||||
v-model="input"
|
||||
class="input-field"
|
||||
placeholder="输入消息..."
|
||||
rows="1"
|
||||
:disabled="isStreaming || hasPendingTools"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
<div class="input-wrapper">
|
||||
<!-- 斜杠命令菜单 -->
|
||||
<Transition name="slash">
|
||||
<div v-if="showSlashMenu && slashCommands.length > 0" class="slash-menu">
|
||||
<div class="slash-header">命令</div>
|
||||
<div
|
||||
v-for="(cmd, idx) in slashCommands"
|
||||
:key="cmd.id"
|
||||
class="slash-item"
|
||||
:class="{
|
||||
'slash-item--active': idx === slashSelectedIndex,
|
||||
'slash-item--skill': cmd.type === 'skill',
|
||||
}"
|
||||
@mouseenter="slashSelectedIndex = idx"
|
||||
@click="executeCommand(cmd)"
|
||||
>
|
||||
<svg class="slash-icon" width="14" height="14" viewBox="0 0 16 16" fill="none">
|
||||
<path :d="cmd.icon" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<div class="slash-info">
|
||||
<span class="slash-label">{{ cmd.type === 'skill' ? '' : '/' }}{{ cmd.label }}</span>
|
||||
<span class="slash-desc">{{ cmd.desc }}</span>
|
||||
</div>
|
||||
<span v-if="cmd.type === 'skill'" class="slash-badge">技能</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<textarea
|
||||
v-model="input"
|
||||
class="input-field"
|
||||
placeholder="输入消息... 输入 / 查看命令"
|
||||
rows="1"
|
||||
:disabled="isStreaming || hasPendingTools"
|
||||
@keydown="handleKeydown"
|
||||
@input="onInputChange"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-if="!isStreaming"
|
||||
class="send-btn"
|
||||
@@ -365,12 +526,6 @@ onMounted(() => {
|
||||
|
||||
<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>
|
||||
@@ -584,6 +739,124 @@ onMounted(() => {
|
||||
30% { transform: translateY(-6px); }
|
||||
}
|
||||
|
||||
.compress-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: rgba(91, 140, 90, 0.08);
|
||||
border: 1px solid rgba(91, 140, 90, 0.2);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--bamboo);
|
||||
font-size: 0.78rem;
|
||||
animation: fadeInUp var(--duration-fast) var(--ease-out-smooth) both;
|
||||
}
|
||||
|
||||
.compress-spinner {
|
||||
animation: compressSpin 1.5s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes compressSpin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 输入区包装 */
|
||||
.input-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 斜杠命令菜单 */
|
||||
.slash-menu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--paper-50);
|
||||
border: 1px solid var(--paper-200);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
.slash-header {
|
||||
padding: 6px 12px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-400);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid var(--paper-200);
|
||||
}
|
||||
|
||||
.slash-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.slash-item:hover,
|
||||
.slash-item--active {
|
||||
background: var(--paper-100);
|
||||
}
|
||||
|
||||
.slash-item--skill .slash-icon {
|
||||
color: var(--bamboo);
|
||||
}
|
||||
|
||||
.slash-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--ink-400);
|
||||
}
|
||||
|
||||
.slash-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.slash-label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-800);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.slash-desc {
|
||||
font-size: 0.7rem;
|
||||
color: var(--ink-400);
|
||||
display: block;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.slash-badge {
|
||||
font-size: 0.6rem;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
background: rgba(91, 140, 90, 0.12);
|
||||
color: var(--bamboo);
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.slash-enter-active,
|
||||
.slash-leave-active {
|
||||
transition: all var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.slash-enter-from,
|
||||
.slash-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
/* 附件上传按钮 */
|
||||
.attach-btn {
|
||||
flex-shrink: 0;
|
||||
@@ -606,7 +879,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.input-field {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: 0.9rem;
|
||||
font-family: var(--font-body);
|
||||
|
||||
279
frontend/src/components/chat/ChatSubAgent.vue
Normal file
279
frontend/src/components/chat/ChatSubAgent.vue
Normal file
@@ -0,0 +1,279 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { SubAgentEntry } from '@/types/chat'
|
||||
|
||||
const props = defineProps<{
|
||||
entry: SubAgentEntry
|
||||
}>()
|
||||
|
||||
const isRunning = computed(() => props.entry.status === 'running')
|
||||
const isDone = computed(() => props.entry.status === 'done')
|
||||
const expanded = ref(true)
|
||||
|
||||
// 截取输出文本预览
|
||||
const outputPreview = computed(() => {
|
||||
const text = props.entry.chunks
|
||||
if (!text) return ''
|
||||
if (text.length <= 200) return text
|
||||
return text.slice(0, 200) + '...'
|
||||
})
|
||||
|
||||
const hasToolCalls = computed(() => props.entry.toolCalls.length > 0)
|
||||
const completedTools = computed(() =>
|
||||
props.entry.toolCalls.filter(c => c.result).length
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="subagent-card" :class="{ 'subagent-card--running': isRunning }">
|
||||
<!-- 头部 -->
|
||||
<div class="subagent-header" @click="expanded = !expanded">
|
||||
<div class="subagent-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="5" r="3" stroke="currentColor" stroke-width="1.3" />
|
||||
<path d="M2 14c0-3.3 2.7-6 6-6s6 2.7 6 6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
|
||||
<path d="M12 3l2 2-2 2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="subagent-info">
|
||||
<span class="subagent-name">{{ entry.skillName }}</span>
|
||||
<span v-if="isRunning" class="subagent-badge badge--running">运行中</span>
|
||||
<span v-else-if="isDone" class="subagent-badge badge--done">已完成</span>
|
||||
</div>
|
||||
<button class="subagent-toggle" :title="expanded ? '收起' : '展开'">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path
|
||||
:d="expanded ? 'M3 7.5l3-3 3 3' : 'M3 4.5l3 3 3-3'"
|
||||
stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 任务描述 -->
|
||||
<div class="subagent-task">{{ entry.task }}</div>
|
||||
|
||||
<!-- 展开内容 -->
|
||||
<div v-if="expanded" class="subagent-body">
|
||||
<!-- 工具调用列表 -->
|
||||
<div v-if="hasToolCalls" class="subagent-tools">
|
||||
<div class="subagent-tools-header">
|
||||
<span class="tools-label">工具调用</span>
|
||||
<span class="tools-count">{{ completedTools }}/{{ entry.toolCalls.length }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="call in entry.toolCalls"
|
||||
:key="call.id"
|
||||
class="subagent-tool-item"
|
||||
>
|
||||
<span class="tool-dot" :class="{ 'tool-dot--done': call.result }" />
|
||||
<span class="tool-name">{{ call.label }}</span>
|
||||
<span v-if="call.result" class="tool-check">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 子 Agent 输出 -->
|
||||
<div v-if="entry.chunks" class="subagent-output">
|
||||
<div class="output-label">输出</div>
|
||||
<div class="output-text">{{ isDone ? entry.chunks : outputPreview }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 运行中指示 -->
|
||||
<div v-if="isRunning" class="subagent-running">
|
||||
<span class="dot" /><span class="dot" /><span class="dot" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.subagent-card {
|
||||
background: linear-gradient(135deg, var(--paper-50) 0%, rgba(91, 140, 90, 0.04) 100%);
|
||||
border: 1px solid rgba(91, 140, 90, 0.2);
|
||||
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;
|
||||
}
|
||||
|
||||
.subagent-card--running {
|
||||
border-color: rgba(91, 140, 90, 0.35);
|
||||
box-shadow: 0 0 0 1px rgba(91, 140, 90, 0.08);
|
||||
}
|
||||
|
||||
.subagent-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.subagent-icon {
|
||||
color: var(--bamboo);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.subagent-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.subagent-name {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--bamboo);
|
||||
}
|
||||
|
||||
.subagent-badge {
|
||||
font-size: 0.65rem;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge--running {
|
||||
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);
|
||||
}
|
||||
|
||||
.subagent-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--ink-300);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.subagent-task {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-400);
|
||||
margin: var(--space-xs) 0;
|
||||
padding-left: calc(16px + var(--space-sm));
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.subagent-body {
|
||||
padding-left: calc(16px + var(--space-sm));
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.subagent-tools {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.subagent-tools-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.tools-label {
|
||||
font-size: 0.68rem;
|
||||
color: var(--ink-300);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.tools-count {
|
||||
font-size: 0.65rem;
|
||||
color: var(--ink-300);
|
||||
background: var(--paper-100);
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.subagent-tool-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.tool-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--paper-300);
|
||||
flex-shrink: 0;
|
||||
transition: background var(--duration-fast);
|
||||
}
|
||||
|
||||
.tool-dot--done {
|
||||
background: var(--bamboo);
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-size: 0.72rem;
|
||||
color: var(--ink-500);
|
||||
}
|
||||
|
||||
.tool-check {
|
||||
font-size: 0.68rem;
|
||||
color: var(--bamboo);
|
||||
}
|
||||
|
||||
.subagent-output {
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.output-label {
|
||||
font-size: 0.68rem;
|
||||
color: var(--ink-300);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.output-text {
|
||||
font-size: 0.78rem;
|
||||
color: var(--ink-600);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background: var(--paper-100);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
}
|
||||
|
||||
/* 运行中动画 */
|
||||
.subagent-running {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
padding: var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.subagent-running .dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--bamboo);
|
||||
animation: toolDotBounce 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.subagent-running .dot:nth-child(2) { animation-delay: 0.16s; }
|
||||
.subagent-running .dot:nth-child(3) { animation-delay: 0.32s; }
|
||||
|
||||
@keyframes toolPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes toolDotBounce {
|
||||
0%, 80%, 100% { transform: scale(0); }
|
||||
40% { transform: scale(1); }
|
||||
}
|
||||
</style>
|
||||
@@ -20,6 +20,7 @@ const isSkipped = computed(() => props.group.status === 'skipped')
|
||||
const TOOL_ICONS: Record<string, string> = {
|
||||
// 大纲
|
||||
list_outlines: 'M3 4h10M3 8h10M3 12h6',
|
||||
get_outline_detail: 'M4 2h8v12H4zM6 5h4M6 8h4',
|
||||
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',
|
||||
@@ -29,11 +30,13 @@ const TOOL_ICONS: Record<string, string> = {
|
||||
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',
|
||||
get_character_detail: '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 4M11 3h3v3',
|
||||
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',
|
||||
get_chapter_detail: 'M4 2h8v12H4zM6 5h4M6 8h4M6 11h4',
|
||||
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',
|
||||
@@ -46,6 +49,7 @@ const TOOL_ICONS: Record<string, string> = {
|
||||
const TOOL_COLORS: Record<string, string> = {
|
||||
// 大纲
|
||||
list_outlines: 'var(--bamboo)',
|
||||
get_outline_detail: 'var(--bamboo)',
|
||||
create_outline: 'var(--bamboo)',
|
||||
update_outline: '#d4a017',
|
||||
delete_outline: 'var(--danger)',
|
||||
@@ -55,11 +59,13 @@ const TOOL_COLORS: Record<string, string> = {
|
||||
save_world_setting: 'var(--bamboo)',
|
||||
// 角色
|
||||
list_characters: '#7c6bc4',
|
||||
get_character_detail: '#7c6bc4',
|
||||
create_character: '#7c6bc4',
|
||||
update_character: '#d4a017',
|
||||
delete_character: 'var(--danger)',
|
||||
// 章节
|
||||
list_chapters: '#4a90d9',
|
||||
get_chapter_detail: '#4a90d9',
|
||||
create_chapter: '#4a90d9',
|
||||
update_chapter: '#d4a017',
|
||||
delete_chapter: 'var(--danger)',
|
||||
@@ -82,6 +88,8 @@ function formatArgs(call: ToolCallEntry): string {
|
||||
if (!args || Object.keys(args).length === 0) return ''
|
||||
|
||||
switch (call.name) {
|
||||
case 'get_outline_detail':
|
||||
return `#${args.outline_id}`
|
||||
case 'create_outline':
|
||||
return args.summary as string ?? ''
|
||||
case 'update_outline':
|
||||
@@ -97,6 +105,8 @@ function formatArgs(call: ToolCallEntry): string {
|
||||
return content.length > 80 ? content.slice(0, 80) + '…' : content
|
||||
}
|
||||
// 角色
|
||||
case 'get_character_detail':
|
||||
return `#${args.character_id}`
|
||||
case 'create_character':
|
||||
return args.name as string ?? ''
|
||||
case 'update_character':
|
||||
@@ -104,6 +114,8 @@ function formatArgs(call: ToolCallEntry): string {
|
||||
case 'delete_character':
|
||||
return `#${args.character_id}`
|
||||
// 章节
|
||||
case 'get_chapter_detail':
|
||||
return `#${args.chapter_id}`
|
||||
case 'create_chapter':
|
||||
return args.title as string ?? ''
|
||||
case 'update_chapter':
|
||||
|
||||
@@ -37,10 +37,10 @@ const selectedSkill = computed(() =>
|
||||
// 工具分组
|
||||
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: ['list_outlines', 'get_outline_detail', '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_characters', 'get_character_detail', 'create_character', 'update_character', 'delete_character'] },
|
||||
{ label: '章节管理', tools: ['list_chapters', 'get_chapter_detail', 'create_chapter', 'update_chapter', 'delete_chapter'] },
|
||||
{ label: '文件管理', tools: ['list_files', 'read_file'] },
|
||||
]
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { streamChat, fetchMessages, clearMessages as apiClearMessages, compressContext } from '@/api/chat'
|
||||
import type { ChatMessage, ChatEntry, ToolCallGroup } from '@/types/chat'
|
||||
import type { TokenUsage, PendingToolCallData } from '@/api/chat'
|
||||
import type { ChatMessage, ChatEntry, ToolCallGroup, SubAgentEntry } from '@/types/chat'
|
||||
import type { TokenUsage, PendingToolCallData, SubAgentStartInfo, SubAgentToolCallInfo, SubAgentToolResultInfo } from '@/api/chat'
|
||||
|
||||
// 中文标签 → 英文工具名(用于解析历史消息中的工具调用日志)
|
||||
const LABEL_TO_TOOL_NAME: Record<string, string> = {
|
||||
'查看小说信息': 'get_novel_info',
|
||||
'更新小说信息': 'update_novel_info',
|
||||
'查询大纲': 'list_outlines',
|
||||
'查看大纲详情': 'get_outline_detail',
|
||||
'创建大纲': 'create_outline',
|
||||
'更新大纲': 'update_outline',
|
||||
'删除大纲': 'delete_outline',
|
||||
@@ -16,20 +17,24 @@ const LABEL_TO_TOOL_NAME: Record<string, string> = {
|
||||
'查询世界观': 'get_world_setting',
|
||||
'保存世界观': 'save_world_setting',
|
||||
'查询角色': 'list_characters',
|
||||
'查看角色详情': 'get_character_detail',
|
||||
'创建角色': 'create_character',
|
||||
'更新角色': 'update_character',
|
||||
'删除角色': 'delete_character',
|
||||
'查询章节': 'list_chapters',
|
||||
'查看章节详情': 'get_chapter_detail',
|
||||
'创建章节': 'create_chapter',
|
||||
'更新章节': 'update_chapter',
|
||||
'删除章节': 'delete_chapter',
|
||||
'查询文件': 'list_files',
|
||||
'读取文件': 'read_file',
|
||||
'派遣子Agent': 'dispatch_subagent',
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析历史 assistant 消息中的工具调用日志,拆分为 ToolCallGroup + 纯文本消息
|
||||
* 格式:[以下操作已执行]\n[工具调用: 标签] 参数: {...} → 结果: {...}\n\n回复文本
|
||||
* 解析历史 assistant 消息中的工具调用日志,拆分为 ToolCallGroup / SubAgentEntry + 纯文本消息
|
||||
* 工具格式:[工具调用: 标签] 参数: {...} → 结果: {...}
|
||||
* 子Agent格式:[子Agent: 技能名] 任务: ... → 结果: ...
|
||||
*/
|
||||
function parseHistoryMessage(msg: ChatMessage): ChatEntry[] {
|
||||
const content = msg.content
|
||||
@@ -37,54 +42,86 @@ function parseHistoryMessage(msg: ChatMessage): ChatEntry[] {
|
||||
return [msg]
|
||||
}
|
||||
|
||||
const entries: ChatEntry[] = []
|
||||
const resultEntries: ChatEntry[] = []
|
||||
// 用双换行分隔工具调用块和后续回复
|
||||
const firstBlankLine = content.indexOf('\n\n')
|
||||
const toolSection = firstBlankLine >= 0 ? content.substring(0, firstBlankLine) : content
|
||||
const replyText = firstBlankLine >= 0 ? content.substring(firstBlankLine + 2).trim() : ''
|
||||
|
||||
// 解析工具调用行
|
||||
// 解析每一行
|
||||
const toolLines = toolSection.split('\n').slice(1) // 跳过 [以下操作已执行]
|
||||
const calls = toolLines
|
||||
.map(line => {
|
||||
const match = line.match(/^\[工具调用: (.+?)\] 参数: (.+?) → 结果: (.+)$/)
|
||||
if (!match) return null
|
||||
const label = match[1]
|
||||
const toolCalls: ToolCallGroup['calls'] = []
|
||||
|
||||
for (const line of toolLines) {
|
||||
// 尝试匹配子 Agent 格式
|
||||
const subMatch = line.match(/^\[子Agent: (.+?)\] 任务: (.+?) → 结果: (.+)$/)
|
||||
if (subMatch) {
|
||||
// 先把之前累积的普通工具调用推入
|
||||
if (toolCalls.length > 0) {
|
||||
resultEntries.push({
|
||||
id: idCounter++,
|
||||
type: 'tool_calls',
|
||||
calls: [...toolCalls],
|
||||
status: 'done',
|
||||
assistantText: '',
|
||||
} as ToolCallGroup)
|
||||
toolCalls.length = 0
|
||||
}
|
||||
// 创建 SubAgentEntry
|
||||
resultEntries.push({
|
||||
id: idCounter++,
|
||||
type: 'subagent',
|
||||
subagentId: `hist-${idCounter}`,
|
||||
skillId: 0,
|
||||
skillName: subMatch[1],
|
||||
task: subMatch[2],
|
||||
status: 'done',
|
||||
chunks: subMatch[3],
|
||||
toolCalls: [],
|
||||
} as SubAgentEntry)
|
||||
continue
|
||||
}
|
||||
|
||||
// 尝试匹配普通工具调用格式
|
||||
const toolMatch = line.match(/^\[工具调用: (.+?)\] 参数: (.+?) → 结果: (.+)$/)
|
||||
if (toolMatch) {
|
||||
const label = toolMatch[1]
|
||||
const name = LABEL_TO_TOOL_NAME[label] || label
|
||||
let args: Record<string, unknown> = {}
|
||||
try { args = JSON.parse(match[2]) } catch { /* ok */ }
|
||||
const resultStr = match[3]
|
||||
try { args = JSON.parse(toolMatch[2]) } catch { /* ok */ }
|
||||
const resultStr = toolMatch[3]
|
||||
let parsedResult: unknown = undefined
|
||||
try { parsedResult = JSON.parse(resultStr) } catch { /* ok */ }
|
||||
return {
|
||||
toolCalls.push({
|
||||
id: `hist-${idCounter++}`,
|
||||
name,
|
||||
label,
|
||||
arguments: args,
|
||||
result: resultStr,
|
||||
parsedResult,
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as ToolCallGroup['calls']
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (calls.length > 0) {
|
||||
entries.push({
|
||||
// 推入剩余的普通工具调用
|
||||
if (toolCalls.length > 0) {
|
||||
resultEntries.push({
|
||||
id: idCounter++,
|
||||
type: 'tool_calls',
|
||||
calls,
|
||||
calls: toolCalls,
|
||||
status: 'done',
|
||||
assistantText: '',
|
||||
} as ToolCallGroup)
|
||||
}
|
||||
|
||||
if (replyText) {
|
||||
entries.push({
|
||||
resultEntries.push({
|
||||
...msg,
|
||||
content: replyText,
|
||||
})
|
||||
}
|
||||
|
||||
return entries
|
||||
return resultEntries
|
||||
}
|
||||
|
||||
// 全局状态(跨组件共享)
|
||||
@@ -210,6 +247,18 @@ export function useChatAgent() {
|
||||
|
||||
/** 核心:发起流式请求 */
|
||||
async function _doStreamRequest(novelId?: number, approvedGroup?: ToolCallGroup) {
|
||||
// ── 预检:发送前检查上次 token 用量,超限先压缩 ──
|
||||
if (lastUsage.value && !isCompressing.value) {
|
||||
const total = lastUsage.value.total_tokens
|
||||
|| (lastUsage.value.prompt_tokens + lastUsage.value.completion_tokens)
|
||||
if (total > DEFAULT_CONTEXT_WINDOW * AUTO_COMPRESS_THRESHOLD) {
|
||||
const result = await compressChat()
|
||||
if (result.success) {
|
||||
// 压缩成功,lastUsage 已清空,继续发送
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isStreaming.value = true
|
||||
streamingContent.value = ''
|
||||
abortController = new AbortController()
|
||||
@@ -225,6 +274,13 @@ export function useChatAgent() {
|
||||
: undefined
|
||||
const assistantText = approvedGroup?.assistantText
|
||||
|
||||
/** 按 subagentId 查找子 Agent entry */
|
||||
function _findSubAgent(subagentId: string): SubAgentEntry | undefined {
|
||||
return [...entries.value].reverse().find(
|
||||
(e): e is SubAgentEntry => 'type' in e && e.type === 'subagent' && e.subagentId === subagentId
|
||||
)
|
||||
}
|
||||
|
||||
await streamChat({
|
||||
messages: recentMessages,
|
||||
novelId,
|
||||
@@ -306,7 +362,76 @@ export function useChatAgent() {
|
||||
}))
|
||||
},
|
||||
|
||||
// ── 子 Agent 事件(通过 subagentId 区分并行的子 Agent)──
|
||||
onSubAgentStart(info: SubAgentStartInfo) {
|
||||
const subEntry: SubAgentEntry = {
|
||||
id: idCounter++,
|
||||
type: 'subagent',
|
||||
subagentId: info.subagent_id,
|
||||
skillId: info.skill_id,
|
||||
skillName: info.skill_name,
|
||||
task: info.task,
|
||||
status: 'running',
|
||||
chunks: '',
|
||||
toolCalls: [],
|
||||
}
|
||||
entries.value.push(subEntry)
|
||||
},
|
||||
|
||||
onSubAgentChunk(text: string, subagentId: string) {
|
||||
const sub = _findSubAgent(subagentId)
|
||||
if (sub) sub.chunks += text
|
||||
},
|
||||
|
||||
onSubAgentToolCall(info: SubAgentToolCallInfo, subagentId: string) {
|
||||
const sub = _findSubAgent(subagentId)
|
||||
if (sub) {
|
||||
sub.toolCalls.push({
|
||||
id: `sub-${idCounter++}`,
|
||||
name: info.name,
|
||||
label: info.label,
|
||||
arguments: info.arguments,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
onSubAgentToolResult(info: SubAgentToolResultInfo, subagentId: string) {
|
||||
const sub = _findSubAgent(subagentId)
|
||||
if (sub) {
|
||||
const call = sub.toolCalls.find(c => c.name === info.name && !c.result)
|
||||
if (call) {
|
||||
call.result = info.result
|
||||
try { call.parsedResult = JSON.parse(info.result) } catch { /* ok */ }
|
||||
}
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('tool-executed', {
|
||||
detail: { name: info.name },
|
||||
}))
|
||||
},
|
||||
|
||||
onSubAgentDone(_result: string, subagentId: string) {
|
||||
const sub = _findSubAgent(subagentId)
|
||||
if (sub) sub.status = 'done'
|
||||
},
|
||||
|
||||
onError(error) {
|
||||
// 检测上下文溢出错误 — 自动压缩并续接
|
||||
const isContextOverflow = /context|token|length|too long|maximum|exceeded|limit|过长|超出|上限/i.test(error)
|
||||
if (isContextOverflow) {
|
||||
entries.value.push({
|
||||
id: idCounter++,
|
||||
novel_id: novelId ?? null,
|
||||
role: 'assistant',
|
||||
content: '⚠️ 上下文已超出限制,正在自动压缩...',
|
||||
created_at: new Date().toISOString(),
|
||||
})
|
||||
compressChat().then(result => {
|
||||
if (result.success) {
|
||||
sendMessage('上下文已压缩,请基于摘要继续之前未完成的工作。')
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
entries.value.push({
|
||||
id: idCounter++,
|
||||
novel_id: novelId ?? null,
|
||||
@@ -337,14 +462,7 @@ export function useChatAgent() {
|
||||
streamingContent.value = ''
|
||||
isStreaming.value = false
|
||||
abortController = null
|
||||
|
||||
// 自动压缩检测:超过 85% 上下文窗口时触发
|
||||
if (usage) {
|
||||
const total = usage.total_tokens || (usage.prompt_tokens + usage.completion_tokens)
|
||||
if (total > DEFAULT_CONTEXT_WINDOW * AUTO_COMPRESS_THRESHOLD) {
|
||||
compressChat()
|
||||
}
|
||||
}
|
||||
// 注:自动压缩已移至 _doStreamRequest 预检阶段(发送前检查)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ const router = createRouter({
|
||||
name: 'novel-edit',
|
||||
component: () => import('@/views/NovelFormView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/skills',
|
||||
name: 'skills',
|
||||
component: () => import('@/views/SkillEditorView.vue'),
|
||||
},
|
||||
// 工作区:大纲/章节/角色/世界观共享父布局(导航栏持久化)
|
||||
{
|
||||
path: '/novels/:id',
|
||||
|
||||
@@ -24,13 +24,30 @@ export interface ToolCallEntry {
|
||||
parsedResult?: unknown
|
||||
}
|
||||
|
||||
/** 对话条目:普通消息或工具调用组 */
|
||||
export type ChatEntry = ChatMessage | ToolCallGroup
|
||||
/** 子 Agent 执行组(在对话中显示) */
|
||||
export interface SubAgentEntry {
|
||||
id: number
|
||||
type: 'subagent'
|
||||
subagentId: string // 后端分配的唯一标识(tc.id),用于区分并行子 Agent
|
||||
skillId: number
|
||||
skillName: string
|
||||
task: string
|
||||
status: 'running' | 'done'
|
||||
chunks: string // 子 Agent 输出文本(实时累积)
|
||||
toolCalls: ToolCallEntry[] // 子 Agent 的工具调用
|
||||
}
|
||||
|
||||
/** 对话条目:普通消息、工具调用组或子 Agent */
|
||||
export type ChatEntry = ChatMessage | ToolCallGroup | SubAgentEntry
|
||||
|
||||
export function isToolCallGroup(entry: ChatEntry): entry is ToolCallGroup {
|
||||
return 'type' in entry && entry.type === 'tool_calls'
|
||||
}
|
||||
|
||||
export function isSubAgentEntry(entry: ChatEntry): entry is SubAgentEntry {
|
||||
return 'type' in entry && entry.type === 'subagent'
|
||||
}
|
||||
|
||||
export interface ChatMessageList {
|
||||
items: ChatMessage[]
|
||||
total: number
|
||||
|
||||
662
frontend/src/views/SkillEditorView.vue
Normal file
662
frontend/src/views/SkillEditorView.vue
Normal file
@@ -0,0 +1,662 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { listSkills, createSkill, updateSkill, deleteSkill, getAvailableTools } from '@/api/skills'
|
||||
import type { Skill, ToolInfo } from '@/api/skills'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const skills = ref<Skill[]>([])
|
||||
const tools = ref<ToolInfo[]>([])
|
||||
const selectedId = ref<number | null>(null)
|
||||
const saving = ref(false)
|
||||
const isNew = ref(false)
|
||||
|
||||
// 编辑表单
|
||||
const formName = ref('')
|
||||
const formDesc = ref('')
|
||||
const formPrompt = ref('')
|
||||
const formTools = ref<string[]>([])
|
||||
|
||||
const selectedSkill = computed(() =>
|
||||
skills.value.find(s => s.id === selectedId.value) ?? null
|
||||
)
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
if (isNew.value) return formName.value.trim().length > 0
|
||||
if (!selectedSkill.value) return false
|
||||
const s = selectedSkill.value
|
||||
return (
|
||||
formName.value !== s.name ||
|
||||
formDesc.value !== s.description ||
|
||||
formPrompt.value !== s.system_prompt ||
|
||||
JSON.stringify(formTools.value.sort()) !== JSON.stringify([...s.allowed_tools].sort())
|
||||
)
|
||||
})
|
||||
|
||||
// 工具分组
|
||||
const TOOL_GROUPS: { label: string; icon: string; tools: string[] }[] = [
|
||||
{ label: '小说信息', icon: 'M4 2h8v12H4z', tools: ['get_novel_info', 'update_novel_info'] },
|
||||
{ label: '大纲管理', icon: 'M3 4h10M3 8h10M3 12h6', tools: ['list_outlines', 'get_outline_detail', 'create_outline', 'update_outline', 'delete_outline', 'reorder_outlines'] },
|
||||
{ label: '世界观', icon: 'M8 1a7 7 0 100 14A7 7 0 008 1z', tools: ['get_world_setting', 'save_world_setting'] },
|
||||
{ label: '角色管理', icon: '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', tools: ['list_characters', 'get_character_detail', 'create_character', 'update_character', 'delete_character'] },
|
||||
{ label: '章节管理', icon: 'M4 2h8v12H4zM6 5h4M6 8h4', tools: ['list_chapters', 'get_chapter_detail', 'create_chapter', 'update_chapter', 'delete_chapter'] },
|
||||
{ label: '文件管理', icon: 'M4 2h5l3 3v9H4V2z', tools: ['list_files', 'read_file'] },
|
||||
]
|
||||
|
||||
// 工具描述映射
|
||||
function getToolDesc(name: string): string {
|
||||
return tools.value.find(t => t.name === name)?.description ?? name
|
||||
}
|
||||
|
||||
// 工具显示名
|
||||
function getToolLabel(name: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
get_novel_info: '查看信息', update_novel_info: '更新信息',
|
||||
list_outlines: '列表', get_outline_detail: '详情', create_outline: '创建', update_outline: '更新', delete_outline: '删除', reorder_outlines: '排序',
|
||||
get_world_setting: '查看', save_world_setting: '保存',
|
||||
list_characters: '列表', get_character_detail: '详情', create_character: '创建', update_character: '更新', delete_character: '删除',
|
||||
list_chapters: '列表', get_chapter_detail: '详情', create_chapter: '创建', update_chapter: '更新', delete_chapter: '删除',
|
||||
list_files: '列表', read_file: '读取',
|
||||
}
|
||||
return labels[name] ?? name
|
||||
}
|
||||
|
||||
// 分组全选/取消
|
||||
function isGroupAllSelected(groupTools: string[]): boolean {
|
||||
return groupTools.every(t => formTools.value.includes(t))
|
||||
}
|
||||
|
||||
function toggleGroup(groupTools: string[]) {
|
||||
if (isGroupAllSelected(groupTools)) {
|
||||
formTools.value = formTools.value.filter(t => !groupTools.includes(t))
|
||||
} else {
|
||||
const toAdd = groupTools.filter(t => !formTools.value.includes(t))
|
||||
formTools.value.push(...toAdd)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadSkills(), loadTools()])
|
||||
if (skills.value.length > 0) {
|
||||
selectSkill(skills.value[0])
|
||||
}
|
||||
})
|
||||
|
||||
async function loadSkills() {
|
||||
try {
|
||||
const result = await listSkills()
|
||||
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,
|
||||
})
|
||||
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)
|
||||
}
|
||||
} catch {
|
||||
toast.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!selectedId.value || !selectedSkill.value) return
|
||||
const name = selectedSkill.value.name
|
||||
try {
|
||||
await deleteSkill(selectedId.value)
|
||||
toast.success(`已删除:${name}`)
|
||||
await loadSkills()
|
||||
if (skills.value.length > 0) {
|
||||
selectSkill(skills.value[0])
|
||||
} else {
|
||||
startNew()
|
||||
}
|
||||
} catch {
|
||||
toast.error('删除失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="skill-editor">
|
||||
<!-- 头部 -->
|
||||
<div class="editor-header">
|
||||
<button class="back-btn" @click="router.back()">← 返回</button>
|
||||
<h1 class="editor-title">技能管理</h1>
|
||||
<p class="editor-subtitle">自定义 AI 助手的行为模式和可用工具</p>
|
||||
</div>
|
||||
|
||||
<div class="editor-body">
|
||||
<!-- 左栏:技能列表 -->
|
||||
<aside class="skill-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<span class="sidebar-title">技能列表</span>
|
||||
<button class="new-btn" @click="startNew" title="新建技能">
|
||||
<svg width="16" height="16" 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="skill-list">
|
||||
<div
|
||||
v-for="skill in skills"
|
||||
:key="skill.id"
|
||||
class="skill-item"
|
||||
:class="{ 'skill-item--active': selectedId === skill.id && !isNew }"
|
||||
@click="selectSkill(skill)"
|
||||
>
|
||||
<div class="item-top">
|
||||
<span class="item-name">{{ skill.name }}</span>
|
||||
<span v-if="skill.is_builtin" class="item-badge">预置</span>
|
||||
</div>
|
||||
<span class="item-desc">{{ skill.description || '暂无描述' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 新建占位 -->
|
||||
<div v-if="isNew" class="skill-item skill-item--active skill-item--new">
|
||||
<div class="item-top">
|
||||
<span class="item-name">新建技能</span>
|
||||
</div>
|
||||
<span class="item-desc">正在编辑...</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 右栏:编辑区 -->
|
||||
<main class="edit-area">
|
||||
<template v-if="selectedId || isNew">
|
||||
<!-- 基本信息 -->
|
||||
<section class="edit-section">
|
||||
<h2 class="section-title">基本信息</h2>
|
||||
<div class="form-row">
|
||||
<div class="form-field">
|
||||
<label class="field-label">名称 <span class="required">*</span></label>
|
||||
<input v-model="formName" class="field-input" placeholder="例如:大纲规划师" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="field-label">描述</label>
|
||||
<input v-model="formDesc" class="field-input" placeholder="简短描述技能的用途" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 系统提示词 -->
|
||||
<section class="edit-section edit-section--grow">
|
||||
<h2 class="section-title">系统提示词</h2>
|
||||
<p class="section-hint">定义 AI 在此技能下的角色定位、专业领域和行为规范</p>
|
||||
<textarea
|
||||
v-model="formPrompt"
|
||||
class="prompt-editor"
|
||||
placeholder="例如:你是大纲规划专家。请帮助用户构建清晰的故事骨架,包括起承转合、情节节点和分章结构..."
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- 可用工具 -->
|
||||
<section class="edit-section">
|
||||
<h2 class="section-title">可用工具</h2>
|
||||
<p class="section-hint">限定此技能可以使用的工具范围。不选择任何工具 = 使用全部工具</p>
|
||||
<div class="tool-grid">
|
||||
<div v-for="group in TOOL_GROUPS" :key="group.label" class="tool-group-card">
|
||||
<div class="group-header" @click="toggleGroup(group.tools)">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
|
||||
<path :d="group.icon" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<span class="group-name">{{ group.label }}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isGroupAllSelected(group.tools)"
|
||||
:indeterminate="group.tools.some(t => formTools.includes(t)) && !isGroupAllSelected(group.tools)"
|
||||
class="group-check"
|
||||
@click.stop="toggleGroup(group.tools)"
|
||||
/>
|
||||
</div>
|
||||
<div class="group-body">
|
||||
<label
|
||||
v-for="toolName in group.tools"
|
||||
:key="toolName"
|
||||
class="tool-label"
|
||||
:title="getToolDesc(toolName)"
|
||||
>
|
||||
<input type="checkbox" :value="toolName" v-model="formTools" />
|
||||
<span>{{ getToolLabel(toolName) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div class="edit-actions">
|
||||
<BaseButton
|
||||
v-if="selectedId && !isNew"
|
||||
variant="ghost"
|
||||
class="delete-action"
|
||||
@click="handleDelete"
|
||||
>
|
||||
删除技能
|
||||
</BaseButton>
|
||||
<div class="actions-right">
|
||||
<BaseButton
|
||||
:loading="saving"
|
||||
:disabled="!hasChanges"
|
||||
@click="handleSave"
|
||||
>
|
||||
{{ isNew ? '创建' : '保存修改' }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="edit-empty">
|
||||
<svg width="48" height="48" 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="var(--ink-200)" stroke-width="0.8" />
|
||||
</svg>
|
||||
<p>选择一个技能进行编辑</p>
|
||||
<p class="edit-empty-sub">或点击 + 创建新技能</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skill-editor {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-lg);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 头部 */
|
||||
.editor-header {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--ink-400);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
padding: 0;
|
||||
margin-bottom: var(--space-sm);
|
||||
transition: color var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.editor-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--ink-900);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.editor-subtitle {
|
||||
font-size: 0.88rem;
|
||||
color: var(--ink-400);
|
||||
}
|
||||
|
||||
/* 主体布局 */
|
||||
.editor-body {
|
||||
display: flex;
|
||||
gap: var(--space-xl);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* 左栏 */
|
||||
.skill-sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: var(--space-lg);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.new-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: 1px solid var(--paper-300);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--ink-400);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.new-btn:hover {
|
||||
border-color: var(--vermilion);
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.skill-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.skill-item {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: all var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.skill-item:hover {
|
||||
background: var(--paper-100);
|
||||
}
|
||||
|
||||
.skill-item--active {
|
||||
background: rgba(91, 140, 90, 0.08);
|
||||
border-color: rgba(91, 140, 90, 0.2);
|
||||
}
|
||||
|
||||
.skill-item--new {
|
||||
border-style: dashed;
|
||||
border-color: var(--vermilion);
|
||||
background: rgba(199, 62, 29, 0.03);
|
||||
}
|
||||
|
||||
.item-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-800);
|
||||
}
|
||||
|
||||
.item-badge {
|
||||
font-size: 0.6rem;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
background: rgba(91, 140, 90, 0.12);
|
||||
color: var(--bamboo);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.item-desc {
|
||||
font-size: 0.72rem;
|
||||
color: var(--ink-400);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 右栏 */
|
||||
.edit-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.edit-section {
|
||||
background: white;
|
||||
border: 1px solid var(--paper-200);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--ink-800);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.section-hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--ink-400);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
/* 表单 */
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-600);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.field-input {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: 0.88rem;
|
||||
font-family: var(--font-body);
|
||||
color: var(--ink-900);
|
||||
background: var(--paper-50);
|
||||
border: 1px solid var(--paper-200);
|
||||
border-radius: var(--radius-md);
|
||||
outline: none;
|
||||
transition: border-color var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.field-input:focus {
|
||||
border-color: var(--vermilion);
|
||||
}
|
||||
|
||||
/* 提示词编辑器 */
|
||||
.prompt-editor {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
padding: var(--space-md);
|
||||
font-size: 0.88rem;
|
||||
font-family: var(--font-body);
|
||||
color: var(--ink-900);
|
||||
background: var(--paper-50);
|
||||
border: 1px solid var(--paper-200);
|
||||
border-radius: var(--radius-md);
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
line-height: 1.7;
|
||||
transition: border-color var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.prompt-editor:focus {
|
||||
border-color: var(--vermilion);
|
||||
}
|
||||
|
||||
/* 工具网格 */
|
||||
.tool-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.tool-group-card {
|
||||
background: var(--paper-50);
|
||||
border: 1px solid var(--paper-200);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background: var(--paper-100);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-600);
|
||||
transition: background var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.group-header:hover {
|
||||
background: var(--paper-200);
|
||||
}
|
||||
|
||||
.group-check {
|
||||
margin-left: auto;
|
||||
accent-color: var(--bamboo);
|
||||
}
|
||||
|
||||
.group-body {
|
||||
padding: 6px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tool-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--ink-600);
|
||||
cursor: pointer;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.tool-label input {
|
||||
accent-color: var(--bamboo);
|
||||
}
|
||||
|
||||
/* 操作栏 */
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: var(--space-md);
|
||||
border-top: 1px solid var(--paper-200);
|
||||
}
|
||||
|
||||
.actions-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.delete-action {
|
||||
color: var(--danger) !important;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.edit-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-xxl) 0;
|
||||
color: var(--ink-300);
|
||||
font-size: 0.92rem;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.edit-empty-sub {
|
||||
font-size: 0.8rem;
|
||||
color: var(--ink-200);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.editor-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
.skill-sidebar {
|
||||
width: 100%;
|
||||
position: static;
|
||||
}
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.tool-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user