AI 对话web、wx优化

This commit is contained in:
2025-12-29 12:49:23 +08:00
parent 02aed37287
commit 6a2544e964
11 changed files with 960 additions and 139 deletions

View File

@@ -386,15 +386,18 @@ $brand-color-hover: #004488;
&.user {
flex-direction: row-reverse;
.message-content {
align-items: flex-end;
}
.message-bubble {
background: $brand-color;
color: #fff;
border-radius: 16px 16px 4px 16px;
}
.message-time {
text-align: right;
color: rgba(255, 255, 255, 0.7);
}
.message-time {
text-align: right;
}
}
}
@@ -419,8 +422,18 @@ $brand-color-hover: #004488;
}
}
.message-bubble {
.message-content {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 80%;
&.user {
align-items: flex-end;
}
}
.message-bubble {
padding: 12px 16px;
border-radius: 16px 16px 16px 4px;
background: #fff;
@@ -458,11 +471,17 @@ $brand-color-hover: #004488;
&:nth-child(3) { animation-delay: 0s; }
}
}
}
.message-time {
font-size: 12px;
color: #94a3b8;
}
// 用户消息气泡中的样式
.message-row.user .message-bubble {
.message-time {
font-size: 12px;
color: #94a3b8;
margin-top: 8px;
color: rgba(255, 255, 255, 0.7);
}
}
}
@@ -538,11 +557,14 @@ $brand-color-hover: #004488;
}
.input-row {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 12px 16px;
}
.chat-textarea {
width: 100%;
flex: 1;
border: none;
outline: none;
resize: none;
@@ -551,25 +573,13 @@ $brand-color-hover: #004488;
background: transparent;
line-height: 1.5;
max-height: 120px;
min-height: 24px;
&::placeholder {
color: #94a3b8;
}
}
.toolbar-row {
padding: 12px 16px;
display: flex;
justify-content: flex-end;
border-top: 1px solid #f8fafc;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.tool-btn {
padding: 8px;
color: #94a3b8;
@@ -578,11 +588,21 @@ $brand-color-hover: #004488;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
&:hover {
color: #64748b;
background: #f1f5f9;
}
&.uploading {
cursor: not-allowed;
opacity: 0.6;
}
.spin {
animation: spin 1s linear infinite;
}
}
.send-btn {
@@ -593,6 +613,7 @@ $brand-color-hover: #004488;
border-radius: 12px;
cursor: not-allowed;
transition: all 0.2s;
flex-shrink: 0;
&.active {
background: $brand-color;
@@ -717,3 +738,137 @@ $brand-color-hover: #004488;
text-decoration: underline;
}
}
// ==================== 文件上传相关样式 ====================
.uploaded-files {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid #f1f5f9;
}
.uploaded-file-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
max-width: 200px;
.file-preview {
width: 32px;
height: 32px;
border-radius: 4px;
overflow: hidden;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
&.image {
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&.document {
background: $brand-color-light;
color: $brand-color;
}
}
.file-name {
flex: 1;
font-size: 12px;
color: #374151;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.remove-file-btn {
padding: 4px;
color: #94a3b8;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
&:hover {
color: #ef4444;
background: #fef2f2;
}
}
}
// 消息气泡外的文件样式
.message-files {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.message-file-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
max-width: 220px;
text-decoration: none;
color: #374151;
transition: all 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
&:hover {
border-color: $brand-color;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.file-preview {
width: 36px;
height: 36px;
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
&.image {
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&.document {
background: $brand-color-light;
color: $brand-color;
}
}
.file-name {
flex: 1;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

View File

@@ -122,17 +122,38 @@
<Headphones v-else :size="16" />
</div>
<!-- 消息内容 -->
<div class="message-bubble" :class="msg.role">
<div
v-if="msg.text"
class="message-text"
v-html="msg.role === 'assistant' ? renderMarkdown(msg.text) : msg.text"
>
<!-- 消息内容区域 -->
<div class="message-content" :class="msg.role">
<!-- 文字气泡 -->
<div v-if="msg.text || (isStreaming && msg.role === 'assistant' && messages.indexOf(msg) === messages.length - 1)" class="message-bubble" :class="msg.role">
<div
v-if="msg.text"
class="message-text"
v-html="msg.role === 'assistant' ? renderMarkdown(msg.text) : msg.text"
>
</div>
<span v-if="isStreaming && msg.role === 'assistant' && messages.indexOf(msg) === messages.length - 1" class="typing-cursor">|</span>
<div v-if="!msg.text && isStreaming && msg.role === 'assistant'" class="loading-dots">
<span></span><span></span><span></span>
</div>
</div>
<span v-if="isStreaming && msg.role === 'assistant' && messages.indexOf(msg) === messages.length - 1" class="typing-cursor">|</span>
<div v-if="!msg.text && isStreaming && msg.role === 'assistant'" class="loading-dots">
<span></span><span></span><span></span>
<!-- 消息携带的文件在气泡外面 -->
<div v-if="msg.files && msg.files.length > 0" class="message-files">
<a
v-for="fileId in msg.files"
:key="fileId"
:href="getFileDownloadUrl(fileId)"
target="_blank"
class="message-file-item"
>
<div v-if="isImageFileById(fileId)" class="file-preview image">
<img :src="getFileDownloadUrl(fileId)" :alt="getFileName(fileId)" />
</div>
<div v-else class="file-preview document">
<FileIcon :size="18" />
</div>
<span class="file-name">{{ getFileName(fileId) }}</span>
</a>
</div>
<div class="message-time">{{ msg.time }}</div>
</div>
@@ -160,8 +181,46 @@
<div class="input-wrapper">
<!-- 输入卡片 -->
<div class="input-card">
<!-- 输入框 -->
<!-- 已上传文件预览 -->
<div v-if="uploadedFiles.length > 0" class="uploaded-files">
<div
v-for="(file, index) in uploadedFiles"
:key="file.id || index"
class="uploaded-file-item"
>
<div v-if="isImageFile(file)" class="file-preview image">
<img :src="getFilePreviewUrl(file)" :alt="file.name" />
</div>
<div v-else class="file-preview document">
<FileIcon :size="20" />
</div>
<span class="file-name">{{ file.name }}</span>
<button class="remove-file-btn" @click="removeUploadedFile(index)">
<X :size="14" />
</button>
</div>
</div>
<!-- 输入框行包含输入框和按钮 -->
<div class="input-row">
<!-- 隐藏的文件输入 -->
<input
ref="fileInputRef"
type="file"
multiple
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt"
style="display: none"
@change="handleFileSelect"
/>
<button
class="tool-btn"
:class="{ uploading: isUploading }"
:disabled="isUploading"
@click="triggerFileUpload"
title="添加附件"
>
<Loader2 v-if="isUploading" :size="18" class="spin" />
<Paperclip v-else :size="18" />
</button>
<textarea
ref="textareaRef"
v-model="inputText"
@@ -171,22 +230,14 @@
:rows="1"
class="chat-textarea"
/>
</div>
<!-- 工具栏 -->
<div class="toolbar-row">
<div class="toolbar-actions">
<button class="tool-btn" title="添加附件">
<Paperclip :size="18" />
</button>
<button
class="send-btn"
:class="{ active: inputText.trim() }"
:disabled="!inputText.trim()"
@click="sendMessage"
>
<Send :size="18" />
</button>
</div>
<button
class="send-btn"
:class="{ active: inputText.trim() || uploadedFiles.length > 0 }"
:disabled="(!inputText.trim() && uploadedFiles.length === 0) || isStreaming"
@click="sendMessage"
>
<Send :size="18" />
</button>
</div>
</div>
<p class="disclaimer">AI 生成内容仅供参考 · 泰豪集团内部绝密信息请勿上传</p>
@@ -212,27 +263,24 @@ import {
Paperclip,
Send,
User,
Headphones
Headphones,
X,
Image,
File as FileIcon,
Loader2
} from 'lucide-vue-next'
import { aiChatAPI, agentAPI } from 'shared/api/ai'
import { fileAPI } from 'shared/api/file'
import type {
TbChat,
TbChatMessage,
TbAgent,
PrepareChatParam,
SSEMessageData,
DifyFileInfo
DifyFileInfo,
TbSysFileDTO
} from 'shared/types'
import { AGENT_ID } from '@/config'
// 显示用消息接口
interface DisplayMessage {
id: string
role: 'user' | 'assistant'
text: string
time: string
messageId?: string
}
import { AGENT_ID, FILE_DOWNLOAD_URL } from '@/config'
// 用户信息TODO: 从实际用户store获取
const userId = computed(()=>{
@@ -255,7 +303,7 @@ const chatHistory = ref<TbChat[]>([])
const currentChatTitle = ref<string>('')
// 聊天消息列表
const messages = ref<DisplayMessage[]>([])
const messages = ref<TbChatMessage[]>([])
// 流式对话状态
const isStreaming = ref(false)
@@ -265,6 +313,14 @@ const eventSource = ref<EventSource | null>(null)
// 输入框文本
const inputText = ref('')
// 上传的文件列表
const uploadedFiles = ref<DifyFileInfo[]>([])
const isUploading = ref(false)
const fileInputRef = ref<HTMLInputElement | null>(null)
// 文件信息缓存 (fileId -> TbSysFileDTO)
const fileInfoCache = ref<Map<string, TbSysFileDTO>>(new Map())
// 消息容器引用
const messagesRef = ref<HTMLElement | null>(null)
const textareaRef = ref<HTMLTextAreaElement | null>(null)
@@ -288,6 +344,7 @@ const startNewChat = async () => {
currentChatTitle.value = ''
messages.value = []
inputText.value = ''
uploadedFiles.value = []
// 创建新会话
if (agentId && userId.value) {
@@ -320,12 +377,14 @@ const loadChat = async (chatId: string) => {
if (result.success && result.dataList) {
const messageList = Array.isArray(result.dataList) ? result.dataList : [result.dataList]
messages.value = messageList.map((msg: TbChatMessage) => ({
...msg,
id: msg.messageId || String(Date.now()),
role: msg.role === 'user' ? 'user' : 'assistant',
text: msg.content || '',
time: formatTime(msg.createTime),
messageId: msg.messageId
} as DisplayMessage))
time: formatTime(msg.createTime)
} as TbChatMessage))
// 加载消息中的文件信息
await loadMessagesFilesInfo(messageList)
}
} catch (error) {
console.error('加载对话消息失败:', error)
@@ -360,20 +419,36 @@ const scrollToBottom = () => {
// 发送消息
const sendMessage = async () => {
if (!inputText.value.trim() || isStreaming.value) return
// 允许只有文件或只有文本
if ((!inputText.value.trim() && uploadedFiles.value.length === 0) || isStreaming.value) return
if (!agentId) {
console.error('未选择智能体')
return
}
const query = inputText.value.trim()
const userMessage: DisplayMessage = {
const query = inputText.value.trim() || '[文件]'
const currentFiles = [...uploadedFiles.value] // 保存当前文件列表副本
const userMessage: TbChatMessage = {
id: String(Date.now()),
role: 'user',
text: query,
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
text: inputText.value.trim(),
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
files: currentFiles.length > 0 ? currentFiles.map(item => item.sys_file_id): undefined
}
// 将上传文件的信息缓存起来,用于立即渲染
currentFiles.forEach(f => {
if (f.sys_file_id) {
fileInfoCache.value.set(f.sys_file_id, {
fileId: f.sys_file_id,
name: f.name,
url: f.preview_url || f.source_url,
extension: f.extension,
mimeType: f.mime_type
} as TbSysFileDTO)
}
})
messages.value.push(userMessage)
inputText.value = ''
@@ -409,9 +484,13 @@ const sendMessage = async () => {
query: query,
agentId: agentId,
userType: userType.value,
userId: userId.value
userId: userId.value,
files: uploadedFiles.value.length > 0 ? uploadedFiles.value : undefined
}
// 清空已上传的文件
uploadedFiles.value = []
try {
// 准备流式对话
const prepareResult = await aiChatAPI.prepareStreamChat(prepareParam)
@@ -422,7 +501,7 @@ const sendMessage = async () => {
const sessionId = prepareResult.data
// 创建AI回复消息占位
const assistantMessage: DisplayMessage = {
const assistantMessage: TbChatMessage = {
id: String(Date.now() + 1),
role: 'assistant',
text: '',
@@ -631,6 +710,127 @@ const handleKeyDown = (e: KeyboardEvent) => {
}
}
// 触发文件选择
const triggerFileUpload = () => {
fileInputRef.value?.click()
}
// 处理文件选择
const handleFileSelect = async (e: Event) => {
const target = e.target as HTMLInputElement
const files = target.files
if (!files || files.length === 0) return
for (const file of Array.from(files)) {
await uploadFile(file)
}
// 清空input允许重复选择同一文件
target.value = ''
}
// 上传单个文件
const uploadFile = async (file: File) => {
if (!agentId) {
console.error('未选择智能体')
return
}
// 文件大小限制 10MB
const maxSize = 10 * 1024 * 1024
if (file.size > maxSize) {
console.error('文件大小超过10MB限制')
return
}
isUploading.value = true
try {
const result = await aiChatAPI.uploadFileForChat(file, agentId)
if (result.success && result.data) {
uploadedFiles.value.push(result.data)
} else {
console.error('文件上传失败:', result.message)
}
} catch (error) {
console.error('文件上传失败:', error)
} finally {
isUploading.value = false
}
}
// 移除已上传的文件
const removeUploadedFile = (index: number) => {
uploadedFiles.value.splice(index, 1)
}
// 判断是否为图片文件
const isImageFile = (file: DifyFileInfo): boolean => {
return file.type === 'image' || file.mime_type?.startsWith('image/') || false
}
// 获取文件预览URL
const getFilePreviewUrl = (file: DifyFileInfo): string => {
return file.preview_url || file.source_url || file.url || ''
}
// 加载消息中的文件信息
const loadMessagesFilesInfo = async (messageList: TbChatMessage[]) => {
// 收集所有文件ID
const fileIds: string[] = []
messageList.forEach(msg => {
if (msg.files) {
const filesArray = Array.isArray(msg.files) ? msg.files : [msg.files]
filesArray.forEach(id => {
if (id && !fileInfoCache.value.has(id)) {
fileIds.push(id)
}
})
}
})
if (fileIds.length === 0) return
// 逐个查询文件信息
for (const fileId of fileIds) {
try {
const res = await fileAPI.getFileById(fileId)
if (res.success && res.data) {
fileInfoCache.value.set(fileId, res.data)
}
} catch (error) {
console.error(`加载文件信息失败: ${fileId}`, error)
}
}
}
// 获取缓存的文件信息
const getFileInfo = (fileId: string): TbSysFileDTO | undefined => {
return fileInfoCache.value.get(fileId)
}
// 获取文件名(从缓存)
const getFileName = (fileId: string): string => {
const fileInfo = fileInfoCache.value.get(fileId)
return fileInfo?.name || fileId.substring(0, 8) + '...'
}
// 获取文件下载URL
const getFileDownloadUrl = (fileId: string): string => {
if (!fileId) return ''
const fileInfo = fileInfoCache.value.get(fileId)
if (fileInfo?.url) return fileInfo.url
return `${FILE_DOWNLOAD_URL}${fileId}`
}
// 判断文件ID对应的文件是否为图片
const isImageFileById = (fileId: string): boolean => {
const fileInfo = fileInfoCache.value.get(fileId)
if (!fileInfo) return false
const ext = (fileInfo.extension || fileInfo.name?.split('.').pop() || '').toLowerCase()
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(ext)
}
// 组件挂载
onMounted(async () => {
// TODO: 根据路由参数或配置获取智能体ID