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