You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1826 lines
44 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div class="ai-chat-container">
<!-- 左侧会话栏 -->
<div :class="['sidebar', { collapsed }]">
<div class="sidebar-header">
<svg-icon class-name="search-icon" icon-class="aichat"/>
<span class="logo-text">AI助手</span>
<el-button
v-if="!collapsed"
class="collapse-btn"
circle
size="small"
@click="collapsed = true"
>
<el-icon>
<ArrowLeft/>
</el-icon>
</el-button>
</div>
<div class="sidebar-content">
<el-button class="new-chat-btn" type="primary" @click="createNewChat">
<el-icon>
<Plus/>
</el-icon>
新建对话
</el-button>
<div class="chat-list">
<template v-for="(group, groupKey) in groupedChats" :key="groupKey">
<div v-if="group.length > 0" class="group-section">
<div class="group-header">{{ getGroupTitle(groupKey) }}</div>
<div
v-for="chat in group"
:key="chat.sessionId"
:class="['chat-item', { active: chat.sessionId === activeChatSessionId }]"
@click="selectChat(chat.sessionId)"
>
<div class="chat-info">
<div class="chat-title-row">
<span class="chat-title">{{ chat.messageTopic }}</span>
<!-- 加载动画 -->
<div v-if="loadingChatId === chat.sessionId" class="loading-indicator">
<el-icon class="is-loading">
<Loading/>
</el-icon>
</div>
<!-- 编辑状态指示器 -->
<div v-if="editingChatId === chat.sessionId" class="editing-indicator">
<el-icon class="is-loading">
<Edit/>
</el-icon>
</div>
<!-- 删除状态指示器 -->
<div v-if="deletingChatId === chat.sessionId" class="deleting-indicator">
<el-icon class="is-loading">
<Delete/>
</el-icon>
</div>
</div>
<div class="chat-bottom-row">
<span class="chat-time">{{ formatTime(chat.updatedAt) }}</span>
<div class="chat-actions">
<el-button
circle
size="small"
@click.stop="editChatTitle(chat)"
title="重命名"
:loading="editingChatId === chat.sessionId"
:disabled="editingChatId === chat.sessionId || deletingChatId === chat.sessionId"
>
<el-icon v-if="editingChatId !== chat.sessionId">
<Edit/>
</el-icon>
</el-button>
<el-button
circle
size="small"
type="danger"
@click.stop="handleDelete(chat.sessionId)"
title="删除"
:loading="deletingChatId === chat.sessionId"
:disabled="deletingChatId === chat.sessionId || editingChatId === chat.sessionId"
>
<el-icon v-if="deletingChatId !== chat.sessionId">
<Delete/>
</el-icon>
</el-button>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- 主内容区左上角 -->
<el-button
v-if="collapsed"
class="expand-btn"
circle
size="small"
@click="collapsed = false"
style="position: absolute; left: 0; top: 24px; z-index: 10;"
>
<el-icon>
<ArrowRight/>
</el-icon>
</el-button>
<!-- 主对话区 -->
<main class="main-content">
<header class="chat-header">
<div class="header-left">
<h2>{{ currentChat?.messageTopic || '新对话' }}</h2>
</div>
<div class="header-right">
<el-select v-model="selectedModel" placeholder="选择模型" @change="onModelChange">
<el-option
v-for="model in aiModelList"
:key="model.modelId"
:label="model.modelName"
:value="model.modelId"
/>
</el-select>
<!-- <el-button @click="showSettings = true" title="设置">-->
<!-- <el-icon>-->
<!-- <Setting/>-->
<!-- </el-icon>-->
<!-- </el-button>-->
</div>
</header>
<!-- 聊天消息区域 -->
<div class="chat-messages" ref="messagesRef">
<!-- 加载动画 -->
<div v-if="loadingChatId && activeChatSessionId && (!currentChat?.messages || currentChat.messages.length === 0)" class="loading-container">
<div class="loading-spinner">
<el-icon class="is-loading">
<Loading/>
</el-icon>
</div>
<div class="loading-text">正在加载对话内容...</div>
</div>
<div v-else-if="!currentChat || !currentChat.messages || currentChat.messages.length === 0" class="welcome-message">
<div class="welcome-icon">🤖</div>
<h3>欢迎使用AI助手</h3>
<p>我可以帮助你回答问题、编写代码、分析数据等</p>
</div>
<div
v-for="(message, index) in currentChat?.messages || []"
:key="index"
:class="['message', message.role]"
>
<div class="message-avatar">
<img
v-if="message.role === 'assistant'"
:src="platformIcon"
:alt="provider"
/>
<img
v-else
src="https://api.dicebear.com/7.x/bottts-neutral/svg?seed=user"
alt="User"
/>
</div>
<div class="message-content">
<div class="message-text" v-html="formatMessage(message.content)"></div>
<div class="message-time">{{ formatTime(message.timestamp) }}</div>
</div>
</div>
<!-- 正在输入指示器 -->
<div v-if="isStreaming" class="message assistant">
<div class="message-avatar">
<img :src="platformIcon" alt="AI"/>
</div>
<div class="message-content">
<div class="message-text">
<div class="streaming-text" v-html="formatMessage(streamingContent)"></div>
<span class="cursor">|</span>
</div>
</div>
<!-- <div class="message-actions">-->
<!-- <el-button-->
<!-- size="small"-->
<!-- type="danger"-->
<!-- @click="stopStreaming"-->
<!-- class="stop-btn"-->
<!-- >-->
<!-- <el-icon>-->
<!-- <Close/>-->
<!-- </el-icon>-->
<!-- 停止-->
<!-- </el-button>-->
<!-- </div>-->
</div>
<!-- 继续按钮区域 -->
<div
v-if="!isStreaming && streamingContent && !currentChat?.messages.some(m => m.role === 'assistant' && m.content === streamingContent)"
class="message assistant">
<div class="message-avatar">
<img src="https://chat.deepseek.com/logo192.png" alt="AI"/>
</div>
<div class="message-content">
<div class="message-text">
<div class="streaming-text" v-html="formatMessage(streamingContent)"></div>
</div>
</div>
<div class="message-actions">
<el-button
size="small"
type="primary"
@click="continueStreaming"
class="continue-btn"
>
<el-icon>
<Position/>
</el-icon>
继续
</el-button>
</div>
</div>
</div>
<!-- 输入区域 -->
<footer class="chat-input">
<div class="input-container">
<div class="suggestion-bar align-to-textarea">
<span
v-for="(sug, i) in suggestions"
:key="i"
class="suggestion-item"
@click="handleSuggestion(sug)"
>{{ sug }}</span>
</div>
<div class="input-area input-area-with-icons">
<div class="input-icons">
<el-button
circle
size="small"
@click="clearInput"
title="清空输入"
>
<el-icon>
<Delete/>
</el-icon>
</el-button>
<el-button
circle
size="small"
@click="toggleHistory"
:type="carryHistory ? 'primary' : ''"
title="携带历史"
>
<el-icon>
<Collection/>
</el-icon>
</el-button>
</div>
<textarea
v-model="inputMessage"
class="message-input"
:placeholder="inputPlaceholder"
:disabled="isStreaming"
@keydown="handleKeydown"
@input="autoResize"
ref="inputRef"
/>
<div class="input-actions">
<!-- <el-upload-->
<!-- :show-file-list="false"-->
<!-- :before-upload="handleFileUpload"-->
<!-- accept=".txt,.md,.json,.csv"-->
<!-- >-->
<!-- <el-button circle size="small" title="上传文件">-->
<!-- <el-icon>-->
<!-- <Upload/>-->
<!-- </el-icon>-->
<!-- </el-button>-->
<!-- </el-upload>-->
<!-- <el-button-->
<!-- type="primary"-->
<!-- @click="isStreaming ? stopStreaming() : sendMessage()"-->
<!-- >-->
<!-- <el-icon>-->
<!-- <Position v-if="!isStreaming"/>-->
<!-- <Close v-else/>-->
<!-- </el-icon>-->
<!-- {{ isStreaming ? '停止' : '发送' }}-->
<!-- </el-button>-->
<el-button
type="primary"
@click="sendMessage()"
:disabled = "isStreaming"
:loading = "isStreaming"
>
<el-icon>
<Position/>
</el-icon>
发送
</el-button>
</div>
</div>
</div>
</footer>
</main>
<!-- 设置弹窗 -->
<el-dialog v-model="showSettings" title="AI设置" width="500px">
<el-form :model="settings" label-width="100px">
<el-form-item label="API Key">
<el-input v-model="settings.apiKey" placeholder="请输入API Key" show-password/>
</el-form-item>
<el-form-item label="API端点">
<el-input v-model="settings.apiEndpoint" placeholder="API端点地址"/>
</el-form-item>
<el-form-item label="温度">
<el-slider
v-model="settings.temperature"
:min="0"
:max="2"
:step="0.1"
show-input
/>
</el-form-item>
<el-form-item label="最大令牌">
<el-input-number
v-model="settings.maxTokens"
:min="1"
:max="8000"
:step="100"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showSettings = false">取消</el-button>
<el-button type="primary" @click="saveSettings">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import {ref, reactive, computed, nextTick, onMounted, onUnmounted} from 'vue'
import axios from 'axios'
import request from '@/utils/request';
import {getToken} from "@/utils/auth";
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
import {ElMessage, ElMessageBox} from 'element-plus'
import {
ArrowLeft,
Plus,
Delete,
Edit,
Setting,
Position,
Upload,
Collection,
ArrowRight,
Close,
Loading
} from '@element-plus/icons-vue'
import service from "@/utils/request";
import {getAiModelJoinList, getAiChatMessageList, getAiChatMessages,updateAiMessageTopic,delAiMessage} from "@/api/ai/skill/aiChat"
import {AIModelVO} from '@/api/ai/skill/aiChat/types';
import {HttpStatus} from "@/enums/RespEnum";
//
interface ChatMessage {
role: 'user' | 'assistant' | 'system'
content: string
timestamp: number
}
interface ChatSession {
sessionId: string
messageTopic: string
messages: ChatMessage[]
createdAt: number
updatedAt: number
}
interface ModelConfig {
label: string
value: string
apiEndpoint: string
modelType: string
}
interface Settings {
apiKey: string
apiEndpoint: string
temperature: number
maxTokens: number
}
//
const collapsed = ref(false)
const activeChatSessionId = ref('')//id
const inputMessage = ref('') //
const isStreaming = ref(false) //
const streamingContent = ref('')
const showSettings = ref(false)
const carryHistory = ref(true)
const messagesRef = ref<HTMLElement>()
const inputRef = ref<HTMLTextAreaElement>()
const loading = ref(false)
// 添加请求取消控制器
const currentRequestController = ref<AbortController | null>(null)
// 添加加载状态跟踪
const loadingChatId = ref<string | null>(null)
// 添加编辑状态跟踪
const editingChatId = ref<string | null>(null)
// const sessionMap = ref(new Map());//根据sessionId组装会话内容
const aiModelList = ref<AIModelVO[]>([])
const provider = ref('');//ai平台编码
const platformIcon = ref('');//ai平台图标
// 聊天会话列表
const chatList = ref<ChatSession[]>([])
// 可用模型
// const availableModels = ref<ModelConfig[]>([
// {label: 'DeepSeek R1', value: 'deepseek-r1', apiEndpoint: 'https://api.deepseek.com/v1/chat/completions'},
// {label: 'GPT-3.5 Turbo', value: 'gpt-3.5-turbo', apiEndpoint: 'https://api.openai.com/v1/chat/completions'},
// {label: 'GPT-4', value: 'gpt-4', apiEndpoint: 'https://api.openai.com/v1/chat/completions'},
// {
// label: '文心一言',
// value: 'ernie-bot',
// apiEndpoint: 'https://wenxin.baidu.com/moduleApi/portal/api/rest/1.0/ernie_bot'
// },
// ])
const selectedModel = ref(0)
const getAiChatSessions = async () => {
// loading.value = true;
const res = await getAiChatMessageList({});
chatList.value = res.data;
console.log(res)
// platformList.value = res.data;
// loading.value = false;
}
const getAiModelList = async () => {
// loading.value = true;
const res = await getAiModelJoinList({modelTypeId: 1});
aiModelList.value = res.data;
await setDefaultAiModel();
console.log(res)
// platformList.value = res.data;
// loading.value = false;
}
const setDefaultAiModel = async () => {
aiModelList.value.forEach((item: AIModelVO) => {
if (item.defaultFlag === "1") {
// alert(JSON.stringify(item))
selectedModel.value = item.modelId;
provider.value = item.platformCode;
console.log(item.platformIcon)
platformIcon.value = item.platformIcon;
}
})
}
// 设置
const settings = reactive<Settings>({
apiKey: '',
apiEndpoint: 'https://api.deepseek.com/v1/chat/completions',
temperature: 0.7,
maxTokens: 2048
})
// 当前对话
const currentChat = computed(() =>
chatList.value.find(chat => chat.sessionId === activeChatSessionId.value)
)
const inputPlaceholder = computed(() =>
isStreaming.value ? 'AI正在思考中...' : '输入消息按Enter发送Shift+Enter换行'
)
// 按时间分组的聊天列表
const groupedChats = computed(() => {
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000)
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000)
const monthAgo = new Date(today.getFullYear(), now.getMonth() - 1, now.getDate())
const groups = {
today: [] as ChatSession[],
yesterday: [] as ChatSession[],
week: [] as ChatSession[],
month: [] as ChatSession[],
older: [] as ChatSession[]
}
chatList.value.forEach(chat => {
const chatDate = new Date(chat.updatedAt)
if (chatDate >= today) {
groups.today.push(chat)
} else if (chatDate >= yesterday) {
groups.yesterday.push(chat)
} else if (chatDate >= weekAgo) {
groups.week.push(chat)
} else if (chatDate >= monthAgo) {
groups.month.push(chat)
} else {
groups.older.push(chat)
}
})
return groups
})
// 获取分组标题
const getGroupTitle = (groupKey: string) => {
const titles = {
today: '今天',
yesterday: '昨天',
week: '一周内',
month: '一月内',
older: '更早'
}
return titles[groupKey] || ''
}
// 检查分组是否有内容
const hasGroupContent = (groupKey: string) => {
return groupedChats.value[groupKey] && groupedChats.value[groupKey].length > 0
}
//新建对话
function createNewChat(){
activeChatSessionId.value = undefined
inputMessage.value = ''
}
// 新建对话后第一次问答
function newChatFirstMessage() {
const newChat: ChatSession = {
sessionId: generateUUID(),
messageTopic: '',
messages: [],
createdAt: Date.now(),
updatedAt: Date.now()
}
chatList.value.unshift(newChat)
activeChatSessionId.value = newChat.sessionId
nextTick(() => {
scrollToBottom()
})
}
const selectChat = async (chatSessionId: string) => {
// 取消之前的请求
if (currentRequestController.value) {
currentRequestController.value.abort()
currentRequestController.value = null
}
// 清理之前的编辑状态
editingChatId.value = null
// 清理之前的删除状态
deletingChatId.value = null
activeChatSessionId.value = chatSessionId
console.log(activeChatSessionId.value)
if (!currentChat.value.messages) {
console.log("----")
// 设置加载状态
loadingChatId.value = chatSessionId
// 创建新的AbortController
currentRequestController.value = new AbortController()
try {
const res = await getAiChatMessages(activeChatSessionId.value);
// 验证当前会话ID是否还是用户选择的那个
if (activeChatSessionId.value === chatSessionId) {
console.log(res)
// res.data已经是对象数组
currentChat.value.messages = res.data.map(item => {
return {
role: item.role,
content: item.content,
timestamp: item.timestamp
// 可以根据需要转换字段
};
});
} else {
console.log('会话已切换,忽略此请求结果')
return
}
} catch (error: any) {
// 如果是取消请求导致的错误,不显示错误信息
if (error.name !== 'AbortError') {
console.error('加载消息失败:', error)
ElMessage.error('加载消息失败')
}
} finally {
// 清除加载状态
if (loadingChatId.value === chatSessionId) {
loadingChatId.value = null
}
currentRequestController.value = null
}
} else {
// 如果消息已存在,清除加载状态
loadingChatId.value = null
}
// console.log("-=-"+JSON.stringify(currentChat.value.messages, null, 2))
nextTick(() => {
scrollToBottom()
})
}
// function selectChat(chatSessionId: string) {
// activeChatSessionId.value = chatSessionId
// messages.value = sessionMap.value.get(activeChatSessionId.value)
// if(!messages.value){
// messages.value = [];
// const res = await getAiChatMessages(activeChatSessionId.value);
// chatList.value = res.data;
//
// }
// console.log(messages.value)
//
// nextTick(() => {
// scrollToBottom()
// })
// }
const editChatTitle = async (chat: ChatSession) => {
ElMessageBox.prompt('请输入新的对话标题', '重命名', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: chat.messageTopic
}).then(({value}) => {
if (value && value.trim()) {
// 设置编辑状态,显示加载动画
editingChatId.value = chat.sessionId
updateAiMessageTopic({sessionId:chat.sessionId,messageTopic:value.trim()}).then((res: any) => {
chat.messageTopic = value.trim()
chat.updatedAt = Date.now()
ElMessage.success('重命名成功')
// 清除编辑状态,隐藏加载动画
editingChatId.value = null
}).catch((error: any) => {
console.error('重命名失败:', error)
ElMessage.error('重命名失败,请重试')
// 清除编辑状态,隐藏加载动画
editingChatId.value = null
})
}
})
}
// function editChatTitle(chat: ChatSession) {
// ElMessageBox.prompt('请输入新的对话标题', '重命名', {
// confirmButtonText: '确定',
// cancelButtonText: '取消',
// inputValue: chat.messageTopic
// }).then(({value}) => {
// if (value && value.trim()) {
// const res = await updateAiMessageTopic({sessionId:chat.sessionId,messageTopic:value.trim()});
//
// chat.messageTopic = value.trim()
// chat.updatedAt = Date.now()
// ElMessage.success('重命名成功')
// }
// })
// }
// 添加删除状态跟踪
const deletingChatId = ref<string | null>(null)
/** 删除按钮操作 */
const handleDelete = async (sessionId: string) => {
// 如果正在删除,阻止重复操作
if (deletingChatId.value) {
return
}
try {
// 显示确认对话框
await proxy?.$modal.confirm('确定要删除这个对话么?')
// 设置删除状态,显示加载动画
deletingChatId.value = sessionId
// 调用删除API
await delAiMessage(sessionId)
// 删除成功,从本地列表中移除
const index = chatList.value.findIndex(chat => chat.sessionId === sessionId)
if (index > -1) {
chatList.value.splice(index, 1)
// 判断删除的是否是当前会话
if (activeChatSessionId.value === sessionId) {
createNewChat()
}
}
proxy?.$modal.msgSuccess("删除成功")
} catch (error: any) {
if (error !== 'cancel') { // 不是用户取消
console.error('删除失败:', error)
ElMessage.error('删除失败,请重试')
}
} finally {
// 清除删除状态,隐藏加载动画
deletingChatId.value = null
}
}
function deleteChat(sessionId: string) {
ElMessageBox.confirm('确定要删除这个对话吗?', '确认删除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
const index = chatList.value.findIndex(chat => chat.sessionId === sessionId)
if (index > -1) {
chatList.value.splice(index, 1)
if (activeChatSessionId.value === sessionId) {
activeChatSessionId.value = chatList.value[0]?.sessionId || ''
}
ElMessage.success('删除成功')
}
})
}
function onModelChange() {
const model = aiModelList.value.find(m => m.modelId === selectedModel.value)
if (model) {
provider.value = model.platformCode
platformIcon.value = model.platformIcon;
}
}
function clearInput() {
inputMessage.value = ''
nextTick(() => {
autoResize()
})
}
function toggleHistory() {
carryHistory.value = !carryHistory.value
ElMessage.info(carryHistory.value ? '已开启历史记录' : '已关闭历史记录')
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage()
}
}
function autoResize() {
const textarea = inputRef.value
if (textarea) {
textarea.style.height = 'auto'
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px'
}
}
function handleFileUpload(file: File) {
const reader = new FileReader()
reader.onload = (e) => {
const content = e.target?.result as string
inputMessage.value = `文件内容:\n${content}`
autoResize()
}
reader.readAsText(file)
return false // 阻止自动上传
}
function generateUUID() {
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
);
}
// 发送消息
const sendMessage = async () => {
// 使用
//输入信息。isStreaming代表是否正在发送信息
if (!inputMessage.value.trim() || isStreaming.value) {
return
}
console.log(activeChatSessionId.value)
// 如果没有当前对话,自动创建一个新的
if (!currentChat.value) {
newChatFirstMessage()
}
const userMsg = inputMessage.value.trim()
//用户当前发送的内容
const userMessage: ChatMessage = {
role: 'user',
content: userMsg,
timestamp: Date.now()
}
// 添加用户消息
currentChat.value.messages.push(userMessage)
currentChat.value.updatedAt = Date.now()
// 更新标题(如果是第一条消息)
if (currentChat.value.messages.length === 1) {
currentChat.value.messageTopic = userMessage.content.slice(0, 20) + (userMessage.content.length > 20 ? '...' : '')
}
inputMessage.value = ''
loading.value = true
isStreaming.value = true
streamingContent.value = ''
scrollToBottom()
try {
//判断是否携带历史记录
let sendMessages = [];
if (carryHistory.value) {
sendMessages = currentChat.value.messages;
} else {
sendMessages.push(userMessage);
}
console.log(sendMessages.map(m => ({role: m.role, content: m.content})));
const response = await service.post('/ai/assistant/chatStream?provider=' + provider.value, {
messages: sendMessages.map(m => ({role: m.role, content: m.content})),
carryHistoryFlag: carryHistory.value ? "1" : "0",
modelId: selectedModel.value,
sessionId: activeChatSessionId.value
}, {
responseType: 'text',
headers: {
'Authorization': 'Bearer ' + getToken(),
'Content-Type': 'application/json'
},
onDownloadProgress: progress => {
const raw = progress.event.target.responseText
console.log(1)
console.log(raw)
console.log(2)
processStream(raw)
}
})
console.log("----" + streamingContent.value);
// 添加用户消息到messages数组
currentChat.value.messages.push({
role: 'assistant',
content: streamingContent.value,
timestamp: Date.now()
})
} catch (error) {
currentChat.value.messages.push({
role: 'assistant',
content: '出错: ' + (error as Error).message,
timestamp: Date.now()
})
} finally {
loading.value = false
isStreaming.value = false;
streamingContent.value = ''
scrollToBottom()
}
}
// 处理流数据
const processStream = (raw) => {
const chunks = raw.split('\n\n').filter(c => c.startsWith('data:'))
let fullText = ''
chunks.forEach(chunk => {
// console.log(chunk)
const content = chunk.substring(5)
if (content && content !== '[DONE]') {
fullText += content
}
})
fullText = fullText.replaceAll("data:", "");
if (fullText.length > streamingContent.value.length) {
streamingContent.value = fullText
scrollToBottom()
}
}
async function streamChatResponse(userInput: string) {
if (!currentChat.value) return
// 构建消息历史
const messages = carryHistory.value
? currentChat.value.messages.map(msg => ({role: msg.role, content: msg.content}))
: [{role: 'user' as const, content: userInput}]
// 创建流式请求
const stream = await createStreamRequest(messages)
const reader = stream.getReader()
const decoder = new TextDecoder()
// 添加AI消息占位
currentChat.value.messages.push({
role: 'assistant',
content: '',
timestamp: Date.now()
})
try {
while (true) {
const {done, value} = await reader.read()
if (done) break
const chunk = decoder.decode(value)
const lines = chunk.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6)
if (data === '[DONE]') {
return
}
try {
const parsed = JSON.parse(data)
if (parsed.choices?.[0]?.delta?.content) {
const content = parsed.choices[0].delta.content
streamingContent.value += content
// 更新最后一条AI消息
const lastMessage = currentChat.value.messages[currentChat.value.messages.length - 1]
if (lastMessage.role === 'assistant') {
lastMessage.content = streamingContent.value
}
nextTick(() => {
scrollToBottom()
})
}
} catch (e) {
console.error('解析流式数据失败:', e)
}
}
}
}
} finally {
reader.releaseLock()
}
}
async function createStreamRequest(messages: Array<{ role: string; content: string }>) {
// 用 fetch 发送 POST 请求,接收流式响应
const response = await fetch('/api/ai/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
// 如果有token等鉴权信息可加在这里
},
body: JSON.stringify({
messages,
model: selectedModel.value,
temperature: settings.temperature,
max_tokens: settings.maxTokens,
api_key: settings.apiKey,
api_endpoint: settings.apiEndpoint
})
});
if (!response.body) throw new Error('No response body');
// 直接返回 ReadableStream兼容原有逻辑
return response.body;
}
function stopStreaming() {
isStreaming.value = false;
streamingContent.value = '';
ElMessage.info('已停止AI思考');
}
function continueStreaming() {
isStreaming.value = true;
streamingContent.value = '';
sendMessage();
}
function scrollToBottom() {
nextTick(() => {
if (messagesRef.value) {
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
}
})
}
function formatMessage(content: string) {
// 简单的markdown格式化
return content
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/`(.*?)`/g, '<code>$1</code>')
.replace(/\n/g, '<br>')
}
function formatTime(timestamp: number) {
const date = new Date(timestamp)
const now = new Date()
const diff = now.getTime() - date.getTime()
if (diff < 60000) { // 1分钟内
return '刚刚'
} else if (diff < 3600000) { // 1小时内
return `${Math.floor(diff / 60000)}分钟前`
} else if (diff < 86400000) { // 1天内
return `${Math.floor(diff / 3600000)}小时前`
} else {
return date.toLocaleDateString()
}
}
function saveSettings() {
// 保存设置到localStorage
localStorage.setItem('ai-chat-settings', JSON.stringify(settings))
ElMessage.success('设置已保存')
showSettings.value = false
}
const suggestions = [
'智能制造MES介绍',
'仓储管理介绍',
'质量管理介绍',
'设备管理介绍',
]
function handleSuggestion(sug: string) {
if (isStreaming.value) return
createNewChat()
inputMessage.value = sug
nextTick(() => {
autoResize()
sendMessage()
})
}
// 生命周期
onMounted(() => {
getAiChatSessions();
getAiModelList();
// availableModels.value = [
// {label: '产品知识库', value: 'deepseek-r1', apiEndpoint: 'https://api.deepseek.com/v1/chat/completions'},
// {label: '设备知识库', value: 'gpt-3.5-turbo', apiEndpoint: 'https://api.openai.com/v1/chat/completions'},
// {label: '维修知识库', value: 'gpt-4', apiEndpoint: 'https://api.openai.com/v1/chat/completions'},
// ]
// 添加mock数据
const mockChats: ChatSession[] = [
{
sessionId: '1',
messageTopic: '如何配置产品参数?',
messages: [
{
role: 'user',
content: '我想了解如何配置产品参数',
timestamp: Date.now() - 2 * 60 * 60 * 1000 // 2小时前
},
{
role: 'assistant',
content: '产品参数配置需要按照以下步骤进行...',
timestamp: Date.now() - 2 * 60 * 60 * 1000 + 60000
}
],
createdAt: Date.now() - 2 * 60 * 60 * 1000,
updatedAt: Date.now() - 2 * 60 * 60 * 1000
},
{
sessionId: '2',
messageTopic: '设备故障排查指南',
messages: [
{
role: 'user',
content: '设备出现故障,需要排查指南',
timestamp: Date.now() - 24 * 60 * 60 * 1000 // 1天前
},
{
role: 'assistant',
content: '设备故障排查需要按照以下流程...',
timestamp: Date.now() - 24 * 60 * 60 * 1000 + 60000
}
],
createdAt: Date.now() - 24 * 60 * 60 * 1000,
updatedAt: Date.now() - 24 * 60 * 60 * 1000
},
{
sessionId: '3',
messageTopic: '维修知识库查询',
messages: [
{
role: 'user',
content: '查询维修相关的知识',
timestamp: Date.now() - 3 * 24 * 60 * 60 * 1000 // 3天前
},
{
role: 'assistant',
content: '维修知识库包含以下内容...',
timestamp: Date.now() - 3 * 24 * 60 * 60 * 1000 + 60000
}
],
createdAt: Date.now() - 3 * 24 * 60 * 60 * 1000,
updatedAt: Date.now() - 3 * 24 * 60 * 60 * 1000
},
{
sessionId: '4',
messageTopic: '系统配置优化建议',
messages: [
{
role: 'user',
content: '系统配置需要优化,有什么建议?',
timestamp: Date.now() - 7 * 24 * 60 * 60 * 1000 // 1周前
},
{
role: 'assistant',
content: '系统配置优化建议如下...',
timestamp: Date.now() - 7 * 24 * 60 * 60 * 1000 + 60000
}
],
createdAt: Date.now() - 7 * 24 * 60 * 60 * 1000,
updatedAt: Date.now() - 7 * 24 * 60 * 60 * 1000
},
{
sessionId: '5',
messageTopic: '历史数据分析报告',
messages: [
{
role: 'user',
content: '需要分析历史数据,生成报告',
timestamp: Date.now() - 30 * 24 * 60 * 60 * 1000 // 1月前
},
{
role: 'assistant',
content: '历史数据分析报告已生成...',
timestamp: Date.now() - 30 * 24 * 60 * 60 * 1000 + 60000
}
],
createdAt: Date.now() - 30 * 24 * 60 * 60 * 1000,
updatedAt: Date.now() - 30 * 24 * 60 * 60 * 1000
}
]
// 将mock数据添加到聊天列表
// chatList.value = mockChats
// 加载设置
const savedSettings = localStorage.getItem('ai-chat-settings')
if (savedSettings) {
Object.assign(settings, JSON.parse(savedSettings))
}
// 创建默认对话 - 确保用户进入时就有新建对话状态
// createNewChat()
// 下拉列表默认值根据settings.apiEndpoint自动匹配
// const matched = availableModels.value.find(m => m.apiEndpoint === settings.apiEndpoint)
// if (matched) {
// selectedModel.value = matched.value
// } else if (!selectedModel.value && availableModels.value.length > 0) {
// selectedModel.value = availableModels.value[0].value
// }
// 自动调整输入框高度
autoResize()
})
onUnmounted(() => {
// 清理工作
if (currentRequestController.value) {
currentRequestController.value.abort()
}
// 清理加载状态
loadingChatId.value = null
// 清理编辑状态
editingChatId.value = null
// 清理删除状态
deletingChatId.value = null
})
</script>
<style scoped>
.ai-chat-container {
display: flex;
height: 93vh;
background: #f5f5f5;
}
.sidebar {
width: 300px;
background: #f1f2f4;
/* border-right: 1px solid #e0e0e0; */
border-bottom: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
transition: all 0.3s ease;
height: 100%;
overflow: hidden;
}
.sidebar.collapsed {
width: 60px;
}
.sidebar-header {
padding: 12px 16px;
border-bottom: 1px solid #e0e0e0;
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.logo-img {
width: 32px;
height: 32px;
border-radius: 6px;
}
.logo-text {
font-size: 18px;
font-weight: 600;
color: #333;
flex: 1;
}
.collapse-btn {
margin-left: auto;
}
.sidebar-content {
flex: 1;
display: flex;
flex-direction: column;
padding: 12px 6px 12px 6px;
overflow: hidden;
min-height: 0;
}
.new-chat-btn {
margin-bottom: 16px;
width: 100%;
flex-shrink: 0;
}
.chat-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding-right: 8px;
min-height: 0;
}
.group-header {
font-size: 14px;
color: #999;
padding: 6px 0;
margin-bottom: 6px;
border-bottom: 1px solid #e0e0e0;
}
.group-section {
margin-bottom: 12px;
}
.chat-item {
display: flex;
flex-direction: column;
padding: 8px 12px;
border-radius: 8px;
margin-bottom: 6px;
cursor: pointer;
transition: background-color 0.2s;
position: relative;
}
.chat-item:hover {
background: #f0f0f0;
}
.chat-item.active {
background: #e3f2fd;
border: 1px solid #2196f3;
}
.chat-info {
flex: 1;
min-width: 0;
margin-bottom: 4px;
}
.chat-title-row {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 2px;
}
.chat-title {
display: block;
font-weight: 500;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: calc(100% - 110px);
}
.loading-indicator {
margin-left: 8px;
display: flex;
align-items: center;
}
.loading-indicator .el-icon {
font-size: 14px;
color: #409EFF;
animation: rotate 1s linear infinite;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.editing-indicator {
margin-left: 8px;
display: flex;
align-items: center;
}
.editing-indicator .el-icon {
font-size: 14px;
color: #E6A23C;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.1);
}
}
.deleting-indicator {
margin-left: 8px;
display: flex;
align-items: center;
}
.deleting-indicator .el-icon {
font-size: 14px;
color: #F56C6C;
animation: shake 0.5s ease-in-out infinite;
}
@keyframes shake {
0%, 100% {
transform: translateX(0);
}
25% {
transform: translateX(-2px);
}
75% {
transform: translateX(2px);
}
}
.chat-bottom-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-time {
font-size: 12px;
color: #999;
}
.chat-actions {
display: flex;
gap: 4px;
justify-content: flex-end;
opacity: 0;
transition: opacity 0.2s;
}
.chat-item:hover .chat-actions {
opacity: 1;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
background: #fff;
}
.chat-header {
padding: 16px 24px;
border-bottom: 1px solid #e0e0e0;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left h2 {
margin: 0;
font-size: 20px;
color: #333;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
width: 260px;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 0;
color: #999;
min-height: 300px;
}
.loading-spinner {
margin-bottom: 16px;
}
.loading-spinner .el-icon {
font-size: 48px;
color: #409EFF;
animation: rotate 1s linear infinite;
}
.loading-text {
font-size: 16px;
color: #666;
font-weight: 500;
}
.welcome-message {
text-align: center;
padding: 60px 20px;
color: #666;
}
.welcome-icon {
font-size: 48px;
margin-bottom: 16px;
}
.welcome-message h3 {
margin: 0 0 8px 0;
color: #333;
}
.welcome-message p {
margin: 0;
font-size: 14px;
}
.message {
display: flex;
gap: 12px;
max-width: 70%;
position: relative;
}
.message.user {
align-self: flex-end;
flex-direction: row-reverse;
}
.message-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
}
.message-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.message-content {
flex: 1;
min-width: 0;
}
.message-text {
padding: 12px 16px;
border-radius: 12px;
background: #f0f0f0;
color: #333;
line-height: 1.5;
word-wrap: break-word;
}
.message.user .message-text {
background: #2196f3;
color: #fff;
}
.message.assistant .message-text {
background: #f5f5f5;
color: #333;
}
.message-actions {
position: absolute;
right: -80px;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 8px;
}
.stop-btn, .continue-btn {
white-space: nowrap;
}
.message-time {
font-size: 12px;
color: #999;
margin-top: 4px;
text-align: right;
}
.message.user .message-time {
text-align: left;
}
.streaming-text {
display: inline;
}
.cursor {
animation: blink 1s infinite;
color: #2196f3;
}
@keyframes blink {
0%, 50% {
opacity: 1;
}
51%, 100% {
opacity: 0;
}
}
.chat-input {
padding: 16px 24px;
border-top: 1px solid #e0e0e0;
background: #fff;
}
.input-container {
max-width: 800px;
margin: 0 auto;
}
.input-toolbar {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.input-toolbar.left-toolbar {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.suggestion-bar {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 8px;
/* 新增:与输入区宽度对齐 */
max-width: 100%;
width: 100%;
box-sizing: border-box;
padding-left: 0;
padding-right: 0;
}
.suggestion-item {
background: #f0f6ff;
color: #409EFF;
border-radius: 16px;
padding: 6px 18px;
font-size: 15px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
border: 1px solid #e0eaff;
user-select: none;
}
.suggestion-item:hover {
background: #409EFF;
color: #fff;
}
.input-area {
position: relative;
display: flex;
align-items: flex-end;
gap: 12px;
}
.input-area.input-area-with-icons {
display: flex;
align-items: flex-end;
gap: 8px;
width: 100%;
}
.input-icons {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 4px;
}
@media (min-width: 600px) {
.input-icons {
flex-direction: row;
align-items: center;
margin-bottom: 0;
margin-right: 8px;
}
}
.message-input {
flex: 1;
}
.message-input {
flex: 1;
min-height: 44px;
max-height: 200px;
padding: 12px 16px;
border: 1px solid #d0d0d0;
border-radius: 8px;
resize: none;
font-size: 14px;
line-height: 1.5;
outline: none;
transition: border-color 0.2s;
}
.message-input:focus {
border-color: #2196f3;
}
.message-input:disabled {
background: #f5f5f5;
cursor: not-allowed;
}
.input-actions {
display: flex;
gap: 8px;
align-items: flex-end;
}
/* 响应式设计 */
@media (max-width: 768px) {
.sidebar {
position: fixed;
left: 0;
top: 0;
height: 100vh;
z-index: 1000;
transform: translateX(-100%);
}
.sidebar:not(.collapsed) {
transform: translateX(0);
}
.main-content {
margin-left: 0;
}
.message {
max-width: 90%;
}
}
/* 滚动条样式 */
.chat-messages::-webkit-scrollbar {
width: 6px;
}
.chat-messages::-webkit-scrollbar-track {
background: #f1f1f1;
}
.chat-messages::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.chat-messages::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 左侧滚动条样式 */
.chat-list::-webkit-scrollbar {
width: 8px;
}
.chat-list::-webkit-scrollbar-track {
background: #f0f0f0;
border-radius: 4px;
margin: 4px 0;
}
.chat-list::-webkit-scrollbar-thumb {
background: #b0b0b0;
border-radius: 4px;
transition: background 0.2s;
border: 1px solid #e0e0e0;
}
.chat-list::-webkit-scrollbar-thumb:hover {
background: #909090;
}
.chat-list::-webkit-scrollbar-corner {
background: #f0f0f0;
}
/* 确保左侧滚动条在Firefox中也能显示 */
.chat-list {
scrollbar-width: thin;
scrollbar-color: #b0b0b0 #f0f0f0;
}
.suggestion-bar.align-to-textarea {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 8px;
max-width: 100%;
width: calc(100% - 160px); /* 80px = 左侧图标宽度+间距 */
margin-left: 80px;
box-sizing: border-box;
padding-left: 0;
padding-right: 0;
}
</style>