refactor: 移除 AttachmentInputPanel 组件并在 MessageInputComponent 中集成 subagent mention 功能

- 删除了 AttachmentInputPanel.vue 组件。
- 增强了 MessageInputComponent.vue,新增了一个用于子代理的提及组。
- 更新了提及逻辑以在提及功能中支持子代理。
- 引入 useAgentMentionConfig.js 来管理代理提及配置。
- 添加 useAgentRunStream.js 用于处理代理运行流逻辑。
- 创建 useAgentThreadState.js 来管理线程状态和正在进行的对话。
- 清理了 AgentView.vue,移除了未使用的样式和配置。
This commit is contained in:
Wenjie Zhang 2026-03-20 17:51:12 +08:00
parent 78f2ae2a03
commit 772ab53d1a
8 changed files with 656 additions and 1383 deletions

View File

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

View File

@ -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) => {

View File

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

View File

@ -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 = () => {

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

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

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

View File

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