对话完成
This commit is contained in:
14
src/App.vue
14
src/App.vue
@@ -7,4 +7,16 @@ import { RouterView } from 'vue-router'
|
|||||||
<RouterView />
|
<RouterView />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style>
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes: [],
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'aiChatPage',
|
||||||
|
component: () => import('../views/AiChatPage.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
516
src/views/AiChatPage.vue
Normal file
516
src/views/AiChatPage.vue
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
AttachmentsProps,
|
||||||
|
BubbleListProps,
|
||||||
|
ConversationsProps,
|
||||||
|
PromptsProps,
|
||||||
|
} from 'ant-design-x-vue'
|
||||||
|
import type { VNode } from 'vue'
|
||||||
|
import {
|
||||||
|
CloudUploadOutlined,
|
||||||
|
CommentOutlined,
|
||||||
|
EllipsisOutlined,
|
||||||
|
FireOutlined,
|
||||||
|
HeartOutlined,
|
||||||
|
PaperClipOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
ReadOutlined,
|
||||||
|
ShareAltOutlined,
|
||||||
|
SmileOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
|
import { Badge, Button, Flex, Space, Typography, theme } from 'ant-design-vue'
|
||||||
|
import {
|
||||||
|
Attachments,
|
||||||
|
Bubble,
|
||||||
|
Conversations,
|
||||||
|
Prompts,
|
||||||
|
Sender,
|
||||||
|
Welcome,
|
||||||
|
} from 'ant-design-x-vue'
|
||||||
|
import { computed, h, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const { token } = theme.useToken()
|
||||||
|
|
||||||
|
const styles = computed(() => {
|
||||||
|
return {
|
||||||
|
layout: {
|
||||||
|
width: '100vw',
|
||||||
|
height: '100vh',
|
||||||
|
'border-radius': '0',
|
||||||
|
display: 'flex',
|
||||||
|
background: `${token.value.colorBgContainer}`,
|
||||||
|
'font-family': `AlibabaPuHuiTi, ${token.value.fontFamily}, sans-serif`,
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
background: `${token.value.colorBgLayout}80`,
|
||||||
|
width: '280px',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
'flex-direction': 'column',
|
||||||
|
},
|
||||||
|
conversations: {
|
||||||
|
padding: '0 12px',
|
||||||
|
flex: 1,
|
||||||
|
'overflow-y': 'auto',
|
||||||
|
},
|
||||||
|
chat: {
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
'max-width': '1300px',
|
||||||
|
margin: '0 auto',
|
||||||
|
'box-sizing': 'border-box',
|
||||||
|
display: 'flex',
|
||||||
|
'flex-direction': 'column',
|
||||||
|
padding: `${token.value.paddingLG}px`,
|
||||||
|
gap: '16px',
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
'padding-top': '32px',
|
||||||
|
'text-align': 'left',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
sender: {
|
||||||
|
'box-shadow': token.value.boxShadow,
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
display: 'flex',
|
||||||
|
height: '72px',
|
||||||
|
'align-items': 'center',
|
||||||
|
'justify-content': 'start',
|
||||||
|
padding: '0 24px',
|
||||||
|
'box-sizing': 'border-box',
|
||||||
|
},
|
||||||
|
'logo-img': {
|
||||||
|
width: '24px',
|
||||||
|
height: '24px',
|
||||||
|
display: 'inline-block',
|
||||||
|
},
|
||||||
|
'logo-span': {
|
||||||
|
display: 'inline-block',
|
||||||
|
margin: '0 8px',
|
||||||
|
'font-weight': 'bold',
|
||||||
|
color: token.value.colorText,
|
||||||
|
'font-size': '16px',
|
||||||
|
},
|
||||||
|
addBtn: {
|
||||||
|
background: '#1677ff0f',
|
||||||
|
border: '1px solid #1677ff34',
|
||||||
|
width: 'calc(100% - 24px)',
|
||||||
|
margin: '0 12px 24px 12px',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
})
|
||||||
|
|
||||||
|
defineOptions({ name: 'PlaygroundIndependentSetup' })
|
||||||
|
|
||||||
|
function renderTitle(icon: VNode, title: string) {
|
||||||
|
return h(Space, { align: 'start' }, () => [icon, h('span', title)])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对话列表记录
|
||||||
|
const defaultConversationsItems = [
|
||||||
|
{
|
||||||
|
key: '0',
|
||||||
|
label: '什么是Ant Design X Vue?',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// 提示集列表记录
|
||||||
|
const placeholderPromptsItems: PromptsProps['items'] = [
|
||||||
|
{
|
||||||
|
key: '1',
|
||||||
|
label: renderTitle(h(FireOutlined, { style: { color: '#FF4D4F' } }), '热门话题'),
|
||||||
|
description: '你感兴趣的话题是什么?',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: '1-1',
|
||||||
|
description: `X的新功能是什么?`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '1-2',
|
||||||
|
description: `什么是AGI?`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '1-3',
|
||||||
|
description: `文档在哪里?`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '2',
|
||||||
|
label: renderTitle(h(ReadOutlined, { style: { color: '#1890FF' } }), '设计指南'),
|
||||||
|
description: '如何设计一个好的产品?',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: '2-1',
|
||||||
|
icon: h(HeartOutlined),
|
||||||
|
description: `知道它的好处`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '2-2',
|
||||||
|
icon: h(SmileOutlined),
|
||||||
|
description: `给AI设定一个角色`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '2-3',
|
||||||
|
icon: h(CommentOutlined),
|
||||||
|
description: `表达感受`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const senderPromptsItems: PromptsProps['items'] = [
|
||||||
|
{
|
||||||
|
key: '1',
|
||||||
|
description: '热点话题',
|
||||||
|
icon: h(FireOutlined, { style: { color: '#FF4D4F' } }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '2',
|
||||||
|
description: '设计指南',
|
||||||
|
icon: h(ReadOutlined, { style: { color: '#1890FF' } }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const roles: BubbleListProps['roles'] = {
|
||||||
|
ai: {
|
||||||
|
placement: 'start',
|
||||||
|
typing: false, // 禁用打字机动画,使用后端流式显示
|
||||||
|
styles: {
|
||||||
|
content: {
|
||||||
|
borderRadius: '16px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
local: {
|
||||||
|
placement: 'end',
|
||||||
|
variant: 'shadow',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== State ====================
|
||||||
|
const headerOpen = ref(false)
|
||||||
|
const content = ref('')
|
||||||
|
const conversationsItems = ref(defaultConversationsItems)
|
||||||
|
const activeKey = ref(defaultConversationsItems[0]?.key)
|
||||||
|
const attachedFiles = ref<AttachmentsProps['items']>([])
|
||||||
|
const agentRequestLoading = ref(false)
|
||||||
|
|
||||||
|
// 消息列表
|
||||||
|
interface Message {
|
||||||
|
id: string
|
||||||
|
role: 'user' | 'ai'
|
||||||
|
content: string
|
||||||
|
status: 'loading' | 'success' | 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = ref<Message[]>([])
|
||||||
|
let messageIdCounter = 0
|
||||||
|
|
||||||
|
// ==================== Runtime ====================
|
||||||
|
async function sendMessage(userMessage: string) {
|
||||||
|
console.log('📝 [Submit] 用户提交消息:', userMessage)
|
||||||
|
if (!userMessage) return
|
||||||
|
|
||||||
|
// 添加用户消息
|
||||||
|
const userMsg: Message = {
|
||||||
|
id: `user_${messageIdCounter++}`,
|
||||||
|
role: 'user',
|
||||||
|
content: userMessage,
|
||||||
|
status: 'success',
|
||||||
|
}
|
||||||
|
messages.value.push(userMsg)
|
||||||
|
|
||||||
|
// 添加 AI 消息占位符
|
||||||
|
const aiMsg: Message = {
|
||||||
|
id: `ai_${messageIdCounter++}`,
|
||||||
|
role: 'ai',
|
||||||
|
content: '',
|
||||||
|
status: 'loading',
|
||||||
|
}
|
||||||
|
messages.value.push(aiMsg)
|
||||||
|
const aiMsgIndex = messages.value.length - 1
|
||||||
|
|
||||||
|
agentRequestLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const convId = parseInt(activeKey.value || '0')
|
||||||
|
const requestBody = {
|
||||||
|
message: userMessage,
|
||||||
|
conversionId: convId,
|
||||||
|
}
|
||||||
|
console.log('📤 [Request] 请求体:', JSON.stringify(requestBody, null, 2))
|
||||||
|
|
||||||
|
// 调用后端接口
|
||||||
|
const response = await fetch('http://localhost:8080/dashscope/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('📥 [Response] 响应状态:', response.status, response.statusText)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`请求失败: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 SSE 流式响应
|
||||||
|
if (response.body) {
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
console.log('🌊 [Stream] 开始读取流数据...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
console.log('✅ [Stream] 流读取完成')
|
||||||
|
const msg = messages.value[aiMsgIndex]
|
||||||
|
if (msg) {
|
||||||
|
msg.status = 'success'
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解码数据
|
||||||
|
const text = decoder.decode(value, { stream: true })
|
||||||
|
buffer += text
|
||||||
|
console.log('📦 [Stream] 收到原始数据:', text)
|
||||||
|
|
||||||
|
// 解析 SSE 格式: data: content\n\n
|
||||||
|
const lines = buffer.split('\n\n')
|
||||||
|
|
||||||
|
// 保留最后一个可能不完整的块
|
||||||
|
if (!text.endsWith('\n\n')) {
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
} else {
|
||||||
|
buffer = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const block of lines) {
|
||||||
|
if (block.startsWith('data:')) {
|
||||||
|
// 提取 data: 后面的内容
|
||||||
|
const content = block.substring(5)
|
||||||
|
console.log('✨ [Parse] 解析后的内容:', JSON.stringify(content))
|
||||||
|
|
||||||
|
// 直接累积到消息内容
|
||||||
|
const msg = messages.value[aiMsgIndex]
|
||||||
|
if (msg) {
|
||||||
|
msg.content += content
|
||||||
|
console.log('🔄 [Update] 更新消息, 当前长度:', msg.content.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [Error] 请求错误:', error)
|
||||||
|
const msg = messages.value[aiMsgIndex]
|
||||||
|
if (msg) {
|
||||||
|
msg.status = 'error'
|
||||||
|
msg.content = '抱歉,发生了错误,请稍后重试。'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
agentRequestLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
activeKey,
|
||||||
|
() => {
|
||||||
|
if (activeKey.value !== undefined) {
|
||||||
|
console.log('🔄 [Conversation] 切换会话:', activeKey.value)
|
||||||
|
messages.value = []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== Event ====================
|
||||||
|
function onSubmit(nextContent: string) {
|
||||||
|
if (!nextContent) return
|
||||||
|
sendMessage(nextContent)
|
||||||
|
content.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPromptsItemClick: PromptsProps['onItemClick'] = (info) => {
|
||||||
|
sendMessage(info.data.description as string)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAddConversation() {
|
||||||
|
conversationsItems.value = [
|
||||||
|
...conversationsItems.value,
|
||||||
|
{
|
||||||
|
key: `${conversationsItems.value.length}`,
|
||||||
|
label: `New Conversation ${conversationsItems.value.length}`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
activeKey.value = `${conversationsItems.value.length}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const onConversationClick: ConversationsProps['onActiveChange'] = (key) => {
|
||||||
|
activeKey.value = key
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileChange: AttachmentsProps['onChange'] = (info) =>
|
||||||
|
(attachedFiles.value = info.fileList)
|
||||||
|
|
||||||
|
// ==================== Nodes ====================
|
||||||
|
const placeholderNode = computed(() =>
|
||||||
|
h(Space, { direction: 'vertical', size: 16, style: styles.value.placeholder }, () => [
|
||||||
|
h(Welcome, {
|
||||||
|
variant: 'borderless',
|
||||||
|
icon: 'https://mdn.alipayobjects.com/huamei_iwk9zp/afts/img/A*s5sNRo5LjfQAAAAAAAAAAAAADgCCAQ/fmt.webp',
|
||||||
|
title: "Hello, I'm Ant Design X",
|
||||||
|
description: '基于Ant Design,AGI产品界面解决方案,创造更美好的智能愿景~',
|
||||||
|
extra: h(Space, {}, () => [
|
||||||
|
h(Button, { icon: h(ShareAltOutlined) }),
|
||||||
|
h(Button, { icon: h(EllipsisOutlined) }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
h(Prompts, {
|
||||||
|
title: '想要了是吧?',
|
||||||
|
items: placeholderPromptsItems,
|
||||||
|
styles: {
|
||||||
|
list: {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
item: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onItemClick: onPromptsItemClick,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const items = computed<BubbleListProps['items']>(() => {
|
||||||
|
console.log('🎨 [Items] 计算 items, messages数量:', messages.value.length)
|
||||||
|
|
||||||
|
if (messages.value.length === 0) {
|
||||||
|
console.log('🎨 [Items] 显示占位符')
|
||||||
|
return [{ content: placeholderNode, variant: 'borderless' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = messages.value.map(({ id, role, content, status }) => {
|
||||||
|
console.log('🎨 [Items] 处理消息:', { id, role, status, contentLength: content?.length })
|
||||||
|
return {
|
||||||
|
key: id,
|
||||||
|
loading: status === 'loading',
|
||||||
|
role: role === 'user' ? 'local' : 'ai',
|
||||||
|
content: content,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('🎨 [Items] 最终 items:', result)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :style="styles.layout">
|
||||||
|
<div :style="styles.menu">
|
||||||
|
<!-- 🌟 Logo -->
|
||||||
|
<div :style="styles.logo">
|
||||||
|
<img
|
||||||
|
src="https://mdn.alipayobjects.com/huamei_iwk9zp/afts/img/A*eco6RrQhxbMAAAAAAAAAAAAADgCCAQ/original"
|
||||||
|
draggable="false"
|
||||||
|
alt="logo"
|
||||||
|
:style="styles['logo-img']"
|
||||||
|
/>
|
||||||
|
<span :style="styles['logo-span']">Ant Design X Vue</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 🌟 添加会话 -->
|
||||||
|
<Button type="link" :style="styles.addBtn" @click="onAddConversation">
|
||||||
|
<PlusOutlined />
|
||||||
|
New Conversation
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- 🌟 会话管理 -->
|
||||||
|
<Conversations
|
||||||
|
:items="conversationsItems"
|
||||||
|
:style="styles.conversations"
|
||||||
|
:active-key="activeKey"
|
||||||
|
@active-change="onConversationClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :style="styles.chat">
|
||||||
|
<!-- 🌟 消息列表 -->
|
||||||
|
<Bubble.List :items="items" :roles="roles" :style="styles.messages" />
|
||||||
|
|
||||||
|
<!-- 🌟 提示词 -->
|
||||||
|
<Prompts :items="senderPromptsItems" @item-click="onPromptsItemClick" />
|
||||||
|
|
||||||
|
<!-- 🌟 输入框 -->
|
||||||
|
<Sender
|
||||||
|
:value="content"
|
||||||
|
:style="styles.sender"
|
||||||
|
:loading="agentRequestLoading"
|
||||||
|
@submit="onSubmit"
|
||||||
|
@change="(value) => (content = value)"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Badge :dot="(attachedFiles?.length ?? 0) > 0 && !headerOpen">
|
||||||
|
<Button type="text" @click="() => (headerOpen = !headerOpen)">
|
||||||
|
<template #icon>
|
||||||
|
<PaperClipOutlined />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</Badge>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #header>
|
||||||
|
<Sender.Header
|
||||||
|
title="Attachments"
|
||||||
|
:open="headerOpen"
|
||||||
|
:styles="{ content: { padding: 0 } }"
|
||||||
|
@open-change="(open) => (headerOpen = open)"
|
||||||
|
>
|
||||||
|
<Attachments
|
||||||
|
:before-upload="() => false"
|
||||||
|
:items="attachedFiles"
|
||||||
|
@change="handleFileChange"
|
||||||
|
>
|
||||||
|
<template #placeholder="type">
|
||||||
|
<Flex
|
||||||
|
v-if="type && type.type === 'inline'"
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
vertical
|
||||||
|
gap="2"
|
||||||
|
>
|
||||||
|
<Typography.Text style="font-size: 30px; line-height: 1">
|
||||||
|
<CloudUploadOutlined />
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Title :level="5" style="margin: 0; font-size: 14px; line-height: 1.5">
|
||||||
|
Upload files
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
Click or drag files to this area to upload
|
||||||
|
</Typography.Text>
|
||||||
|
</Flex>
|
||||||
|
<Typography.Text v-if="type && type.type === 'drop'">
|
||||||
|
Drop file here
|
||||||
|
</Typography.Text>
|
||||||
|
</template>
|
||||||
|
</Attachments>
|
||||||
|
</Sender.Header>
|
||||||
|
</template>
|
||||||
|
</Sender>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user