workcase web ai聊天

This commit is contained in:
2025-12-23 16:56:22 +08:00
parent e75b2f3bab
commit 9dea8f3b2a
12 changed files with 430 additions and 88 deletions

View File

@@ -22,7 +22,7 @@
* Base64 编码的 32 字节密钥256 位)
*/
export const AES_SECRET_KEY = 'MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=' // Base64 编码,解码后是 "12345678901234567890123456789012" (32字节)
export const AGENT_ID = '17664699513920001'
// ============================================
// 类型定义
// ============================================

View File

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

View File

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

View File

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

View File

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