feat: 对接AI对话接口
This commit is contained in:
79
src/api/agent.ts
Normal file
79
src/api/agent.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,13 +5,13 @@ import { removeLocal, setLocal } from '@/utils/local'
|
|||||||
import MyEvent from '@/utils/myEvent'
|
import MyEvent from '@/utils/myEvent'
|
||||||
export const useUserInfoStore = defineStore('userInfo', () => {
|
export const useUserInfoStore = defineStore('userInfo', () => {
|
||||||
const state = ref({
|
const state = ref({
|
||||||
userInfo: {},
|
userInfo: {},
|
||||||
token: '',
|
token: '',
|
||||||
generateParams: {
|
generateParams: {
|
||||||
stylist: '',
|
stylist: '',
|
||||||
sex: '',
|
sex: '',
|
||||||
stylistImage: ''
|
stylistImage: ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// getters
|
// getters
|
||||||
|
|||||||
@@ -8,17 +8,21 @@
|
|||||||
<SvgIcon name="equal" color="#0d0d0d" size="24" />
|
<SvgIcon name="equal" color="#0d0d0d" size="24" />
|
||||||
</div>
|
</div>
|
||||||
<div class="agent-body flex-1 flex flex-col">
|
<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" />
|
<Input is-agent-mode @send="handleSendMessage" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive } from 'vue'
|
import { ref, reactive, computed, onUnmounted } from 'vue'
|
||||||
import List from './List.vue'
|
import List from './List.vue'
|
||||||
import Input from '../../components/Input.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(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
title: string
|
title: string
|
||||||
@@ -28,24 +32,35 @@
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const messageList = ref([
|
const messageList = ref([])
|
||||||
{ id: 1, text: 'Hello', isUser: true },
|
const listRef = ref()
|
||||||
{
|
const params = reactive<AgentParamsType>({
|
||||||
id: 2,
|
threadId: '',
|
||||||
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?'
|
message: '',
|
||||||
|
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyIiwiaWF0IjoxNzcwNzkxMzEyLCJleHAiOjE3NzA4Nzc3MTJ9.xydPinm9l5Yq6GMkfaaVvdHjiINaYrp5VkRM7B9g83A',
|
||||||
|
checkpointId: '',
|
||||||
|
configParams: {
|
||||||
|
type: 'Chair',
|
||||||
|
region: 'China',
|
||||||
|
style: 'Transitional',
|
||||||
|
temperature: 0.7
|
||||||
},
|
},
|
||||||
{
|
imageUrlList: []
|
||||||
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
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
const handleSendMessage = (message: string) => {
|
const abort = new AbortController()
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
abort.abort()
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSendMessage = async (message: string) => {
|
||||||
console.log('Message sent:', message)
|
console.log('Message sent:', message)
|
||||||
|
params.message = message.text
|
||||||
|
params.imageUrlList = message.images || []
|
||||||
messageList.value.push({
|
messageList.value.push({
|
||||||
id: messageList.value.length + 1,
|
id: messageList.value.length + 1,
|
||||||
text: message,
|
text: message.text,
|
||||||
isUser: true
|
isUser: true
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -58,44 +73,138 @@
|
|||||||
thinking: false,
|
thinking: false,
|
||||||
thinkingText: '',
|
thinkingText: '',
|
||||||
thinkingCollapsed: false,
|
thinkingCollapsed: false,
|
||||||
streaming: false
|
streaming: true
|
||||||
})
|
})
|
||||||
messageList.value.push(aiMessage)
|
messageList.value.push(aiMessage)
|
||||||
|
|
||||||
// Simulate AI response
|
const threadId = '' //
|
||||||
setTimeout(() => {
|
console.log('token---', params.token, '参数---', params)
|
||||||
aiMessage.loading = false
|
|
||||||
aiMessage.thinking = true
|
|
||||||
aiMessage.thinkingText = '思考中...'
|
|
||||||
|
|
||||||
// Simulate thinking process
|
try {
|
||||||
setTimeout(() => {
|
const urlParams = new URLSearchParams<AgentParamsType>({
|
||||||
aiMessage.thinkingText = '分析用户需求:设计复古风格沙发,强调流畅线条和雕塑轮廓。'
|
...params,
|
||||||
}, 1000)
|
configParams: JSON.stringify(params.configParams)
|
||||||
|
})
|
||||||
|
|
||||||
setTimeout(() => {
|
const response = await fetch(`/api/ai-design/chat?${urlParams.toString()}`, {
|
||||||
aiMessage.thinkingText += '\n考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素考虑颜色方案:黄色基调,结合复古元素。'
|
method: 'GET',
|
||||||
}, 2000)
|
signal: abort.signal
|
||||||
|
})
|
||||||
|
|
||||||
setTimeout(() => {
|
// 检查响应内容类型,判断是否为流式响应
|
||||||
aiMessage.thinkingText += '\n生成设计草图...'
|
const contentType = response.headers.get('content-type') || ''
|
||||||
aiMessage.thinking = false
|
const isStreamResponse =
|
||||||
aiMessage.streaming = true
|
contentType.includes('text/event-stream') || contentType.includes('stream')
|
||||||
|
|
||||||
// Simulate streaming response
|
if (!response.ok) {
|
||||||
const response = '根据您的描述,我为您设计了一个复古风格的沙发。这个沙发采用黄色皮革材质,线条流畅,轮廓雕塑感强。整体造型优雅,结合了现代舒适与复古美学。'
|
// 非流式错误响应,使用 text() 读取错误信息
|
||||||
let index = 0
|
const errorText = await response.text()
|
||||||
const interval = setInterval(() => {
|
console.error('请求错误:', errorText)
|
||||||
if (index < response.length) {
|
throw new Error(`发起对话错误--- ${response.status}: ${errorText}`)
|
||||||
aiMessage.text += response[index]
|
}
|
||||||
index++
|
|
||||||
} else {
|
// 不是流式响应,使用 text()读取错误信息
|
||||||
clearInterval(interval)
|
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.streaming = false
|
||||||
|
aiMessage.loading = false
|
||||||
|
flag = false
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}, 50)
|
|
||||||
}, 3000)
|
buffer += decoder.decode(value, { stream: true })
|
||||||
}, 1000)
|
|
||||||
|
// 优先按空行拆分事件块(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>
|
</script>
|
||||||
|
|
||||||
@@ -127,6 +236,8 @@
|
|||||||
}
|
}
|
||||||
.agent-body {
|
.agent-body {
|
||||||
padding: 3.2rem;
|
padding: 3.2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
row-gap: 2.4rem;
|
||||||
.assist-input-wrapper {
|
.assist-input-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 14.4rem;
|
height: 14.4rem;
|
||||||
|
|||||||
@@ -4,8 +4,13 @@
|
|||||||
<div class="thumb">
|
<div class="thumb">
|
||||||
<img :src="content.isUser ? userThumb : agentThumb" class="thumb-icon" />
|
<img :src="content.isUser ? userThumb : agentThumb" class="thumb-icon" />
|
||||||
</div>
|
</div>
|
||||||
<div class="message-context" v-show="!content.loading && !content.thinking && !content.streaming">
|
<div
|
||||||
<div class="message-txt">{{ content.text }}</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 }">
|
<div class="operate flex" :class="{ 'is-user': content.isUser }">
|
||||||
<template v-if="content.isUser">
|
<template v-if="content.isUser">
|
||||||
<SvgIcon name="copy" size="16" color="#000" @click.stop="handleCopyText" />
|
<SvgIcon name="copy" size="16" color="#000" @click.stop="handleCopyText" />
|
||||||
@@ -23,9 +28,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-context" v-show="content.loading">
|
<div class="message-context" v-show="content.loading">
|
||||||
<div class="generating">
|
<div class="generating">Generating...</div>
|
||||||
Generating...
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="message-context" v-show="content.thinking">
|
<div class="message-context" v-show="content.thinking">
|
||||||
<div class="thinking">
|
<div class="thinking">
|
||||||
@@ -39,18 +42,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-context" v-show="content.streaming">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import gsap from 'gsap'
|
import gsap from 'gsap'
|
||||||
import userThumb from '@/assets/images/user-thumb.jpg'
|
import userThumb from '@/assets/images/user-thumb.jpg'
|
||||||
import agentThumb from '@/assets/images/agent-thumb.jpg'
|
import agentThumb from '@/assets/images/agent-thumb.jpg'
|
||||||
|
import markdownIt from 'markdown-it'
|
||||||
|
|
||||||
|
const md = new markdownIt()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -58,6 +66,21 @@
|
|||||||
content: Object
|
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([
|
const operateList = ref([
|
||||||
{
|
{
|
||||||
name: 'thumbUp',
|
name: 'thumbUp',
|
||||||
@@ -111,12 +134,6 @@
|
|||||||
const toggleThinkingCollapsed = () => {
|
const toggleThinkingCollapsed = () => {
|
||||||
props.content.thinkingCollapsed = !props.content.thinkingCollapsed
|
props.content.thinkingCollapsed = !props.content.thinkingCollapsed
|
||||||
}
|
}
|
||||||
|
|
||||||
const playLoadingAnimate = () => {}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
// playLoadingAnimate()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
@@ -129,7 +146,7 @@
|
|||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
.message-wrapper {
|
.message-wrapper {
|
||||||
column-gap: 0.9rem;
|
column-gap: 0.9rem;
|
||||||
// align-items: flex-start;
|
// align-items: flex-start;
|
||||||
&.is-user {
|
&.is-user {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
flex-direction: row-reverse;
|
flex-direction: row-reverse;
|
||||||
@@ -148,6 +165,10 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.message-context{
|
||||||
|
line-height: 2rem;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.operate {
|
.operate {
|
||||||
margin-top: 1.3rem;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,20 +1,41 @@
|
|||||||
<template>
|
<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" />
|
<Item v-for="message in messageList" :key="message.id" :content="message" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, nextTick, watch } from 'vue'
|
||||||
import Item from './Item.vue'
|
import Item from './Item.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
messageList: Array<any>
|
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>
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.agent-list {
|
.agent-list {
|
||||||
row-gap: 3.2rem;
|
row-gap: 3.2rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
// 隐藏滚动条
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
317
src/views/home/agent/example.vue
Normal file
317
src/views/home/agent/example.vue
Normal 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>
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
:placeholder="$t('Input.placeholder')"
|
:placeholder="$t('Input.placeholder')"
|
||||||
@input="handleEditorInput"
|
@input="handleEditorInput"
|
||||||
@paste="handleEditorPaste"
|
@paste="handleEditorPaste"
|
||||||
|
@keypress="handleKeyPress"
|
||||||
>
|
>
|
||||||
<!-- <Tag v-if="showReportTag" /> -->
|
<!-- <Tag v-if="showReportTag" /> -->
|
||||||
<div
|
<div
|
||||||
@@ -188,7 +189,7 @@
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const emits = defineEmits(['send'])
|
const emits = defineEmits(['send'])
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -295,14 +296,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSendAgent=()=>{
|
const handleKeyPress = (e) => {
|
||||||
emits('send', inputValue.value)
|
// 检测回车
|
||||||
// 发送后清空输入框
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
if(editorRef.value){
|
e.preventDefault()
|
||||||
editorRef.value.innerHTML = ''
|
handleSendAgent()
|
||||||
}
|
}
|
||||||
inputValue.value = ''
|
}
|
||||||
}
|
|
||||||
|
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 外部变化
|
// 监听 inputValue 外部变化
|
||||||
watch(inputValue, () => {
|
watch(inputValue, () => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
@@ -314,7 +324,6 @@
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
autoResizeEditor()
|
autoResizeEditor()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
const typeValue = ref<string>('')
|
const typeValue = ref<string>('')
|
||||||
const areaValue = ref<string>('')
|
const areaValue = ref<string>('')
|
||||||
@@ -598,7 +607,7 @@
|
|||||||
.agent {
|
.agent {
|
||||||
padding: 1.2rem;
|
padding: 1.2rem;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
border-radius: 1.5rem;
|
border-radius: 1.5rem;
|
||||||
border: 0.1rem solid #0000001a;
|
border: 0.1rem solid #0000001a;
|
||||||
.scroll-content {
|
.scroll-content {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -611,7 +620,7 @@
|
|||||||
min-height: initial;
|
min-height: initial;
|
||||||
max-height: initial;
|
max-height: initial;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.operate {
|
.operate {
|
||||||
|
|||||||
Reference in New Issue
Block a user