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

@@ -30,7 +30,7 @@ DROP TABLE IF EXISTS workcase.tb_chat_room CASCADE;
CREATE TABLE workcase.tb_chat_room(
optsn VARCHAR(50) NOT NULL, -- 流水号
room_id VARCHAR(50) NOT NULL, -- 聊天室ID
workcase_id VARCHAR(50) NOT NULL, -- 关联工单ID
workcase_id VARCHAR(50) DEFAULT NULL, -- 关联工单ID
room_name VARCHAR(200) NOT NULL, -- 聊天室名称(如:工单#12345的客服支持
room_type VARCHAR(20) NOT NULL DEFAULT 'workcase', -- 聊天室类型workcase-工单客服
status VARCHAR(20) NOT NULL DEFAULT 'active', -- 状态active-活跃 closed-已关闭 archived-已归档

View File

@@ -16,6 +16,8 @@ import org.xyzh.api.ai.service.AgentChatService;
import org.xyzh.common.auth.utils.LoginUtil;
import org.xyzh.common.core.domain.LoginDomain;
import org.xyzh.common.core.domain.ResultDomain;
import org.xyzh.common.core.page.PageDomain;
import org.xyzh.common.core.page.PageRequest;
import org.xyzh.common.utils.NonUtils;
import org.xyzh.common.utils.validation.ValidationParam;
import org.xyzh.common.utils.validation.ValidationResult;
@@ -128,7 +130,7 @@ public class ChatController {
* @author yslg
* @since 2025-12-17
*/
@GetMapping("/conversations")
@PostMapping("/conversation/list")
public ResultDomain<TbChat> getChatList(@RequestBody TbChat filter, @RequestHeader("Authorization") String token) {
log.info("获取会话列表: agentId={}", filter.getAgentId());
@@ -142,6 +144,26 @@ public class ChatController {
return chatService.getChatList(filter);
}
/**
* @description 分页获取对话列表
* @param pageRequest 分页请求参数
* @author yslg
* @since 2025-12-17
*/
@PostMapping("/conversation/page")
public ResultDomain<PageDomain<TbChat>> getChatPage(@RequestBody PageRequest<TbChat> pageRequest, @RequestHeader("Authorization") String token) {
log.info("分页获取会话列表: agentId={}", pageRequest.getFilter().getAgentId());
pageRequest.getFilter().setUserType(false);
if(NonUtils.isNotEmpty(token)){
LoginDomain loginDomain = LoginUtil.getCurrentLogin();
if (NonUtils.isNotEmpty(loginDomain) && loginDomain.getUser().getStatus()!="guest") {
pageRequest.getFilter().setUserType(true);
}
}
return chatService.getChatPage(pageRequest);
}
// ====================== 消息管理 ======================
/**

View File

@@ -22,6 +22,9 @@ import org.xyzh.api.ai.dto.TbChatMessage;
import org.xyzh.api.ai.service.AgentChatService;
import org.xyzh.api.ai.service.AgentService;
import org.xyzh.common.core.domain.ResultDomain;
import org.xyzh.common.core.page.PageDomain;
import org.xyzh.common.core.page.PageParam;
import org.xyzh.common.core.page.PageRequest;
import org.xyzh.common.redis.service.RedisService;
import org.xyzh.common.utils.id.IdUtil;
import org.xyzh.common.auth.utils.LoginUtil;
@@ -200,6 +203,30 @@ public class AgentChatServiceImpl implements AgentChatService {
return ResultDomain.success("查询成功", chatList);
}
@Override
public ResultDomain<PageDomain<TbChat>> getChatPage(PageRequest<TbChat> pageRequest) {
TbChat filter = pageRequest.getFilter();
// 判断agent是否是outer来客才需要校验
if (!filter.getUserType() && !isOuterAgent(filter.getAgentId())) {
return ResultDomain.<PageDomain<TbChat>>failure("智能体不可用");
}
// 获取用户ID
String userId = getUserIdByType(filter);
if (userId == null) {
return ResultDomain.<PageDomain<TbChat>>failure("用户信息获取失败");
}
filter.setUserId(userId);
// 分页查询
PageParam pageParam = pageRequest.getPageParam();
List<TbChat> chatList = chatMapper.selectChatPage(filter, pageParam);
long total = chatMapper.countChats(filter);
pageParam.setTotal((int) total);
PageDomain<TbChat> pageDomain = new PageDomain<>(pageParam, chatList);
return ResultDomain.<PageDomain<TbChat>>success("查询成功", pageDomain);
}
// ====================== 智能体聊天管理 ======================
@Override

View File

@@ -5,6 +5,8 @@ import org.xyzh.api.ai.dto.ChatPrepareData;
import org.xyzh.api.ai.dto.TbChat;
import org.xyzh.api.ai.dto.TbChatMessage;
import org.xyzh.common.core.domain.ResultDomain;
import org.xyzh.common.core.page.PageDomain;
import org.xyzh.common.core.page.PageRequest;
public interface AgentChatService {
@@ -41,6 +43,13 @@ public interface AgentChatService {
*/
ResultDomain<TbChat> getChatList(TbChat filter);
/**
* 分页获取会话列表
* @param pageRequest 分页请求参数
* @return 分页会话列表
*/
ResultDomain<PageDomain<TbChat>> getChatPage(PageRequest<TbChat> pageRequest);
// ====================== 智能体聊天管理 ======================

View File

@@ -68,7 +68,12 @@ public class WorkcaseChatContorller {
if (!vr.isValid()) {
return ResultDomain.failure(vr.getAllErrors());
}
return chatRoomService.createChatRoom(chatRoom);
chatRoom.setCreator(chatRoom.getGuestId());
try {
return chatRoomService.createChatRoom(chatRoom);
} catch (Exception e) {
return ResultDomain.failure(e.getMessage());
}
}
@Operation(summary = "更新聊天室")

View File

@@ -1,4 +1,5 @@
import { api } from '@/api/index'
import { API_BASE_URL } from '@/config'
import type { ResultDomain } from '@/types'
import type { TbChat, TbChatMessage, ChatPrepareData, StopChatParam, CommentMessageParam, DifyFileInfo } from '@/types/ai'
@@ -37,7 +38,7 @@ export const aiChatAPI = {
* @param chat 会话信息
*/
async deleteChat(chat: TbChat): Promise<ResultDomain<TbChat>> {
const response = await api.delete<TbChat>(`${this.baseUrl}/conversation`, { data: chat })
const response = await api.delete<TbChat>(`${this.baseUrl}/conversation`, chat )
return response.data
},
@@ -46,7 +47,7 @@ export const aiChatAPI = {
* @param filter 筛选条件
*/
async getChatList(filter: TbChat): Promise<ResultDomain<TbChat>> {
const response = await api.get<TbChat>(`${this.baseUrl}/conversations`, { data: filter })
const response = await api.post<TbChat>(`${this.baseUrl}/conversation/list`, filter)
return response.data
},
@@ -77,7 +78,7 @@ export const aiChatAPI = {
* @param sessionId 会话ID
*/
getStreamChatUrl(sessionId: string): string {
return `${this.baseUrl}/stream?sessionId=${sessionId}`
return `${API_BASE_URL}${this.baseUrl}/stream?sessionId=${sessionId}`
},
/**

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)

View File

@@ -118,7 +118,7 @@
<script setup lang="ts">
import { ref, nextTick, onMounted } from 'vue'
import WorkcaseCreator from '@/components/WorkcaseCreator/WorkcaseCreator.uvue'
import { guestAPI, aiChatAPI } from '@/api'
import { guestAPI, aiChatAPI, workcaseChatAPI } from '@/api'
import type { TbWorkcaseDTO } from '@/types'
import { AGENT_ID } from '@/config'
// 前端消息展示类型
@@ -478,21 +478,42 @@
})
}
// 联系人工客服
function contactHuman() {
uni.showModal({
title: '联系人工客服',
content: '客服电话400-123-4567\n工作时间9:00-18:00\n\n是否拨打电话',
confirmText: '拨打',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
uni.makePhoneCall({
phoneNumber: '400-123-4567'
})
}
// 联系人工客服 - 创建聊天室并进入
async function contactHuman() {
uni.showLoading({ title: '正在连接客服...' })
try {
// 创建聊天室
const res = await workcaseChatAPI.createChatRoom({
guestId: userInfo.value.userId || userInfo.value.wechatId,
guestName: userInfo.value.username || '访客',
roomName: `${userInfo.value.username || '访客'}的咨询`,
roomType: 'guest',
status: 'active',
aiSessionId: chatId.value || ''
})
uni.hideLoading()
if (res.success && res.data) {
const roomId = res.data.roomId
console.log('创建聊天室成功:', roomId)
// 跳转到聊天室页面
uni.navigateTo({
url: `/pages/chatRoom/chatRoom/chatRoom?roomId=${roomId}&roomName=${encodeURIComponent(res.data.roomName || '人工客服')}`
})
} else {
uni.showToast({
title: res.message || '连接客服失败',
icon: 'none'
})
}
})
} catch (error: any) {
uni.hideLoading()
console.error('创建聊天室失败:', error)
uni.showToast({
title: '连接客服失败,请稍后重试',
icon: 'none'
})
}
}
// 处理快速问题