feat: 对接AI对话接口
This commit is contained in:
@@ -8,17 +8,21 @@
|
||||
<SvgIcon name="equal" color="#0d0d0d" size="24" />
|
||||
</div>
|
||||
<div class="agent-body flex-1 flex flex-col">
|
||||
<List :message-list="messageList" />
|
||||
<List ref="listRef" :message-list="messageList" />
|
||||
<Input is-agent-mode @send="handleSendMessage" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref, reactive, computed, onUnmounted } from 'vue'
|
||||
import List from './List.vue'
|
||||
import Input from '../../components/Input.vue'
|
||||
import { fetchAgentReply } from '@/api/agent'
|
||||
import type { AgentParamsType } from '@/api/agent'
|
||||
import { useUserInfoStore } from '@/stores'
|
||||
|
||||
const userStore = useUserInfoStore()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
@@ -28,24 +32,35 @@
|
||||
}
|
||||
)
|
||||
|
||||
const messageList = ref([
|
||||
{ id: 1, text: 'Hello', isUser: true },
|
||||
{
|
||||
id: 2,
|
||||
text: 'Hey, I am your design assistant FiDA. I noticed that you want to design a yellow sofa. I can help you! Tell me what else you need?'
|
||||
const messageList = ref([])
|
||||
const listRef = ref()
|
||||
const params = reactive<AgentParamsType>({
|
||||
threadId: '',
|
||||
message: '',
|
||||
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyIiwiaWF0IjoxNzcwNzkxMzEyLCJleHAiOjE3NzA4Nzc3MTJ9.xydPinm9l5Yq6GMkfaaVvdHjiINaYrp5VkRM7B9g83A',
|
||||
checkpointId: '',
|
||||
configParams: {
|
||||
type: 'Chair',
|
||||
region: 'China',
|
||||
style: 'Transitional',
|
||||
temperature: 0.7
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
text: 'Please design a vintage-inspired sofa with smooth, flowing lines and a sculptural silhouette. The sofa features a retro aesthetic combined with elegant curves, creating a timeless and refined look.',
|
||||
isUser: true
|
||||
}
|
||||
])
|
||||
imageUrlList: []
|
||||
})
|
||||
|
||||
const handleSendMessage = (message: string) => {
|
||||
const abort = new AbortController()
|
||||
|
||||
onUnmounted(() => {
|
||||
abort.abort()
|
||||
})
|
||||
|
||||
const handleSendMessage = async (message: string) => {
|
||||
console.log('Message sent:', message)
|
||||
params.message = message.text
|
||||
params.imageUrlList = message.images || []
|
||||
messageList.value.push({
|
||||
id: messageList.value.length + 1,
|
||||
text: message,
|
||||
text: message.text,
|
||||
isUser: true
|
||||
})
|
||||
|
||||
@@ -58,44 +73,138 @@
|
||||
thinking: false,
|
||||
thinkingText: '',
|
||||
thinkingCollapsed: false,
|
||||
streaming: false
|
||||
streaming: true
|
||||
})
|
||||
messageList.value.push(aiMessage)
|
||||
|
||||
// Simulate AI response
|
||||
setTimeout(() => {
|
||||
aiMessage.loading = false
|
||||
aiMessage.thinking = true
|
||||
aiMessage.thinkingText = '思考中...'
|
||||
const threadId = '' //
|
||||
console.log('token---', params.token, '参数---', params)
|
||||
|
||||
// Simulate thinking process
|
||||
setTimeout(() => {
|
||||
aiMessage.thinkingText = '分析用户需求:设计复古风格沙发,强调流畅线条和雕塑轮廓。'
|
||||
}, 1000)
|
||||
try {
|
||||
const urlParams = new URLSearchParams<AgentParamsType>({
|
||||
...params,
|
||||
configParams: JSON.stringify(params.configParams)
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
aiMessage.thinkingText += '\n考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素。'
|
||||
}, 2000)
|
||||
const response = await fetch(`/api/ai-design/chat?${urlParams.toString()}`, {
|
||||
method: 'GET',
|
||||
signal: abort.signal
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
aiMessage.thinkingText += '\n生成设计草图...'
|
||||
aiMessage.thinking = false
|
||||
aiMessage.streaming = true
|
||||
// 检查响应内容类型,判断是否为流式响应
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
const isStreamResponse =
|
||||
contentType.includes('text/event-stream') || contentType.includes('stream')
|
||||
|
||||
// Simulate streaming response
|
||||
const response = '根据您的描述,我为您设计了一个复古风格的沙发。这个沙发采用黄色皮革材质,线条流畅,轮廓雕塑感强。整体造型优雅,结合了现代舒适与复古美学。'
|
||||
let index = 0
|
||||
const interval = setInterval(() => {
|
||||
if (index < response.length) {
|
||||
aiMessage.text += response[index]
|
||||
index++
|
||||
} else {
|
||||
clearInterval(interval)
|
||||
if (!response.ok) {
|
||||
// 非流式错误响应,使用 text() 读取错误信息
|
||||
const errorText = await response.text()
|
||||
console.error('请求错误:', errorText)
|
||||
throw new Error(`发起对话错误--- ${response.status}: ${errorText}`)
|
||||
}
|
||||
|
||||
// 不是流式响应,使用 text()读取错误信息
|
||||
if (!isStreamResponse) {
|
||||
const text = await response.text()
|
||||
try {
|
||||
const errorData = JSON.parse(text)
|
||||
console.error('非流式响应:', errorData)
|
||||
} catch (e) {
|
||||
console.error('非流式响应文本:', text)
|
||||
}
|
||||
aiMessage.streaming = false
|
||||
aiMessage.loading = false
|
||||
return
|
||||
}
|
||||
|
||||
// 流式响应处理
|
||||
let contentBody = ''
|
||||
let buffer = ''
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) throw new Error('无法获取流读取器')
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
try {
|
||||
let flag = true
|
||||
while (flag) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
console.log('传输结束 end---', contentBody)
|
||||
aiMessage.streaming = false
|
||||
aiMessage.loading = false
|
||||
flag = false
|
||||
break
|
||||
}
|
||||
}, 50)
|
||||
}, 3000)
|
||||
}, 1000)
|
||||
|
||||
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
|
||||
|
||||
// 过滤掉 id: 等字段,只取 data:
|
||||
const dataLines = event
|
||||
.split(/\n/)
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.replace(/^data:\s*/, '').trim())
|
||||
|
||||
if (dataLines.length === 0) continue
|
||||
const jsonText = dataLines.join('\n')
|
||||
|
||||
try {
|
||||
const jsonData = JSON.parse(jsonText)
|
||||
// 赋值 thread_id 和 checkpoint_id
|
||||
if (jsonData.thread_id) params.threadId = jsonData.thread_id
|
||||
if (jsonData.checkpoint_id) params.checkpointId = jsonData.checkpoint_id
|
||||
if (
|
||||
jsonData.content &&
|
||||
jsonData.content.length > 0 &&
|
||||
jsonData.type !== 'end'
|
||||
) {
|
||||
contentBody += jsonData.content
|
||||
aiMessage.text = contentBody
|
||||
}
|
||||
if (jsonData.type === 'end') {
|
||||
aiMessage.streaming = false
|
||||
aiMessage.loading = false
|
||||
flag = false
|
||||
break
|
||||
}
|
||||
} catch (e) {
|
||||
// 检查是否为纯文本 [DONE]
|
||||
if (jsonText.trim() === '[DONE]') {
|
||||
console.log('结束-----------------------')
|
||||
aiMessage.streaming = false
|
||||
aiMessage.loading = false
|
||||
flag = false
|
||||
break
|
||||
}
|
||||
// JSON 不完整:保留到下一次循环
|
||||
if (!jsonText.trim().endsWith('}')) {
|
||||
buffer = 'data: ' + jsonText // 重新放回缓存
|
||||
continue
|
||||
} else {
|
||||
console.warn('⚠️ JSON 格式错误,跳过:', jsonText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('流式传输错误:', error)
|
||||
aiMessage.streaming = false
|
||||
aiMessage.loading = false
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('fetch请求失败:', error)
|
||||
aiMessage.streaming = false
|
||||
aiMessage.loading = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -127,6 +236,8 @@
|
||||
}
|
||||
.agent-body {
|
||||
padding: 3.2rem;
|
||||
overflow: hidden;
|
||||
row-gap: 2.4rem;
|
||||
.assist-input-wrapper {
|
||||
width: 100%;
|
||||
height: 14.4rem;
|
||||
|
||||
@@ -4,8 +4,13 @@
|
||||
<div class="thumb">
|
||||
<img :src="content.isUser ? userThumb : agentThumb" class="thumb-icon" />
|
||||
</div>
|
||||
<div class="message-context" v-show="!content.loading && !content.thinking && !content.streaming">
|
||||
<div class="message-txt">{{ content.text }}</div>
|
||||
<div
|
||||
class="message-context"
|
||||
v-show="!content.loading && !content.thinking && !content.streaming"
|
||||
>
|
||||
<div class="message-txt markdown-body">
|
||||
<div v-html="formatMessage"></div>
|
||||
</div>
|
||||
<div class="operate flex" :class="{ 'is-user': content.isUser }">
|
||||
<template v-if="content.isUser">
|
||||
<SvgIcon name="copy" size="16" color="#000" @click.stop="handleCopyText" />
|
||||
@@ -23,9 +28,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-context" v-show="content.loading">
|
||||
<div class="generating">
|
||||
Generating...
|
||||
</div>
|
||||
<div class="generating">Generating...</div>
|
||||
</div>
|
||||
<div class="message-context" v-show="content.thinking">
|
||||
<div class="thinking">
|
||||
@@ -39,18 +42,23 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-context" v-show="content.streaming">
|
||||
<div class="message-txt">{{ content.text }}</div>
|
||||
<div class="message-txt">
|
||||
<div v-html="formatMessage"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import gsap from 'gsap'
|
||||
import userThumb from '@/assets/images/user-thumb.jpg'
|
||||
import agentThumb from '@/assets/images/agent-thumb.jpg'
|
||||
import markdownIt from 'markdown-it'
|
||||
|
||||
const md = new markdownIt()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -58,6 +66,21 @@
|
||||
content: Object
|
||||
}>()
|
||||
|
||||
const formatMessage = computed(() => {
|
||||
// MARKDOWN.renderer.rules.link_open = (tokens, idx, options, env, self) => {
|
||||
// const aIndex = tokens[idx].attrIndex('target')
|
||||
// if (aIndex < 0) {
|
||||
// tokens[idx].attrPush(['target', '_blank'])
|
||||
// } else {
|
||||
// tokens[idx].attrs[aIndex][1] = '_blank'
|
||||
// }
|
||||
// return self.renderToken(tokens, idx, options, env, self)
|
||||
// }
|
||||
const str = md.render(props.content.text)
|
||||
console.log('str',str)
|
||||
return str
|
||||
})
|
||||
|
||||
const operateList = ref([
|
||||
{
|
||||
name: 'thumbUp',
|
||||
@@ -111,12 +134,6 @@
|
||||
const toggleThinkingCollapsed = () => {
|
||||
props.content.thinkingCollapsed = !props.content.thinkingCollapsed
|
||||
}
|
||||
|
||||
const playLoadingAnimate = () => {}
|
||||
|
||||
onMounted(() => {
|
||||
// playLoadingAnimate()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@@ -129,7 +146,7 @@
|
||||
font-size: 1.4rem;
|
||||
.message-wrapper {
|
||||
column-gap: 0.9rem;
|
||||
// align-items: flex-start;
|
||||
// align-items: flex-start;
|
||||
&.is-user {
|
||||
text-align: right;
|
||||
flex-direction: row-reverse;
|
||||
@@ -148,6 +165,10 @@
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
.message-context{
|
||||
line-height: 2rem;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
}
|
||||
.operate {
|
||||
margin-top: 1.3rem;
|
||||
@@ -191,20 +212,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @supports (-webkit-mask-image: url(#mask)) or (mask-image: url(#mask)) {
|
||||
// .generating::after {
|
||||
// content: '';
|
||||
// position: absolute;
|
||||
// top: 0;
|
||||
// left: 0;
|
||||
// width: 100%;
|
||||
// height: 100%;
|
||||
// background: url('path-to-noise-texture.png'); /* Replace with a noise texture image sized appropriately (e.g., small grain at 0.03 scale) */
|
||||
// opacity: 1; /* 100% density */
|
||||
// mix-blend-mode: color; /* Approximate duotone blending */
|
||||
// -webkit-mask-image: -webkit-linear-gradient(#c05f20, #290d99);
|
||||
// mask-image: linear-gradient(#c05f20, #290d99);
|
||||
// }
|
||||
// }
|
||||
</style>
|
||||
|
||||
@@ -1,20 +1,41 @@
|
||||
<template>
|
||||
<div class="agent-list flex flex-col flex-1">
|
||||
<div class="agent-list flex flex-col flex-1" ref="listContainer">
|
||||
<Item v-for="message in messageList" :key="message.id" :content="message" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, nextTick, watch } from 'vue'
|
||||
import Item from './Item.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
messageList: Array<any>
|
||||
}>()
|
||||
|
||||
const listContainer = ref<HTMLDivElement>()
|
||||
|
||||
const scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
if (listContainer.value) {
|
||||
listContainer.value.scrollTop = listContainer.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => props.messageList, () => {
|
||||
scrollToBottom()
|
||||
}, { deep: true })
|
||||
|
||||
defineExpose({ scrollToBottom })
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.agent-list {
|
||||
row-gap: 3.2rem;
|
||||
overflow-y: auto;
|
||||
// 隐藏滚动条
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user