Chat.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535
  1. <template>
  2. <div class="setter-chat">
  3. <MessageList
  4. ref="messageListRef"
  5. :messages="messages"
  6. :loading="isRunning"
  7. @retry="handleRetry"
  8. :can-add-to-kb="false"
  9. >
  10. <template #message-extra="{ item }">
  11. <WorkflowTraceBubble v-if="props.showWorkflowTrace" :message="item" />
  12. </template>
  13. </MessageList>
  14. <div class="setter-chat__footer">
  15. <el-dialog
  16. v-model="uploadDialogVisible"
  17. title="上传文件"
  18. width="560px"
  19. append-to-body
  20. :close-on-click-modal="false"
  21. >
  22. <FileUploadInput
  23. v-model="attachments"
  24. :multiple="true"
  25. :allow-link-input="false"
  26. tip="上传后会作为本次 AI 对话的 files 参数。"
  27. />
  28. <template #footer>
  29. <el-button @click="uploadDialogVisible = false">关闭</el-button>
  30. <el-button type="primary" @click="uploadDialogVisible = false">完成</el-button>
  31. </template>
  32. </el-dialog>
  33. <ChatInput
  34. v-model="senderValue"
  35. :loading="isRunning"
  36. :attachments="attachments"
  37. @submit="handleSend"
  38. @cancel="handleCancel"
  39. >
  40. <template #prefix-extra>
  41. <el-badge :value="attachments.length" :hidden="!attachments.length">
  42. <el-button round plain color="#626aef" @click="uploadDialogVisible = true">
  43. <el-icon>
  44. <Paperclip />
  45. </el-icon>
  46. </el-button>
  47. </el-badge>
  48. </template>
  49. </ChatInput>
  50. </div>
  51. </div>
  52. </template>
  53. <script setup lang="ts">
  54. import { computed, nextTick, ref, watch } from 'vue'
  55. import { ElMessage } from 'element-plus'
  56. import { Paperclip } from '@element-plus/icons-vue'
  57. import { agent } from '@repo/api-service'
  58. import ChatInput from '@/components/Chat/ChatInput.vue'
  59. import MessageList from '@/components/Chat/MessageList.vue'
  60. import FileUploadInput from '@/features/fileUpload/FileUploadInput.vue'
  61. import WorkflowTraceBubble from './WorkflowTraceBubble.vue'
  62. import { useRunnerStore } from '@/store/modules/runner.store'
  63. import { cloneDeep } from 'lodash-es'
  64. import type { WorkflowUploadFile } from '@/features/fileUpload/shared'
  65. import type {
  66. BubbleMessage,
  67. ChatReference,
  68. ChatSseMessage,
  69. ChatToolCall,
  70. ChatToolResult
  71. } from '@/views/chat/types'
  72. import type { IWorkflowNode } from '@repo/workflow'
  73. interface Props {
  74. node?: IWorkflowNode
  75. workflowId?: string
  76. baseParams?: Record<string, any>
  77. showWorkflowTrace?: boolean
  78. validateBeforeSend?: () => boolean | Record<string, any> | Promise<boolean | Record<string, any>>
  79. onFirstSend?: () => void
  80. }
  81. const props = withDefaults(defineProps<Props>(), {
  82. baseParams: () => ({}),
  83. showWorkflowTrace: false,
  84. validateBeforeSend: undefined,
  85. onFirstSend: undefined
  86. })
  87. const emit = defineEmits<{
  88. 'run-started': [nodeId: string]
  89. }>()
  90. const runnerStore = useRunnerStore()
  91. const messages = ref<BubbleMessage[]>([])
  92. const senderValue = ref('')
  93. const attachments = ref<WorkflowUploadFile[]>([])
  94. const uploadDialogVisible = ref(false)
  95. const messageListRef = ref<InstanceType<typeof MessageList>>()
  96. const activeAiMessage = ref<BubbleMessage | null>(null)
  97. const handledChatMessageCount = ref(0)
  98. const startingRunner = ref(false)
  99. const isRunning = computed(
  100. () =>
  101. startingRunner.value ||
  102. (!!activeAiMessage.value &&
  103. (runnerStore.status === 'connecting' || runnerStore.status === 'running'))
  104. )
  105. const appAgentId = computed(
  106. () => props.workflowId || props.node?.appAgentId || (props.node?.data as any)?.appAgentId || ''
  107. )
  108. const scrollToBottom = () => {
  109. nextTick(() => {
  110. messageListRef.value?.scrollToBottom?.()
  111. })
  112. }
  113. const normalizeText = (value?: unknown) => `${value || ''}`.trim()
  114. const createMessageKey = (prefix: string) =>
  115. `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
  116. const createUserMessage = (content: string, files: WorkflowUploadFile[] = []): BubbleMessage => ({
  117. key: createMessageKey('user'),
  118. role: 'user',
  119. placement: 'end',
  120. content,
  121. loading: false,
  122. shape: 'corner',
  123. variant: 'outlined',
  124. isMarkdown: true,
  125. typing: false,
  126. isFog: false,
  127. message_files: files.map((item) => item.path || item.id).filter(Boolean)
  128. })
  129. const createAiMessage = (): BubbleMessage => ({
  130. key: createMessageKey('ai'),
  131. role: 'ai',
  132. placement: 'start',
  133. content: '',
  134. answerText: '',
  135. thinking: '',
  136. toolCalls: [],
  137. toolResults: [],
  138. references: [],
  139. assistantMessageId: '',
  140. loading: true,
  141. shape: 'corner',
  142. variant: 'filled',
  143. isMarkdown: true,
  144. typing: { step: 3, interval: 25 },
  145. isFog: true,
  146. ...(props.showWorkflowTrace
  147. ? {
  148. workflowTraceVisible: true,
  149. workflowTraceNodes: []
  150. }
  151. : {})
  152. })
  153. const syncWorkflowTraceToActiveMessage = () => {
  154. if (!props.showWorkflowTrace || !activeAiMessage.value) return
  155. activeAiMessage.value.workflowTraceNodes = cloneDeep(runnerStore.nodes)
  156. }
  157. const getFilesParam = () =>
  158. attachments.value
  159. .map((item) => ({
  160. id: item.id,
  161. name: item.name,
  162. extensionName: item.extensionName,
  163. size: item.size,
  164. path: item.path || item.id
  165. }))
  166. .filter((item) => item.path)
  167. const normalizeToolCall = (event: ChatSseMessage): ChatToolCall => {
  168. const data = event.data || {}
  169. return {
  170. id: data.id || event.id,
  171. toolCallId: data.tool_call_id || data.toolCallId,
  172. toolName: data.tool_name || data.toolName,
  173. arguments: data.arguments || data.params || data.args || {},
  174. content: event.content || data.content
  175. }
  176. }
  177. const normalizeToolResult = (event: ChatSseMessage): ChatToolResult => {
  178. const data = event.data || {}
  179. return {
  180. id: data.id || event.id,
  181. toolCallId: data.tool_call_id || data.toolCallId,
  182. toolName: data.tool_name || data.toolName,
  183. success: data.success,
  184. error: data.error,
  185. output: data.output,
  186. thought: data.thought,
  187. durationMs: data.duration_ms || data.durationMs,
  188. displayType: data.display_type || data.displayType,
  189. contentItems: data.content_items || data.contentItems
  190. }
  191. }
  192. const normalizeReferences = (event: ChatSseMessage): ChatReference[] => {
  193. const data = event.data || {}
  194. const eventRefs = (event as ChatSseMessage & { references?: unknown }).references
  195. const refs = data.knowledge_references || data.references || eventRefs || []
  196. if (!Array.isArray(refs)) return []
  197. return refs.map((item: Record<string, any>) => ({
  198. id: item.id,
  199. knowledgeBaseId: item.knowledge_base_id || item.knowledgeBaseId,
  200. knowledgeId: item.knowledge_id || item.knowledgeId,
  201. knowledgeTitle: item.knowledge_title || item.knowledgeTitle,
  202. knowledgeFilename: item.knowledge_filename || item.knowledgeFilename,
  203. knowledgeDescription: item.knowledge_description || item.knowledgeDescription,
  204. content: item.content,
  205. matchedContent: item.matched_content || item.matchedContent,
  206. score: item.score,
  207. chunkType: item.chunk_type || item.chunkType
  208. }))
  209. }
  210. const normalizeChatEvent = (raw: Record<string, any>): ChatSseMessage => {
  211. const data = raw.data && typeof raw.data === 'object' ? raw.data : raw
  212. const content =
  213. raw.content ??
  214. raw.answer ??
  215. raw.output ??
  216. raw.delta ??
  217. raw.text ??
  218. data.content ??
  219. data.answer ??
  220. ''
  221. return {
  222. id: raw.id || data.id,
  223. response_type:
  224. raw.response_type ||
  225. raw.responseType ||
  226. raw.type ||
  227. raw.event ||
  228. (content ? 'agent_query' : 'complete'),
  229. content,
  230. done: raw.done || raw.is_end || raw.finished,
  231. data
  232. }
  233. }
  234. const buildReferenceMarkdown = (references: ChatReference[] = []) => {
  235. if (!references.length) return ''
  236. const items = references.map((item, index) => {
  237. const title =
  238. item.knowledgeTitle || item.knowledgeFilename || item.knowledgeId || `引用 ${index + 1}`
  239. const content = item.matchedContent || item.content || ''
  240. return `\n${index + 1}. ${title}${content ? `\n ${content}` : ''}`
  241. })
  242. return `\n\n**引用**${items.join('')}`
  243. }
  244. const updateMessageContent = (message: BubbleMessage) => {
  245. const answer = normalizeText(message.answerText || message.content)
  246. message.content = `${answer}${buildReferenceMarkdown(message.references)}`
  247. }
  248. const finishActiveMessage = (error?: string) => {
  249. const message = activeAiMessage.value
  250. if (!message) return
  251. if (error && !message.content && !message.answerText) {
  252. message.content = error
  253. message.answerText = error
  254. message.error = true
  255. }
  256. message.loading = false
  257. message.isFog = false
  258. message.streamCompleted = true
  259. updateMessageContent(message)
  260. activeAiMessage.value = null
  261. scrollToBottom()
  262. }
  263. const applyStructuredEventToMessage = (message: BubbleMessage, event: ChatSseMessage) => {
  264. const data = event.data || {}
  265. switch (event.response_type) {
  266. case 'answer':
  267. message.assistantMessageId =
  268. data.assistant_message_id || data.assistantMessageId || message.assistantMessageId
  269. if (event.content) {
  270. message.answerText = `${message.answerText || ''}${event.content}`
  271. }
  272. break
  273. case 'agent_query':
  274. case 'message':
  275. case 'delta':
  276. message.assistantMessageId =
  277. data.assistant_message_id || data.assistantMessageId || message.assistantMessageId
  278. break
  279. case 'thinking': {
  280. const thought = normalizeText(event.content || data.thought || data.content)
  281. if (thought) {
  282. message.thinking = `${message.thinking || ''}${message.thinking ? '\n\n' : ''}${thought}`
  283. }
  284. break
  285. }
  286. case 'tool_call':
  287. message.toolCalls = [...(message.toolCalls || []), normalizeToolCall(event)]
  288. break
  289. case 'tool_result': {
  290. message.toolResults = [...(message.toolResults || []), normalizeToolResult(event)]
  291. const thought = normalizeText(data.thought)
  292. if (thought) {
  293. message.thinking = `${message.thinking || ''}${message.thinking ? '\n\n' : ''}${thought}`
  294. }
  295. break
  296. }
  297. case 'references':
  298. message.references = [...(message.references || []), ...normalizeReferences(event)]
  299. break
  300. case 'complete':
  301. break
  302. default:
  303. break
  304. }
  305. updateMessageContent(message)
  306. message.loading = event.response_type !== 'complete' && !event.done
  307. message.isFog = false
  308. if (event.response_type === 'complete' || event.done) {
  309. message.streamCompleted = true
  310. }
  311. }
  312. const handleRunnerChatMessages = () => {
  313. const list = runnerStore.agentChatMessages
  314. if (handledChatMessageCount.value >= list.length) return
  315. const nextMessages = list.slice(handledChatMessageCount.value)
  316. handledChatMessageCount.value = list.length
  317. if (!activeAiMessage.value) return
  318. nextMessages.forEach((item) => {
  319. applyStructuredEventToMessage(activeAiMessage.value!, normalizeChatEvent(item.msg || {}))
  320. })
  321. scrollToBottom()
  322. }
  323. const handleSend = async (content?: string) => {
  324. const query = normalizeText(content)
  325. if (!query) {
  326. ElMessage.warning('请输入问题')
  327. return
  328. }
  329. if (!props.node?.id || !appAgentId.value) {
  330. ElMessage.warning('缺少智能体或节点信息')
  331. return
  332. }
  333. if (isRunning.value) return
  334. let executeParams = cloneDeep(props.baseParams)
  335. if (props.validateBeforeSend) {
  336. const passed = await props.validateBeforeSend()
  337. if (!passed) return
  338. if (typeof passed === 'object') {
  339. executeParams = cloneDeep(passed)
  340. }
  341. }
  342. const files = getFilesParam()
  343. messages.value.push(createUserMessage(query, attachments.value))
  344. const aiMessage = createAiMessage()
  345. messages.value.push(aiMessage)
  346. activeAiMessage.value = aiMessage
  347. senderValue.value = ''
  348. attachments.value = []
  349. handledChatMessageCount.value = 0
  350. scrollToBottom()
  351. startingRunner.value = true
  352. try {
  353. const response = await agent.postAgentDoExecute({
  354. appAgentId: appAgentId.value,
  355. start_node_id: props.node.id,
  356. is_debugger: true,
  357. responseType: 'ws',
  358. query,
  359. files: files.map((item) => item.id),
  360. params: {
  361. ...executeParams
  362. }
  363. })
  364. const agentRunnerKey = response?.result
  365. if (!agentRunnerKey) {
  366. finishActiveMessage('智能体启动失败')
  367. ElMessage.error('智能体启动失败')
  368. return
  369. }
  370. runnerStore.startRunner(agentRunnerKey, props.node.id)
  371. syncWorkflowTraceToActiveMessage()
  372. props.onFirstSend?.()
  373. emit('run-started', props.node.id)
  374. handledChatMessageCount.value = 0
  375. } catch (error) {
  376. console.error('postAgentDoExecute error', error)
  377. finishActiveMessage('智能体运行失败')
  378. ElMessage.error('智能体运行失败')
  379. } finally {
  380. startingRunner.value = false
  381. }
  382. }
  383. const handleCancel = () => {
  384. runnerStore.stopRunner()
  385. finishActiveMessage()
  386. }
  387. const handleRetry = (message: BubbleMessage) => {
  388. const index = messages.value.findIndex((item) => item === message)
  389. if (index <= 0) return
  390. const previous = messages.value[index - 1]
  391. if (previous?.role !== 'user') return
  392. handleSend(previous.content)
  393. }
  394. const resetConversation = () => {
  395. runnerStore.resetRunner()
  396. messages.value = []
  397. senderValue.value = ''
  398. attachments.value = []
  399. uploadDialogVisible.value = false
  400. activeAiMessage.value = null
  401. handledChatMessageCount.value = 0
  402. startingRunner.value = false
  403. }
  404. defineExpose({
  405. scrollToBottom,
  406. resetConversation
  407. })
  408. watch(
  409. () => runnerStore.agentChatMessages.length,
  410. () => {
  411. handleRunnerChatMessages()
  412. }
  413. )
  414. watch(
  415. () => runnerStore.nodes.map((node) => ({ ...node })),
  416. () => {
  417. syncWorkflowTraceToActiveMessage()
  418. scrollToBottom()
  419. },
  420. { deep: true }
  421. )
  422. watch(
  423. () => runnerStore.status,
  424. (status) => {
  425. if (!activeAiMessage.value) return
  426. if (status === 'finished') {
  427. finishActiveMessage()
  428. }
  429. if (status === 'error') {
  430. finishActiveMessage(runnerStore.errorMsg || '智能体运行失败')
  431. }
  432. if (status === 'suspended') {
  433. finishActiveMessage('智能体运行已挂起')
  434. }
  435. }
  436. )
  437. </script>
  438. <style lang="less" scoped>
  439. .setter-chat {
  440. height: 100%;
  441. min-height: 0;
  442. display: flex;
  443. flex-direction: column;
  444. background: var(--bg-page);
  445. overflow: hidden;
  446. }
  447. .setter-chat__footer {
  448. flex-shrink: 0;
  449. }
  450. :deep(.chat-content) {
  451. min-height: 0;
  452. padding: 12px;
  453. }
  454. :deep(.sender-wrapper) {
  455. width: calc(100% - 24px);
  456. margin-bottom: 0;
  457. padding: 10px 0;
  458. }
  459. :deep(.el-bubble-end .el-bubble-content) {
  460. background-color: var(--el-color-primary-light-9);
  461. border-radius: 8px 0 8px 8px;
  462. color: var(--text-primary);
  463. border: none;
  464. }
  465. :deep(.el-bubble-start .el-bubble-content) {
  466. background-color: var(--bg-container);
  467. border: none;
  468. color: var(--text-primary);
  469. width: 100%;
  470. max-width: min(920px, calc(100vw - 220px));
  471. }
  472. :deep(.el-sender-footer) {
  473. border: none;
  474. }
  475. </style>