ForcePilot/web/src/components/AgentChatComponent.vue

2426 lines
66 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="chat-container">
<div class="chat">
<div class="chat-header">
<div class="header__left">
<slot name="header-left"></slot>
<div
v-if="currentThread?.title && currentThread.title !== '新的对话'"
class="conversation-title"
>
{{ currentThread.title }}
</div>
</div>
<div class="header__right">
<slot
name="header-right"
:is-agent-panel-open="isAgentPanelOpen"
:has-active-thread="!!currentChatId"
:toggle-agent-panel="toggleAgentPanel"
></slot>
</div>
</div>
<div class="chat-content-container">
<!-- Main Chat Area -->
<div class="chat-main" ref="chatMainRef">
<div class="chat-box">
<template v-for="row in conversationRows" :key="row.key">
<div v-if="row.type === 'conversation'" class="conv-box">
<template
v-for="(displayItem, itemIndex) in getConversationDisplayItems(row.conv)"
:key="displayItem.key"
>
<AgentMessageComponent
v-if="displayItem.type === 'message'"
:message="displayItem.message"
:is-processing="isDisplayMessageProcessing(row.conv, displayItem)"
:show-refs="showMsgRefs(displayItem.message)"
:hide-tool-calls="true"
@retry="retryMessage(displayItem.message)"
>
</AgentMessageComponent>
<ToolCallsGroupComponent
v-else
:tool-calls="displayItem.toolCalls"
:is-active="
isToolGroupActive(row.conv, itemIndex, getConversationDisplayItems(row.conv))
"
/>
</template>
<!-- 显示对话最后一个消息使用的模型 -->
<RefsComponent
v-if="shouldShowRefs(row.conv)"
:message="getLastMessage(row.conv)"
:show-refs="['model', 'copy', 'sources']"
:is-latest-message="false"
:sources="getConversationSources(row.conv)"
/>
</div>
<div v-else class="chat-inline-notice">
<span>{{ row.notice.message }}</span>
</div>
</template>
<!-- 生成中的加载状态 - 增强条件支持主聊天和resume流程 -->
<div class="generating-status" v-if="isReplyLoading && conversations.length > 0">
<div class="generating-indicator">
<div class="loading-dots">
<div></div>
<div></div>
<div></div>
</div>
<span class="generating-text">正在生成回复...</span>
</div>
</div>
</div>
<div class="bottom" :class="{ 'start-screen': !conversations.length }">
<!-- 人工审批弹窗 - 放在输入框上方 -->
<HumanApprovalModal
:visible="approvalState.showModal"
:questions="approvalState.questions"
@submit="handleQuestionSubmit"
@cancel="handleQuestionCancel"
/>
<div class="message-input-wrapper">
<!-- 加载状态:加载消息 -->
<div v-if="isLoadingMessages" class="chat-loading">
<div class="loading-spinner"></div>
<span>正在加载消息...</span>
</div>
<!-- 打招呼区域 - 在输入框上方 -->
<div v-if="!conversations.length" class="chat-examples-input">
<h1>{{ randomGreeting }}</h1>
</div>
<div v-if="showStartAgentSegment" class="agent-segment-wrapper">
<a-segmented
:value="currentAgentId"
:options="agentSegmentOptions"
@change="handleStartAgentChange"
/>
</div>
<div v-else-if="showStartAgentDropdown" class="agent-switcher-wrapper">
<a-dropdown :trigger="['click']" placement="bottomCenter">
<button type="button" class="agent-switcher-btn">
<component :is="currentAgentIcon" size="16" class="agent-switcher-icon" />
<span class="agent-switcher-text">{{ currentAgentName }}</span>
<ChevronDown size="16" class="agent-switcher-chevron" />
</button>
<template #overlay>
<a-menu class="agent-switcher-menu">
<a-menu-item
v-for="agent in startAgents"
:key="agent.id"
@click="handleStartAgentChange(agent.id)"
>
<div class="agent-switcher-menu-item">
<component
:is="getAgentIconComponent(agent.id)"
size="16"
class="agent-switcher-menu-icon"
/>
<span class="agent-switcher-menu-text">{{
agent.name || 'Unknown'
}}</span>
<span
v-if="agent.id === currentAgentId"
class="agent-switcher-menu-badge"
>
当前
</span>
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<AgentArtifactsCard
:artifacts="currentArtifacts"
:thread-id="currentChatId"
:agent-id="currentThread?.agent_id || currentAgentId"
:agent-config-id="selectedAgentConfigId"
@saved="handleArtifactSaved"
/>
<AgentInputArea
v-model="userInput"
:is-loading="isProcessing"
:disabled="!currentAgent"
:send-button-disabled="isSendButtonDisabled"
:mention="mentionConfig"
:thread-id="currentChatId"
:supports-file-upload="supportsFileUpload"
:has-active-thread="!!currentChatId"
:todos="currentTodos"
@send="handleSendOrStop"
@upload-attachment="handleAttachmentUpload"
>
<template #actions-left-extra>
<slot name="input-actions-left"></slot>
</template>
</AgentInputArea>
<!-- 示例问题 -->
<div
class="example-questions"
v-if="!conversations.length && exampleQuestions.length > 0"
>
<div class="example-chips">
<div
v-for="question in exampleQuestions"
:key="question.id"
class="example-chip"
@click="handleExampleClick(question.text)"
>
{{ question.text }}
</div>
</div>
</div>
<div class="bottom-actions" v-if="conversations.length > 0">
<p class="note">当前智能体:{{ currentThreadAgentName }};请注意辨别内容的可靠性</p>
</div>
</div>
</div>
</div>
<!-- Agent Panel Area -->
<div
class="agent-panel-wrapper"
ref="panelWrapperRef"
:class="{
'is-visible': isAgentPanelOpen,
'no-transition': isResizing
}"
:style="{
flexBasis: isAgentPanelOpen ? `${panelRatio * 100}%` : '0px'
}"
>
<AgentPanel
v-if="isAgentPanelOpen"
:agent-state="currentAgentState"
:thread-id="currentChatId"
:agent-id="currentThread?.agent_id || currentAgentId"
:agent-config-id="selectedAgentConfigId"
:panel-ratio="panelRatio"
@refresh="handleAgentStateRefresh"
@resize="handlePanelResize"
@resizing="handleResizingChange"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {
ref,
reactive,
onMounted,
watch,
nextTick,
computed,
onUnmounted,
onActivated,
onDeactivated,
h
} from 'vue'
import { message } from 'ant-design-vue'
import AgentInputArea from '@/components/AgentInputArea.vue'
import AgentMessageComponent from '@/components/AgentMessageComponent.vue'
import RefsComponent from '@/components/RefsComponent.vue'
import ToolCallsGroupComponent from '@/components/ToolCallsGroupComponent.vue'
import { Bot, Telescope, ChevronDown } from 'lucide-vue-next'
import { handleChatError, handleValidationError } from '@/utils/errorHandler'
import { ScrollController } from '@/utils/scrollController'
import { AgentValidator } from '@/utils/agentValidator'
import { useAgentStore } from '@/stores/agent'
import { useChatThreadsStore } from '@/stores/chatThreads'
import { useChatUIStore } from '@/stores/chatUI'
import { useConfigStore } from '@/stores/config'
import { storeToRefs } from 'pinia'
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 { useStreamSmoother } from '@/composables/useStreamSmoother'
import { useAgentMentionConfig } from '@/composables/useAgentMentionConfig'
import AgentArtifactsCard from '@/components/AgentArtifactsCard.vue'
import AgentPanel from '@/components/AgentPanel.vue'
// ==================== PROPS & EMITS ====================
const props = defineProps({
agentId: { type: String, default: '' },
singleMode: { type: Boolean, default: true }
})
const emit = defineEmits(['thread-change'])
// ==================== STORE MANAGEMENT ====================
const agentStore = useAgentStore()
const chatThreadsStore = useChatThreadsStore()
const chatUIStore = useChatUIStore()
const configStore = useConfigStore()
const {
agents,
selectedAgentId,
defaultAgentId,
selectedAgentConfigId,
agentConfig,
configurableItems,
availableKnowledgeBases,
availableMcps,
availableSkills
} = storeToRefs(agentStore)
const { threads, currentThreadId, currentThread } = storeToRefs(chatThreadsStore)
// ==================== LOCAL CHAT & UI STATE ====================
const userInput = ref('')
const sendCooldownActive = ref(false)
let sendCooldownTimer = null
const useRunsApi =
import.meta.env.VITE_USE_RUNS_API === 'true' &&
localStorage.getItem('force_legacy_stream') !== 'true'
// 预设的打招呼文本
const greetingMessages = [
'👋 您好,有什么可以帮您?',
'👋 你好!有什么想聊的吗?',
'👋 嘿,有什么我可以帮助你的?',
'👋 欢迎!今天想讨论什么话题?',
'👋 你好呀,随时为你服务!'
]
// 随机选择一个打招呼文本
const randomGreeting = greetingMessages[Math.floor(Math.random() * greetingMessages.length)]
// 从智能体元数据获取示例问题
const exampleQuestions = computed(() => {
const agentId = currentAgentId.value
let examples = []
if (agentId && agents.value && agents.value.length > 0) {
const agent = agents.value.find((a) => a.id === agentId)
examples = agent ? agent.metadata?.examples || [] : []
}
return examples.map((text, index) => ({
id: index + 1,
text: text
}))
})
// 业务状态(保留在组件本地)
const chatState = reactive({
currentThreadId: null,
// 以threadId为键的线程状态
threadStates: {}
})
const setCurrentThreadId = (threadId) => {
chatState.currentThreadId = threadId || null
chatThreadsStore.setCurrentThreadId(threadId || null)
}
const streamSmoother = useStreamSmoother({
getThreadState: (threadId) => chatState.threadStates[threadId] || null
})
const { getThreadState, resetOnGoingConv, stopThreadStream } = useAgentThreadState({
chatState,
getCurrentThreadId: () => chatState.currentThreadId,
onStopThread: (threadId) => streamSmoother.flushThread(threadId),
onBeforeResetThread: (threadId) => streamSmoother.resetThread(threadId),
onBeforeCleanupThread: (threadId) => streamSmoother.resetThread(threadId)
})
// 组件级别的消息、附件与提示状态
const threadMessages = ref({})
const threadFilesMap = ref({})
const threadAttachmentsMap = ref({})
const threadConfigNoticeMap = ref({})
const threadPendingConfigNoticeMap = ref({})
const threadConfigSnapshotMap = ref({})
const configNoticeSyncDepth = ref(0)
const configNoticeScrollVersion = ref(0)
// 本地 UI 状态(仅在本组件使用)
const localUIState = reactive({
chatMainWidth: typeof window !== 'undefined' ? window.innerWidth : 0
})
// Agent Panel State
const isAgentPanelOpen = ref(false)
const isResizing = ref(false)
const panelRatio = ref(0.3) // 面板宽度比例 (0-1)
const panelWrapperRef = ref(null) // 直接操作 DOM
const minPanelRatio = 0.2 // 最小比例 20%
const maxPanelRatio = 0.8 // 最大比例 80%
let resizeStartX = 0
let resizeStartWidth = 0
let panelContainerWidth = 0
// ==================== COMPUTED PROPERTIES ====================
const currentAgentId = computed(() => {
if (props.singleMode) {
return props.agentId || defaultAgentId.value
} else {
return selectedAgentId.value
}
})
const currentAgentName = computed(() => {
const agent = currentAgent.value
return agent ? agent.name : '智能体'
})
const currentAgent = computed(() => {
if (!currentAgentId.value || !agents.value || !agents.value.length) return null
return agents.value.find((a) => a.id === currentAgentId.value) || null
})
const startAgents = computed(() => agents.value || [])
const currentChatId = computed(() => currentThreadId.value)
const currentThreadAgentName = computed(() => {
const threadAgentId = currentThread.value?.agent_id
if (threadAgentId && agents.value?.length) {
const threadAgent = agents.value.find((agent) => agent.id === threadAgentId)
if (threadAgent?.name) {
return threadAgent.name
}
}
return currentAgentName.value
})
const currentAgentIcon = computed(() => getAgentIconComponent(currentAgentId.value))
// 检查当前智能体是否支持文件上传
const supportsFileUpload = computed(() => {
if (!currentAgent.value) return false
const capabilities = currentAgent.value.capabilities || []
return capabilities.includes('file_upload')
})
const supportsFiles = computed(() => {
if (!currentAgent.value) return false
const capabilities = currentAgent.value.capabilities || []
return capabilities.includes('files')
})
// AgentState 相关计算属性
const currentAgentState = computed(() => {
return currentChatId.value ? getThreadState(currentChatId.value)?.agentState || null : null
})
const currentThreadFiles = computed(() => {
if (!currentChatId.value) return []
return threadFilesMap.value[currentChatId.value] || []
})
const currentThreadAttachments = computed(() => {
if (!currentChatId.value) return []
return threadAttachmentsMap.value[currentChatId.value] || []
})
const currentArtifacts = computed(() => {
const artifacts = currentAgentState.value?.artifacts
return Array.isArray(artifacts) ? artifacts : []
})
const currentTodos = computed(() => {
const todos = currentAgentState.value?.todos
return Array.isArray(todos) ? todos : []
})
const { mentionConfig } = useAgentMentionConfig({
currentAgentState,
currentThreadAttachments,
configurableItems,
agentConfig,
availableKnowledgeBases,
availableMcps,
availableSkills
})
const currentThreadMessages = computed(() => threadMessages.value[currentChatId.value] || [])
const currentThreadHasHistory = computed(() => currentThreadMessages.value.length > 0)
const currentThreadConfigNotice = computed(() => {
if (!currentChatId.value) return null
return threadConfigNoticeMap.value[currentChatId.value] || null
})
// 计算是否显示Refs组件的条件
const shouldShowRefs = computed(() => {
return (conv) => {
return (
getLastMessage(conv) &&
conv.status !== 'streaming' &&
!approvalState.showModal &&
!(
approvalState.threadId &&
chatState.currentThreadId === approvalState.threadId &&
isProcessing.value
)
)
}
})
// 当前线程状态的computed属性
const currentThreadState = computed(() => {
return getThreadState(currentChatId.value)
})
const onGoingConvMessages = computed(() => {
const threadState = currentThreadState.value
if (!threadState || !threadState.onGoingConv) return []
const msgs = Object.values(threadState.onGoingConv.msgChunks).map(
MessageProcessor.mergeMessageChunk
)
return msgs.length > 0
? MessageProcessor.convertToolResultToMessages(msgs).filter((msg) => msg.type !== 'tool')
: []
})
const historyConversations = computed(() => {
return MessageProcessor.convertServerHistoryToMessages(currentThreadMessages.value)
})
const conversations = computed(() => {
const historyConvs = historyConversations.value
const mergedOngoingMessages = stripDuplicatedOngoingHumanMessage(
historyConvs,
onGoingConvMessages.value
)
// 如果有进行中的消息且线程状态显示正在流式处理,添加进行中的对话
if (mergedOngoingMessages.length > 0) {
const onGoingConv = {
messages: mergedOngoingMessages,
status: 'streaming'
}
return [...historyConvs, onGoingConv]
}
return historyConvs
})
const conversationRows = computed(() => {
const rows = conversations.value.map((conv, index) => ({
type: 'conversation',
key: conv.status === 'streaming' ? 'ongoing-conversation' : `history-${index}`,
conv
}))
if (currentThreadConfigNotice.value) {
const insertAfterCount = Math.max(
0,
Math.min(
Number(currentThreadConfigNotice.value.insertAfterConversationCount) || 0,
rows.length
)
)
rows.splice(insertAfterCount, 0, {
type: 'notice',
key: currentThreadConfigNotice.value.id,
notice: currentThreadConfigNotice.value
})
}
return rows
})
// 智能体图标映射
const agentIconMap = {
ChatbotAgent: Bot,
DeepAgent: Telescope
}
const getAgentIconComponent = (agentId) => {
return agentIconMap[agentId] || Bot
}
const agentSegmentOptions = computed(() => {
return startAgents.value.map((agent) => {
const IconComponent = getAgentIconComponent(agent.id)
return {
label: () =>
h('div', { class: 'agent-option-label' }, [
h(IconComponent, { size: 16, class: 'agent-option-icon' }),
h('span', null, agent.name || 'Unknown')
]),
value: agent.id
}
})
})
const showStartAgentSelector = computed(() => {
return !props.singleMode && !conversations.value.length && startAgents.value.length > 1
})
const showStartAgentDropdown = computed(() => {
return (
showStartAgentSelector.value &&
(startAgents.value.length >= 4 || localUIState.chatMainWidth < 380)
)
})
const showStartAgentSegment = computed(() => {
return showStartAgentSelector.value && !showStartAgentDropdown.value
})
const handleStartAgentChange = async (agentId) => {
if (!agentId || agentId === currentAgentId.value) return
if (conversations.value.length > 0) return
try {
await agentStore.selectAgent(agentId)
} catch (error) {
handleChatError(error, 'load')
}
}
const isLoadingMessages = computed(() => chatUIStore.isLoadingMessages)
const isStreaming = computed(() => {
const threadState = currentThreadState.value
return threadState ? threadState.isStreaming : false
})
const isProcessing = computed(() => isStreaming.value)
const isReplyLoading = computed(() => {
const threadState = currentThreadState.value
return Boolean(threadState?.replyLoadingVisible)
})
const isSendButtonDisabled = computed(() => {
return (
sendCooldownActive.value || ((!userInput.value || !currentAgent.value) && !isProcessing.value)
)
})
const startSendCooldown = () => {
sendCooldownActive.value = true
if (sendCooldownTimer) {
clearTimeout(sendCooldownTimer)
}
sendCooldownTimer = setTimeout(() => {
sendCooldownActive.value = false
sendCooldownTimer = null
}, 2000)
}
const createClientRequestId = () => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `req-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
const buildOptimisticHumanMessage = ({ requestId, text, imageContent = null }) => {
const message = {
id: requestId,
role: 'user',
type: 'human',
content: text,
message_type: imageContent ? 'multimodal_image' : 'text',
extra_metadata: {
request_id: requestId
}
}
if (imageContent) {
message.image_content = imageContent
}
return message
}
const getMessageRequestId = (message) => {
if (!message || typeof message !== 'object') return null
const metadataRequestId = message.extra_metadata?.request_id
if (typeof metadataRequestId === 'string' && metadataRequestId.trim()) {
return metadataRequestId.trim()
}
if (message.type === 'human' && typeof message.id === 'string' && message.id.trim()) {
return message.id.trim()
}
return null
}
// 历史消息已落库时ongoing 里仍会保留当前轮的本地 user message
// 切回线程后按 request_id 去掉这条重复消息,只保留仍在流式更新的部分。
const stripDuplicatedOngoingHumanMessage = (historyConvs, ongoingMessages) => {
if (!Array.isArray(historyConvs) || !historyConvs.length || !Array.isArray(ongoingMessages)) {
return ongoingMessages
}
const firstOngoingMessage = ongoingMessages[0]
if (!firstOngoingMessage || firstOngoingMessage.type !== 'human') {
return ongoingMessages
}
const lastHistoryConv = historyConvs[historyConvs.length - 1]
const historyMessages = Array.isArray(lastHistoryConv?.messages) ? lastHistoryConv.messages : []
const lastHistoryHuman = historyMessages.find((message) => message?.type === 'human')
if (!lastHistoryHuman) {
return ongoingMessages
}
const historyRequestId = getMessageRequestId(lastHistoryHuman)
const ongoingRequestId = getMessageRequestId(firstOngoingMessage)
if (!historyRequestId || !ongoingRequestId || historyRequestId !== ongoingRequestId) {
return ongoingMessages
}
return ongoingMessages.slice(1)
}
// 发送 runs 前先在前端插入一条用户消息,避免等待 worker 轮询后消息才出现。
const insertOptimisticHumanMessage = (threadState, { requestId, text, imageContent = null }) => {
if (!threadState || !requestId) return
threadState.pendingRequestId = requestId
threadState.replyLoadingVisible = false
threadState.onGoingConv.msgChunks[requestId] = [
buildOptimisticHumanMessage({ requestId, text, imageContent })
]
}
const CONFIG_CHANGE_NOTICE_MESSAGE =
'在运行过程中切换或修改配置可能会影响最终效果,建议新建一个对话。'
const withConfigNoticeSync = async (task) => {
configNoticeSyncDepth.value += 1
try {
return await task()
} finally {
configNoticeSyncDepth.value = Math.max(0, configNoticeSyncDepth.value - 1)
}
}
const buildThreadConfigSnapshot = () => {
return {
agentId: currentAgentId.value || '',
agentConfigId: selectedAgentConfigId.value ?? null,
configJson: JSON.stringify(agentConfig.value || {})
}
}
const syncThreadConfigSnapshot = (threadId, options = {}) => {
if (!threadId) return
const { overwrite = true } = options
if (!overwrite && threadConfigSnapshotMap.value[threadId]) return
if (threadPendingConfigNoticeMap.value[threadId]) return
// 线程切换时先记录当前 UI 的配置快照,避免同步 thread 绑定配置时误报。
threadConfigSnapshotMap.value = {
...threadConfigSnapshotMap.value,
[threadId]: buildThreadConfigSnapshot()
}
}
const upsertThreadConfigNotice = (threadId, insertAfterConversationCount) => {
if (!threadId) return
const existingNotice = threadConfigNoticeMap.value[threadId]
const nextNotice = {
id: existingNotice?.id || `config-change-notice-${threadId}`,
message: existingNotice?.message || CONFIG_CHANGE_NOTICE_MESSAGE,
insertAfterConversationCount
}
const shouldScroll =
!existingNotice || existingNotice.insertAfterConversationCount !== insertAfterConversationCount
threadConfigNoticeMap.value = {
...threadConfigNoticeMap.value,
[threadId]: nextNotice
}
if (threadPendingConfigNoticeMap.value[threadId]) {
const nextPendingNotices = { ...threadPendingConfigNoticeMap.value }
delete nextPendingNotices[threadId]
threadPendingConfigNoticeMap.value = nextPendingNotices
}
if (shouldScroll) {
configNoticeScrollVersion.value += 1
}
}
const queuePendingThreadConfigNotice = (threadId) => {
if (!threadId) return
threadPendingConfigNoticeMap.value = {
...threadPendingConfigNoticeMap.value,
[threadId]: {
id: `config-change-notice-${threadId}`,
message: CONFIG_CHANGE_NOTICE_MESSAGE
}
}
}
const flushPendingThreadConfigNotice = (threadId) => {
if (
!threadId ||
!currentThreadHasHistory.value ||
!threadPendingConfigNoticeMap.value[threadId]
) {
return
}
upsertThreadConfigNotice(threadId, conversations.value.length)
}
const maybeInsertThreadConfigNotice = () => {
const threadId = currentChatId.value
if (!threadId || configNoticeSyncDepth.value > 0) {
return
}
const previousSnapshot = threadConfigSnapshotMap.value[threadId]
const currentSnapshot = buildThreadConfigSnapshot()
if (!previousSnapshot) {
threadConfigSnapshotMap.value = {
...threadConfigSnapshotMap.value,
[threadId]: currentSnapshot
}
return
}
if (
previousSnapshot.agentId === currentSnapshot.agentId &&
previousSnapshot.agentConfigId === currentSnapshot.agentConfigId &&
previousSnapshot.configJson === currentSnapshot.configJson
) {
return
}
if (currentThreadHasHistory.value) {
upsertThreadConfigNotice(threadId, conversations.value.length)
} else if (chatUIStore.isLoadingMessages) {
// 历史线程仍在加载时先挂起提示,避免消息返回后把变更误当成新的基线。
queuePendingThreadConfigNotice(threadId)
} else {
return
}
threadConfigSnapshotMap.value = {
...threadConfigSnapshotMap.value,
[threadId]: currentSnapshot
}
}
// ==================== SCROLL & RESIZE HANDLING ====================
const scrollController = new ScrollController('.chat-main')
const chatMainRef = ref(null)
let chatMainResizeObserver = null
// 初始化延迟标志,避免首次挂载时 ResizeObserver 立即触发导致侧边栏意外关闭
let isResizeObserverReady = false
let resizeObserverReadyTimer = null
const armResizeObserver = () => {
if (resizeObserverReadyTimer) {
clearTimeout(resizeObserverReadyTimer)
}
isResizeObserverReady = false
// keep-alive 切页回来时等布局稳定后再恢复宽度判断,避免隐藏态宽度污染侧边栏状态。
resizeObserverReadyTimer = setTimeout(() => {
isResizeObserverReady = true
}, 50)
}
const stopChatMainResizeObserver = () => {
if (resizeObserverReadyTimer) {
clearTimeout(resizeObserverReadyTimer)
resizeObserverReadyTimer = null
}
isResizeObserverReady = false
if (chatMainResizeObserver) {
chatMainResizeObserver.disconnect()
chatMainResizeObserver = null
}
}
const startChatMainResizeObserver = () => {
if (!window.ResizeObserver || !chatMainRef.value || chatMainResizeObserver) {
return
}
localUIState.chatMainWidth = chatMainRef.value.clientWidth || window.innerWidth
chatMainResizeObserver = new ResizeObserver((entries) => {
// 初始化期间跳过检查,等待 layout 稳定
if (!isResizeObserverReady) return
for (const entry of entries) {
const width = entry.contentRect.width
if (!width) continue
localUIState.chatMainWidth = width
}
})
chatMainResizeObserver.observe(chatMainRef.value)
armResizeObserver()
}
onMounted(() => {
nextTick(() => {
const chatMainContainer = document.querySelector('.chat-main')
if (chatMainContainer) {
chatMainContainer.addEventListener('scroll', scrollController.handleScroll, { passive: true })
}
startChatMainResizeObserver()
})
})
onActivated(() => {
nextTick(() => {
startChatMainResizeObserver()
})
})
onDeactivated(() => {
stopChatMainResizeObserver()
})
onUnmounted(() => {
scrollController.cleanup()
stopChatMainResizeObserver()
if (sendCooldownTimer) {
clearTimeout(sendCooldownTimer)
sendCooldownTimer = null
}
// 清理所有线程状态
resetOnGoingConv()
})
// ==================== 线程管理方法 ====================
const setThreadAgentConfigId = (threadId, agentConfigId) => {
if (!threadId) return
const thread = threads.value.find((item) => item.id === threadId)
if (thread) {
thread.metadata = {
...(thread.metadata || {}),
agent_config_id: agentConfigId ?? null
}
}
}
const syncSelectedConfigForThread = async (thread) => {
const threadAgentConfigId = thread?.metadata?.agent_config_id
if (!threadAgentConfigId) return
const targetAgentId = thread.agent_id || currentAgentId.value
if (!targetAgentId) return
const configList = agentStore.agentConfigs[targetAgentId] || []
if (!configList.length) {
await agentStore.fetchAgentConfigs(targetAgentId)
}
if (selectedAgentConfigId.value !== threadAgentConfigId) {
await agentStore.selectAgentConfig(threadAgentConfigId)
}
}
// 获取当前智能体的线程列表
const fetchThreads = async (agentId = null) => {
const targetAgentId = props.singleMode ? agentId || currentAgentId.value : agentId
if (props.singleMode && !targetAgentId) return
await chatThreadsStore.loadThreads(targetAgentId)
}
// 创建新线程
const createThread = async (agentId, title = '新的对话') => {
if (!agentId) return null
try {
const thread = await chatThreadsStore.createThread(agentId, title)
if (thread) {
threadMessages.value[thread.id] = []
threadFilesMap.value[thread.id] = []
threadAttachmentsMap.value[thread.id] = []
}
return thread
} catch (error) {
console.error('Failed to create thread:', error)
handleChatError(error, 'create')
throw error
}
}
// 获取线程消息
const fetchThreadMessages = async ({ agentId, threadId, delay = 0 }) => {
if (!threadId || !agentId) return
// 如果指定了延迟,等待指定时间(用于确保后端数据库事务提交)
if (delay > 0) {
await new Promise((resolve) => setTimeout(resolve, delay))
}
try {
const response = await agentApi.getAgentHistory(threadId)
threadMessages.value[threadId] = response.history || []
} catch (error) {
handleChatError(error, 'load')
throw error
}
}
const fetchThreadFiles = async (threadId) => {
if (!threadId) return
try {
const response = await threadApi.listThreadFiles(threadId, '/home/gem/user-data', false)
const entries = Array.isArray(response?.files) ? response.files : []
threadFilesMap.value[threadId] = entries
} catch (error) {
console.warn('Failed to fetch thread files:', error)
threadFilesMap.value[threadId] = []
}
}
const fetchThreadAttachments = async (threadId) => {
if (!threadId) return
try {
const response = await threadApi.getThreadAttachments(threadId)
threadAttachmentsMap.value[threadId] = Array.isArray(response?.attachments)
? response.attachments
: []
} catch (error) {
console.warn('Failed to fetch thread attachments:', error)
threadAttachmentsMap.value[threadId] = []
}
}
const refreshThreadFilesAndAttachments = async (threadId) => {
if (!threadId) return
await Promise.all([fetchThreadFiles(threadId), fetchThreadAttachments(threadId)])
}
const handleArtifactSaved = async () => {
if (!currentChatId.value) return
await refreshThreadFilesAndAttachments(currentChatId.value)
isAgentPanelOpen.value = true
}
const fetchAgentState = async (agentId, threadId) => {
if (!threadId) return
try {
const res = await agentApi.getAgentState(threadId)
const targetChatId = currentChatId.value || threadId
const ts = getThreadState(targetChatId)
if (ts) {
ts.agentState = res.agent_state || null
} else {
const newTs = getThreadState(threadId)
if (newTs) newTs.agentState = res.agent_state || null
}
} catch {
// 忽略状态拉取失败,不阻塞主流程
}
}
const ensureActiveThread = async (title = '新的对话') => {
if (currentChatId.value) return currentChatId.value
try {
const newThread = await createThread(currentAgentId.value, title || '新的对话')
if (newThread) {
setCurrentThreadId(newThread.id)
return newThread.id
}
} catch {
// createThread 已处理错误提示
}
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 Promise.all([
fetchAgentState(currentAgentId.value, threadId),
refreshThreadFilesAndAttachments(threadId)
])
isAgentPanelOpen.value = true
} catch (error) {
message.destroy('upload-attachment')
handleChatError(error, 'upload')
}
}
// ==================== 审批功能管理 ====================
const { approvalState, handleApproval, processApprovalInStream } = useApproval({
getThreadState,
resetOnGoingConv,
fetchThreadMessages
})
const { handleAgentResponse, handleStreamChunk } = useAgentStreamHandler({
getThreadState,
processApprovalInStream,
currentAgentId,
supportsFiles,
streamSmoother
})
const { startRunStream, resumeActiveRunForThread, stopRunStreamSubscription } = useAgentRunStream({
getThreadState,
useRunsApi,
currentAgentId,
handleStreamChunk,
processApprovalInStream,
fetchThreadMessages,
fetchAgentState,
resetOnGoingConv,
onScrollToBottom: () => scrollController.scrollToBottom(),
streamSmoother
})
// 发送消息并处理流式响应
const sendMessage = async ({
agentId,
threadId,
text,
signal = undefined,
imageData = undefined
}) => {
if (!agentId || !threadId || !text) {
const error = new Error('Missing agent, thread, or message text')
handleChatError(error, 'send')
return Promise.reject(error)
}
if (!selectedAgentConfigId.value) {
const error = new Error('Missing agent_config_id')
handleChatError(error, 'send')
return Promise.reject(error)
}
setThreadAgentConfigId(threadId, selectedAgentConfigId.value)
const requestData = {
query: text,
thread_id: threadId,
agent_config_id: selectedAgentConfigId.value
}
// 如果有图片,添加到请求中
if (imageData && imageData.imageContent) {
requestData.image_content = imageData.imageContent
}
try {
return await agentApi.sendAgentMessage(requestData, signal ? { signal } : undefined)
} catch (error) {
handleChatError(error, 'send')
throw error
}
}
// ==================== CHAT ACTIONS ====================
// 获取第一个非置顶的对话
const getFirstNonPinnedChat = (chatList) => {
if (!chatList || chatList.length === 0) return null
return chatList.find((chat) => !chat.is_pinned) || chatList[0]
}
const selectChat = async (chatId) => {
const targetChat = threads.value.find((chat) => chat.id === chatId) || null
const targetAgentId = targetChat?.agent_id || currentAgentId.value
const previousThreadId = chatState.currentThreadId
if (!targetAgentId) {
handleValidationError('选择对话失败:缺少智能体信息')
return
}
if (!AgentValidator.validateAgentIdWithError(targetAgentId, '选择对话', handleValidationError))
return
// 中断之前线程的流式输出(如果存在)
if (previousThreadId && previousThreadId !== chatId) {
stopThreadStream(previousThreadId)
// run 模式下仅断开 SSE 订阅,不取消后台运行任务
stopRunStreamSubscription(previousThreadId)
}
if (previousThreadId !== chatId) {
isAgentPanelOpen.value = false
}
try {
await withConfigNoticeSync(async () => {
// 先更新当前线程,确保底部智能体名称与选中项即时同步。
setCurrentThreadId(chatId)
if (
!props.singleMode &&
targetChat?.agent_id &&
targetChat.agent_id !== currentAgentId.value
) {
await agentStore.selectAgent(targetChat.agent_id)
}
await syncSelectedConfigForThread(targetChat)
syncThreadConfigSnapshot(chatId)
})
} catch (error) {
setCurrentThreadId(previousThreadId)
handleChatError(error, 'load')
return
}
chatUIStore.isLoadingMessages = true
try {
await fetchThreadMessages({ agentId: targetAgentId, threadId: chatId })
} catch (error) {
handleChatError(error, 'load')
} finally {
chatUIStore.isLoadingMessages = false
}
await nextTick()
scrollController.scrollToBottomStaticForce()
// await fetchAgentState(targetAgentId, chatId)
await handleAgentStateRefresh(chatId)
syncThreadConfigSnapshot(chatId, { overwrite: false })
await resumeActiveRunForThread(chatId)
}
const selectThreadFromRoute = async (threadId) => {
if (!agentStore.isInitialized) {
await initAll()
}
if (!threadId) {
const previousThreadId = chatState.currentThreadId
if (previousThreadId) {
stopThreadStream(previousThreadId)
stopRunStreamSubscription(previousThreadId)
}
isAgentPanelOpen.value = false
setCurrentThreadId(null)
return true
}
if (chatState.currentThreadId === threadId) {
return true
}
if (!threads.value.length || !threads.value.find((thread) => thread.id === threadId)) {
await loadChatsList()
}
const targetThread = threads.value.find((thread) => thread.id === threadId)
if (!targetThread) {
return false
}
await selectChat(threadId)
return true
}
const handleSendMessage = async ({ image } = {}) => {
const text = userInput.value.trim()
const imageContent = image?.imageContent || null
if ((!text && !image) || !currentAgent.value || isProcessing.value || sendCooldownActive.value)
return
if (!selectedAgentConfigId.value) {
message.error('请先选择智能体配置后再发送消息')
return
}
// 发送后进入短暂冷却,防止连续触发停止
startSendCooldown()
let threadId = currentChatId.value
if (!threadId) {
threadId = await ensureActiveThread(text)
if (!threadId) {
message.error('创建对话失败,请重试')
return
}
}
userInput.value = ''
await nextTick()
scrollController.scrollToBottom(true)
const threadState = getThreadState(threadId)
if (!threadState) return
if (useRunsApi) {
if ((threadMessages.value[threadId] || []).length === 0) {
const autoTitle = text.replace(/\s+/g, ' ').trim().slice(0, 2000)
if (autoTitle) {
void (async () => {
try {
const generatedTitle = await agentApi.generateTitle(
autoTitle,
configStore.config?.fast_model
)
if (generatedTitle) {
const finalTitle = generatedTitle.slice(0, 30).replace(/\s+/g, ' ').trim()
if (finalTitle) {
void chatThreadsStore.updateThread(threadId, finalTitle).catch(() => {})
}
}
} catch (e) {
console.error('Title generation failed:', e)
// 失败时使用原始文本作为标题
void chatThreadsStore.updateThread(threadId, autoTitle.slice(0, 30)).catch(() => {})
}
})()
}
}
resetOnGoingConv(threadId)
const requestId = createClientRequestId()
insertOptimisticHumanMessage(threadState, {
requestId,
text,
imageContent
})
threadState.isStreaming = true
try {
const runResp = await agentApi.createAgentRun({
query: text,
agent_config_id: selectedAgentConfigId.value,
thread_id: threadId,
meta: {
request_id: requestId
},
image_content: imageContent
})
const runId = runResp?.run_id
if (!runId) {
throw new Error('创建 run 失败:缺少 run_id')
}
await startRunStream(threadId, runId, 0)
} catch (error) {
threadState.isStreaming = false
threadState.replyLoadingVisible = false
threadState.pendingRequestId = null
resetOnGoingConv(threadId)
handleChatError(error, 'send')
}
return
}
// 如果是新对话,用 fast-model 异步生成标题(不阻塞消息发送)
if ((threadMessages.value[threadId] || []).length === 0) {
const autoTitle = text.replace(/\s+/g, ' ').trim().slice(0, 2000)
if (autoTitle) {
void (async () => {
try {
const generatedTitle = await agentApi.generateTitle(
autoTitle,
configStore.config?.fast_model
)
if (generatedTitle) {
const finalTitle = generatedTitle.slice(0, 30).replace(/\s+/g, ' ').trim()
if (finalTitle) {
void chatThreadsStore.updateThread(threadId, finalTitle).catch(() => {})
}
}
} catch (e) {
console.error('Title generation failed:', e)
// 失败时使用原始文本作为标题
void chatThreadsStore.updateThread(threadId, autoTitle.slice(0, 30)).catch(() => {})
}
})()
}
}
threadState.isStreaming = true
resetOnGoingConv(threadId)
const requestId = createClientRequestId()
insertOptimisticHumanMessage(threadState, {
requestId,
text,
imageContent
})
threadState.streamAbortController = new AbortController()
try {
const response = await sendMessage({
agentId: currentAgentId.value,
threadId: threadId,
text: text,
signal: threadState.streamAbortController?.signal,
imageData: image
})
await handleAgentResponse(response, threadId)
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Stream error:', error)
handleChatError(error, 'send')
} else {
console.warn('[Interrupted] Catch')
}
threadState.isStreaming = false
} finally {
threadState.streamAbortController = null
// 异步加载历史记录,保持当前消息显示直到历史记录加载完成
fetchThreadMessages({ agentId: currentAgentId.value, threadId: threadId }).finally(() => {
// 历史记录加载完成后,安全地清空当前进行中的对话
resetOnGoingConv(threadId)
handleAgentStateRefresh(threadId)
scrollController.scrollToBottom()
})
}
}
// 发送或中断
const handleSendOrStop = async (payload) => {
if (sendCooldownActive.value) {
return
}
const threadId = currentChatId.value
const threadState = getThreadState(threadId)
if (isProcessing.value && threadState) {
if (useRunsApi && threadState.activeRunId) {
try {
await agentApi.cancelAgentRun(threadState.activeRunId)
message.info('已发送取消请求')
} catch (error) {
handleChatError(error, 'stop')
}
return
}
if (threadState.streamAbortController) {
// 中断生成
threadState.streamAbortController.abort()
// 中断后刷新消息历史,确保显示最新的状态
try {
await fetchThreadMessages({ agentId: currentAgentId.value, threadId: threadId, delay: 500 })
fetchAgentState(currentAgentId.value, threadId)
message.info('已中断对话生成')
} catch (error) {
console.error('刷新消息历史失败:', error)
message.info('已中断对话生成')
}
return
}
}
await handleSendMessage(payload)
}
// ==================== 人工审批处理 ====================
const handleApprovalWithStream = async (answer) => {
const threadId = approvalState.threadId
if (!threadId) {
message.error('无效的提问请求')
approvalState.showModal = false
return
}
const threadState = getThreadState(threadId)
if (!threadState) {
message.error('无法找到对应的对话线程')
approvalState.showModal = false
return
}
try {
// 使用审批 composable 处理审批
const response = await handleApproval(answer, currentAgentId.value, selectedAgentConfigId.value)
if (!response) return // 如果 handleApproval 抛出错误,这里不会执行
// 处理流式响应
await handleAgentResponse(response, threadId)
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Resume approval error:', error)
}
} finally {
if (threadState) {
threadState.isStreaming = false
threadState.streamAbortController = null
}
// 异步加载历史记录,保持当前消息显示直到历史记录加载完成
fetchThreadMessages({ agentId: currentAgentId.value, threadId: threadId }).finally(() => {
resetOnGoingConv(threadId)
fetchAgentState(currentAgentId.value, threadId)
scrollController.scrollToBottom()
})
}
}
const handleQuestionSubmit = (answer) => {
handleApprovalWithStream(answer)
}
const handleQuestionCancel = () => {
handleApprovalWithStream('reject')
}
// 处理示例问题点击
const handleExampleClick = (questionText) => {
userInput.value = questionText
nextTick(() => {
handleSendMessage()
})
}
const buildExportPayload = () => {
const agentId = currentAgentId.value
let agentDescription = ''
if (agentId && agents.value && agents.value.length > 0) {
const agent = agents.value.find((a) => a.id === agentId)
agentDescription = agent ? agent.description || '' : ''
}
const payload = {
chatTitle: currentThread.value?.title || '新对话',
agentName: currentAgentName.value || currentAgent.value?.name || '智能助手',
agentDescription: agentDescription || currentAgent.value?.description || '',
messages: conversations.value ? JSON.parse(JSON.stringify(conversations.value)) : [],
onGoingMessages: onGoingConvMessages.value
? JSON.parse(JSON.stringify(onGoingConvMessages.value))
: []
}
return payload
}
defineExpose({
getExportPayload: buildExportPayload,
selectThreadFromRoute
})
const handleAgentStateRefresh = async (threadId = null) => {
if (!currentAgentId.value) return
const chatId = threadId || currentChatId.value
if (!chatId) return
await Promise.all([
fetchAgentState(currentAgentId.value, chatId),
refreshThreadFilesAndAttachments(chatId)
])
}
const toggleAgentPanel = async () => {
const nextOpen = !isAgentPanelOpen.value
isAgentPanelOpen.value = nextOpen
if (nextOpen) {
await handleAgentStateRefresh()
}
}
// 处理面板宽度调整(使用比例)
// 向右拖动(deltaX > 0)让面板变窄,向左拖动(deltaX < 0)让面板变宽
const handlePanelResize = (clientX) => {
if (!panelWrapperRef.value) return
if (!panelContainerWidth) {
const container = document.querySelector('.chat-content-container')
panelContainerWidth = container ? container.clientWidth : window.innerWidth
}
const deltaX = clientX - resizeStartX
const newWidth = resizeStartWidth - deltaX
const newRatio = newWidth / panelContainerWidth
if (newRatio >= minPanelRatio && newRatio <= maxPanelRatio) {
panelWrapperRef.value.style.setProperty('flex', `0 0 ${newWidth}px`, 'important')
}
}
// 拖拽状态变化时,同步最终状态到 Vue 响应式数据
const handleResizingChange = (isResizingState, clientX = 0) => {
isResizing.value = isResizingState
if (isResizingState && panelWrapperRef.value) {
resizeStartX = clientX
resizeStartWidth = panelWrapperRef.value.offsetWidth
if (!panelContainerWidth) {
const container = document.querySelector('.chat-content-container')
panelContainerWidth = container ? container.clientWidth : window.innerWidth
}
return
}
if (!isResizingState && panelWrapperRef.value && panelContainerWidth) {
const finalWidth = panelWrapperRef.value.offsetWidth
panelRatio.value = finalWidth / panelContainerWidth
panelWrapperRef.value.style.removeProperty('flex')
resizeStartX = 0
resizeStartWidth = 0
panelContainerWidth = 0 // 重置,供下次使用
}
}
// ==================== HELPER FUNCTIONS ====================
const extractAssistantMessageBody = (message) => {
let content = typeof message?.content === 'string' ? message.content.trim() : ''
let reasoningContent = message?.additional_kwargs?.reasoning_content || ''
if (!reasoningContent && content) {
const thinkRegex = /<think>(.*?)<\/think>|<think>(.*?)$/s
const thinkMatch = content.match(thinkRegex)
if (thinkMatch) {
reasoningContent = (thinkMatch[1] || thinkMatch[2] || '').trim()
content = content.replace(thinkMatch[0], '').trim()
}
}
return { content, reasoningContent }
}
const hasVisibleAssistantBody = (message) => {
if (!message || message.type !== 'ai') return true
const { content, reasoningContent } = extractAssistantMessageBody(message)
return Boolean(
content ||
reasoningContent ||
message.error_type ||
message.extra_metadata?.error_type ||
message.isStoppedByUser
)
}
const getMessageToolCalls = (message) => {
if (!Array.isArray(message?.tool_calls)) return []
return message.tool_calls.filter((toolCall) => {
return (
toolCall &&
(toolCall.id || toolCall.name || toolCall.function?.name) &&
(toolCall.args !== undefined ||
toolCall.function?.arguments !== undefined ||
toolCall.tool_call_result !== undefined)
)
})
}
// 将 AI 消息拆成“正文块”和“工具块”,再跨消息合并相邻工具块。
const getConversationDisplayItems = (conv) => {
if (!Array.isArray(conv?.messages) || conv.messages.length === 0) return []
const items = []
let pendingToolGroup = null
const flushToolGroup = () => {
if (pendingToolGroup && pendingToolGroup.toolCalls.length > 0) {
items.push(pendingToolGroup)
}
pendingToolGroup = null
}
conv.messages.forEach((message, index) => {
if (message.type !== 'ai') {
flushToolGroup()
items.push({
type: 'message',
key: message.id || `message-${index}`,
message,
sourceIndex: index
})
return
}
if (hasVisibleAssistantBody(message)) {
flushToolGroup()
items.push({
type: 'message',
key: message.id || `message-${index}`,
message,
sourceIndex: index
})
}
const toolCalls = getMessageToolCalls(message)
if (toolCalls.length === 0) return
if (!pendingToolGroup) {
pendingToolGroup = {
type: 'tool-group',
key: `tool-group-${message.id || index}`,
toolCalls: []
}
}
pendingToolGroup.toolCalls.push(...toolCalls)
})
flushToolGroup()
return items
}
const isDisplayMessageProcessing = (conv, displayItem) => {
return (
displayItem?.type === 'message' &&
isReplyLoading.value &&
conv?.status === 'streaming' &&
displayItem.sourceIndex === conv.messages.length - 1
)
}
const isToolGroupActive = (conv, itemIndex, displayItems) => {
return (
isReplyLoading.value && conv?.status === 'streaming' && itemIndex === displayItems.length - 1
)
}
const getLastMessage = (conv) => {
if (!conv?.messages?.length) return null
for (let i = conv.messages.length - 1; i >= 0; i--) {
if (conv.messages[i].type === 'ai') return conv.messages[i]
}
return null
}
const showMsgRefs = (msg) => {
// 如果正在审批中,不显示 refs
if (approvalState.showModal) {
return false
}
// 如果当前线程ID与审批线程ID匹配但审批框已关闭说明刚刚处理完审批
// 且当前有新的流式处理正在进行,则不显示之前被中断的消息的 refs
if (
approvalState.threadId &&
chatState.currentThreadId === approvalState.threadId &&
!approvalState.showModal &&
isProcessing
) {
return false
}
// 只有真正完成的消息才显示 refs
if (msg.isLast && msg.status === 'finished') {
return ['copy', 'sources']
}
return false
}
const getConversationSources = (conv) => {
return MessageProcessor.extractSourcesFromConversation(conv, availableKnowledgeBases.value)
}
// ==================== LIFECYCLE & WATCHERS ====================
const loadChatsList = async () => {
const agentId = props.singleMode ? currentAgentId.value : null
if (props.singleMode && !agentId) {
console.warn('No agent selected, cannot load chats list')
threads.value = []
setCurrentThreadId(null)
threadFilesMap.value = {}
threadAttachmentsMap.value = {}
return
}
try {
await fetchThreads(agentId)
if (props.singleMode && currentAgentId.value !== agentId) return
// 如果当前线程不在线程列表中,清空当前线程
if (
chatState.currentThreadId &&
!threads.value.find((t) => t.id === chatState.currentThreadId)
) {
setCurrentThreadId(null)
}
// singleMode 保持旧行为:自动选择首个可用对话
if (props.singleMode && threads.value.length > 0 && !chatState.currentThreadId) {
await selectChat(getFirstNonPinnedChat(threads.value).id)
}
} catch (error) {
handleChatError(error, 'load')
}
}
const initAll = async () => {
try {
if (!agentStore.isInitialized) {
await agentStore.initialize()
}
} catch (error) {
handleChatError(error, 'load')
}
}
onMounted(async () => {
await initAll()
scrollController.enableAutoScroll()
})
watch(
currentAgentId,
async (newAgentId, oldAgentId) => {
if (!props.singleMode) {
if (oldAgentId === undefined) {
await loadChatsList()
}
return
}
if (newAgentId !== oldAgentId) {
// 清理当前线程状态
setCurrentThreadId(null)
threadMessages.value = {}
threadFilesMap.value = {}
threadAttachmentsMap.value = {}
// 清理所有线程状态
resetOnGoingConv()
if (newAgentId) {
await loadChatsList()
} else {
threads.value = []
}
}
},
{ immediate: true }
)
watch(
currentThreadMessages,
() => {
if (currentThreadHasHistory.value) {
flushPendingThreadConfigNotice(currentChatId.value)
syncThreadConfigSnapshot(currentChatId.value, { overwrite: false })
}
},
{ deep: false }
)
watch(currentAgentId, (newAgentId, oldAgentId) => {
if (oldAgentId === undefined || newAgentId === oldAgentId) return
maybeInsertThreadConfigNotice()
})
watch(selectedAgentConfigId, (newConfigId, oldConfigId) => {
if (oldConfigId === undefined || newConfigId === oldConfigId) return
maybeInsertThreadConfigNotice()
})
watch(
() => JSON.stringify(agentConfig.value || {}),
(newConfigJson, oldConfigJson) => {
if (oldConfigJson === undefined || newConfigJson === oldConfigJson) return
maybeInsertThreadConfigNotice()
}
)
watch(
conversations,
() => {
if (isProcessing.value) {
scrollController.scrollToBottom()
}
},
{ deep: true, flush: 'post' }
)
watch(
configNoticeScrollVersion,
() => {
if (!currentChatId.value) return
scrollController.scrollToBottom(true)
},
{ flush: 'post' }
)
watch(currentChatId, (threadId, oldThreadId) => {
if (threadId === oldThreadId) return
emit('thread-change', threadId || '')
})
</script>
<style lang="less" scoped>
@import '@/assets/css/main.css';
@import '@/assets/css/animations.less';
.chat-container {
display: flex;
width: 100%;
height: 100%;
position: relative;
}
.chat {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden; /* Changed from overflow-x: hidden to overflow: hidden */
position: relative;
box-sizing: border-box;
transition: all 0.3s ease;
.chat-header {
user-select: none;
z-index: 10;
height: var(--header-height);
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 8px;
flex-shrink: 0; /* Prevent header from shrinking */
.header__left,
.header__right {
display: flex;
align-items: center;
gap: 8px;
}
.switch-icon {
color: var(--gray-500);
transition: all 0.2s ease;
}
.agent-nav-btn:hover .switch-icon {
color: var(--main-500);
}
.conversation-title {
font-size: 15px;
font-weight: 400;
color: var(--text-primary);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-left: 8px;
}
}
}
.chat-content-container {
flex: 1;
display: flex;
flex-direction: row;
overflow: hidden;
position: relative;
width: 100%;
contain: layout;
}
.chat-main {
flex: 1 1 0;
display: flex;
flex-direction: column;
overflow-y: auto; /* Scroll is here now */
position: relative;
transition:
flex-basis 0.3s cubic-bezier(0.4, 0, 0.2, 1),
width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
min-width: 0; /* Prevent flex item from overflowing */
scrollbar-width: none;
}
.agent-panel-wrapper {
flex: 0 0 auto;
align-self: stretch;
height: auto;
overflow: hidden;
z-index: 20;
margin: 0 8px 8px;
margin-left: 0;
background: var(--gray-0);
border-radius: 16px;
border: 1px solid var(--gray-150);
min-width: 0;
will-change: flex-basis;
}
/* Workbench transition animations */
.agent-panel-wrapper {
transition: flex-basis 0.3s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0;
transform: translateX(10px);
margin-left: -16px;
}
.agent-panel-wrapper.is-visible {
opacity: 1;
transform: translateX(0);
margin-left: 0;
}
.agent-panel-wrapper.no-transition {
transition: none !important;
}
.chat-examples-input {
padding: 24px 0;
text-align: center;
h1 {
font-size: 1.4rem;
color: var(--gray-1000);
margin: 0;
}
}
.agent-segment-wrapper {
width: fit-content;
max-width: 100%;
margin: 0 auto 18px;
overflow-x: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
:deep(.ant-segmented) {
width: auto;
max-width: 100%;
white-space: nowrap;
background: var(--gray-50);
border: 1px solid var(--gray-150);
border-radius: 10px;
}
:deep(.ant-segmented-group) {
width: auto;
display: inline-flex;
}
:deep(.ant-segmented-item) {
flex: 0 0 auto;
min-width: 0;
}
:deep(.ant-segmented-item-label) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.agent-switcher-wrapper {
display: flex;
justify-content: center;
margin: 0 auto 18px;
}
.agent-switcher-btn {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
max-width: 100%;
padding: 4px 12px;
border: 1px solid var(--gray-150);
border-radius: 8px;
background: var(--gray-0);
color: var(--gray-900);
cursor: pointer;
transition:
background-color 0.2s ease,
border-color 0.2s ease,
color 0.2s ease;
&:hover {
background: var(--gray-0);
border-color: var(--gray-200);
}
}
.agent-switcher-icon,
.agent-switcher-chevron {
flex-shrink: 0;
color: var(--gray-600);
}
.agent-switcher-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:deep(.agent-switcher-menu) {
min-width: 220px;
}
:deep(.agent-switcher-menu-item) {
display: flex;
align-items: center;
gap: 8px;
}
:deep(.agent-switcher-menu-icon) {
flex-shrink: 0;
color: var(--gray-600);
}
:deep(.agent-switcher-menu-text) {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:deep(.agent-switcher-menu-badge) {
flex-shrink: 0;
padding: 1px 8px;
border-radius: 999px;
background: var(--main-30);
color: var(--main-700);
font-size: 12px;
}
.example-questions {
margin-top: 16px;
text-align: center;
.example-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
.example-chip {
padding: 6px 12px;
background: var(--gray-25);
// border: 1px solid var(--gray-100);
border-radius: 16px;
cursor: pointer;
font-size: 0.8rem;
color: var(--gray-700);
transition: all 0.15s ease;
white-space: nowrap;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
// background: var(--main-25);
border-color: var(--main-200);
color: var(--main-700);
box-shadow: 0 0px 4px rgba(0, 0, 0, 0.03);
}
&:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
}
}
.chat-loading {
padding: 0 50px;
text-align: center;
position: absolute;
top: 20%;
width: 100%;
z-index: 9;
animation: slideInUp 0.5s ease-out;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
span {
color: var(--gray-700);
font-size: 14px;
}
.loading-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--gray-200);
border-top-color: var(--main-color);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
}
.chat-box {
width: 100%;
max-width: 800px;
margin: 0 auto;
flex-grow: 1;
padding: 1rem 1.5rem;
display: flex;
flex-direction: column;
}
.conv-box {
display: flex;
flex-direction: column;
}
.chat-inline-notice {
display: flex;
justify-content: center;
padding: 6px 16px 12px;
color: var(--gray-500);
font-size: 12px;
line-height: 1.6;
text-align: center;
}
.bottom {
position: sticky;
bottom: 0;
width: 100%;
margin: 0 auto;
padding: 4px 1rem 0 1rem;
z-index: 1000;
.message-input-wrapper {
width: 100%;
max-width: 800px;
margin: 0 auto;
.bottom-actions {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
background: var(--gray-0);
}
.note {
font-size: small;
color: var(--gray-300);
margin: 4px 0;
user-select: none;
}
}
&.start-screen {
position: absolute;
top: 45%;
left: 50%;
transform: translate(-50%, -50%);
bottom: auto;
max-width: 800px;
width: 90%;
background: transparent;
padding: 0;
border-top: none;
z-index: 100; /* Ensure it's above other elements */
}
}
.loading-dots {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 3px;
}
.loading-dots div {
width: 6px;
height: 6px;
background: linear-gradient(135deg, var(--main-color), var(--main-700));
border-radius: 50%;
animation: dotPulse 1.4s infinite ease-in-out both;
}
.loading-dots div:nth-child(1) {
animation-delay: -0.32s;
}
.loading-dots div:nth-child(2) {
animation-delay: -0.16s;
}
.loading-dots div:nth-child(3) {
animation-delay: 0s;
}
.generating-status {
display: flex;
justify-content: flex-start;
padding: 1rem 0;
animation: fadeInUp 0.4s ease-out;
transition: all 0.2s;
}
.generating-indicator {
display: flex;
align-items: center;
padding: 0.75rem 0rem;
.generating-text {
margin-left: 12px;
font-size: 14px;
font-weight: 500;
letter-spacing: 0.025em;
/* 恢复灰色调:深灰 -> 亮灰(高光) -> 深灰 */
background: linear-gradient(
90deg,
var(--gray-700) 0%,
var(--gray-700) 40%,
var(--gray-300) 45%,
var(--gray-200) 50%,
var(--gray-300) 55%,
var(--gray-700) 60%,
var(--gray-700) 100%
);
background-size: 200% auto;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: waveFlash 2s linear infinite;
}
}
@keyframes waveFlash {
0% {
background-position: 200% center;
}
100% {
background-position: -200% center;
}
}
@media (max-width: 768px) {
.agent-segment-wrapper {
margin-bottom: 8px;
:deep(.ant-segmented-item-label) {
font-size: 12px;
}
}
.agent-switcher-wrapper {
margin-bottom: 8px;
}
.agent-switcher-btn {
width: 100%;
justify-content: center;
}
.chat-header {
.header__left {
.text {
display: none;
}
}
}
}
// 智能体选择器的图标对齐
.agent-segment-wrapper {
:deep(.ant-segmented-item-label) {
display: flex;
align-items: center;
gap: 6px;
}
:deep(.agent-option-label) {
display: flex;
align-items: center;
gap: 6px;
}
:deep(.agent-option-icon) {
flex-shrink: 0;
color: var(--gray-600);
}
}
</style>
<style lang="less">
.agent-nav-btn {
display: flex;
gap: 6px;
padding: 6px 8px;
height: 32px;
justify-content: center;
align-items: center;
border-radius: 6px;
color: var(--gray-900);
cursor: pointer;
width: auto;
font-size: 15px;
transition: background-color 0.3s;
border: none;
background: transparent;
&:hover:not(.is-disabled) {
background-color: var(--gray-100);
}
&.is-disabled {
cursor: not-allowed;
opacity: 0.7;
pointer-events: none;
}
.nav-btn-icon {
height: 18px;
}
.loading-icon {
animation: spin 1s linear infinite;
}
}
.hide-text {
display: none;
}
@media (min-width: 769px) {
.hide-text {
display: inline;
}
}
/* AgentState 按钮有内容时的样式 */
.agent-nav-btn.agent-state-btn.has-content:hover:not(.is-disabled) {
color: var(--main-700);
background-color: var(--main-20);
}
.agent-nav-btn.agent-state-btn.active {
color: var(--main-700);
background-color: var(--main-20);
}
</style>