feat: 对接AI对话接口

This commit is contained in:
2026-02-11 16:32:38 +08:00
parent 0d13d69339
commit 7656250d9a
7 changed files with 637 additions and 95 deletions

79
src/api/agent.ts Normal file
View File

@@ -0,0 +1,79 @@
import request from '@/utils/request'
// 对话
export interface AgentParamsType {
message: string // 消息
threadId: string // 对话ID
checkpointId?: string // 检查点ID
imageUrlList?: string[] // 图片URL列表
configParams: Record<string, any> // 其他配置参数
token: string
}
export const fetchAgentReply = (data: AgentParamsType): Promise<AgentResponse> => {
return request({
url: '/api/ai-design/chat',
method: 'post',
data,
meta: { responseAll: true }
})
}
// 流式对话
export const fetchAgentReplyStream = async (
data: AgentParamsType,
onMessage: (chunk: string) => void,
onEnd: () => void
) => {
try {
const params = new URLSearchParams({
message: data.message,
threadId: data.threadId,
token: data.token,
configParams: JSON.stringify(data.configParams)
})
const response = await fetch(`/api/ai-design/chat?${params.toString()}`, {
method: 'GET'
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const reader = response.body?.getReader()
if (!reader) {
throw new Error('Response body is not readable')
}
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || '' // 保留不完整的行
for (const line of lines) {
console.log('line---', line)
const trimmedLine = line.trim()
if (trimmedLine.startsWith('data: ')) {
const chunk = trimmedLine.slice(6)
if (chunk === '[DONE]') {
onEnd()
return
} else {
onMessage(chunk)
}
}
}
}
onEnd()
} catch (error) {
console.error('Stream error:', error)
onEnd()
}
}

View File

@@ -5,13 +5,13 @@ import { removeLocal, setLocal } from '@/utils/local'
import MyEvent from '@/utils/myEvent'
export const useUserInfoStore = defineStore('userInfo', () => {
const state = ref({
userInfo: {},
token: '',
generateParams: {
stylist: '',
sex: '',
stylistImage: ''
}
userInfo: {},
token: '',
generateParams: {
stylist: '',
sex: '',
stylistImage: ''
}
})
// getters

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,317 @@
<template>
<div class="asistant-container flex flex-column">
<div class="header">
<HeaderTitle hasSetting styleType="3" @clickProfile="handleClickProfile" />
</div>
<div class="content flex-1">
<NoticeList
ref="noticeListRef"
:list="messageList"
:is-streaming="isStreaming"
:streaming-message="currentStreamingMessage"
/>
</div>
<div class="footer">
<InputArea @send-message="handleSendMessage" />
<div class="continue flex">
<div class="btn flex flex-center" @click="handleContinue">Generate</div>
</div>
</div>
<Profile ref="profileRef" />
</div>
</template>
<script setup lang="ts">
import HeaderTitle from '@/components/HeaderTitle.vue'
import NoticeList from './components/NoticeList.vue'
import InputArea from './components/InputArea.vue'
import Profile from '../Workshop/profile.vue'
import { ref, onMounted, onUnmounted, onActivated } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserInfoStore, useGenerateStore } from '@/stores'
import { streamChatAddress } from '@/api/workshop'
import { generateUUID } from '@/utils/tools'
import { showToast } from 'vant'
const router = useRouter()
const route = useRoute()
const generateStore = useGenerateStore()
const userInfoStore = useUserInfoStore()
defineOptions({
name: 'asistant'
})
// 定义NoticeList组件引用类型
interface NoticeListRef {
simulateSendMessage: () => void
scrollToBottom: () => void
}
// 定义消息类型
interface ChatMessage {
sessionId: string | number
type: string
content: string
timestamp: string
id?: string
self?: boolean
}
const profileRef = ref<InstanceType<typeof Profile> | null>(null)
const handleClickProfile = () => {
profileRef.value.open()
}
const noticeListRef = ref<NoticeListRef | null>(null)
const messageList = ref<ChatMessage[]>([])
// 流式消息相关状态
const isStreaming = ref<boolean>(false)
const currentStreamingMessage = ref<ChatMessage | null>(null)
const sessionId = ref<string>('')
const sendPrefilledMessage = () => {
const { message, ...restQuery } = route.query
if (typeof message === 'string' && message.trim()) {
handleSendMessage(message)
router.replace({
path: route.path,
query: restQuery
})
}
}
onMounted(() => {
console.log('1111111111111');
sessionId.value = Math.floor(Date.now() / 1000).toString()
generateStore.setSessionId(sessionId.value)
})
onActivated(() => {
sendPrefilledMessage()
noticeListRef.value?.scrollToBottom()
})
onUnmounted(() => {
abort.abort()
})
const handleSendMessage = (message: string): void => {
console.log('发送:', message)
// 添加用户消息到列表
const userMessage: ChatMessage = {
id: generateUUID(),
type: 'text',
content: message,
timestamp: new Date().toISOString(),
sessionId: sessionId.value,
self: true
}
messageList.value.push(userMessage)
// 开始流式接收AI回复
handleFetchMessage(message)
}
const abort = new AbortController()
const handleFetchMessage = (message: string) => {
const params = {
message: message,
sessionId: sessionId.value,
gender: userInfoStore.state.generateParams.sex
}
// 创建AI消息对象
const aiMessage: ChatMessage = {
id: generateUUID(),
type: 'text',
content: '',
timestamp: new Date().toISOString(),
sessionId: sessionId.value
}
// 添加到消息列表
isStreaming.value = true
messageList.value.push(aiMessage)
currentStreamingMessage.value = aiMessage
// 直接使用 fetch 进行流式请求
const token = userInfoStore.state.token
const baseURL = import.meta.env.MODE === 'development' ? '' : import.meta.env.VITE_APP_URL
// 构建查询参数
const queryParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
queryParams.append(key, String(value))
})
const url = `${baseURL}${streamChatAddress}?${queryParams.toString()}`
fetch(url, {
method: 'GET',
headers: {
Authorization: token,
'Content-Type': 'application/json'
},
credentials: 'include'
})
.then(async (response) => {
// 检查响应内容类型,判断是否为流式响应
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)
showToast({
message: `failed to fetch: ${response.status}`,
position: 'top',
icon: 'none'
})
throw new Error(`发起对话错误--- ${response.status}: ${errorText}`)
}
// 不是流式响应,使用 text()读取错误信息
if (!isStreamResponse) {
const text = await response.text()
try {
const errorData = JSON.parse(text)
if (errorData.message || errorData.error) {
showToast({
message: errorData.message || errorData.error || '请求失败',
position: 'top',
icon: 'none'
})
}
} catch (e) {
// 如果不是 JSON直接显示文本内容
showToast({
message: text || '请求失败',
position: 'top',
icon: 'none'
})
throw new Error(text || '请求失败')
}
isStreaming.value = false
currentStreamingMessage.value = null
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)
isStreaming.value = false
currentStreamingMessage.value = null
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
// 过滤掉 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)
if (jsonData.content && jsonData.content.length > 0 && jsonData.type !== 'end') {
contentBody += jsonData.content
currentStreamingMessage.value.content = contentBody
}
} catch (e) {
// JSON 不完整:保留到下一次循环
if (!jsonText.trim().endsWith('}')) {
buffer = 'data: ' + jsonText // 重新放回缓存
continue
} else {
console.warn('⚠️ JSON 格式错误,跳过:', jsonText)
}
}
}
}
} catch (error) {
console.error('流式传输错误:', error)
isStreaming.value = false
currentStreamingMessage.value = null
} finally {
reader.releaseLock()
}
})
.catch((error) => {
console.error('fetch请求失败:', error)
isStreaming.value = false
currentStreamingMessage.value = null
})
}
const handleContinue = () => {
// router.push('/workshop/selectStyle')
// 模拟接口之后再跳转
generateStore.clearProductData()
router.push('/workshop/selectStyle')
}
</script>
<style lang="less" scoped>
.asistant-container {
height: 100%;
overflow: hidden;
}
.header {
flex-shrink: 0;
}
.content {
overflow: hidden;
display: flex;
flex-direction: column;
}
.footer {
flex-shrink: 0;
.continue {
font-family: 'satoshiRegular';
font-size: 3.6rem;
color: #fff;
text-align: right;
padding: 2.6rem 4.5rem;
flex-direction: row-reverse;
.btn {
border-radius: 0.7rem;
background-color: #000;
width: 24.6rem;
height: 6.7rem;
box-sizing: border-box;
}
}
}
</style>

View File

@@ -22,6 +22,7 @@
:placeholder="$t('Input.placeholder')"
@input="handleEditorInput"
@paste="handleEditorPaste"
@keypress="handleKeyPress"
>
<!-- <Tag v-if="showReportTag" /> -->
<div
@@ -188,7 +189,7 @@
}
)
const emits = defineEmits(['send'])
const emits = defineEmits(['send'])
const { t } = useI18n()
@@ -295,14 +296,23 @@
}
}
const handleSendAgent=()=>{
emits('send', inputValue.value)
// 发送后清空输入框
if(editorRef.value){
editorRef.value.innerHTML = ''
}
inputValue.value = ''
}
const handleKeyPress = (e) => {
// 检测回车
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendAgent()
}
}
const handleSendAgent = () => {
if (!inputValue.value.trim()) return
emits('send', { text: inputValue.value.trim(), images: uploadedImages.value })
// 发送后清空输入框
if (editorRef.value) {
editorRef.value.innerHTML = ''
}
inputValue.value = ''
}
// 监听 inputValue 外部变化
watch(inputValue, () => {
nextTick(() => {
@@ -314,7 +324,6 @@
onMounted(() => {
autoResizeEditor()
})
const typeValue = ref<string>('')
const areaValue = ref<string>('')
@@ -598,7 +607,7 @@
.agent {
padding: 1.2rem;
box-shadow: none;
border-radius: 1.5rem;
border-radius: 1.5rem;
border: 0.1rem solid #0000001a;
.scroll-content {
padding: 0;
@@ -611,7 +620,7 @@
min-height: initial;
max-height: initial;
padding: 0;
height: 100%;
height: 100%;
}
}
.operate {