Files
FiDA_Front/src/views/home/agent/components/Agent.vue
2026-03-31 15:53:08 +08:00

695 lines
18 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="agent-container flex flex-col">
<div class="agent-header flex align-center space-between">
<div class="header-title-wrapper">
<div class="agent-title">{{ props.title }}</div>
<div class="agent-name">AI Assistant 1.0</div>
</div>
<!-- <SvgIcon name="equal" color="#0d0d0d" size="24" /> -->
</div>
<div class="agent-body flex-1 flex flex-col">
<List ref="listRef" :message-list="messageList" @regenerate="handleRegenerate" />
<Input
ref="inputRef"
is-agent-mode
:generating="isGenerating"
@send="handleSendMessage"
@pause="handlePause"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onUnmounted, onMounted, nextTick, watch } from 'vue'
import List from './List.vue'
import Input from '../../components/Input.vue'
import { chatUrl } from '@/api/agent'
import type { AgentParamsType } from '@/api/agent'
import { useUserInfoStore, useProjectStore, useAgentStore } from '@/stores'
import MyEvent from '@/utils/myEvent'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const userStore = useUserInfoStore()
const agentStore = useAgentStore()
const projectStore = useProjectStore()
const reportsContent = ref('')
const isGeneratingReport = ref(false)
const emits = defineEmits(['update:sketchList', 'setTitle'])
const props = withDefaults(
defineProps<{
title: string
}>(),
{
title: 'Retro Sofa Sketch'
}
)
const messageList = ref([])
const listRef = ref()
const inputRef = ref()
const isGenerating = ref(false)
const isPaused = ref(false) // 标记是否为主动暂停
const params = reactive<AgentParamsType>({
projectID: null,
message: '',
token: userStore.state.token,
versionID: '',
needSuggestion: false,
useReport: false,
configParams: {
type: '',
region: '',
style: ''
},
imageUrlList: [],
quotaUrl: []
})
const sketchList = ref([])
watch(
sketchList,
(newVal) => {
emits('update:sketchList', newVal)
},
{ deep: true }
)
// 每次请求时创建新的 AbortController
let abort: AbortController
const createAbortController = () => {
// 如果已有未完成的中止,先中止它
if (abort) {
abort.abort()
}
abort = new AbortController()
return abort
}
onUnmounted(() => {
abort?.abort()
})
onMounted(() => {
// 检查 store 中是否有初始项目数据
// projectStore.setId('1') // 临时设置项目ID为1实际应用中应根据上下文动态设置
const initialData = agentStore.getInitialProjectData
if (initialData) {
// 等待页面渲染完成后自动发送初始消息
params.configParams = {
type: initialData.type,
region: initialData.area,
style: initialData.style,
temperature: 0.7
}
params.needSuggestion = initialData.needSuggestion || false
params.useReport = initialData.useReport
handleSendMessage({
text: initialData.text,
images: initialData.images,
useReport: initialData.useReport,
tempImages: initialData.tempImages,
quoteList: initialData.quoteList
})
// 更新 configParams
// 清空初始数据
agentStore.clearInitialProjectData()
}
})
const handleSendMessage = async (
message: {
text: string
images: Array<{ url: string; name: string }>
tempImages: any[]
useReport: boolean
quoteList: Array<string>
},
skipUserMessage = false
) => {
isPaused.value = false
isGenerating.value = true
params.message = message.text
if (message.hasOwnProperty('useReport')) {
params.useReport = message.useReport
}
params.imageUrlList = message.images || []
params.quotaUrl = message.quoteList || []
// 如果不是重新生成模式,则添加用户消息到列表
if (!skipUserMessage) {
messageList.value.push({
id: messageList.value.length + 1,
text: message.text,
isUser: true,
imageUrls: message.tempImages.concat(message.quoteList)
})
}
// Add AI loading message
const aiMessage = reactive({
id: messageList.value.length + 1,
text: '',
isUser: false,
sessionId: projectStore.state.id,
loading: true,
thinking: false,
thinkingText: '',
streaming: true
})
messageList.value.push(aiMessage)
// 创建新的 AbortController
const abortController = createAbortController()
// console.log('token---', params.token, '参数---', params)
params.projectID = projectStore.state.id
try {
const urlParams = new URLSearchParams<AgentParamsType>({
...params,
configParams: JSON.stringify(params.configParams)
})
const BASEURL = import.meta.env.VITE_APP_URL
// console.log('params', params)
// debugger
const response = await fetch(`${BASEURL}${chatUrl}?${urlParams.toString()}`, {
method: 'GET',
signal: abortController.signal
})
// 检查响应内容类型,判断是否为流式响应
const contentType = response.headers.get('content-type') || ''
const isStreamResponse =
contentType.includes('text/event-stream') || contentType.includes('stream')
if (!response.ok) {
// 非流式错误响应,使用 text() 读取错误信息
const errorText = await response.text()
console.error('请求错误:', errorText)
if (!isPaused.value) {
aiMessage.text = '发送失败,请重试'
}
aiMessage.streaming = false
aiMessage.loading = false
isGenerating.value = false
return
}
// 不是流式响应,使用 text()读取错误信息
if (!isStreamResponse) {
const text = await response.text()
try {
const errorData = JSON.parse(text)
console.error('非流式响应:', errorData)
} catch (e) {
console.error('非流式响应文本:', text)
}
if (!isPaused.value) {
aiMessage.text = '发送失败,请重试'
}
aiMessage.streaming = false
aiMessage.loading = false
isGenerating.value = false
return
}
// 流式响应处理
let contentBody = ''
let buffer = ''
const reader = response.body?.getReader()
if (!reader) throw new Error('无法获取流读取器')
const decoder = new TextDecoder()
let previousEventName = '' // 记录上一个事件名称
let hasReportStarted = false // 标记 report 是否已经开始
let hasSketchEvent = false
let hasReportEvent = false
try {
let flag = true
while (flag) {
const { done, value } = await reader.read()
if (done) {
if (hasSketchEvent) {
aiMessage.text += `<slot slot-name="sketch"></slot>`
}
if (hasReportEvent) {
aiMessage.text += `<slot slot-name="card" title="Report" content="Report"></slot>`
}
aiMessage.streaming = false
aiMessage.loading = false
isGenerating.value = false
flag = false
break
}
buffer += decoder.decode(value, { stream: true })
// 优先按空行拆分事件块SSE标准
let events = buffer.split(/\n\n/)
buffer = events.pop() // 保留不完整块
for (let event of events) {
if (!event.trim()) continue
const eventName = event
.split(/\n/)
.find((line) => line.startsWith('event:'))
?.replace(/^event:\s*/, '')
?.trim()
if (!hasReportStarted && eventName === 'report') {
isGeneratingReport.value = true
hasReportEvent = true
// contentBody += `<slot slot-name="card" title="Report" content="Report"></slot>`
hasReportStarted = true
ElMessage.success(t('agent.generatingReport'))
}
if (
previousEventName === 'report' &&
eventName !== 'report' &&
reportsContent.value
) {
isGeneratingReport.value = false
sessionStorage.setItem(
'reportsContent_' + projectStore.state.id,
reportsContent.value
)
}
previousEventName = eventName
// console.log('eventName:', eventName, 'event:', event)
// 根据事件名称精确判断,而不是用 includes
if (eventName === 'error') {
aiMessage.text = '出现错误,请重试'
aiMessage.streaming = false
aiMessage.loading = false
isGenerating.value = false
flag = false
break
}
if (eventName === 'todo') {
continue
}
let isNodeIdEvent = eventName === 'nodeId'
let hasSketch = eventName === 'sketchIDAndUrl'
const dataLines = event
.split(/\n/)
.filter((line) => line.startsWith('data:'))
.map((line) => line.replace(/^data:\s*/, ''))
.filter((content) => content.startsWith('{') || content.startsWith('['))
// console.log('dataLInes', dataLines)
if (isNodeIdEvent) {
const versionID = event
.split(/\n/)
.filter((line) => line.startsWith('data:'))
.map((line) => line.replace(/^data:\s*/, ''))[0]
params.versionID = versionID
projectStore.setProject({ nodeId: versionID })
}
if (eventName === 'tool') {
MyEvent.emit('loading-sketch', sketchList.value.length)
}
if (dataLines.length === 0) continue
const jsonText = dataLines.join('\n')
try {
const jsonData = JSON.parse(jsonText)
// console.log('jsonData', jsonData)
if (jsonData.webAddress) {
aiMessage.webAddress = JSON.parse(jsonData.webAddress)
contentBody += `<slot slot-name="url"></slot>`
}
if (jsonData.title) {
emits('setTitle', jsonData.title)
MyEvent.emit('newTitle', jsonData.title)
}
if (hasSketch) {
hasSketchEvent = true
Object.keys(jsonData).forEach((key) => {
if (!sketchList.value.some((item) => item[key])) {
sketchList.value.push({
[key]: jsonData[key]
})
}
})
// 通知 Preview 有新 sketch 正在加载,传入 sketch 索引
MyEvent.emit('loading-sketch', sketchList.value.length - 1)
MyEvent.emit('OpenSketch')
// contentBody += `<slot slot-name="sketch"></slot>`
}
if (eventName === 'reportName' || eventName === 'reportTitle') {
aiMessage.reportName = jsonData.reportName || jsonData.reportTitle
}
if (eventName === 'report') {
reportsContent.value += jsonData.report
} else {
if (jsonData.reasoning) {
aiMessage.thinking = true
aiMessage.loading = false
aiMessage.thinkingText += jsonData.reasoning
} else {
aiMessage.thinking = false
if (
jsonData.content &&
jsonData.content.length > 0 &&
jsonData.type !== 'end'
) {
contentBody += jsonData.content
aiMessage.text = contentBody
aiMessage.loading = false
}
}
}
if (jsonData.type === 'end') {
aiMessage.streaming = false
aiMessage.loading = false
isGenerating.value = false
flag = false
break
}
} catch (e) {
// 检查是否为纯文本 [DONE]
if (jsonText.trim() === '[DONE]') {
console.log('结束-----------------------')
aiMessage.text = contentBody
aiMessage.streaming = false
aiMessage.loading = false
isGenerating.value = false
flag = false
break
}
// JSON 不完整:保留到下一次循环
if (!jsonText.trim().endsWith('}')) {
buffer = 'data: ' + jsonText // 重新放回缓存
continue
} else {
console.warn('⚠️ JSON 格式错误,跳过:', jsonText)
}
}
// 更新上一个事件名称
previousEventName = eventName
}
}
} catch (error) {
console.error('流式传输错误:', error)
if (!isPaused.value) {
aiMessage.text = '发送失败,请重试'
}
aiMessage.streaming = false
aiMessage.loading = false
isGenerating.value = false
} finally {
reader.releaseLock()
}
} catch (error) {
console.error('fetch请求失败:', error)
if (!isPaused.value) {
aiMessage.text = '发送失败,请重试'
}
aiMessage.streaming = false
aiMessage.loading = false
isGenerating.value = false
}
}
const handlePause = () => {
isPaused.value = true
isGenerating.value = false
abort?.abort()
MyEvent.emit('stopChat')
}
const handleRegenerate = async (aiMessage: any) => {
// 找到当前 AI 消息在列表中的索引
const aiIndex = messageList.value.findIndex((msg) => msg.id === aiMessage.id)
if (aiIndex === -1) return
// 找到对应的用户消息AI 消息前面的最近一条用户消息)
let userMessage = null
for (let i = aiIndex - 1; i >= 0; i--) {
if (messageList.value[i].isUser) {
userMessage = messageList.value[i]
break
}
}
if (!userMessage) return
// 删除当前的 AI 回复消息
messageList.value.splice(aiIndex, 1)
// 重新调用 API跳过用户消息添加因为用户消息已存在
await handleSendMessage(
{
text: userMessage.text,
images: userMessage.images || []
},
true
)
}
// 处理对话列表,将连续的 assistant 消息合并为一条
const processDialogue = (
dialogue,
startIndex,
existingImgList,
sessionId,
firstReportIndex = -1
) => {
if (!dialogue || dialogue.length === 0) return []
const result = []
let i = startIndex
while (i < dialogue.length) {
const item = dialogue[i]
if (item.role === 'user') {
// user 角色直接添加
result.push({
...item,
text: item.content,
isUser: true,
id: result.length + 1,
sessionId: sessionId
})
i++
} else if (item.role === 'assistant') {
// assistant 角色,拼接直到下一个 user
let combinedContent = item.content || ''
let combinedThinkingText = item.reasoning || ''
let combinedImageUrl = item.image_url || null
let reportName = item.reportName || null
let webAddress = item.webAddress || null
// 继续往后找连续的 assistant 消息
let j = i + 1
while (j < dialogue.length && dialogue[j].role === 'assistant') {
combinedContent += dialogue[j].content || ''
combinedThinkingText += dialogue[j].reasoning || ''
// 如果有 image_url 则保留
if (dialogue[j].image_url) {
combinedImageUrl = dialogue[j].image_url
}
if (dialogue[j].reportName) {
reportName = dialogue[j].reportName
}
if (dialogue[j].webAddress) {
combinedContent += `<slot slot-name="url"></slot>`
webAddress = dialogue[j].webAddress
// console.log('webAddress22222222222222', dialogue[j].webAddress)
// debugger
}
j++
}
// 如果 firstReportIndex 在当前合并范围内,则把 slot 追加到末尾
if (firstReportIndex >= i && firstReportIndex < j) {
combinedContent += `<slot slot-name="card" title="Report" content="Report"></slot>`
}
result.push({
...item,
reportName,
content: combinedContent,
thinkingText: combinedThinkingText,
text: combinedContent,
image_url: combinedImageUrl,
webAddress: !!webAddress ? JSON.parse(webAddress) : null,
isUser: false,
id: result.length + 1,
sessionId: sessionId
})
i = j
} else {
// 其他角色直接添加
result.push({
...item,
text: item.content,
isUser: item.role === 'user',
id: result.length + 1,
sessionId: sessionId
})
i++
}
}
return result
}
const processSession = (session, imgList, ancestorsList, idCounterRef) => {
if (!session) return
// 1. 找到第一个 report 项的索引,供 processDialogue 使用
let firstReportIndex = -1
if (session.dialogue) {
for (let i = 0; i < session.dialogue.length; i++) {
if (session.dialogue[i].report) {
firstReportIndex = i
break
}
}
}
// 2. 收集 report 内容并保存到 sessionStorage
let reportStr = ''
session.dialogue?.forEach((item) => {
if (item.report) {
reportStr += item.report
}
})
if (reportStr && session.id) {
sessionStorage.setItem(`reportsContent_${session.id}`, reportStr)
}
// 3. 收集 sketchIDAndUrl 到 imgList
if (session.sketchIDAndUrl) {
imgList.push(session.sketchIDAndUrl)
}
// 4. 处理 dialogue
const list = processDialogue(session.dialogue, 0, imgList, session.id, firstReportIndex)
list.forEach((el) => {
el.id = idCounterRef.value++
})
ancestorsList.push(...list)
}
const setChatInfo = (info) => {
const initialData = agentStore.getInitialProjectData
if (isGenerating.value || initialData) return
const data = info.conversation
let project = info.project
if (info.id) {
project = info
}
params.versionID = ''
sketchList.value = []
if (project) {
params.configParams.type = project.type
params.configParams.region = project.area
params.configParams.style = project.style
params.configParams.temperature = project.temperature
}
if (!data) {
messageList.value = []
return
}
const { ancestors, current } = data
const imgList = []
const ancestorsList = []
const idCounterRef = { value: 1 }
ancestors?.forEach((item) => {
processSession(item, imgList, ancestorsList, idCounterRef)
})
processSession(current, imgList, ancestorsList, idCounterRef)
nextTick(() => {
ancestorsList.forEach((item) => {
if (item.image_url && item.role !== 'user') {
item.text += `<slot slot-name="sketch"></slot>`
}
})
messageList.value = [...ancestorsList]
params.versionID = current?.id
sketchList.value = imgList
})
}
defineExpose({
setChatInfo
})
</script>
<style lang="less" scoped>
.c-svg {
width: initial;
}
.agent-container {
// width: 39%;
// width: 63.4rem;
// flex: 1;
width: 67.4rem;
margin-right: 2.7rem;
background-color: #fff;
border-radius: 2rem;
box-shadow: 0px 15px 21px 0px #0000000d;
.agent-header {
height: 7.4rem;
border-bottom: 0.1rem solid #c9c9c9;
padding: 1.4rem 3.4rem 1.4rem 3.1rem;
.agent-title {
font-size: 1.8rem;
margin-bottom: 0.6rem;
}
.agent-name {
font-size: 1.4rem;
}
}
.agent-body {
padding: 3.2rem;
overflow: hidden;
row-gap: 2.4rem;
.assist-input-wrapper {
width: 100%;
// height: 14.4rem;
min-height: 14.4rem !important;
max-height: 18.1rem !important;
}
}
.input-wrapper {
height: 14.4rem;
padding: 0 2rem 3rem;
}
}
</style>