|
|
@@ -0,0 +1,449 @@
|
|
|
+<template>
|
|
|
+ <div class="setter-chat">
|
|
|
+ <MessageList ref="messageListRef" :messages="messages" :loading="isRunning" @retry="handleRetry" />
|
|
|
+
|
|
|
+ <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 { useRunnerStore } from '@/store/modules/runner.store'
|
|
|
+
|
|
|
+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
|
|
|
+}
|
|
|
+
|
|
|
+const props = defineProps<Props>()
|
|
|
+
|
|
|
+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
|
|
|
+})
|
|
|
+
|
|
|
+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 'agent_query':
|
|
|
+ case 'answer':
|
|
|
+ case 'message':
|
|
|
+ case 'delta':
|
|
|
+ message.assistantMessageId =
|
|
|
+ data.assistant_message_id || data.assistantMessageId || message.assistantMessageId
|
|
|
+ if (event.content) {
|
|
|
+ message.answerText = `${message.answerText || ''}${event.content}`
|
|
|
+ }
|
|
|
+ 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:
|
|
|
+ if (event.content) {
|
|
|
+ message.answerText = `${message.answerText || ''}${event.content}`
|
|
|
+ }
|
|
|
+ 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
|
|
|
+
|
|
|
+ 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',
|
|
|
+ params: {
|
|
|
+ query,
|
|
|
+ files
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ const agentRunnerKey = response?.result
|
|
|
+ if (!agentRunnerKey) {
|
|
|
+ finishActiveMessage('智能体启动失败')
|
|
|
+ ElMessage.error('智能体启动失败')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ runnerStore.startRunner(agentRunnerKey, 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)
|
|
|
+}
|
|
|
+
|
|
|
+watch(
|
|
|
+ () => runnerStore.agentChatMessages.length,
|
|
|
+ () => {
|
|
|
+ handleRunnerChatMessages()
|
|
|
+ }
|
|
|
+)
|
|
|
+
|
|
|
+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;
|
|
|
+ border-top: 1px solid var(--border-light);
|
|
|
+ background: var(--bg-container);
|
|
|
+}
|
|
|
+
|
|
|
+: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);
|
|
|
+}
|
|
|
+</style>
|