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-header">
|
||||
<div class="header__left">
|
||||
<slot name="header-left" class="nav-btn"></slot>
|
||||
<slot name="header-left"></slot>
|
||||
<div
|
||||
v-if="!chatUIStore.isSidebarOpen && !userStore.isAdmin"
|
||||
type="button"
|
||||
@ -72,8 +72,8 @@
|
||||
|
||||
<div class="chat-content-container">
|
||||
<!-- Main Chat Area -->
|
||||
<div class="chat-main" ref="chatMainContainer">
|
||||
<div class="chat-box" ref="messagesContainer">
|
||||
<div class="chat-main">
|
||||
<div class="chat-box">
|
||||
<div class="conv-box" v-for="(conv, index) in conversations" :key="index">
|
||||
<AgentMessageComponent
|
||||
v-for="(message, msgIndex) in conv.messages"
|
||||
@ -140,21 +140,17 @@
|
||||
</div>
|
||||
|
||||
<AgentInputArea
|
||||
ref="messageInputRef"
|
||||
v-model="userInput"
|
||||
:is-loading="isProcessing"
|
||||
:disabled="!currentAgent"
|
||||
:send-button-disabled="(!userInput || !currentAgent) && !isProcessing"
|
||||
placeholder="输入问题..."
|
||||
:mention="mentionConfig"
|
||||
:supports-file-upload="supportsFileUpload"
|
||||
:agent-id="currentAgentId"
|
||||
:thread-id="currentChatId"
|
||||
:ensure-thread="ensureActiveThread"
|
||||
:has-state-content="hasAgentStateContent"
|
||||
:is-panel-open="isAgentPanelOpen"
|
||||
:mention="mentionConfig"
|
||||
@send="handleSendOrStop"
|
||||
@attachment-changed="handleAgentStateRefresh"
|
||||
@upload-attachment="handleAttachmentUpload"
|
||||
@toggle-panel="toggleAgentPanel"
|
||||
>
|
||||
<template #actions-left-extra>
|
||||
@ -235,7 +231,10 @@ import { MessageProcessor } from '@/utils/messageProcessor'
|
||||
import { agentApi, threadApi } from '@/apis'
|
||||
import HumanApprovalModal from '@/components/HumanApprovalModal.vue'
|
||||
import { useApproval } from '@/composables/useApproval'
|
||||
import { useAgentThreadState } from '@/composables/useAgentThreadState'
|
||||
import { useAgentRunStream } from '@/composables/useAgentRunStream'
|
||||
import { useAgentStreamHandler } from '@/composables/useAgentStreamHandler'
|
||||
import { useAgentMentionConfig } from '@/composables/useAgentMentionConfig'
|
||||
import AgentPanel from '@/components/AgentPanel.vue'
|
||||
import UserInfoComponent from '@/components/UserInfoComponent.vue'
|
||||
|
||||
@ -270,16 +269,6 @@ const useRunsApi =
|
||||
import.meta.env.VITE_USE_RUNS_API === '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 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({
|
||||
currentThreadId: null,
|
||||
// 以threadId为键的线程状态
|
||||
threadStates: {}
|
||||
})
|
||||
const { getThreadState, resetOnGoingConv, stopThreadStream } = useAgentThreadState({
|
||||
chatState,
|
||||
getCurrentThreadId: () => chatState.currentThreadId
|
||||
})
|
||||
|
||||
// 组件级别的线程和消息状态
|
||||
const threads = ref([])
|
||||
@ -383,14 +368,18 @@ const supportsFiles = computed(() => {
|
||||
return capabilities.includes('files')
|
||||
})
|
||||
|
||||
const currentCapabilities = computed(() => {
|
||||
return currentAgent.value?.capabilities || []
|
||||
})
|
||||
|
||||
// AgentState 相关计算属性
|
||||
const currentAgentState = computed(() => {
|
||||
return currentChatId.value ? getThreadState(currentChatId.value)?.agentState || null : null
|
||||
})
|
||||
const { mentionConfig } = useAgentMentionConfig({
|
||||
currentAgentState,
|
||||
configurableItems,
|
||||
agentConfig,
|
||||
availableKnowledgeBases,
|
||||
availableMcps,
|
||||
availableSkills
|
||||
})
|
||||
|
||||
const countFiles = (files) => {
|
||||
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] || [])
|
||||
|
||||
// 计算是否显示Refs组件的条件
|
||||
@ -568,12 +490,10 @@ const isStreaming = computed(() => {
|
||||
const isProcessing = computed(() => isStreaming.value)
|
||||
|
||||
// ==================== SCROLL & RESIZE HANDLING ====================
|
||||
// Update scroll controller to target .chat-main
|
||||
const scrollController = new ScrollController('.chat-main')
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
// Update event listener to target .chat-main
|
||||
const chatMainContainer = document.querySelector('.chat-main')
|
||||
if (chatMainContainer) {
|
||||
chatMainContainer.addEventListener('scroll', scrollController.handleScroll, { passive: true })
|
||||
@ -585,78 +505,11 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopTypingRenderLoop()
|
||||
typingChunkQueue.length = 0
|
||||
pendingTypingChars = 0
|
||||
scrollController.cleanup()
|
||||
// 清理所有线程状态
|
||||
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) => {
|
||||
@ -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 = '新的对话') => {
|
||||
if (currentChatId.value) return currentChatId.value
|
||||
try {
|
||||
@ -1290,6 +693,40 @@ const ensureActiveThread = async (title = '新的对话') => {
|
||||
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({
|
||||
getThreadState,
|
||||
@ -1304,6 +741,17 @@ const { handleAgentResponse, handleStreamChunk } = useAgentStreamHandler({
|
||||
supportsTodo,
|
||||
supportsFiles
|
||||
})
|
||||
const { startRunStream, resumeActiveRunForThread, stopRunStreamSubscription } = useAgentRunStream({
|
||||
getThreadState,
|
||||
useRunsApi,
|
||||
currentAgentId,
|
||||
handleStreamChunk,
|
||||
processApprovalInStream,
|
||||
fetchThreadMessages,
|
||||
fetchAgentState,
|
||||
resetOnGoingConv,
|
||||
onScrollToBottom: () => scrollController.scrollToBottom()
|
||||
})
|
||||
|
||||
// 发送消息并处理流式响应
|
||||
const sendMessage = async ({
|
||||
@ -1392,14 +840,7 @@ const createNewChat = async () => {
|
||||
if (newThread) {
|
||||
// 中断之前线程的流式输出(如果存在)
|
||||
const previousThreadId = chatState.currentThreadId
|
||||
if (previousThreadId) {
|
||||
const previousThreadState = getThreadState(previousThreadId)
|
||||
if (previousThreadState?.isStreaming && previousThreadState.streamAbortController) {
|
||||
previousThreadState.streamAbortController.abort()
|
||||
previousThreadState.isStreaming = false
|
||||
previousThreadState.streamAbortController = null
|
||||
}
|
||||
}
|
||||
stopThreadStream(previousThreadId)
|
||||
|
||||
chatState.currentThreadId = newThread.id
|
||||
}
|
||||
@ -1425,12 +866,7 @@ const selectChat = async (chatId) => {
|
||||
|
||||
// 中断之前线程的流式输出(如果存在)
|
||||
if (previousThreadId && previousThreadId !== chatId) {
|
||||
const previousThreadState = getThreadState(previousThreadId)
|
||||
if (previousThreadState?.isStreaming && previousThreadState.streamAbortController) {
|
||||
previousThreadState.streamAbortController.abort()
|
||||
previousThreadState.isStreaming = false
|
||||
previousThreadState.streamAbortController = null
|
||||
}
|
||||
stopThreadStream(previousThreadId)
|
||||
// run 模式下仅断开 SSE 订阅,不取消后台运行任务
|
||||
stopRunStreamSubscription(previousThreadId)
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<MessageInputComponent
|
||||
ref="inputRef"
|
||||
:key="inputKey"
|
||||
:model-value="modelValue"
|
||||
@update:modelValue="updateValue"
|
||||
:is-loading="isLoading"
|
||||
@ -53,96 +52,42 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ref } from 'vue'
|
||||
import MessageInputComponent from '@/components/MessageInputComponent.vue'
|
||||
import ImagePreviewComponent from '@/components/ImagePreviewComponent.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'
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
sendButtonDisabled: { type: Boolean, default: false },
|
||||
placeholder: { type: String, default: '输入问题...' },
|
||||
mention: { type: Object, default: () => null },
|
||||
supportsFileUpload: { type: Boolean, default: false },
|
||||
agentId: { type: String, default: '' },
|
||||
threadId: { type: String, default: null },
|
||||
ensureThread: { type: Function, required: true },
|
||||
hasStateContent: { type: Boolean, default: false },
|
||||
isPanelOpen: { type: Boolean, default: false },
|
||||
mention: { type: Object, default: () => null }
|
||||
isPanelOpen: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:modelValue',
|
||||
'send',
|
||||
'keydown',
|
||||
'attachment-changed',
|
||||
'upload-attachment',
|
||||
'toggle-panel'
|
||||
])
|
||||
|
||||
const inputRef = 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) => {
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
|
||||
const handleAttachmentUpload = async (files) => {
|
||||
const handleAttachmentUpload = (files) => {
|
||||
if (!files?.length) return
|
||||
if (!AgentValidator.validateAgentIdWithError(props.agentId, '上传附件', handleValidationError))
|
||||
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')
|
||||
}
|
||||
emit('upload-attachment', files)
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<!-- 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>
|
||||
@ -129,7 +142,6 @@ import { ref, computed, onMounted, nextTick, watch, onBeforeUnmount, useSlots }
|
||||
import {
|
||||
SendOutlined,
|
||||
ArrowUpOutlined,
|
||||
LoadingOutlined,
|
||||
PauseOutlined,
|
||||
PlusOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
@ -192,12 +204,13 @@ const slots = useSlots()
|
||||
// @ 提及功能是否启用
|
||||
const mentionEnabled = computed(() => {
|
||||
if (!props.mention) return false
|
||||
const { files, knowledgeBases, mcps, skills } = props.mention
|
||||
const { files, knowledgeBases, mcps, skills, subagents } = props.mention
|
||||
return (
|
||||
(Array.isArray(files) && files.length > 0) ||
|
||||
(Array.isArray(knowledgeBases) && knowledgeBases.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',
|
||||
knowledge: 'knowledge',
|
||||
mcp: 'mcp',
|
||||
skill: 'skill'
|
||||
skill: 'skill',
|
||||
subagent: 'subagent'
|
||||
}
|
||||
|
||||
const formatMentionToken = (type, value) => {
|
||||
@ -215,27 +229,18 @@ const formatMentionToken = (type, value) => {
|
||||
|
||||
// 检测是否在 @ 触发位置
|
||||
const checkMentionTrigger = (textarea) => {
|
||||
console.log(
|
||||
'[Mention] checkMentionTrigger called, textarea:',
|
||||
!!textarea,
|
||||
'mentionEnabled:',
|
||||
mentionEnabled.value
|
||||
)
|
||||
if (!textarea || !mentionEnabled.value) return false
|
||||
|
||||
const cursorPos = textarea.selectionStart
|
||||
const textBeforeCursor = inputValue.value.slice(0, cursorPos)
|
||||
console.log('[Mention] textBeforeCursor:', JSON.stringify(textBeforeCursor))
|
||||
|
||||
// 检查是否以 @ 结尾(刚输入 @)或 @ 后有内容
|
||||
const atMatch = textBeforeCursor.match(/@(\S*)$/)
|
||||
console.log('[Mention] atMatch:', atMatch)
|
||||
if (atMatch) {
|
||||
mentionQuery.value = atMatch[1]
|
||||
mentionPopupVisible.value = true
|
||||
mentionSelectedIndex.value = 0
|
||||
updateMentionItems(mentionQuery.value)
|
||||
console.log('[Mention] popup should be visible now')
|
||||
return true
|
||||
}
|
||||
|
||||
@ -246,12 +251,12 @@ const checkMentionTrigger = (textarea) => {
|
||||
// 更新提及候选项
|
||||
const updateMentionItems = (query = '') => {
|
||||
if (!props.mention) {
|
||||
mentionItems.value = { files: [], knowledgeBases: [], mcps: [], skills: [] }
|
||||
mentionItems.value = { files: [], knowledgeBases: [], mcps: [], skills: [], subagents: [] }
|
||||
return
|
||||
}
|
||||
|
||||
const lowerQuery = query.toLowerCase()
|
||||
const { files = [], knowledgeBases = [], mcps = [], skills = [] } = props.mention
|
||||
const { files = [], knowledgeBases = [], mcps = [], skills = [], subagents = [] } = props.mention
|
||||
|
||||
const filterItems = (list) =>
|
||||
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 = {
|
||||
files: filterItems(fileItems),
|
||||
knowledgeBases: filterItems(knowledgeItems),
|
||||
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 kbLen = mentionItems.value.knowledgeBases.length
|
||||
const mcpLen = mentionItems.value.mcps.length
|
||||
const skillsLen = mentionItems.value.skills.length
|
||||
|
||||
if (type === 'file') {
|
||||
return mentionSelectedIndex.value === index
|
||||
@ -341,8 +361,10 @@ const isItemSelected = (type, index) => {
|
||||
return mentionSelectedIndex.value === filesLen + index
|
||||
} else if (type === 'mcp') {
|
||||
return mentionSelectedIndex.value === filesLen + kbLen + index
|
||||
} else {
|
||||
} else if (type === 'skill') {
|
||||
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.knowledgeBases.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.knowledgeBases,
|
||||
...mentionItems.value.mcps,
|
||||
...mentionItems.value.skills
|
||||
...mentionItems.value.skills,
|
||||
...mentionItems.value.subagents
|
||||
]
|
||||
|
||||
const total = allItems.length
|
||||
@ -465,15 +489,6 @@ const hasOptionsLeft = computed(() => {
|
||||
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 = {
|
||||
SendOutlined: SendOutlined,
|
||||
@ -511,12 +526,6 @@ const handleKeyPress = (e) => {
|
||||
// 检测 @ 触发
|
||||
const handleKeyUp = (e) => {
|
||||
if (e.key === '@' && mentionEnabled.value) {
|
||||
console.log(
|
||||
'[Mention] @ detected, mentionEnabled:',
|
||||
mentionEnabled.value,
|
||||
'mention:',
|
||||
props.mention
|
||||
)
|
||||
nextTick(() => {
|
||||
checkMentionTrigger(e.target)
|
||||
})
|
||||
@ -533,7 +542,6 @@ const handleInput = (e) => {
|
||||
const cursorPos = e.target.selectionStart
|
||||
const textBeforeCursor = value.slice(0, cursorPos)
|
||||
if (textBeforeCursor.endsWith('@')) {
|
||||
console.log('[Mention] @ detected via input event')
|
||||
checkMentionTrigger(e.target)
|
||||
}
|
||||
}
|
||||
@ -554,7 +562,7 @@ const handleSendOrStop = () => {
|
||||
// @ 提及功能状态
|
||||
const mentionPopupVisible = ref(false)
|
||||
const mentionQuery = ref('')
|
||||
const mentionItems = ref({ files: [], knowledgeBases: [], mcps: [], skills: [] })
|
||||
const mentionItems = ref({ files: [], knowledgeBases: [], mcps: [], skills: [], subagents: [] })
|
||||
const mentionSelectedIndex = ref(0)
|
||||
|
||||
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
|
||||
ref="chatComponentRef"
|
||||
:single-mode="false"
|
||||
@close-config-sidebar="() => (chatUIStore.isConfigSidebarOpen = false)"
|
||||
>
|
||||
<template #input-actions-left>
|
||||
<button
|
||||
@ -266,34 +265,6 @@ const handleFeedback = () => {
|
||||
display: flex;
|
||||
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 {
|
||||
@ -301,408 +272,6 @@ const handleFeedback = () => {
|
||||
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 {
|
||||
position: fixed;
|
||||
@ -802,60 +371,3 @@ const handleFeedback = () => {
|
||||
}
|
||||
}
|
||||
</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