workcase web ai聊天
This commit is contained in:
@@ -22,7 +22,7 @@
|
||||
* Base64 编码的 32 字节密钥(256 位)
|
||||
*/
|
||||
export const AES_SECRET_KEY = 'MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=' // Base64 编码,解码后是 "12345678901234567890123456789012" (32字节)
|
||||
|
||||
export const AGENT_ID = '17664699513920001'
|
||||
// ============================================
|
||||
// 类型定义
|
||||
// ============================================
|
||||
|
||||
@@ -108,8 +108,15 @@ declare module 'shared/types' {
|
||||
TbChatMessage,
|
||||
DifyFileInfo,
|
||||
ChatPrepareData,
|
||||
StopChatParams,
|
||||
CommentMessageParams
|
||||
CreateChatParam,
|
||||
PrepareChatParam,
|
||||
StopChatParam,
|
||||
CommentMessageParam,
|
||||
ChatListParam,
|
||||
ChatMessageListParam,
|
||||
SSEMessageData,
|
||||
SSECallbacks,
|
||||
SSETask
|
||||
} from '../../../shared/src/types/ai'
|
||||
|
||||
// 重新导出 menu
|
||||
|
||||
@@ -365,6 +365,20 @@ $brand-color-hover: #004488;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px;
|
||||
|
||||
.chat-header {
|
||||
text-align: center;
|
||||
padding: 16px 0 24px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.chat-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.message-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
@@ -421,6 +435,31 @@ $brand-color-hover: #004488;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.typing-cursor {
|
||||
display: inline-block;
|
||||
animation: blink 0.8s infinite;
|
||||
color: $brand-color;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.loading-dots {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px 0;
|
||||
|
||||
span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #94a3b8;
|
||||
border-radius: 50%;
|
||||
animation: bounce 1.4s infinite ease-in-out both;
|
||||
|
||||
&:nth-child(1) { animation-delay: -0.32s; }
|
||||
&:nth-child(2) { animation-delay: -0.16s; }
|
||||
&:nth-child(3) { animation-delay: 0s; }
|
||||
}
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
@@ -429,6 +468,16 @@ $brand-color-hover: #004488;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: scale(0); }
|
||||
40% { transform: scale(1); }
|
||||
}
|
||||
|
||||
// ==================== 快捷命令栏 ====================
|
||||
.quick-bar {
|
||||
padding: 8px 16px;
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
<div class="history-icons">
|
||||
<button
|
||||
v-for="conv in chatHistory.slice(0, 8)"
|
||||
:key="conv.id"
|
||||
@click="loadChat(conv.id); toggleHistory()"
|
||||
:key="conv.chatId"
|
||||
@click="loadChat(conv.chatId!); toggleHistory()"
|
||||
class="history-icon-btn"
|
||||
:class="{ active: currentChatId === conv.id }"
|
||||
:class="{ active: currentChatId === conv.chatId }"
|
||||
:title="conv.title"
|
||||
>
|
||||
<MessageCircle :size="16" />
|
||||
@@ -52,19 +52,19 @@
|
||||
</div>
|
||||
<div
|
||||
v-for="conv in chatHistory"
|
||||
:key="conv.id"
|
||||
@click="loadChat(conv.id)"
|
||||
:key="conv.chatId"
|
||||
@click="loadChat(conv.chatId!)"
|
||||
class="conversation-item"
|
||||
:class="{ active: currentChatId === conv.id }"
|
||||
:class="{ active: currentChatId === conv.chatId }"
|
||||
>
|
||||
<MessageCircle :size="16" class="conv-icon" />
|
||||
<div class="conv-info">
|
||||
<div class="conv-title">{{ conv.title }}</div>
|
||||
<div class="conv-time">{{ conv.time }}</div>
|
||||
<div class="conv-title">{{ conv.title || '新对话' }}</div>
|
||||
<div class="conv-time">{{ formatTime(conv.createTime) }}</div>
|
||||
</div>
|
||||
<button
|
||||
class="delete-btn"
|
||||
@click.stop="deleteConversation(conv.id)"
|
||||
@click.stop="deleteConversation(conv.chatId!)"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
@@ -104,9 +104,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<div v-else class="messages-list">
|
||||
<!-- 对话标题 -->
|
||||
<div v-if="currentChatTitle" class="chat-header">
|
||||
<h2 class="chat-title">{{ currentChatTitle }}</h2>
|
||||
</div>
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
@@ -121,7 +124,13 @@
|
||||
|
||||
<!-- 消息内容 -->
|
||||
<div class="message-bubble" :class="msg.role">
|
||||
<p class="message-text">{{ msg.text }}</p>
|
||||
<p class="message-text">
|
||||
{{ msg.text }}
|
||||
<span v-if="isStreaming && msg.role === 'assistant' && messages.indexOf(msg) === messages.length - 1" class="typing-cursor">|</span>
|
||||
</p>
|
||||
<div v-if="!msg.text && isStreaming && msg.role === 'assistant'" class="loading-dots">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<div class="message-time">{{ msg.time }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -185,7 +194,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { ref, nextTick, onMounted, computed } from 'vue'
|
||||
import {
|
||||
Plus,
|
||||
MessageCircle,
|
||||
@@ -202,38 +211,53 @@ import {
|
||||
User,
|
||||
Headphones
|
||||
} from 'lucide-vue-next'
|
||||
import { aiChatAPI, agentAPI } from 'shared/api/ai'
|
||||
import type {
|
||||
TbChat,
|
||||
TbChatMessage,
|
||||
TbAgent,
|
||||
PrepareChatParam,
|
||||
SSEMessageData,
|
||||
DifyFileInfo
|
||||
} from 'shared/types'
|
||||
import { AGENT_ID } from '@/config'
|
||||
|
||||
interface Message {
|
||||
id: number
|
||||
// 显示用消息接口
|
||||
interface DisplayMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
text: string
|
||||
time: string
|
||||
messageId?: string
|
||||
}
|
||||
|
||||
interface Conversation {
|
||||
id: number
|
||||
title: string
|
||||
time: string
|
||||
}
|
||||
// 用户信息(TODO: 从实际用户store获取)
|
||||
const userId = computed(()=>{
|
||||
return JSON.parse(localStorage.getItem("loginDomain")||'{}').user.userId;
|
||||
})
|
||||
const userType = ref<boolean>(true) // 系统内部人员
|
||||
|
||||
// 智能体信息
|
||||
const currentAgent = ref<TbAgent | null>(null)
|
||||
const agentId = AGENT_ID
|
||||
|
||||
// 历史对话展开状态
|
||||
const isHistoryOpen = ref(false)
|
||||
|
||||
// 当前选中的对话ID
|
||||
const currentChatId = ref<number | null>(null)
|
||||
const currentChatId = ref<string | null>(null)
|
||||
|
||||
// 历史对话列表
|
||||
const chatHistory = ref<Conversation[]>([
|
||||
{ id: 1, title: '发电机故障咨询', time: '今天 10:30' },
|
||||
{ id: 2, title: '设备维保规范查询', time: '今天 09:15' },
|
||||
{ id: 3, title: '配件更换流程', time: '昨天 16:42' },
|
||||
{ id: 4, title: 'TH-500GF参数查询', time: '昨天 14:20' },
|
||||
{ id: 5, title: '巡检报告模板', time: '12月10日' },
|
||||
{ id: 6, title: '客户投诉处理流程', time: '12月09日' }
|
||||
])
|
||||
const chatHistory = ref<TbChat[]>([])
|
||||
const currentChatTitle = ref<string>('')
|
||||
|
||||
// 聊天消息列表
|
||||
const messages = ref<Message[]>([])
|
||||
const messages = ref<DisplayMessage[]>([])
|
||||
|
||||
// 流式对话状态
|
||||
const isStreaming = ref(false)
|
||||
const currentTaskId = ref<string>('')
|
||||
const eventSource = ref<EventSource | null>(null)
|
||||
|
||||
// 输入框文本
|
||||
const inputText = ref('')
|
||||
@@ -256,31 +280,69 @@ const toggleHistory = () => {
|
||||
}
|
||||
|
||||
// 开始新对话
|
||||
const startNewChat = () => {
|
||||
const startNewChat = async () => {
|
||||
currentChatId.value = null
|
||||
currentChatTitle.value = ''
|
||||
messages.value = []
|
||||
inputText.value = ''
|
||||
|
||||
// 创建新会话
|
||||
if (agentId && userId.value) {
|
||||
try {
|
||||
const result = await aiChatAPI.createChat({
|
||||
agentId: agentId,
|
||||
userId: userId.value,
|
||||
userType: userType.value
|
||||
})
|
||||
if (result.success && result.data) {
|
||||
currentChatId.value = result.data.chatId || null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建会话失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载历史对话
|
||||
const loadChat = (chatId: number) => {
|
||||
const loadChat = async (chatId: string) => {
|
||||
currentChatId.value = chatId
|
||||
// 模拟加载历史对话
|
||||
messages.value = [
|
||||
{
|
||||
id: 1,
|
||||
role: 'assistant',
|
||||
text: '您好,欢迎使用泰豪智能客服,请问有什么可以帮您的?',
|
||||
time: '10:00'
|
||||
messages.value = []
|
||||
|
||||
// 设置当前对话标题
|
||||
const chat = chatHistory.value.find((c: TbChat) => c.chatId === chatId)
|
||||
currentChatTitle.value = chat?.title || '新对话'
|
||||
|
||||
try {
|
||||
const result = await aiChatAPI.getMessageList({ chatId })
|
||||
if (result.success && result.dataList) {
|
||||
const messageList = Array.isArray(result.dataList) ? result.dataList : [result.dataList]
|
||||
messages.value = messageList.map((msg: TbChatMessage) => ({
|
||||
id: msg.messageId || String(Date.now()),
|
||||
role: msg.role === 'user' ? 'user' : 'assistant',
|
||||
text: msg.content || '',
|
||||
time: formatTime(msg.createTime),
|
||||
messageId: msg.messageId
|
||||
} as DisplayMessage))
|
||||
}
|
||||
]
|
||||
} catch (error) {
|
||||
console.error('加载对话消息失败:', error)
|
||||
}
|
||||
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
// 删除对话
|
||||
const deleteConversation = (convId: number) => {
|
||||
chatHistory.value = chatHistory.value.filter(c => c.id !== convId)
|
||||
if (currentChatId.value === convId) {
|
||||
startNewChat()
|
||||
const deleteConversation = async (chatId: string) => {
|
||||
try {
|
||||
const result = await aiChatAPI.deleteChat({ chatId })
|
||||
if (result.success) {
|
||||
chatHistory.value = chatHistory.value.filter((c: TbChat) => c.chatId !== chatId)
|
||||
if (currentChatId.value === chatId) {
|
||||
startNewChat()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除对话失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,17 +357,21 @@ const scrollToBottom = () => {
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = async () => {
|
||||
if (!inputText.value.trim()) return
|
||||
if (!inputText.value.trim() || isStreaming.value) return
|
||||
if (!agentId) {
|
||||
console.error('未选择智能体')
|
||||
return
|
||||
}
|
||||
|
||||
const userMessage: Message = {
|
||||
id: Date.now(),
|
||||
const query = inputText.value.trim()
|
||||
const userMessage: DisplayMessage = {
|
||||
id: String(Date.now()),
|
||||
role: 'user',
|
||||
text: inputText.value.trim(),
|
||||
text: query,
|
||||
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
messages.value.push(userMessage)
|
||||
const query = inputText.value
|
||||
inputText.value = ''
|
||||
|
||||
// 重置输入框高度
|
||||
@@ -315,29 +381,151 @@ const sendMessage = async () => {
|
||||
|
||||
scrollToBottom()
|
||||
|
||||
// 模拟 AI 回复
|
||||
setTimeout(() => {
|
||||
const assistantMessage: Message = {
|
||||
id: Date.now() + 1,
|
||||
// 如果没有当前会话,先创建
|
||||
if (!currentChatId.value) {
|
||||
try {
|
||||
const createResult = await aiChatAPI.createChat({
|
||||
agentId: agentId,
|
||||
userId: userId.value,
|
||||
userType: userType.value,
|
||||
title: query.slice(0, 20) + (query.length > 20 ? '...' : '')
|
||||
})
|
||||
if (createResult.success && createResult.data) {
|
||||
currentChatId.value = createResult.data.chatId || null
|
||||
chatHistory.value.unshift(createResult.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建会话失败:', error)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 准备流式对话参数
|
||||
const prepareParam: PrepareChatParam = {
|
||||
chatId: currentChatId.value!,
|
||||
query: query,
|
||||
agentId: agentId,
|
||||
userType: userType.value,
|
||||
userId: userId.value
|
||||
}
|
||||
|
||||
try {
|
||||
// 准备流式对话
|
||||
const prepareResult = await aiChatAPI.prepareStreamChat(prepareParam)
|
||||
if (!prepareResult.success || !prepareResult.data) {
|
||||
throw new Error('准备流式对话失败')
|
||||
}
|
||||
|
||||
const sessionId = prepareResult.data
|
||||
|
||||
// 创建AI回复消息占位
|
||||
const assistantMessage: DisplayMessage = {
|
||||
id: String(Date.now() + 1),
|
||||
role: 'assistant',
|
||||
text: `感谢您的咨询!关于"${query}",我正在为您查询相关信息...\n\n这是一个模拟回复。在实际应用中,这里会连接到后端AI服务获取真实回答。`,
|
||||
text: '',
|
||||
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
messages.value.push(assistantMessage)
|
||||
|
||||
// 保存到对话列表
|
||||
if (!currentChatId.value) {
|
||||
const newConv: Conversation = {
|
||||
id: Date.now(),
|
||||
title: query.slice(0, 20) + (query.length > 20 ? '...' : ''),
|
||||
time: '刚刚'
|
||||
// 开始流式对话
|
||||
isStreaming.value = true
|
||||
eventSource.value = aiChatAPI.createStreamChat(sessionId)
|
||||
|
||||
const es = eventSource.value
|
||||
if (!es) return
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
console.log('SSE原始数据:', event.data)
|
||||
const data = JSON.parse(event.data)
|
||||
console.log('SSE解析数据:', data)
|
||||
|
||||
if (data.task_id) {
|
||||
currentTaskId.value = data.task_id
|
||||
}
|
||||
|
||||
// 支持多种事件格式
|
||||
if (data.event === 'message' && data.answer) {
|
||||
// 更新最后一条消息
|
||||
const lastMsg = messages.value[messages.value.length - 1]
|
||||
if (lastMsg && lastMsg.role === 'assistant') {
|
||||
lastMsg.text += data.answer
|
||||
}
|
||||
scrollToBottom()
|
||||
} else if (data.event === 'message_end' || data.event === 'workflow_finished') {
|
||||
closeEventSource()
|
||||
} else if (data.event === 'error') {
|
||||
console.error('SSE错误:', data.message)
|
||||
closeEventSource()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析SSE数据失败:', e, event.data)
|
||||
}
|
||||
chatHistory.value.unshift(newConv)
|
||||
currentChatId.value = newConv.id
|
||||
}
|
||||
|
||||
scrollToBottom()
|
||||
}, 1000)
|
||||
es.onerror = () => {
|
||||
closeEventSource()
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error)
|
||||
isStreaming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭EventSource
|
||||
const closeEventSource = () => {
|
||||
if (eventSource.value) {
|
||||
eventSource.value.close()
|
||||
eventSource.value = null
|
||||
}
|
||||
isStreaming.value = false
|
||||
currentTaskId.value = ''
|
||||
}
|
||||
|
||||
// 停止对话
|
||||
const stopChat = async () => {
|
||||
if (currentTaskId.value && agentId && userId.value) {
|
||||
try {
|
||||
await aiChatAPI.stopChat({
|
||||
taskId: currentTaskId.value,
|
||||
agentId: agentId,
|
||||
userId: userId.value
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('停止对话失败:', error)
|
||||
}
|
||||
}
|
||||
closeEventSource()
|
||||
}
|
||||
|
||||
// 加载对话列表
|
||||
const loadChatHistory = async () => {
|
||||
if (!userId.value) return
|
||||
|
||||
try {
|
||||
const result = await aiChatAPI.getChatList({
|
||||
agentId: agentId
|
||||
})
|
||||
if (result.success && result.dataList) {
|
||||
chatHistory.value = result.dataList
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载对话列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time?: string | number): string => {
|
||||
if (!time) return ''
|
||||
const date = new Date(time)
|
||||
const now = new Date()
|
||||
const isToday = date.toDateString() === now.toDateString()
|
||||
|
||||
if (isToday) {
|
||||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
|
||||
}
|
||||
|
||||
// 处理快捷命令
|
||||
@@ -362,6 +550,19 @@ const handleKeyDown = (e: KeyboardEvent) => {
|
||||
sendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载
|
||||
onMounted(async () => {
|
||||
// TODO: 根据路由参数或配置获取智能体ID
|
||||
// 示例:从路由获取 agentId
|
||||
// const route = useRoute()
|
||||
// if (route.query.agentId) {
|
||||
// await loadAgent(route.query.agentId as string)
|
||||
// }
|
||||
|
||||
// 加载对话历史
|
||||
await loadChatHistory()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -145,7 +145,7 @@ const userId = ref(localStorage.getItem('userId') || '')
|
||||
|
||||
// 搜索文本
|
||||
const searchText = ref('')
|
||||
|
||||
const userType = true //web端固定这个
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
const messageLoading = ref(false)
|
||||
|
||||
Reference in New Issue
Block a user