refactor: 移除 AttachmentInputPanel 组件并在 MessageInputComponent 中集成 subagent mention 功能
- 删除了 AttachmentInputPanel.vue 组件。 - 增强了 MessageInputComponent.vue,新增了一个用于子代理的提及组。 - 更新了提及逻辑以在提及功能中支持子代理。 - 引入 useAgentMentionConfig.js 来管理代理提及配置。 - 添加 useAgentRunStream.js 用于处理代理运行流逻辑。 - 创建 useAgentThreadState.js 来管理线程状态和正在进行的对话。 - 清理了 AgentView.vue,移除了未使用的样式和配置。
This commit is contained in:
parent
78f2ae2a03
commit
772ab53d1a
@ -26,7 +26,7 @@
|
|||||||
<div class="chat">
|
<div class="chat">
|
||||||
<div class="chat-header">
|
<div class="chat-header">
|
||||||
<div class="header__left">
|
<div class="header__left">
|
||||||
<slot name="header-left" class="nav-btn"></slot>
|
<slot name="header-left"></slot>
|
||||||
<div
|
<div
|
||||||
v-if="!chatUIStore.isSidebarOpen && !userStore.isAdmin"
|
v-if="!chatUIStore.isSidebarOpen && !userStore.isAdmin"
|
||||||
type="button"
|
type="button"
|
||||||
@ -72,8 +72,8 @@
|
|||||||
|
|
||||||
<div class="chat-content-container">
|
<div class="chat-content-container">
|
||||||
<!-- Main Chat Area -->
|
<!-- Main Chat Area -->
|
||||||
<div class="chat-main" ref="chatMainContainer">
|
<div class="chat-main">
|
||||||
<div class="chat-box" ref="messagesContainer">
|
<div class="chat-box">
|
||||||
<div class="conv-box" v-for="(conv, index) in conversations" :key="index">
|
<div class="conv-box" v-for="(conv, index) in conversations" :key="index">
|
||||||
<AgentMessageComponent
|
<AgentMessageComponent
|
||||||
v-for="(message, msgIndex) in conv.messages"
|
v-for="(message, msgIndex) in conv.messages"
|
||||||
@ -140,21 +140,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AgentInputArea
|
<AgentInputArea
|
||||||
ref="messageInputRef"
|
|
||||||
v-model="userInput"
|
v-model="userInput"
|
||||||
:is-loading="isProcessing"
|
:is-loading="isProcessing"
|
||||||
:disabled="!currentAgent"
|
:disabled="!currentAgent"
|
||||||
:send-button-disabled="(!userInput || !currentAgent) && !isProcessing"
|
:send-button-disabled="(!userInput || !currentAgent) && !isProcessing"
|
||||||
placeholder="输入问题..."
|
placeholder="输入问题..."
|
||||||
|
:mention="mentionConfig"
|
||||||
:supports-file-upload="supportsFileUpload"
|
:supports-file-upload="supportsFileUpload"
|
||||||
:agent-id="currentAgentId"
|
|
||||||
:thread-id="currentChatId"
|
|
||||||
:ensure-thread="ensureActiveThread"
|
|
||||||
:has-state-content="hasAgentStateContent"
|
:has-state-content="hasAgentStateContent"
|
||||||
:is-panel-open="isAgentPanelOpen"
|
:is-panel-open="isAgentPanelOpen"
|
||||||
:mention="mentionConfig"
|
|
||||||
@send="handleSendOrStop"
|
@send="handleSendOrStop"
|
||||||
@attachment-changed="handleAgentStateRefresh"
|
@upload-attachment="handleAttachmentUpload"
|
||||||
@toggle-panel="toggleAgentPanel"
|
@toggle-panel="toggleAgentPanel"
|
||||||
>
|
>
|
||||||
<template #actions-left-extra>
|
<template #actions-left-extra>
|
||||||
@ -235,7 +231,10 @@ import { MessageProcessor } from '@/utils/messageProcessor'
|
|||||||
import { agentApi, threadApi } from '@/apis'
|
import { agentApi, threadApi } from '@/apis'
|
||||||
import HumanApprovalModal from '@/components/HumanApprovalModal.vue'
|
import HumanApprovalModal from '@/components/HumanApprovalModal.vue'
|
||||||
import { useApproval } from '@/composables/useApproval'
|
import { useApproval } from '@/composables/useApproval'
|
||||||
|
import { useAgentThreadState } from '@/composables/useAgentThreadState'
|
||||||
|
import { useAgentRunStream } from '@/composables/useAgentRunStream'
|
||||||
import { useAgentStreamHandler } from '@/composables/useAgentStreamHandler'
|
import { useAgentStreamHandler } from '@/composables/useAgentStreamHandler'
|
||||||
|
import { useAgentMentionConfig } from '@/composables/useAgentMentionConfig'
|
||||||
import AgentPanel from '@/components/AgentPanel.vue'
|
import AgentPanel from '@/components/AgentPanel.vue'
|
||||||
import UserInfoComponent from '@/components/UserInfoComponent.vue'
|
import UserInfoComponent from '@/components/UserInfoComponent.vue'
|
||||||
|
|
||||||
@ -270,16 +269,6 @@ const useRunsApi =
|
|||||||
import.meta.env.VITE_USE_RUNS_API === 'true' &&
|
import.meta.env.VITE_USE_RUNS_API === 'true' &&
|
||||||
localStorage.getItem('force_legacy_stream') !== 'true'
|
localStorage.getItem('force_legacy_stream') !== 'true'
|
||||||
|
|
||||||
const ACTIVE_RUN_STORAGE_TTL_MS = 60 * 60 * 1000
|
|
||||||
const ACTIVE_RUN_CLIENT_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
|
||||||
const typingChunkQueue = []
|
|
||||||
let typingAnimationFrameId = null
|
|
||||||
let typingLastFrameTs = 0
|
|
||||||
let pendingTypingChars = 0
|
|
||||||
const MIN_TYPING_CPS = 32
|
|
||||||
const MAX_TYPING_CPS = 320
|
|
||||||
const TYPING_BACKLOG_HIGH_WATERMARK = 500
|
|
||||||
|
|
||||||
// 从智能体元数据获取示例问题
|
// 从智能体元数据获取示例问题
|
||||||
const exampleQuestions = computed(() => {
|
const exampleQuestions = computed(() => {
|
||||||
const agentId = currentAgentId.value
|
const agentId = currentAgentId.value
|
||||||
@ -294,20 +283,16 @@ const exampleQuestions = computed(() => {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Keep per-thread streaming scratch data in a consistent shape.
|
|
||||||
const createOnGoingConvState = () => ({
|
|
||||||
msgChunks: {},
|
|
||||||
currentRequestKey: null,
|
|
||||||
currentAssistantKey: null,
|
|
||||||
toolCallBuffers: {}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 业务状态(保留在组件本地)
|
// 业务状态(保留在组件本地)
|
||||||
const chatState = reactive({
|
const chatState = reactive({
|
||||||
currentThreadId: null,
|
currentThreadId: null,
|
||||||
// 以threadId为键的线程状态
|
// 以threadId为键的线程状态
|
||||||
threadStates: {}
|
threadStates: {}
|
||||||
})
|
})
|
||||||
|
const { getThreadState, resetOnGoingConv, stopThreadStream } = useAgentThreadState({
|
||||||
|
chatState,
|
||||||
|
getCurrentThreadId: () => chatState.currentThreadId
|
||||||
|
})
|
||||||
|
|
||||||
// 组件级别的线程和消息状态
|
// 组件级别的线程和消息状态
|
||||||
const threads = ref([])
|
const threads = ref([])
|
||||||
@ -383,14 +368,18 @@ const supportsFiles = computed(() => {
|
|||||||
return capabilities.includes('files')
|
return capabilities.includes('files')
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentCapabilities = computed(() => {
|
|
||||||
return currentAgent.value?.capabilities || []
|
|
||||||
})
|
|
||||||
|
|
||||||
// AgentState 相关计算属性
|
// AgentState 相关计算属性
|
||||||
const currentAgentState = computed(() => {
|
const currentAgentState = computed(() => {
|
||||||
return currentChatId.value ? getThreadState(currentChatId.value)?.agentState || null : null
|
return currentChatId.value ? getThreadState(currentChatId.value)?.agentState || null : null
|
||||||
})
|
})
|
||||||
|
const { mentionConfig } = useAgentMentionConfig({
|
||||||
|
currentAgentState,
|
||||||
|
configurableItems,
|
||||||
|
agentConfig,
|
||||||
|
availableKnowledgeBases,
|
||||||
|
availableMcps,
|
||||||
|
availableSkills
|
||||||
|
})
|
||||||
|
|
||||||
const countFiles = (files) => {
|
const countFiles = (files) => {
|
||||||
if (!files) return 0
|
if (!files) return 0
|
||||||
@ -419,73 +408,6 @@ watch(hasAgentStateContent, (newVal, oldVal) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const mentionConfig = computed(() => {
|
|
||||||
const rawFiles = currentAgentState.value?.files || {}
|
|
||||||
const files = []
|
|
||||||
|
|
||||||
// 处理 files - 兼容字典格式 {"/path/file": {content: [...]}} 和旧数组格式
|
|
||||||
if (typeof rawFiles === 'object' && !Array.isArray(rawFiles) && rawFiles !== null) {
|
|
||||||
// 新格式:字典格式 {"/attachments/xxx/file.md": {...}}
|
|
||||||
Object.entries(rawFiles).forEach(([filePath, fileData]) => {
|
|
||||||
files.push({
|
|
||||||
path: filePath,
|
|
||||||
...fileData
|
|
||||||
})
|
|
||||||
})
|
|
||||||
} else if (Array.isArray(rawFiles)) {
|
|
||||||
// 旧格式:数组格式
|
|
||||||
rawFiles.forEach((item) => {
|
|
||||||
if (typeof item === 'object' && item !== null) {
|
|
||||||
Object.entries(item).forEach(([filePath, fileData]) => {
|
|
||||||
files.push({
|
|
||||||
path: filePath,
|
|
||||||
...fileData
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter KBs and MCPs based on agent config
|
|
||||||
const configItems = configurableItems.value || {}
|
|
||||||
const currentConfig = agentConfig.value || {}
|
|
||||||
const allowedKbNames = new Set()
|
|
||||||
const allowedMcpNames = new Set()
|
|
||||||
const allowedSkillNames = new Set()
|
|
||||||
|
|
||||||
Object.entries(configItems).forEach(([key, item]) => {
|
|
||||||
const kind = item?.template_metadata?.kind
|
|
||||||
const val = currentConfig[key]
|
|
||||||
|
|
||||||
if (Array.isArray(val)) {
|
|
||||||
if (kind === 'knowledges') {
|
|
||||||
val.forEach((v) => allowedKbNames.add(v))
|
|
||||||
} else if (kind === 'mcps') {
|
|
||||||
val.forEach((v) => allowedMcpNames.add(v))
|
|
||||||
} else if (kind === 'skills' || key === 'skills') {
|
|
||||||
val.forEach((v) => allowedSkillNames.add(v))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const knowledgeBases = availableKnowledgeBases.value.filter((kb) => allowedKbNames.has(kb.name))
|
|
||||||
const mcps = availableMcps.value.filter((mcp) => allowedMcpNames.has(mcp.name))
|
|
||||||
const skills = availableSkills.value.filter((skill) => {
|
|
||||||
const skillName = skill.name || ''
|
|
||||||
const skillSlug = skill.slug || ''
|
|
||||||
return allowedSkillNames.has(skillName) || allowedSkillNames.has(skillSlug)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!files.length && !knowledgeBases.length && !mcps.length && !skills.length) return null
|
|
||||||
|
|
||||||
return {
|
|
||||||
files,
|
|
||||||
knowledgeBases,
|
|
||||||
mcps,
|
|
||||||
skills
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const currentThreadMessages = computed(() => threadMessages.value[currentChatId.value] || [])
|
const currentThreadMessages = computed(() => threadMessages.value[currentChatId.value] || [])
|
||||||
|
|
||||||
// 计算是否显示Refs组件的条件
|
// 计算是否显示Refs组件的条件
|
||||||
@ -568,12 +490,10 @@ const isStreaming = computed(() => {
|
|||||||
const isProcessing = computed(() => isStreaming.value)
|
const isProcessing = computed(() => isStreaming.value)
|
||||||
|
|
||||||
// ==================== SCROLL & RESIZE HANDLING ====================
|
// ==================== SCROLL & RESIZE HANDLING ====================
|
||||||
// Update scroll controller to target .chat-main
|
|
||||||
const scrollController = new ScrollController('.chat-main')
|
const scrollController = new ScrollController('.chat-main')
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
// Update event listener to target .chat-main
|
|
||||||
const chatMainContainer = document.querySelector('.chat-main')
|
const chatMainContainer = document.querySelector('.chat-main')
|
||||||
if (chatMainContainer) {
|
if (chatMainContainer) {
|
||||||
chatMainContainer.addEventListener('scroll', scrollController.handleScroll, { passive: true })
|
chatMainContainer.addEventListener('scroll', scrollController.handleScroll, { passive: true })
|
||||||
@ -585,78 +505,11 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopTypingRenderLoop()
|
|
||||||
typingChunkQueue.length = 0
|
|
||||||
pendingTypingChars = 0
|
|
||||||
scrollController.cleanup()
|
scrollController.cleanup()
|
||||||
// 清理所有线程状态
|
// 清理所有线程状态
|
||||||
resetOnGoingConv()
|
resetOnGoingConv()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ==================== THREAD STATE MANAGEMENT ====================
|
|
||||||
// 获取指定线程的状态,如果不存在则创建
|
|
||||||
const getThreadState = (threadId) => {
|
|
||||||
if (!threadId) return null
|
|
||||||
if (!chatState.threadStates[threadId]) {
|
|
||||||
chatState.threadStates[threadId] = {
|
|
||||||
isStreaming: false,
|
|
||||||
streamAbortController: null,
|
|
||||||
runStreamAbortController: null,
|
|
||||||
activeRunId: null,
|
|
||||||
runLastSeq: '0',
|
|
||||||
lastRetryableJobTry: null,
|
|
||||||
onGoingConv: createOnGoingConvState(),
|
|
||||||
agentState: null // 添加 agentState 字段
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return chatState.threadStates[threadId]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清理指定线程的状态
|
|
||||||
const cleanupThreadState = (threadId) => {
|
|
||||||
if (!threadId) return
|
|
||||||
const threadState = chatState.threadStates[threadId]
|
|
||||||
if (threadState) {
|
|
||||||
clearTypingQueueForThread(threadId)
|
|
||||||
if (threadState.streamAbortController) {
|
|
||||||
threadState.streamAbortController.abort()
|
|
||||||
}
|
|
||||||
if (threadState.runStreamAbortController) {
|
|
||||||
threadState.runStreamAbortController.abort()
|
|
||||||
}
|
|
||||||
delete chatState.threadStates[threadId]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== STREAM HANDLING LOGIC ====================
|
|
||||||
const resetOnGoingConv = (threadId = null) => {
|
|
||||||
const targetThreadId = threadId || currentChatId.value
|
|
||||||
|
|
||||||
if (targetThreadId) {
|
|
||||||
// 清理指定线程的状态
|
|
||||||
const threadState = getThreadState(targetThreadId)
|
|
||||||
if (threadState) {
|
|
||||||
clearTypingQueueForThread(targetThreadId)
|
|
||||||
if (threadState.streamAbortController) {
|
|
||||||
threadState.streamAbortController.abort()
|
|
||||||
threadState.streamAbortController = null
|
|
||||||
}
|
|
||||||
if (threadState.runStreamAbortController) {
|
|
||||||
threadState.runStreamAbortController.abort()
|
|
||||||
threadState.runStreamAbortController = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 直接重置对话状态
|
|
||||||
threadState.onGoingConv = createOnGoingConvState()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 如果没有当前线程,清理所有线程状态
|
|
||||||
Object.keys(chatState.threadStates).forEach((tid) => {
|
|
||||||
cleanupThreadState(tid)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 线程管理方法 ====================
|
// ==================== 线程管理方法 ====================
|
||||||
// 获取当前智能体的线程列表
|
// 获取当前智能体的线程列表
|
||||||
const fetchThreads = async (agentId = null) => {
|
const fetchThreads = async (agentId = null) => {
|
||||||
@ -826,456 +679,6 @@ const fetchAgentState = async (agentId, threadId) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const RUN_TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled', 'interrupted'])
|
|
||||||
|
|
||||||
const getActiveRunStorageKey = (threadId) => `active_run:${threadId}`
|
|
||||||
|
|
||||||
const normalizeRunSeq = (value) => {
|
|
||||||
if (value === undefined || value === null) return '0'
|
|
||||||
const text = String(value).trim()
|
|
||||||
return text || '0'
|
|
||||||
}
|
|
||||||
|
|
||||||
const parseRunSeq = (value) => {
|
|
||||||
const text = normalizeRunSeq(value)
|
|
||||||
if (text.includes('-')) {
|
|
||||||
const [majorRaw, minorRaw] = text.split('-', 2)
|
|
||||||
let major = 0n
|
|
||||||
let minor = 0n
|
|
||||||
try {
|
|
||||||
major = BigInt(majorRaw || '0')
|
|
||||||
minor = BigInt(minorRaw || '0')
|
|
||||||
} catch {
|
|
||||||
return { kind: 'legacy', value: 0 }
|
|
||||||
}
|
|
||||||
return { kind: 'stream', major, minor }
|
|
||||||
}
|
|
||||||
const numberValue = Number.parseInt(text, 10)
|
|
||||||
if (!Number.isNaN(numberValue)) {
|
|
||||||
return { kind: 'legacy', value: numberValue }
|
|
||||||
}
|
|
||||||
return { kind: 'legacy', value: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
const compareRunSeq = (incoming, current) => {
|
|
||||||
const left = parseRunSeq(incoming)
|
|
||||||
const right = parseRunSeq(current)
|
|
||||||
|
|
||||||
if (left.kind === 'stream' && right.kind === 'stream') {
|
|
||||||
if (left.major > right.major) return 1
|
|
||||||
if (left.major < right.major) return -1
|
|
||||||
if (left.minor > right.minor) return 1
|
|
||||||
if (left.minor < right.minor) return -1
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (left.kind === 'legacy' && right.kind === 'legacy') {
|
|
||||||
return left.value - right.value
|
|
||||||
}
|
|
||||||
|
|
||||||
if (left.kind === 'stream' && right.kind === 'legacy') return 1
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveActiveRunSnapshot = (threadId, runId, lastSeq = '0') => {
|
|
||||||
if (!threadId || !runId) return
|
|
||||||
localStorage.setItem(
|
|
||||||
getActiveRunStorageKey(threadId),
|
|
||||||
JSON.stringify({
|
|
||||||
run_id: runId,
|
|
||||||
last_seq: normalizeRunSeq(lastSeq),
|
|
||||||
created_at: Date.now(),
|
|
||||||
client_id: ACTIVE_RUN_CLIENT_ID
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadActiveRunSnapshot = (threadId) => {
|
|
||||||
if (!threadId) return null
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(getActiveRunStorageKey(threadId))
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearActiveRunSnapshot = (threadId) => {
|
|
||||||
if (!threadId) return
|
|
||||||
localStorage.removeItem(getActiveRunStorageKey(threadId))
|
|
||||||
}
|
|
||||||
|
|
||||||
const splitChars = (text) => {
|
|
||||||
if (typeof text !== 'string' || !text) return []
|
|
||||||
return Array.from(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
const calcTypingCps = () => {
|
|
||||||
if (pendingTypingChars <= 0) return MIN_TYPING_CPS
|
|
||||||
const ratio = Math.min(1, pendingTypingChars / TYPING_BACKLOG_HIGH_WATERMARK)
|
|
||||||
return Math.round(MIN_TYPING_CPS + ratio * (MAX_TYPING_CPS - MIN_TYPING_CPS))
|
|
||||||
}
|
|
||||||
|
|
||||||
const scheduleTypingRender = () => {
|
|
||||||
if (typingAnimationFrameId !== null) return
|
|
||||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
|
||||||
typingAnimationFrameId = window.requestAnimationFrame(drainTypingQueue)
|
|
||||||
} else {
|
|
||||||
typingAnimationFrameId = setTimeout(() => {
|
|
||||||
typingAnimationFrameId = null
|
|
||||||
drainTypingQueue(Date.now())
|
|
||||||
}, 16)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stopTypingRenderLoop = () => {
|
|
||||||
if (typingAnimationFrameId === null) return
|
|
||||||
if (typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') {
|
|
||||||
window.cancelAnimationFrame(typingAnimationFrameId)
|
|
||||||
} else {
|
|
||||||
clearTimeout(typingAnimationFrameId)
|
|
||||||
}
|
|
||||||
typingAnimationFrameId = null
|
|
||||||
typingLastFrameTs = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const enqueueLoadingChunkForTyping = (threadId, chunk) => {
|
|
||||||
if (!threadId || !chunk) return
|
|
||||||
if (chunk.status !== 'loading') {
|
|
||||||
typingChunkQueue.push({ threadId, chunk, isChar: false })
|
|
||||||
scheduleTypingRender()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const msg = chunk.msg || {}
|
|
||||||
const msgType = String(msg.type || '').toLowerCase()
|
|
||||||
const isToolMessage = msgType === 'tool' || msgType.includes('tool')
|
|
||||||
const streamText = typeof chunk.response === 'string' ? chunk.response : ''
|
|
||||||
const contentChars = !isToolMessage ? splitChars(streamText) : []
|
|
||||||
if (contentChars.length === 0) {
|
|
||||||
typingChunkQueue.push({ threadId, chunk, isChar: false })
|
|
||||||
scheduleTypingRender()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const char of contentChars) {
|
|
||||||
typingChunkQueue.push({
|
|
||||||
threadId,
|
|
||||||
isChar: true,
|
|
||||||
chunk: {
|
|
||||||
...chunk,
|
|
||||||
response: char,
|
|
||||||
msg: {
|
|
||||||
...msg,
|
|
||||||
content: char
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
pendingTypingChars += contentChars.length
|
|
||||||
scheduleTypingRender()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将队列按 threadId 分区,对匹配项执行回调,返回移除的字符数
|
|
||||||
const partitionTypingQueue = (threadId, onMatch = null) => {
|
|
||||||
const remaining = []
|
|
||||||
let charCount = 0
|
|
||||||
for (const item of typingChunkQueue) {
|
|
||||||
if (item.threadId === threadId) {
|
|
||||||
if (onMatch) onMatch(item)
|
|
||||||
if (item.isChar) charCount += 1
|
|
||||||
} else {
|
|
||||||
remaining.push(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
typingChunkQueue.length = 0
|
|
||||||
typingChunkQueue.push(...remaining)
|
|
||||||
pendingTypingChars = Math.max(0, pendingTypingChars - charCount)
|
|
||||||
typingChunkQueue.length === 0 ? stopTypingRenderLoop() : scheduleTypingRender()
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearTypingQueueForThread = (threadId) => {
|
|
||||||
if (!threadId || typingChunkQueue.length === 0) return
|
|
||||||
partitionTypingQueue(threadId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const flushTypingQueueForThread = (threadId) => {
|
|
||||||
if (!threadId || typingChunkQueue.length === 0) return
|
|
||||||
partitionTypingQueue(threadId, (item) => handleStreamChunk(item.chunk, threadId))
|
|
||||||
}
|
|
||||||
|
|
||||||
function drainTypingQueue(frameTs = Date.now()) {
|
|
||||||
typingAnimationFrameId = null
|
|
||||||
if (typingChunkQueue.length === 0) {
|
|
||||||
typingLastFrameTs = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!typingLastFrameTs) {
|
|
||||||
typingLastFrameTs = frameTs
|
|
||||||
}
|
|
||||||
|
|
||||||
const elapsedSeconds = Math.max(0.001, (frameTs - typingLastFrameTs) / 1000)
|
|
||||||
typingLastFrameTs = frameTs
|
|
||||||
const cps = calcTypingCps()
|
|
||||||
let budget = Math.max(1, Math.floor(elapsedSeconds * cps))
|
|
||||||
|
|
||||||
while (budget > 0 && typingChunkQueue.length > 0) {
|
|
||||||
const item = typingChunkQueue.shift()
|
|
||||||
if (!item) break
|
|
||||||
handleStreamChunk(item.chunk, item.threadId)
|
|
||||||
if (item.isChar) {
|
|
||||||
pendingTypingChars = Math.max(0, pendingTypingChars - 1)
|
|
||||||
}
|
|
||||||
budget -= 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typingChunkQueue.length > 0) {
|
|
||||||
scheduleTypingRender()
|
|
||||||
} else {
|
|
||||||
typingLastFrameTs = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const processRunSseResponse = async (response, onEvent) => {
|
|
||||||
if (!response || !response.body) return
|
|
||||||
const reader = response.body.getReader()
|
|
||||||
const decoder = new TextDecoder()
|
|
||||||
let buffer = ''
|
|
||||||
let eventType = 'message'
|
|
||||||
let dataLines = []
|
|
||||||
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (done) break
|
|
||||||
buffer += decoder.decode(value, { stream: true })
|
|
||||||
const lines = buffer.split('\n')
|
|
||||||
buffer = lines.pop() || ''
|
|
||||||
|
|
||||||
for (const rawLine of lines) {
|
|
||||||
const line = rawLine.replace(/\r$/, '')
|
|
||||||
if (!line) {
|
|
||||||
if (dataLines.length > 0) {
|
|
||||||
const dataText = dataLines.join('\n')
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(dataText)
|
|
||||||
onEvent(eventType, parsed)
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to parse run SSE data:', e, dataText)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
eventType = 'message'
|
|
||||||
dataLines = []
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (line.startsWith('event:')) {
|
|
||||||
eventType = line.slice(6).trim() || 'message'
|
|
||||||
} else if (line.startsWith('data:')) {
|
|
||||||
dataLines.push(line.slice(5).trim())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (dataLines.length > 0) {
|
|
||||||
const dataText = dataLines.join('\n')
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(dataText)
|
|
||||||
onEvent(eventType, parsed)
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to parse trailing run SSE data:', e, dataText)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
reader.releaseLock()
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stopRunStreamSubscription = (threadId) => {
|
|
||||||
const ts = getThreadState(threadId)
|
|
||||||
if (!ts) return
|
|
||||||
if (ts.runStreamAbortController) {
|
|
||||||
ts.runStreamAbortController.abort()
|
|
||||||
ts.runStreamAbortController = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const startRunStream = async (threadId, runId, afterSeq = '0') => {
|
|
||||||
if (!threadId || !runId || !useRunsApi) return
|
|
||||||
const ts = getThreadState(threadId)
|
|
||||||
if (!ts) return
|
|
||||||
|
|
||||||
stopRunStreamSubscription(threadId)
|
|
||||||
const runController = new AbortController()
|
|
||||||
ts.runStreamAbortController = runController
|
|
||||||
ts.activeRunId = runId
|
|
||||||
ts.runLastSeq = normalizeRunSeq(afterSeq)
|
|
||||||
ts.lastRetryableJobTry = null
|
|
||||||
ts.isStreaming = true
|
|
||||||
saveActiveRunSnapshot(threadId, runId, ts.runLastSeq)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await agentApi.streamAgentRunEvents(runId, ts.runLastSeq, {
|
|
||||||
signal: runController.signal
|
|
||||||
})
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`SSE response not ok: ${response.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
await processRunSseResponse(response, (event, data) => {
|
|
||||||
if (!data || ts.activeRunId !== runId) return
|
|
||||||
|
|
||||||
if (data.seq !== undefined && data.seq !== null) {
|
|
||||||
const incomingSeq = normalizeRunSeq(data.seq)
|
|
||||||
if (compareRunSeq(incomingSeq, ts.runLastSeq) <= 0) return
|
|
||||||
ts.runLastSeq = incomingSeq
|
|
||||||
saveActiveRunSnapshot(threadId, runId, incomingSeq)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event === 'heartbeat') return
|
|
||||||
|
|
||||||
const payload = data.payload || {}
|
|
||||||
const isRetryableError = event === 'error' && payload?.chunk?.retryable === true
|
|
||||||
if (isRetryableError) {
|
|
||||||
const parsedJobTry = Number.parseInt(payload?.chunk?.job_try, 10)
|
|
||||||
const retryJobTry = Number.isNaN(parsedJobTry) ? null : parsedJobTry
|
|
||||||
if (retryJobTry !== null && ts.lastRetryableJobTry === retryJobTry) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ts.lastRetryableJobTry = retryJobTry
|
|
||||||
console.warn('Run encountered retryable error, waiting for worker retry', {
|
|
||||||
threadId,
|
|
||||||
runId,
|
|
||||||
retryJobTry,
|
|
||||||
errorType: payload?.chunk?.error_type
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(payload.items)) {
|
|
||||||
payload.items.forEach((chunk) => {
|
|
||||||
enqueueLoadingChunkForTyping(threadId, chunk)
|
|
||||||
})
|
|
||||||
} else if (payload.chunk) {
|
|
||||||
enqueueLoadingChunkForTyping(threadId, payload.chunk)
|
|
||||||
}
|
|
||||||
|
|
||||||
const approvalStatuses = ['ask_user_question_required', 'human_approval_required']
|
|
||||||
const isApprovalEvent =
|
|
||||||
approvalStatuses.includes(event) || approvalStatuses.includes(payload?.chunk?.status)
|
|
||||||
|
|
||||||
if (isApprovalEvent) {
|
|
||||||
const approvalChunk = payload?.chunk || { status: event, thread_id: threadId }
|
|
||||||
processApprovalInStream(approvalChunk, threadId, currentAgentId.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event === 'close') {
|
|
||||||
flushTypingQueueForThread(threadId)
|
|
||||||
ts.isStreaming = false
|
|
||||||
if (RUN_TERMINAL_STATUSES.has(data.status)) {
|
|
||||||
ts.activeRunId = null
|
|
||||||
ts.lastRetryableJobTry = null
|
|
||||||
clearActiveRunSnapshot(threadId)
|
|
||||||
fetchThreadMessages({ agentId: currentAgentId.value, threadId, delay: 200 }).finally(
|
|
||||||
() => {
|
|
||||||
fetchAgentState(currentAgentId.value, threadId)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else if (ts.activeRunId === runId) {
|
|
||||||
window.setTimeout(() => {
|
|
||||||
if (ts.activeRunId === runId && !ts.runStreamAbortController) {
|
|
||||||
void startRunStream(threadId, runId, ts.runLastSeq)
|
|
||||||
}
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunkStatus = payload?.chunk?.status
|
|
||||||
if (
|
|
||||||
event === 'finished' ||
|
|
||||||
event === 'error' ||
|
|
||||||
event === 'interrupted' ||
|
|
||||||
approvalStatuses.includes(event) ||
|
|
||||||
approvalStatuses.includes(chunkStatus)
|
|
||||||
) {
|
|
||||||
flushTypingQueueForThread(threadId)
|
|
||||||
ts.isStreaming = false
|
|
||||||
ts.activeRunId = null
|
|
||||||
ts.lastRetryableJobTry = null
|
|
||||||
clearActiveRunSnapshot(threadId)
|
|
||||||
fetchThreadMessages({ agentId: currentAgentId.value, threadId, delay: 300 }).finally(() => {
|
|
||||||
resetOnGoingConv(threadId)
|
|
||||||
fetchAgentState(currentAgentId.value, threadId)
|
|
||||||
scrollController.scrollToBottom()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
if (error?.name !== 'AbortError') {
|
|
||||||
console.error('Run SSE stream error:', error)
|
|
||||||
handleChatError(error, 'stream')
|
|
||||||
if (ts.activeRunId === runId) {
|
|
||||||
window.setTimeout(() => {
|
|
||||||
if (ts.activeRunId === runId && !ts.runStreamAbortController) {
|
|
||||||
void startRunStream(threadId, runId, ts.runLastSeq)
|
|
||||||
}
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (ts.runStreamAbortController === runController) {
|
|
||||||
ts.runStreamAbortController = null
|
|
||||||
}
|
|
||||||
if (!ts.activeRunId) {
|
|
||||||
ts.isStreaming = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resumeActiveRunForThread = async (threadId) => {
|
|
||||||
if (!useRunsApi || !threadId) return
|
|
||||||
const ts = getThreadState(threadId)
|
|
||||||
if (!ts || ts.runStreamAbortController) return
|
|
||||||
|
|
||||||
const snapshot = loadActiveRunSnapshot(threadId)
|
|
||||||
if (snapshot?.run_id) {
|
|
||||||
if (Date.now() - Number(snapshot.created_at || 0) > ACTIVE_RUN_STORAGE_TTL_MS) {
|
|
||||||
clearActiveRunSnapshot(threadId)
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const runRes = await agentApi.getAgentRun(snapshot.run_id)
|
|
||||||
const run = runRes?.run
|
|
||||||
if (run && !RUN_TERMINAL_STATUSES.has(run.status)) {
|
|
||||||
await startRunStream(threadId, run.id, snapshot.last_seq || '0')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
clearActiveRunSnapshot(threadId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const active = await agentApi.getThreadActiveRun(threadId)
|
|
||||||
const run = active?.run
|
|
||||||
if (run && !RUN_TERMINAL_STATUSES.has(run.status)) {
|
|
||||||
await startRunStream(threadId, run.id, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to load active run for thread:', threadId, e)
|
|
||||||
}
|
|
||||||
|
|
||||||
ts.activeRunId = null
|
|
||||||
ts.runLastSeq = '0'
|
|
||||||
ts.isStreaming = false
|
|
||||||
clearActiveRunSnapshot(threadId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ensureActiveThread = async (title = '新的对话') => {
|
const ensureActiveThread = async (title = '新的对话') => {
|
||||||
if (currentChatId.value) return currentChatId.value
|
if (currentChatId.value) return currentChatId.value
|
||||||
try {
|
try {
|
||||||
@ -1290,6 +693,40 @@ const ensureActiveThread = async (title = '新的对话') => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAttachmentUpload = async (files) => {
|
||||||
|
if (!files?.length) return
|
||||||
|
if (!AgentValidator.validateAgentIdWithError(currentAgentId.value, '上传附件', handleValidationError))
|
||||||
|
return
|
||||||
|
|
||||||
|
const preferredTitle = files[0]?.name || '新的对话'
|
||||||
|
let threadId = currentChatId.value
|
||||||
|
|
||||||
|
if (!threadId) {
|
||||||
|
threadId = await ensureActiveThread(preferredTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!threadId) {
|
||||||
|
message.error('创建对话失败,无法上传附件')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
message.loading({
|
||||||
|
content: '正在上传附件...',
|
||||||
|
key: 'upload-attachment',
|
||||||
|
duration: 0
|
||||||
|
})
|
||||||
|
for (const file of files) {
|
||||||
|
await threadApi.uploadThreadAttachment(threadId, file)
|
||||||
|
}
|
||||||
|
message.success({ content: '附件上传成功', key: 'upload-attachment', duration: 2 })
|
||||||
|
await fetchAgentState(currentAgentId.value, threadId)
|
||||||
|
} catch (error) {
|
||||||
|
message.destroy('upload-attachment')
|
||||||
|
handleChatError(error, 'upload')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 审批功能管理 ====================
|
// ==================== 审批功能管理 ====================
|
||||||
const { approvalState, handleApproval, processApprovalInStream } = useApproval({
|
const { approvalState, handleApproval, processApprovalInStream } = useApproval({
|
||||||
getThreadState,
|
getThreadState,
|
||||||
@ -1304,6 +741,17 @@ const { handleAgentResponse, handleStreamChunk } = useAgentStreamHandler({
|
|||||||
supportsTodo,
|
supportsTodo,
|
||||||
supportsFiles
|
supportsFiles
|
||||||
})
|
})
|
||||||
|
const { startRunStream, resumeActiveRunForThread, stopRunStreamSubscription } = useAgentRunStream({
|
||||||
|
getThreadState,
|
||||||
|
useRunsApi,
|
||||||
|
currentAgentId,
|
||||||
|
handleStreamChunk,
|
||||||
|
processApprovalInStream,
|
||||||
|
fetchThreadMessages,
|
||||||
|
fetchAgentState,
|
||||||
|
resetOnGoingConv,
|
||||||
|
onScrollToBottom: () => scrollController.scrollToBottom()
|
||||||
|
})
|
||||||
|
|
||||||
// 发送消息并处理流式响应
|
// 发送消息并处理流式响应
|
||||||
const sendMessage = async ({
|
const sendMessage = async ({
|
||||||
@ -1392,14 +840,7 @@ const createNewChat = async () => {
|
|||||||
if (newThread) {
|
if (newThread) {
|
||||||
// 中断之前线程的流式输出(如果存在)
|
// 中断之前线程的流式输出(如果存在)
|
||||||
const previousThreadId = chatState.currentThreadId
|
const previousThreadId = chatState.currentThreadId
|
||||||
if (previousThreadId) {
|
stopThreadStream(previousThreadId)
|
||||||
const previousThreadState = getThreadState(previousThreadId)
|
|
||||||
if (previousThreadState?.isStreaming && previousThreadState.streamAbortController) {
|
|
||||||
previousThreadState.streamAbortController.abort()
|
|
||||||
previousThreadState.isStreaming = false
|
|
||||||
previousThreadState.streamAbortController = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
chatState.currentThreadId = newThread.id
|
chatState.currentThreadId = newThread.id
|
||||||
}
|
}
|
||||||
@ -1425,12 +866,7 @@ const selectChat = async (chatId) => {
|
|||||||
|
|
||||||
// 中断之前线程的流式输出(如果存在)
|
// 中断之前线程的流式输出(如果存在)
|
||||||
if (previousThreadId && previousThreadId !== chatId) {
|
if (previousThreadId && previousThreadId !== chatId) {
|
||||||
const previousThreadState = getThreadState(previousThreadId)
|
stopThreadStream(previousThreadId)
|
||||||
if (previousThreadState?.isStreaming && previousThreadState.streamAbortController) {
|
|
||||||
previousThreadState.streamAbortController.abort()
|
|
||||||
previousThreadState.isStreaming = false
|
|
||||||
previousThreadState.streamAbortController = null
|
|
||||||
}
|
|
||||||
// run 模式下仅断开 SSE 订阅,不取消后台运行任务
|
// run 模式下仅断开 SSE 订阅,不取消后台运行任务
|
||||||
stopRunStreamSubscription(previousThreadId)
|
stopRunStreamSubscription(previousThreadId)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<MessageInputComponent
|
<MessageInputComponent
|
||||||
ref="inputRef"
|
ref="inputRef"
|
||||||
:key="inputKey"
|
|
||||||
:model-value="modelValue"
|
:model-value="modelValue"
|
||||||
@update:modelValue="updateValue"
|
@update:modelValue="updateValue"
|
||||||
:is-loading="isLoading"
|
:is-loading="isLoading"
|
||||||
@ -53,96 +52,42 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { message } from 'ant-design-vue'
|
|
||||||
import MessageInputComponent from '@/components/MessageInputComponent.vue'
|
import MessageInputComponent from '@/components/MessageInputComponent.vue'
|
||||||
import ImagePreviewComponent from '@/components/ImagePreviewComponent.vue'
|
import ImagePreviewComponent from '@/components/ImagePreviewComponent.vue'
|
||||||
import AttachmentOptionsComponent from '@/components/AttachmentOptionsComponent.vue'
|
import AttachmentOptionsComponent from '@/components/AttachmentOptionsComponent.vue'
|
||||||
import { threadApi } from '@/apis'
|
|
||||||
import { AgentValidator } from '@/utils/agentValidator'
|
|
||||||
import { handleChatError, handleValidationError } from '@/utils/errorHandler'
|
|
||||||
import { FolderCode } from 'lucide-vue-next'
|
import { FolderCode } from 'lucide-vue-next'
|
||||||
|
|
||||||
const props = defineProps({
|
defineProps({
|
||||||
modelValue: { type: String, default: '' },
|
modelValue: { type: String, default: '' },
|
||||||
isLoading: { type: Boolean, default: false },
|
isLoading: { type: Boolean, default: false },
|
||||||
disabled: { type: Boolean, default: false },
|
disabled: { type: Boolean, default: false },
|
||||||
sendButtonDisabled: { type: Boolean, default: false },
|
sendButtonDisabled: { type: Boolean, default: false },
|
||||||
placeholder: { type: String, default: '输入问题...' },
|
placeholder: { type: String, default: '输入问题...' },
|
||||||
|
mention: { type: Object, default: () => null },
|
||||||
supportsFileUpload: { type: Boolean, default: false },
|
supportsFileUpload: { type: Boolean, default: false },
|
||||||
agentId: { type: String, default: '' },
|
|
||||||
threadId: { type: String, default: null },
|
|
||||||
ensureThread: { type: Function, required: true },
|
|
||||||
hasStateContent: { type: Boolean, default: false },
|
hasStateContent: { type: Boolean, default: false },
|
||||||
isPanelOpen: { type: Boolean, default: false },
|
isPanelOpen: { type: Boolean, default: false }
|
||||||
mention: { type: Object, default: () => null }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
'update:modelValue',
|
'update:modelValue',
|
||||||
'send',
|
'send',
|
||||||
'keydown',
|
'keydown',
|
||||||
'attachment-changed',
|
'upload-attachment',
|
||||||
'toggle-panel'
|
'toggle-panel'
|
||||||
])
|
])
|
||||||
|
|
||||||
const inputRef = ref(null)
|
const inputRef = ref(null)
|
||||||
const currentImage = ref(null)
|
const currentImage = ref(null)
|
||||||
|
|
||||||
// 用于强制重建输入组件的 key
|
|
||||||
const inputKey = ref(0)
|
|
||||||
|
|
||||||
// 监听 hasStateContent 变化,当从有 state 切换到无 state 时重建组件
|
|
||||||
watch(
|
|
||||||
() => props.hasStateContent,
|
|
||||||
(newVal, oldVal) => {
|
|
||||||
// 当 hasStateContent 从 true 变为 false 时,重建输入组件
|
|
||||||
if (oldVal === true && newVal === false) {
|
|
||||||
inputKey.value++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const updateValue = (val) => {
|
const updateValue = (val) => {
|
||||||
emit('update:modelValue', val)
|
emit('update:modelValue', val)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAttachmentUpload = async (files) => {
|
const handleAttachmentUpload = (files) => {
|
||||||
if (!files?.length) return
|
if (!files?.length) return
|
||||||
if (!AgentValidator.validateAgentIdWithError(props.agentId, '上传附件', handleValidationError))
|
emit('upload-attachment', files)
|
||||||
return
|
|
||||||
|
|
||||||
const preferredTitle = files[0]?.name || '新的对话'
|
|
||||||
let threadId = props.threadId
|
|
||||||
|
|
||||||
if (!threadId) {
|
|
||||||
try {
|
|
||||||
threadId = await props.ensureThread(preferredTitle)
|
|
||||||
} catch (e) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!threadId) {
|
|
||||||
message.error('创建对话失败,无法上传附件')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const hide = message.loading({
|
|
||||||
content: '正在上传附件...',
|
|
||||||
key: 'upload-attachment',
|
|
||||||
duration: 0
|
|
||||||
})
|
|
||||||
for (const file of files) {
|
|
||||||
await threadApi.uploadThreadAttachment(threadId, file)
|
|
||||||
}
|
|
||||||
message.success({ content: '附件上传成功', key: 'upload-attachment', duration: 2 })
|
|
||||||
emit('attachment-changed', threadId)
|
|
||||||
} catch (error) {
|
|
||||||
message.destroy('upload-attachment')
|
|
||||||
handleChatError(error, 'upload')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleImageUpload = (imageData) => {
|
const handleImageUpload = (imageData) => {
|
||||||
|
|||||||
@ -1,166 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="attachment-panel">
|
|
||||||
<label class="attachment-upload" :class="{ disabled: disabled || isUploading }">
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
accept=".txt,.md,.docx,.html,.htm"
|
|
||||||
:disabled="disabled || isUploading"
|
|
||||||
@change="handleFileChange"
|
|
||||||
/>
|
|
||||||
<Paperclip size="14" />
|
|
||||||
<span>{{ isUploading ? '上传中…' : '添加附件' }}</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<p class="attachment-hint" v-if="limits">支持 {{ extensionsText }},单文件 ≤ {{ sizeHint }}</p>
|
|
||||||
|
|
||||||
<div class="attachment-list" v-if="attachments.length">
|
|
||||||
<div class="attachment-chip" v-for="item in attachments" :key="item.file_id">
|
|
||||||
<Paperclip size="14" class="chip-icon" />
|
|
||||||
<span class="chip-name" :title="item.file_name">{{ item.file_name }}</span>
|
|
||||||
<span class="chip-status" :class="`status-${item.status}`">
|
|
||||||
{{ statusLabel(item) }}
|
|
||||||
</span>
|
|
||||||
<a-tooltip title="移除附件">
|
|
||||||
<a-button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
class="chip-remove"
|
|
||||||
:disabled="disabled"
|
|
||||||
@click="$emit('remove', item.file_id)"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<Trash2 size="14" />
|
|
||||||
</template>
|
|
||||||
</a-button>
|
|
||||||
</a-tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { Paperclip, Trash2 } from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
attachments: { type: Array, default: () => [] },
|
|
||||||
limits: { type: Object, default: () => null },
|
|
||||||
isUploading: { type: Boolean, default: false },
|
|
||||||
disabled: { type: Boolean, default: false }
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['upload', 'remove'])
|
|
||||||
|
|
||||||
const extensionsText = computed(() => {
|
|
||||||
if (!props.limits?.allowed_extensions?.length) return 'txt/md/docx/html'
|
|
||||||
return props.limits.allowed_extensions.map((item) => item.replace('.', '')).join(' / ')
|
|
||||||
})
|
|
||||||
|
|
||||||
const sizeHint = computed(() => {
|
|
||||||
if (!props.limits?.max_size_bytes) return '5 MB'
|
|
||||||
const mb = props.limits.max_size_bytes / (1024 * 1024)
|
|
||||||
return `${mb.toFixed(1)} MB`
|
|
||||||
})
|
|
||||||
|
|
||||||
const statusLabel = (item) => {
|
|
||||||
if (item.status === 'parsed') {
|
|
||||||
return item.truncated ? '已解析(截断)' : '已解析'
|
|
||||||
}
|
|
||||||
if (item.status === 'failed') return '解析失败'
|
|
||||||
return '处理中'
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileChange = (event) => {
|
|
||||||
const files = Array.from(event.target.files || [])
|
|
||||||
event.target.value = ''
|
|
||||||
if (!files.length) return
|
|
||||||
emit('upload', files)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.attachment-panel {
|
|
||||||
min-width: 220px;
|
|
||||||
max-width: 320px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attachment-upload {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--main-700);
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attachment-upload.disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attachment-upload input {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attachment-hint {
|
|
||||||
margin: 4px 0 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--gray-500);
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attachment-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
max-height: 220px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attachment-chip {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
background: var(--gray-25);
|
|
||||||
border: 1px solid var(--gray-100);
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip-icon {
|
|
||||||
color: var(--gray-600);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip-name {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--gray-800);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip-status {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--gray-600);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip-status.status-parsed {
|
|
||||||
color: var(--color-success-600);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip-status.status-failed {
|
|
||||||
color: var(--color-error-600);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip-remove {
|
|
||||||
margin-left: 2px;
|
|
||||||
color: var(--gray-500);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip-remove:hover {
|
|
||||||
color: var(--gray-700);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -97,6 +97,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Subagents 列表 -->
|
||||||
|
<div v-if="mentionItems.subagents.length > 0" class="mention-group">
|
||||||
|
<div class="mention-group-title">Subagents</div>
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in mentionItems.subagents"
|
||||||
|
:key="'subagent-' + item.value"
|
||||||
|
:class="['mention-item', { active: isItemSelected('subagent', index) }]"
|
||||||
|
@click="insertMention(item)"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 无结果 -->
|
<!-- 无结果 -->
|
||||||
<div v-if="!hasAnyItems" class="mention-empty">暂无可引用的项</div>
|
<div v-if="!hasAnyItems" class="mention-empty">暂无可引用的项</div>
|
||||||
</div>
|
</div>
|
||||||
@ -129,7 +142,6 @@ import { ref, computed, onMounted, nextTick, watch, onBeforeUnmount, useSlots }
|
|||||||
import {
|
import {
|
||||||
SendOutlined,
|
SendOutlined,
|
||||||
ArrowUpOutlined,
|
ArrowUpOutlined,
|
||||||
LoadingOutlined,
|
|
||||||
PauseOutlined,
|
PauseOutlined,
|
||||||
PlusOutlined
|
PlusOutlined
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
@ -192,12 +204,13 @@ const slots = useSlots()
|
|||||||
// @ 提及功能是否启用
|
// @ 提及功能是否启用
|
||||||
const mentionEnabled = computed(() => {
|
const mentionEnabled = computed(() => {
|
||||||
if (!props.mention) return false
|
if (!props.mention) return false
|
||||||
const { files, knowledgeBases, mcps, skills } = props.mention
|
const { files, knowledgeBases, mcps, skills, subagents } = props.mention
|
||||||
return (
|
return (
|
||||||
(Array.isArray(files) && files.length > 0) ||
|
(Array.isArray(files) && files.length > 0) ||
|
||||||
(Array.isArray(knowledgeBases) && knowledgeBases.length > 0) ||
|
(Array.isArray(knowledgeBases) && knowledgeBases.length > 0) ||
|
||||||
(Array.isArray(mcps) && mcps.length > 0) ||
|
(Array.isArray(mcps) && mcps.length > 0) ||
|
||||||
(Array.isArray(skills) && skills.length > 0)
|
(Array.isArray(skills) && skills.length > 0) ||
|
||||||
|
(Array.isArray(subagents) && subagents.length > 0)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -205,7 +218,8 @@ const mentionTypePrefixMap = {
|
|||||||
file: 'file',
|
file: 'file',
|
||||||
knowledge: 'knowledge',
|
knowledge: 'knowledge',
|
||||||
mcp: 'mcp',
|
mcp: 'mcp',
|
||||||
skill: 'skill'
|
skill: 'skill',
|
||||||
|
subagent: 'subagent'
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatMentionToken = (type, value) => {
|
const formatMentionToken = (type, value) => {
|
||||||
@ -215,27 +229,18 @@ const formatMentionToken = (type, value) => {
|
|||||||
|
|
||||||
// 检测是否在 @ 触发位置
|
// 检测是否在 @ 触发位置
|
||||||
const checkMentionTrigger = (textarea) => {
|
const checkMentionTrigger = (textarea) => {
|
||||||
console.log(
|
|
||||||
'[Mention] checkMentionTrigger called, textarea:',
|
|
||||||
!!textarea,
|
|
||||||
'mentionEnabled:',
|
|
||||||
mentionEnabled.value
|
|
||||||
)
|
|
||||||
if (!textarea || !mentionEnabled.value) return false
|
if (!textarea || !mentionEnabled.value) return false
|
||||||
|
|
||||||
const cursorPos = textarea.selectionStart
|
const cursorPos = textarea.selectionStart
|
||||||
const textBeforeCursor = inputValue.value.slice(0, cursorPos)
|
const textBeforeCursor = inputValue.value.slice(0, cursorPos)
|
||||||
console.log('[Mention] textBeforeCursor:', JSON.stringify(textBeforeCursor))
|
|
||||||
|
|
||||||
// 检查是否以 @ 结尾(刚输入 @)或 @ 后有内容
|
// 检查是否以 @ 结尾(刚输入 @)或 @ 后有内容
|
||||||
const atMatch = textBeforeCursor.match(/@(\S*)$/)
|
const atMatch = textBeforeCursor.match(/@(\S*)$/)
|
||||||
console.log('[Mention] atMatch:', atMatch)
|
|
||||||
if (atMatch) {
|
if (atMatch) {
|
||||||
mentionQuery.value = atMatch[1]
|
mentionQuery.value = atMatch[1]
|
||||||
mentionPopupVisible.value = true
|
mentionPopupVisible.value = true
|
||||||
mentionSelectedIndex.value = 0
|
mentionSelectedIndex.value = 0
|
||||||
updateMentionItems(mentionQuery.value)
|
updateMentionItems(mentionQuery.value)
|
||||||
console.log('[Mention] popup should be visible now')
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -246,12 +251,12 @@ const checkMentionTrigger = (textarea) => {
|
|||||||
// 更新提及候选项
|
// 更新提及候选项
|
||||||
const updateMentionItems = (query = '') => {
|
const updateMentionItems = (query = '') => {
|
||||||
if (!props.mention) {
|
if (!props.mention) {
|
||||||
mentionItems.value = { files: [], knowledgeBases: [], mcps: [], skills: [] }
|
mentionItems.value = { files: [], knowledgeBases: [], mcps: [], skills: [], subagents: [] }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const lowerQuery = query.toLowerCase()
|
const lowerQuery = query.toLowerCase()
|
||||||
const { files = [], knowledgeBases = [], mcps = [], skills = [] } = props.mention
|
const { files = [], knowledgeBases = [], mcps = [], skills = [], subagents = [] } = props.mention
|
||||||
|
|
||||||
const filterItems = (list) =>
|
const filterItems = (list) =>
|
||||||
list.filter((item) => {
|
list.filter((item) => {
|
||||||
@ -319,11 +324,25 @@ const updateMentionItems = (query = '') => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const subagentItems = subagents.map((subagent) => {
|
||||||
|
const subagentValue = subagent.id || subagent.value || subagent.name || ''
|
||||||
|
const subagentLabel = subagent.name || subagent.label || subagentValue
|
||||||
|
return {
|
||||||
|
value: subagentValue,
|
||||||
|
label: subagentLabel,
|
||||||
|
type: 'subagent',
|
||||||
|
insertValue: subagentValue,
|
||||||
|
tokenLabel: formatMentionToken('subagent', subagentValue),
|
||||||
|
description: subagent.description || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
mentionItems.value = {
|
mentionItems.value = {
|
||||||
files: filterItems(fileItems),
|
files: filterItems(fileItems),
|
||||||
knowledgeBases: filterItems(knowledgeItems),
|
knowledgeBases: filterItems(knowledgeItems),
|
||||||
mcps: filterItems(mcpItems),
|
mcps: filterItems(mcpItems),
|
||||||
skills: filterItems(skillItems)
|
skills: filterItems(skillItems),
|
||||||
|
subagents: filterItems(subagentItems)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -334,6 +353,7 @@ const isItemSelected = (type, index) => {
|
|||||||
const filesLen = mentionItems.value.files.length
|
const filesLen = mentionItems.value.files.length
|
||||||
const kbLen = mentionItems.value.knowledgeBases.length
|
const kbLen = mentionItems.value.knowledgeBases.length
|
||||||
const mcpLen = mentionItems.value.mcps.length
|
const mcpLen = mentionItems.value.mcps.length
|
||||||
|
const skillsLen = mentionItems.value.skills.length
|
||||||
|
|
||||||
if (type === 'file') {
|
if (type === 'file') {
|
||||||
return mentionSelectedIndex.value === index
|
return mentionSelectedIndex.value === index
|
||||||
@ -341,8 +361,10 @@ const isItemSelected = (type, index) => {
|
|||||||
return mentionSelectedIndex.value === filesLen + index
|
return mentionSelectedIndex.value === filesLen + index
|
||||||
} else if (type === 'mcp') {
|
} else if (type === 'mcp') {
|
||||||
return mentionSelectedIndex.value === filesLen + kbLen + index
|
return mentionSelectedIndex.value === filesLen + kbLen + index
|
||||||
} else {
|
} else if (type === 'skill') {
|
||||||
return mentionSelectedIndex.value === filesLen + kbLen + mcpLen + index
|
return mentionSelectedIndex.value === filesLen + kbLen + mcpLen + index
|
||||||
|
} else {
|
||||||
|
return mentionSelectedIndex.value === filesLen + kbLen + mcpLen + skillsLen + index
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -353,7 +375,8 @@ const hasAnyItems = computed(() => {
|
|||||||
items.files.length > 0 ||
|
items.files.length > 0 ||
|
||||||
items.knowledgeBases.length > 0 ||
|
items.knowledgeBases.length > 0 ||
|
||||||
items.mcps.length > 0 ||
|
items.mcps.length > 0 ||
|
||||||
items.skills.length > 0
|
items.skills.length > 0 ||
|
||||||
|
items.subagents.length > 0
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -431,7 +454,8 @@ const handleMentionNavigation = (e) => {
|
|||||||
...mentionItems.value.files,
|
...mentionItems.value.files,
|
||||||
...mentionItems.value.knowledgeBases,
|
...mentionItems.value.knowledgeBases,
|
||||||
...mentionItems.value.mcps,
|
...mentionItems.value.mcps,
|
||||||
...mentionItems.value.skills
|
...mentionItems.value.skills,
|
||||||
|
...mentionItems.value.subagents
|
||||||
]
|
]
|
||||||
|
|
||||||
const total = allItems.length
|
const total = allItems.length
|
||||||
@ -465,15 +489,6 @@ const hasOptionsLeft = computed(() => {
|
|||||||
return Boolean(renderedNodes && renderedNodes.length)
|
return Boolean(renderedNodes && renderedNodes.length)
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasActionsLeft = computed(() => {
|
|
||||||
const slot = slots['actions-left']
|
|
||||||
if (!slot) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const renderedNodes = slot()
|
|
||||||
return Boolean(renderedNodes && renderedNodes.length)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 图标映射
|
// 图标映射
|
||||||
const iconComponents = {
|
const iconComponents = {
|
||||||
SendOutlined: SendOutlined,
|
SendOutlined: SendOutlined,
|
||||||
@ -511,12 +526,6 @@ const handleKeyPress = (e) => {
|
|||||||
// 检测 @ 触发
|
// 检测 @ 触发
|
||||||
const handleKeyUp = (e) => {
|
const handleKeyUp = (e) => {
|
||||||
if (e.key === '@' && mentionEnabled.value) {
|
if (e.key === '@' && mentionEnabled.value) {
|
||||||
console.log(
|
|
||||||
'[Mention] @ detected, mentionEnabled:',
|
|
||||||
mentionEnabled.value,
|
|
||||||
'mention:',
|
|
||||||
props.mention
|
|
||||||
)
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
checkMentionTrigger(e.target)
|
checkMentionTrigger(e.target)
|
||||||
})
|
})
|
||||||
@ -533,7 +542,6 @@ const handleInput = (e) => {
|
|||||||
const cursorPos = e.target.selectionStart
|
const cursorPos = e.target.selectionStart
|
||||||
const textBeforeCursor = value.slice(0, cursorPos)
|
const textBeforeCursor = value.slice(0, cursorPos)
|
||||||
if (textBeforeCursor.endsWith('@')) {
|
if (textBeforeCursor.endsWith('@')) {
|
||||||
console.log('[Mention] @ detected via input event')
|
|
||||||
checkMentionTrigger(e.target)
|
checkMentionTrigger(e.target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -554,7 +562,7 @@ const handleSendOrStop = () => {
|
|||||||
// @ 提及功能状态
|
// @ 提及功能状态
|
||||||
const mentionPopupVisible = ref(false)
|
const mentionPopupVisible = ref(false)
|
||||||
const mentionQuery = ref('')
|
const mentionQuery = ref('')
|
||||||
const mentionItems = ref({ files: [], knowledgeBases: [], mcps: [], skills: [] })
|
const mentionItems = ref({ files: [], knowledgeBases: [], mcps: [], skills: [], subagents: [] })
|
||||||
const mentionSelectedIndex = ref(0)
|
const mentionSelectedIndex = ref(0)
|
||||||
|
|
||||||
const adjustTextareaHeight = () => {
|
const adjustTextareaHeight = () => {
|
||||||
|
|||||||
114
web/src/composables/useAgentMentionConfig.js
Normal file
114
web/src/composables/useAgentMentionConfig.js
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
export function useAgentMentionConfig({
|
||||||
|
currentAgentState,
|
||||||
|
configurableItems,
|
||||||
|
agentConfig,
|
||||||
|
availableKnowledgeBases,
|
||||||
|
availableMcps,
|
||||||
|
availableSkills
|
||||||
|
}) {
|
||||||
|
const mentionConfig = computed(() => {
|
||||||
|
const rawFiles = currentAgentState.value?.files || {}
|
||||||
|
const files = []
|
||||||
|
|
||||||
|
// 处理 files - 兼容字典格式 {"/path/file": {content: [...]}} 和旧数组格式
|
||||||
|
if (typeof rawFiles === 'object' && !Array.isArray(rawFiles) && rawFiles !== null) {
|
||||||
|
// 新格式:字典格式 {"/attachments/xxx/file.md": {...}}
|
||||||
|
Object.entries(rawFiles).forEach(([filePath, fileData]) => {
|
||||||
|
files.push({
|
||||||
|
path: filePath,
|
||||||
|
...fileData
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} else if (Array.isArray(rawFiles)) {
|
||||||
|
// 旧格式:数组格式
|
||||||
|
rawFiles.forEach((item) => {
|
||||||
|
if (typeof item === 'object' && item !== null) {
|
||||||
|
Object.entries(item).forEach(([filePath, fileData]) => {
|
||||||
|
files.push({
|
||||||
|
path: filePath,
|
||||||
|
...fileData
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const configItems = configurableItems.value || {}
|
||||||
|
const currentConfig = agentConfig.value || {}
|
||||||
|
const allowedKbNames = new Set()
|
||||||
|
const allowedMcpNames = new Set()
|
||||||
|
const allowedSkillNames = new Set()
|
||||||
|
const allowedSubagentNames = new Set()
|
||||||
|
const subagentOptionMap = new Map()
|
||||||
|
|
||||||
|
Object.entries(configItems).forEach(([key, item]) => {
|
||||||
|
const kind = item?.template_metadata?.kind
|
||||||
|
const val = currentConfig[key]
|
||||||
|
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
if (kind === 'knowledges') {
|
||||||
|
val.forEach((v) => allowedKbNames.add(v))
|
||||||
|
} else if (kind === 'mcps') {
|
||||||
|
val.forEach((v) => allowedMcpNames.add(v))
|
||||||
|
} else if (kind === 'skills' || key === 'skills') {
|
||||||
|
val.forEach((v) => allowedSkillNames.add(v))
|
||||||
|
} else if (kind === 'subagents' || key === 'subagents') {
|
||||||
|
val.forEach((v) => allowedSubagentNames.add(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'subagents' || key === 'subagents') {
|
||||||
|
const options = Array.isArray(item?.options) ? item.options : []
|
||||||
|
options.forEach((option) => {
|
||||||
|
if (option == null) return
|
||||||
|
|
||||||
|
const value =
|
||||||
|
typeof option === 'object'
|
||||||
|
? option.id || option.value || option.name || option.label
|
||||||
|
: option
|
||||||
|
if (!value) return
|
||||||
|
|
||||||
|
subagentOptionMap.set(value, {
|
||||||
|
id: value,
|
||||||
|
name: typeof option === 'object' ? option.name || option.label || value : value,
|
||||||
|
description: typeof option === 'object' ? option.description || '' : ''
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const knowledgeBases = availableKnowledgeBases.value.filter((kb) => allowedKbNames.has(kb.name))
|
||||||
|
const mcps = availableMcps.value.filter((mcp) => allowedMcpNames.has(mcp.name))
|
||||||
|
const skills = availableSkills.value.filter((skill) => {
|
||||||
|
const skillName = skill.name || ''
|
||||||
|
const skillSlug = skill.slug || ''
|
||||||
|
return allowedSkillNames.has(skillName) || allowedSkillNames.has(skillSlug)
|
||||||
|
})
|
||||||
|
const subagents = Array.from(allowedSubagentNames)
|
||||||
|
.filter((name) => !!name)
|
||||||
|
.map((name) =>
|
||||||
|
subagentOptionMap.get(name) || {
|
||||||
|
id: name,
|
||||||
|
name,
|
||||||
|
description: ''
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!files.length && !knowledgeBases.length && !mcps.length && !skills.length && !subagents.length)
|
||||||
|
return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
files,
|
||||||
|
knowledgeBases,
|
||||||
|
mcps,
|
||||||
|
skills,
|
||||||
|
subagents
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
mentionConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
342
web/src/composables/useAgentRunStream.js
Normal file
342
web/src/composables/useAgentRunStream.js
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
import { unref } from 'vue'
|
||||||
|
import { agentApi } from '@/apis'
|
||||||
|
import { handleChatError } from '@/utils/errorHandler'
|
||||||
|
|
||||||
|
const RUN_TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled', 'interrupted'])
|
||||||
|
const ACTIVE_RUN_STORAGE_TTL_MS = 60 * 60 * 1000
|
||||||
|
const ACTIVE_RUN_CLIENT_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
|
||||||
|
const getActiveRunStorageKey = (threadId) => `active_run:${threadId}`
|
||||||
|
|
||||||
|
const normalizeRunSeq = (value) => {
|
||||||
|
if (value === undefined || value === null) return '0'
|
||||||
|
const text = String(value).trim()
|
||||||
|
return text || '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseRunSeq = (value) => {
|
||||||
|
const text = normalizeRunSeq(value)
|
||||||
|
if (text.includes('-')) {
|
||||||
|
const [majorRaw, minorRaw] = text.split('-', 2)
|
||||||
|
let major = 0n
|
||||||
|
let minor = 0n
|
||||||
|
try {
|
||||||
|
major = BigInt(majorRaw || '0')
|
||||||
|
minor = BigInt(minorRaw || '0')
|
||||||
|
} catch {
|
||||||
|
return { kind: 'legacy', value: 0 }
|
||||||
|
}
|
||||||
|
return { kind: 'stream', major, minor }
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberValue = Number.parseInt(text, 10)
|
||||||
|
if (!Number.isNaN(numberValue)) {
|
||||||
|
return { kind: 'legacy', value: numberValue }
|
||||||
|
}
|
||||||
|
return { kind: 'legacy', value: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const compareRunSeq = (incoming, current) => {
|
||||||
|
const left = parseRunSeq(incoming)
|
||||||
|
const right = parseRunSeq(current)
|
||||||
|
|
||||||
|
if (left.kind === 'stream' && right.kind === 'stream') {
|
||||||
|
if (left.major > right.major) return 1
|
||||||
|
if (left.major < right.major) return -1
|
||||||
|
if (left.minor > right.minor) return 1
|
||||||
|
if (left.minor < right.minor) return -1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left.kind === 'legacy' && right.kind === 'legacy') {
|
||||||
|
return left.value - right.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left.kind === 'stream' && right.kind === 'legacy') return 1
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
const processRunSseResponse = async (response, onEvent) => {
|
||||||
|
if (!response || !response.body) return
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
let eventType = 'message'
|
||||||
|
let dataLines = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.replace(/\r$/, '')
|
||||||
|
if (!line) {
|
||||||
|
if (dataLines.length > 0) {
|
||||||
|
const dataText = dataLines.join('\n')
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(dataText)
|
||||||
|
onEvent(eventType, parsed)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to parse run SSE data:', e, dataText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eventType = 'message'
|
||||||
|
dataLines = []
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('event:')) {
|
||||||
|
eventType = line.slice(6).trim() || 'message'
|
||||||
|
} else if (line.startsWith('data:')) {
|
||||||
|
dataLines.push(line.slice(5).trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataLines.length > 0) {
|
||||||
|
const dataText = dataLines.join('\n')
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(dataText)
|
||||||
|
onEvent(eventType, parsed)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to parse trailing run SSE data:', e, dataText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
reader.releaseLock()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentRunStream({
|
||||||
|
getThreadState,
|
||||||
|
useRunsApi,
|
||||||
|
currentAgentId,
|
||||||
|
handleStreamChunk,
|
||||||
|
processApprovalInStream,
|
||||||
|
fetchThreadMessages,
|
||||||
|
fetchAgentState,
|
||||||
|
resetOnGoingConv,
|
||||||
|
onScrollToBottom
|
||||||
|
}) {
|
||||||
|
const saveActiveRunSnapshot = (threadId, runId, lastSeq = '0') => {
|
||||||
|
if (!threadId || !runId) return
|
||||||
|
localStorage.setItem(
|
||||||
|
getActiveRunStorageKey(threadId),
|
||||||
|
JSON.stringify({
|
||||||
|
run_id: runId,
|
||||||
|
last_seq: normalizeRunSeq(lastSeq),
|
||||||
|
created_at: Date.now(),
|
||||||
|
client_id: ACTIVE_RUN_CLIENT_ID
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadActiveRunSnapshot = (threadId) => {
|
||||||
|
if (!threadId) return null
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(getActiveRunStorageKey(threadId))
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearActiveRunSnapshot = (threadId) => {
|
||||||
|
if (!threadId) return
|
||||||
|
localStorage.removeItem(getActiveRunStorageKey(threadId))
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopRunStreamSubscription = (threadId) => {
|
||||||
|
const ts = getThreadState(threadId)
|
||||||
|
if (!ts) return
|
||||||
|
if (ts.runStreamAbortController) {
|
||||||
|
ts.runStreamAbortController.abort()
|
||||||
|
ts.runStreamAbortController = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startRunStream = async (threadId, runId, afterSeq = '0') => {
|
||||||
|
if (!threadId || !runId || !useRunsApi) return
|
||||||
|
const ts = getThreadState(threadId)
|
||||||
|
if (!ts) return
|
||||||
|
|
||||||
|
stopRunStreamSubscription(threadId)
|
||||||
|
const runController = new AbortController()
|
||||||
|
ts.runStreamAbortController = runController
|
||||||
|
ts.activeRunId = runId
|
||||||
|
ts.runLastSeq = normalizeRunSeq(afterSeq)
|
||||||
|
ts.lastRetryableJobTry = null
|
||||||
|
ts.isStreaming = true
|
||||||
|
saveActiveRunSnapshot(threadId, runId, ts.runLastSeq)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await agentApi.streamAgentRunEvents(runId, ts.runLastSeq, {
|
||||||
|
signal: runController.signal
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`SSE response not ok: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await processRunSseResponse(response, (event, data) => {
|
||||||
|
if (!data || ts.activeRunId !== runId) return
|
||||||
|
|
||||||
|
if (data.seq !== undefined && data.seq !== null) {
|
||||||
|
const incomingSeq = normalizeRunSeq(data.seq)
|
||||||
|
if (compareRunSeq(incomingSeq, ts.runLastSeq) <= 0) return
|
||||||
|
ts.runLastSeq = incomingSeq
|
||||||
|
saveActiveRunSnapshot(threadId, runId, incomingSeq)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === 'heartbeat') return
|
||||||
|
|
||||||
|
const payload = data.payload || {}
|
||||||
|
const isRetryableError = event === 'error' && payload?.chunk?.retryable === true
|
||||||
|
if (isRetryableError) {
|
||||||
|
const parsedJobTry = Number.parseInt(payload?.chunk?.job_try, 10)
|
||||||
|
const retryJobTry = Number.isNaN(parsedJobTry) ? null : parsedJobTry
|
||||||
|
if (retryJobTry !== null && ts.lastRetryableJobTry === retryJobTry) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ts.lastRetryableJobTry = retryJobTry
|
||||||
|
console.warn('Run encountered retryable error, waiting for worker retry', {
|
||||||
|
threadId,
|
||||||
|
runId,
|
||||||
|
retryJobTry,
|
||||||
|
errorType: payload?.chunk?.error_type
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(payload.items)) {
|
||||||
|
payload.items.forEach((chunk) => {
|
||||||
|
handleStreamChunk(chunk, threadId)
|
||||||
|
})
|
||||||
|
} else if (payload.chunk) {
|
||||||
|
handleStreamChunk(payload.chunk, threadId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const approvalStatuses = ['ask_user_question_required', 'human_approval_required']
|
||||||
|
const isApprovalEvent =
|
||||||
|
approvalStatuses.includes(event) || approvalStatuses.includes(payload?.chunk?.status)
|
||||||
|
|
||||||
|
if (isApprovalEvent) {
|
||||||
|
const approvalChunk = payload?.chunk || { status: event, thread_id: threadId }
|
||||||
|
processApprovalInStream(approvalChunk, threadId, unref(currentAgentId))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === 'close') {
|
||||||
|
ts.isStreaming = false
|
||||||
|
if (RUN_TERMINAL_STATUSES.has(data.status)) {
|
||||||
|
ts.activeRunId = null
|
||||||
|
ts.lastRetryableJobTry = null
|
||||||
|
clearActiveRunSnapshot(threadId)
|
||||||
|
fetchThreadMessages({ agentId: unref(currentAgentId), threadId, delay: 200 }).finally(
|
||||||
|
() => {
|
||||||
|
fetchAgentState(unref(currentAgentId), threadId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else if (ts.activeRunId === runId) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (ts.activeRunId === runId && !ts.runStreamAbortController) {
|
||||||
|
void startRunStream(threadId, runId, ts.runLastSeq)
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkStatus = payload?.chunk?.status
|
||||||
|
if (
|
||||||
|
event === 'finished' ||
|
||||||
|
event === 'error' ||
|
||||||
|
event === 'interrupted' ||
|
||||||
|
approvalStatuses.includes(event) ||
|
||||||
|
approvalStatuses.includes(chunkStatus)
|
||||||
|
) {
|
||||||
|
ts.isStreaming = false
|
||||||
|
ts.activeRunId = null
|
||||||
|
ts.lastRetryableJobTry = null
|
||||||
|
clearActiveRunSnapshot(threadId)
|
||||||
|
fetchThreadMessages({ agentId: unref(currentAgentId), threadId, delay: 300 }).finally(() => {
|
||||||
|
resetOnGoingConv(threadId)
|
||||||
|
fetchAgentState(unref(currentAgentId), threadId)
|
||||||
|
onScrollToBottom()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name !== 'AbortError') {
|
||||||
|
console.error('Run SSE stream error:', error)
|
||||||
|
handleChatError(error, 'stream')
|
||||||
|
if (ts.activeRunId === runId) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (ts.activeRunId === runId && !ts.runStreamAbortController) {
|
||||||
|
void startRunStream(threadId, runId, ts.runLastSeq)
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (ts.runStreamAbortController === runController) {
|
||||||
|
ts.runStreamAbortController = null
|
||||||
|
}
|
||||||
|
if (!ts.activeRunId) {
|
||||||
|
ts.isStreaming = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resumeActiveRunForThread = async (threadId) => {
|
||||||
|
if (!useRunsApi || !threadId) return
|
||||||
|
const ts = getThreadState(threadId)
|
||||||
|
if (!ts || ts.runStreamAbortController) return
|
||||||
|
|
||||||
|
const snapshot = loadActiveRunSnapshot(threadId)
|
||||||
|
if (snapshot?.run_id) {
|
||||||
|
if (Date.now() - Number(snapshot.created_at || 0) > ACTIVE_RUN_STORAGE_TTL_MS) {
|
||||||
|
clearActiveRunSnapshot(threadId)
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const runRes = await agentApi.getAgentRun(snapshot.run_id)
|
||||||
|
const run = runRes?.run
|
||||||
|
if (run && !RUN_TERMINAL_STATUSES.has(run.status)) {
|
||||||
|
await startRunStream(threadId, run.id, snapshot.last_seq || '0')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
clearActiveRunSnapshot(threadId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const active = await agentApi.getThreadActiveRun(threadId)
|
||||||
|
const run = active?.run
|
||||||
|
if (run && !RUN_TERMINAL_STATUSES.has(run.status)) {
|
||||||
|
await startRunStream(threadId, run.id, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to load active run for thread:', threadId, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
ts.activeRunId = null
|
||||||
|
ts.runLastSeq = '0'
|
||||||
|
ts.isStreaming = false
|
||||||
|
clearActiveRunSnapshot(threadId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startRunStream,
|
||||||
|
resumeActiveRunForThread,
|
||||||
|
stopRunStreamSubscription
|
||||||
|
}
|
||||||
|
}
|
||||||
82
web/src/composables/useAgentThreadState.js
Normal file
82
web/src/composables/useAgentThreadState.js
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
const createOnGoingConvState = () => ({
|
||||||
|
msgChunks: {},
|
||||||
|
currentRequestKey: null,
|
||||||
|
currentAssistantKey: null,
|
||||||
|
toolCallBuffers: {}
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useAgentThreadState({ chatState, getCurrentThreadId }) {
|
||||||
|
const getThreadState = (threadId) => {
|
||||||
|
if (!threadId) return null
|
||||||
|
if (!chatState.threadStates[threadId]) {
|
||||||
|
chatState.threadStates[threadId] = {
|
||||||
|
isStreaming: false,
|
||||||
|
streamAbortController: null,
|
||||||
|
runStreamAbortController: null,
|
||||||
|
activeRunId: null,
|
||||||
|
runLastSeq: '0',
|
||||||
|
lastRetryableJobTry: null,
|
||||||
|
onGoingConv: createOnGoingConvState(),
|
||||||
|
agentState: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chatState.threadStates[threadId]
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopThreadStream = (threadId) => {
|
||||||
|
if (!threadId) return
|
||||||
|
const threadState = chatState.threadStates[threadId]
|
||||||
|
if (!threadState?.streamAbortController) return
|
||||||
|
|
||||||
|
threadState.streamAbortController.abort()
|
||||||
|
threadState.streamAbortController = null
|
||||||
|
threadState.isStreaming = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanupThreadState = (threadId) => {
|
||||||
|
if (!threadId) return
|
||||||
|
const threadState = chatState.threadStates[threadId]
|
||||||
|
if (!threadState) return
|
||||||
|
|
||||||
|
if (threadState.streamAbortController) {
|
||||||
|
threadState.streamAbortController.abort()
|
||||||
|
}
|
||||||
|
if (threadState.runStreamAbortController) {
|
||||||
|
threadState.runStreamAbortController.abort()
|
||||||
|
}
|
||||||
|
delete chatState.threadStates[threadId]
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetOnGoingConv = (threadId = null) => {
|
||||||
|
const targetThreadId =
|
||||||
|
threadId || (typeof getCurrentThreadId === 'function' ? getCurrentThreadId() : null)
|
||||||
|
|
||||||
|
if (targetThreadId) {
|
||||||
|
const threadState = getThreadState(targetThreadId)
|
||||||
|
if (!threadState) return
|
||||||
|
|
||||||
|
if (threadState.streamAbortController) {
|
||||||
|
threadState.streamAbortController.abort()
|
||||||
|
threadState.streamAbortController = null
|
||||||
|
}
|
||||||
|
if (threadState.runStreamAbortController) {
|
||||||
|
threadState.runStreamAbortController.abort()
|
||||||
|
threadState.runStreamAbortController = null
|
||||||
|
}
|
||||||
|
|
||||||
|
threadState.onGoingConv = createOnGoingConvState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.keys(chatState.threadStates).forEach((id) => {
|
||||||
|
cleanupThreadState(id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getThreadState,
|
||||||
|
cleanupThreadState,
|
||||||
|
resetOnGoingConv,
|
||||||
|
stopThreadStream
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,7 +6,6 @@
|
|||||||
<AgentChatComponent
|
<AgentChatComponent
|
||||||
ref="chatComponentRef"
|
ref="chatComponentRef"
|
||||||
:single-mode="false"
|
:single-mode="false"
|
||||||
@close-config-sidebar="() => (chatUIStore.isConfigSidebarOpen = false)"
|
|
||||||
>
|
>
|
||||||
<template #input-actions-left>
|
<template #input-actions-left>
|
||||||
<button
|
<button
|
||||||
@ -266,34 +265,6 @@ const handleFeedback = () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-agent-selected {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background-color: var(--bg-content);
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-agent-content {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
|
|
||||||
svg {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// .content {
|
|
||||||
// border-radius: var(--gap-radius);
|
|
||||||
// border: 1px solid var(--gray-300);
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
@ -301,408 +272,6 @@ const handleFeedback = () => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 配置弹窗内容样式
|
|
||||||
.conf-content {
|
|
||||||
max-height: 70vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
|
|
||||||
.agent-info {
|
|
||||||
padding: 0;
|
|
||||||
width: 100%;
|
|
||||||
overflow-y: visible;
|
|
||||||
max-height: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-model {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.config-modal-content {
|
|
||||||
user-select: text;
|
|
||||||
|
|
||||||
div[role='alert'] {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--gray-700);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: 20px;
|
|
||||||
gap: 10px;
|
|
||||||
|
|
||||||
.form-actions-left,
|
|
||||||
.form-actions-right {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加新按钮的样式
|
|
||||||
.agent-action-buttons {
|
|
||||||
margin-top: 16px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-button {
|
|
||||||
background-color: var(--gray-0);
|
|
||||||
border: 1px solid var(--main-20);
|
|
||||||
text-align: left;
|
|
||||||
height: auto;
|
|
||||||
padding: 8px 12px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--main-20);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.primary-action {
|
|
||||||
color: var(--main-color);
|
|
||||||
border-color: var(--main-color);
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
color: var(--main-color);
|
|
||||||
background-color: var(--main-20);
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.anticon {
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-option {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
.agent-option-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-option-description {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--gray-700);
|
|
||||||
word-break: break-word;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 工具选择器样式(与项目风格一致)
|
|
||||||
.tools-selector {
|
|
||||||
.tools-summary {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
// margin-bottom: 8px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: var(--gray-50);
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid var(--gray-200);
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--gray-700);
|
|
||||||
transition: border-color 0.2s ease;
|
|
||||||
|
|
||||||
.tools-summary-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
.tools-count {
|
|
||||||
color: var(--gray-900);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-tools-btn {
|
|
||||||
background: var(--main-color);
|
|
||||||
border: none;
|
|
||||||
color: var(--gray-0);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 4px 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
height: 28px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--main-color);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-tools-preview {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 8px 0;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
min-height: 32px;
|
|
||||||
:deep(.ant-tag) {
|
|
||||||
margin: 0;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--gray-100);
|
|
||||||
border: 1px solid var(--gray-300);
|
|
||||||
color: var(--gray-900);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 400;
|
|
||||||
.anticon-close {
|
|
||||||
color: var(--gray-600);
|
|
||||||
margin-left: 4px;
|
|
||||||
&:hover {
|
|
||||||
color: var(--gray-900);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 工具选择弹窗样式(与项目风格一致)
|
|
||||||
.tools-modal {
|
|
||||||
:deep(.ant-modal-content) {
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
:deep(.ant-modal-header) {
|
|
||||||
background: var(--gray-0);
|
|
||||||
border-bottom: 1px solid var(--gray-200);
|
|
||||||
padding: 16px 20px;
|
|
||||||
.ant-modal-title {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--gray-900);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
:deep(.ant-modal-body) {
|
|
||||||
padding: 20px;
|
|
||||||
background: var(--gray-0);
|
|
||||||
}
|
|
||||||
.tools-modal-content {
|
|
||||||
.tools-search {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
:deep(.ant-input) {
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid var(--gray-300);
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-size: 14px;
|
|
||||||
&:focus {
|
|
||||||
border-color: var(--main-color);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.tools-list {
|
|
||||||
max-height: 350px;
|
|
||||||
overflow-y: auto;
|
|
||||||
border: 1px solid var(--gray-200);
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
background: var(--gray-0);
|
|
||||||
.tool-item {
|
|
||||||
padding: 14px 16px;
|
|
||||||
border-bottom: 1px solid var(--gray-100);
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
background 0.2s,
|
|
||||||
border 0.2s;
|
|
||||||
border-left: 3px solid transparent;
|
|
||||||
&:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
&:hover {
|
|
||||||
background: var(--gray-50);
|
|
||||||
}
|
|
||||||
&.selected {
|
|
||||||
background: var(--main-10);
|
|
||||||
border-left: 3px solid var(--main-color);
|
|
||||||
}
|
|
||||||
.tool-content {
|
|
||||||
.tool-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
.tool-name {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--gray-900);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.tool-indicator {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.tool-description {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--gray-700);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
line-height: 1.5;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.tools-modal-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 12px 0 0 0;
|
|
||||||
border-top: 1px solid var(--gray-200);
|
|
||||||
.selected-count {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--gray-700);
|
|
||||||
background: none;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
.modal-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
:deep(.ant-btn) {
|
|
||||||
border-radius: 8px;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 6px 18px;
|
|
||||||
height: 36px;
|
|
||||||
font-size: 14px;
|
|
||||||
&.ant-btn-default {
|
|
||||||
border: 1px solid var(--gray-300);
|
|
||||||
color: var(--gray-900);
|
|
||||||
background: var(--gray-0);
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--main-color);
|
|
||||||
color: var(--main-color);
|
|
||||||
background: var(--main-10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&.ant-btn-primary {
|
|
||||||
background: var(--main-color);
|
|
||||||
border: none;
|
|
||||||
color: var(--gray-0);
|
|
||||||
&:hover {
|
|
||||||
background: var(--main-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 多选卡片样式
|
|
||||||
.multi-select-cards {
|
|
||||||
.multi-select-label {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--gray-600);
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.options-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-card {
|
|
||||||
border: 1px solid var(--gray-300);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
background: var(--gray-0);
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--main-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.selected {
|
|
||||||
border-color: var(--main-color);
|
|
||||||
background: var(--main-10);
|
|
||||||
|
|
||||||
.option-indicator {
|
|
||||||
color: var(--main-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-text {
|
|
||||||
color: var(--main-color);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.unselected {
|
|
||||||
.option-indicator {
|
|
||||||
color: var(--gray-400);
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-text {
|
|
||||||
color: var(--gray-700);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-content {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-text {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.4;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-indicator {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 16px;
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 响应式适配
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.multi-select-cards {
|
|
||||||
.options-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.conf-content {
|
|
||||||
max-height: 60vh;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 自定义更多菜单样式
|
// 自定义更多菜单样式
|
||||||
.more-popup-menu {
|
.more-popup-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@ -802,60 +371,3 @@ const handleFeedback = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style lang="less">
|
|
||||||
.toggle-conf {
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&.nav-btn {
|
|
||||||
height: 2.5rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: var(--gray-900);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 15px;
|
|
||||||
width: auto;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
transition: background-color 0.3s;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.text {
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--main-20);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn-icon {
|
|
||||||
width: 1.5rem;
|
|
||||||
height: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 针对 Ant Design Select 组件的深度样式修复
|
|
||||||
:deep(.ant-select-item-option-content) {
|
|
||||||
.agent-option-name {
|
|
||||||
color: var(--main-color);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 菜单项布局样式
|
|
||||||
.menu-item-layout {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-item-full {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user