| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535 |
- <template>
- <div class="setter-chat">
- <MessageList
- ref="messageListRef"
- :messages="messages"
- :loading="isRunning"
- @retry="handleRetry"
- :can-add-to-kb="false"
- >
- <template #message-extra="{ item }">
- <WorkflowTraceBubble v-if="props.showWorkflowTrace" :message="item" />
- </template>
- </MessageList>
- <div class="setter-chat__footer">
- <el-dialog
- v-model="uploadDialogVisible"
- title="上传文件"
- width="560px"
- append-to-body
- :close-on-click-modal="false"
- >
- <FileUploadInput
- v-model="attachments"
- :multiple="true"
- :allow-link-input="false"
- tip="上传后会作为本次 AI 对话的 files 参数。"
- />
- <template #footer>
- <el-button @click="uploadDialogVisible = false">关闭</el-button>
- <el-button type="primary" @click="uploadDialogVisible = false">完成</el-button>
- </template>
- </el-dialog>
- <ChatInput
- v-model="senderValue"
- :loading="isRunning"
- :attachments="attachments"
- @submit="handleSend"
- @cancel="handleCancel"
- >
- <template #prefix-extra>
- <el-badge :value="attachments.length" :hidden="!attachments.length">
- <el-button round plain color="#626aef" @click="uploadDialogVisible = true">
- <el-icon>
- <Paperclip />
- </el-icon>
- </el-button>
- </el-badge>
- </template>
- </ChatInput>
- </div>
- </div>
- </template>
- <script setup lang="ts">
- import { computed, nextTick, ref, watch } from 'vue'
- import { ElMessage } from 'element-plus'
- import { Paperclip } from '@element-plus/icons-vue'
- import { agent } from '@repo/api-service'
- import ChatInput from '@/components/Chat/ChatInput.vue'
- import MessageList from '@/components/Chat/MessageList.vue'
- import FileUploadInput from '@/features/fileUpload/FileUploadInput.vue'
- import WorkflowTraceBubble from './WorkflowTraceBubble.vue'
- import { useRunnerStore } from '@/store/modules/runner.store'
- import { cloneDeep } from 'lodash-es'
- import type { WorkflowUploadFile } from '@/features/fileUpload/shared'
- import type {
- BubbleMessage,
- ChatReference,
- ChatSseMessage,
- ChatToolCall,
- ChatToolResult
- } from '@/views/chat/types'
- import type { IWorkflowNode } from '@repo/workflow'
- interface Props {
- node?: IWorkflowNode
- workflowId?: string
- baseParams?: Record<string, any>
- showWorkflowTrace?: boolean
- validateBeforeSend?: () => boolean | Record<string, any> | Promise<boolean | Record<string, any>>
- onFirstSend?: () => void
- }
- const props = withDefaults(defineProps<Props>(), {
- baseParams: () => ({}),
- showWorkflowTrace: false,
- validateBeforeSend: undefined,
- onFirstSend: undefined
- })
- const emit = defineEmits<{
- 'run-started': [nodeId: string]
- }>()
- const runnerStore = useRunnerStore()
- const messages = ref<BubbleMessage[]>([])
- const senderValue = ref('')
- const attachments = ref<WorkflowUploadFile[]>([])
- const uploadDialogVisible = ref(false)
- const messageListRef = ref<InstanceType<typeof MessageList>>()
- const activeAiMessage = ref<BubbleMessage | null>(null)
- const handledChatMessageCount = ref(0)
- const startingRunner = ref(false)
- const isRunning = computed(
- () =>
- startingRunner.value ||
- (!!activeAiMessage.value &&
- (runnerStore.status === 'connecting' || runnerStore.status === 'running'))
- )
- const appAgentId = computed(
- () => props.workflowId || props.node?.appAgentId || (props.node?.data as any)?.appAgentId || ''
- )
- const scrollToBottom = () => {
- nextTick(() => {
- messageListRef.value?.scrollToBottom?.()
- })
- }
- const normalizeText = (value?: unknown) => `${value || ''}`.trim()
- const createMessageKey = (prefix: string) =>
- `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
- const createUserMessage = (content: string, files: WorkflowUploadFile[] = []): BubbleMessage => ({
- key: createMessageKey('user'),
- role: 'user',
- placement: 'end',
- content,
- loading: false,
- shape: 'corner',
- variant: 'outlined',
- isMarkdown: true,
- typing: false,
- isFog: false,
- message_files: files.map((item) => item.path || item.id).filter(Boolean)
- })
- const createAiMessage = (): BubbleMessage => ({
- key: createMessageKey('ai'),
- role: 'ai',
- placement: 'start',
- content: '',
- answerText: '',
- thinking: '',
- toolCalls: [],
- toolResults: [],
- references: [],
- assistantMessageId: '',
- loading: true,
- shape: 'corner',
- variant: 'filled',
- isMarkdown: true,
- typing: { step: 3, interval: 25 },
- isFog: true,
- ...(props.showWorkflowTrace
- ? {
- workflowTraceVisible: true,
- workflowTraceNodes: []
- }
- : {})
- })
- const syncWorkflowTraceToActiveMessage = () => {
- if (!props.showWorkflowTrace || !activeAiMessage.value) return
- activeAiMessage.value.workflowTraceNodes = cloneDeep(runnerStore.nodes)
- }
- const getFilesParam = () =>
- attachments.value
- .map((item) => ({
- id: item.id,
- name: item.name,
- extensionName: item.extensionName,
- size: item.size,
- path: item.path || item.id
- }))
- .filter((item) => item.path)
- const normalizeToolCall = (event: ChatSseMessage): ChatToolCall => {
- const data = event.data || {}
- return {
- id: data.id || event.id,
- toolCallId: data.tool_call_id || data.toolCallId,
- toolName: data.tool_name || data.toolName,
- arguments: data.arguments || data.params || data.args || {},
- content: event.content || data.content
- }
- }
- const normalizeToolResult = (event: ChatSseMessage): ChatToolResult => {
- const data = event.data || {}
- return {
- id: data.id || event.id,
- toolCallId: data.tool_call_id || data.toolCallId,
- toolName: data.tool_name || data.toolName,
- success: data.success,
- error: data.error,
- output: data.output,
- thought: data.thought,
- durationMs: data.duration_ms || data.durationMs,
- displayType: data.display_type || data.displayType,
- contentItems: data.content_items || data.contentItems
- }
- }
- const normalizeReferences = (event: ChatSseMessage): ChatReference[] => {
- const data = event.data || {}
- const eventRefs = (event as ChatSseMessage & { references?: unknown }).references
- const refs = data.knowledge_references || data.references || eventRefs || []
- if (!Array.isArray(refs)) return []
- return refs.map((item: Record<string, any>) => ({
- id: item.id,
- knowledgeBaseId: item.knowledge_base_id || item.knowledgeBaseId,
- knowledgeId: item.knowledge_id || item.knowledgeId,
- knowledgeTitle: item.knowledge_title || item.knowledgeTitle,
- knowledgeFilename: item.knowledge_filename || item.knowledgeFilename,
- knowledgeDescription: item.knowledge_description || item.knowledgeDescription,
- content: item.content,
- matchedContent: item.matched_content || item.matchedContent,
- score: item.score,
- chunkType: item.chunk_type || item.chunkType
- }))
- }
- const normalizeChatEvent = (raw: Record<string, any>): ChatSseMessage => {
- const data = raw.data && typeof raw.data === 'object' ? raw.data : raw
- const content =
- raw.content ??
- raw.answer ??
- raw.output ??
- raw.delta ??
- raw.text ??
- data.content ??
- data.answer ??
- ''
- return {
- id: raw.id || data.id,
- response_type:
- raw.response_type ||
- raw.responseType ||
- raw.type ||
- raw.event ||
- (content ? 'agent_query' : 'complete'),
- content,
- done: raw.done || raw.is_end || raw.finished,
- data
- }
- }
- const buildReferenceMarkdown = (references: ChatReference[] = []) => {
- if (!references.length) return ''
- const items = references.map((item, index) => {
- const title =
- item.knowledgeTitle || item.knowledgeFilename || item.knowledgeId || `引用 ${index + 1}`
- const content = item.matchedContent || item.content || ''
- return `\n${index + 1}. ${title}${content ? `\n ${content}` : ''}`
- })
- return `\n\n**引用**${items.join('')}`
- }
- const updateMessageContent = (message: BubbleMessage) => {
- const answer = normalizeText(message.answerText || message.content)
- message.content = `${answer}${buildReferenceMarkdown(message.references)}`
- }
- const finishActiveMessage = (error?: string) => {
- const message = activeAiMessage.value
- if (!message) return
- if (error && !message.content && !message.answerText) {
- message.content = error
- message.answerText = error
- message.error = true
- }
- message.loading = false
- message.isFog = false
- message.streamCompleted = true
- updateMessageContent(message)
- activeAiMessage.value = null
- scrollToBottom()
- }
- const applyStructuredEventToMessage = (message: BubbleMessage, event: ChatSseMessage) => {
- const data = event.data || {}
- switch (event.response_type) {
- case 'answer':
- message.assistantMessageId =
- data.assistant_message_id || data.assistantMessageId || message.assistantMessageId
- if (event.content) {
- message.answerText = `${message.answerText || ''}${event.content}`
- }
- break
- case 'agent_query':
- case 'message':
- case 'delta':
- message.assistantMessageId =
- data.assistant_message_id || data.assistantMessageId || message.assistantMessageId
- break
- case 'thinking': {
- const thought = normalizeText(event.content || data.thought || data.content)
- if (thought) {
- message.thinking = `${message.thinking || ''}${message.thinking ? '\n\n' : ''}${thought}`
- }
- break
- }
- case 'tool_call':
- message.toolCalls = [...(message.toolCalls || []), normalizeToolCall(event)]
- break
- case 'tool_result': {
- message.toolResults = [...(message.toolResults || []), normalizeToolResult(event)]
- const thought = normalizeText(data.thought)
- if (thought) {
- message.thinking = `${message.thinking || ''}${message.thinking ? '\n\n' : ''}${thought}`
- }
- break
- }
- case 'references':
- message.references = [...(message.references || []), ...normalizeReferences(event)]
- break
- case 'complete':
- break
- default:
- break
- }
- updateMessageContent(message)
- message.loading = event.response_type !== 'complete' && !event.done
- message.isFog = false
- if (event.response_type === 'complete' || event.done) {
- message.streamCompleted = true
- }
- }
- const handleRunnerChatMessages = () => {
- const list = runnerStore.agentChatMessages
- if (handledChatMessageCount.value >= list.length) return
- const nextMessages = list.slice(handledChatMessageCount.value)
- handledChatMessageCount.value = list.length
- if (!activeAiMessage.value) return
- nextMessages.forEach((item) => {
- applyStructuredEventToMessage(activeAiMessage.value!, normalizeChatEvent(item.msg || {}))
- })
- scrollToBottom()
- }
- const handleSend = async (content?: string) => {
- const query = normalizeText(content)
- if (!query) {
- ElMessage.warning('请输入问题')
- return
- }
- if (!props.node?.id || !appAgentId.value) {
- ElMessage.warning('缺少智能体或节点信息')
- return
- }
- if (isRunning.value) return
- let executeParams = cloneDeep(props.baseParams)
- if (props.validateBeforeSend) {
- const passed = await props.validateBeforeSend()
- if (!passed) return
- if (typeof passed === 'object') {
- executeParams = cloneDeep(passed)
- }
- }
- const files = getFilesParam()
- messages.value.push(createUserMessage(query, attachments.value))
- const aiMessage = createAiMessage()
- messages.value.push(aiMessage)
- activeAiMessage.value = aiMessage
- senderValue.value = ''
- attachments.value = []
- handledChatMessageCount.value = 0
- scrollToBottom()
- startingRunner.value = true
- try {
- const response = await agent.postAgentDoExecute({
- appAgentId: appAgentId.value,
- start_node_id: props.node.id,
- is_debugger: true,
- responseType: 'ws',
- query,
- files: files.map((item) => item.id),
- params: {
- ...executeParams
- }
- })
- const agentRunnerKey = response?.result
- if (!agentRunnerKey) {
- finishActiveMessage('智能体启动失败')
- ElMessage.error('智能体启动失败')
- return
- }
- runnerStore.startRunner(agentRunnerKey, props.node.id)
- syncWorkflowTraceToActiveMessage()
- props.onFirstSend?.()
- emit('run-started', props.node.id)
- handledChatMessageCount.value = 0
- } catch (error) {
- console.error('postAgentDoExecute error', error)
- finishActiveMessage('智能体运行失败')
- ElMessage.error('智能体运行失败')
- } finally {
- startingRunner.value = false
- }
- }
- const handleCancel = () => {
- runnerStore.stopRunner()
- finishActiveMessage()
- }
- const handleRetry = (message: BubbleMessage) => {
- const index = messages.value.findIndex((item) => item === message)
- if (index <= 0) return
- const previous = messages.value[index - 1]
- if (previous?.role !== 'user') return
- handleSend(previous.content)
- }
- const resetConversation = () => {
- runnerStore.resetRunner()
- messages.value = []
- senderValue.value = ''
- attachments.value = []
- uploadDialogVisible.value = false
- activeAiMessage.value = null
- handledChatMessageCount.value = 0
- startingRunner.value = false
- }
- defineExpose({
- scrollToBottom,
- resetConversation
- })
- watch(
- () => runnerStore.agentChatMessages.length,
- () => {
- handleRunnerChatMessages()
- }
- )
- watch(
- () => runnerStore.nodes.map((node) => ({ ...node })),
- () => {
- syncWorkflowTraceToActiveMessage()
- scrollToBottom()
- },
- { deep: true }
- )
- watch(
- () => runnerStore.status,
- (status) => {
- if (!activeAiMessage.value) return
- if (status === 'finished') {
- finishActiveMessage()
- }
- if (status === 'error') {
- finishActiveMessage(runnerStore.errorMsg || '智能体运行失败')
- }
- if (status === 'suspended') {
- finishActiveMessage('智能体运行已挂起')
- }
- }
- )
- </script>
- <style lang="less" scoped>
- .setter-chat {
- height: 100%;
- min-height: 0;
- display: flex;
- flex-direction: column;
- background: var(--bg-page);
- overflow: hidden;
- }
- .setter-chat__footer {
- flex-shrink: 0;
- }
- :deep(.chat-content) {
- min-height: 0;
- padding: 12px;
- }
- :deep(.sender-wrapper) {
- width: calc(100% - 24px);
- margin-bottom: 0;
- padding: 10px 0;
- }
- :deep(.el-bubble-end .el-bubble-content) {
- background-color: var(--el-color-primary-light-9);
- border-radius: 8px 0 8px 8px;
- color: var(--text-primary);
- border: none;
- }
- :deep(.el-bubble-start .el-bubble-content) {
- background-color: var(--bg-container);
- border: none;
- color: var(--text-primary);
- width: 100%;
- max-width: min(920px, calc(100vw - 220px));
- }
- :deep(.el-sender-footer) {
- border: none;
- }
- </style>
|