Files
geekai/web/src/views/ChatPlus.vue

1553 lines
44 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="chat-page">
<el-container>
<el-aside v-show="store.chatListExtend">
<div class="flex w-full justify-center pt-3 pb-3">
<img :src="logo" style="max-height: 40px" :alt="title" v-if="logo !== ''" />
<h2 v-else>{{ title }}</h2>
</div>
<div class="chat-list-container">
<el-button @click="_newChat" type="primary" class="newChat">
<i class="iconfont icon-new-chat mr-1"></i>
新建对话
</el-button>
<div class="search-box">
<el-input
v-model="chatName"
placeholder="搜索会话"
@keyup="searchChat($event)"
style=""
class="search-input"
>
<template #prefix>
<el-icon class="el-input__icon">
<Search />
</el-icon>
</template>
</el-input>
</div>
<el-scrollbar :height="chatListHeight">
<div class="content">
<el-row v-for="chat in chatList" :key="chat.chat_id">
<div
:class="chat.chat_id === chatId ? 'chat-list-item active' : 'chat-list-item'"
@click="loadChat(chat)"
>
<el-image :src="chat.icon" class="avatar" />
<span class="chat-title-input" v-if="chat.edit">
<el-input
v-model="tmpChatTitle"
size="small"
@keydown="titleKeydown($event, chat)"
:id="'chat-' + chat.chat_id"
@blur="editConfirm(chat)"
@click="stopPropagation($event)"
placeholder="请输入标题"
/>
</span>
<span v-else class="chat-title">{{ chat.title }}</span>
<span class="chat-opt">
<el-dropdown trigger="click">
<span class="el-dropdown-link" @click="stopPropagation($event)">
<el-icon><More /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item :icon="Edit" @click="editChatTitle(chat)"
>重命名</el-dropdown-item
>
<el-dropdown-item
:icon="Delete"
style="
--el-text-color-regular: var(--el-color-danger);
--el-dropdown-menuItem-hover-fill: #f8e1de;
--el-dropdown-menuItem-hover-color: var(--el-color-danger);
"
@click="removeChat(chat)"
>删除</el-dropdown-item
>
<el-dropdown-item :icon="Share" @click="shareChat(chat)"
>分享</el-dropdown-item
>
</el-dropdown-menu>
</template>
</el-dropdown>
</span>
</div>
</el-row>
</div>
</el-scrollbar>
</div>
<div class="tool-box">
<el-button type="primary" size="small" @click="clearAllChats">
<i class="iconfont icon-clear"></i> 清除所有对话
</el-button>
</div>
</el-aside>
<el-main
v-loading="loading"
element-loading-background="rgba(122, 122, 122, 0.3)"
class="relative"
>
<div class="absolute top-2 left-2 cursor-pointer">
<div @click="store.setChatListExtend(!store.chatListExtend)">
<el-tooltip content="隐藏对话列表" placement="right" v-if="store.chatListExtend">
<i class="iconfont icon-colspan text-xl"></i>
</el-tooltip>
<el-tooltip content="展开对话列表" placement="right" v-else>
<i class="iconfont icon-expand text-xl"></i>
</el-tooltip>
</div>
</div>
<div class="chat-container">
<div class="chat-config">
<el-select
v-model="roleId"
filterable
placeholder="应用"
@change="_newChat"
class="role-select"
style="width: 150px"
>
<el-option v-for="item in roles" :key="item.id" :label="item.name" :value="item.id">
<div class="role-option">
<el-image :src="item.icon"></el-image>
<span>{{ item.name }}</span>
</div>
</el-option>
</el-select>
<el-popover
placement="bottom"
:width="800"
trigger="click"
popper-class="model-selector-popover"
ref="modelSelectorRef"
>
<template #reference>
<div class="model-selector-trigger">
<el-button
type="primary"
:disabled="disableModel"
class="adaptive-width-button"
size="small"
plain
>
<div class="selected-model-display">
<span class="model-name-text">{{ getSelectedModelName() }}</span>
<el-tag
v-if="getSelectedModel()"
size="small"
type="info"
style="margin-left: 8px; flex-shrink: 0"
>
{{ getSelectedModel() && getSelectedModel().power }}算力
</el-tag>
</div>
</el-button>
</div>
</template>
<div class="model-selector-container">
<div class="model-search">
<el-input
v-model="modelSearchKeyword"
placeholder="搜索模型"
prefix-icon="el-icon-search"
clearable
style="width: 200px"
/>
<el-button
:type="showFreeModelsOnly ? 'primary' : 'default'"
size="default"
@click="toggleFreeModels"
style="margin-left: 10px"
>
<i class="iconfont icon-free" style="margin-right: 4px"></i>
免费模型
</el-button>
</div>
<div class="category-tabs">
<div
class="category-tab"
:class="{ active: activeCategory === '' }"
@click="activeCategory = ''"
>
全部
</div>
<div
v-for="category in modelCategories"
:key="category"
class="category-tab"
:class="{ active: activeCategory === category }"
@click="activeCategory = category"
>
{{ category }}
</div>
<div
v-if="activeCategory && modelCategories.length > 0"
class="category-tab reset-filter"
@click="activeCategory = ''"
>
<i class="el-icon-close"></i> 清除筛选
</div>
</div>
<div v-if="displayedModels.length === 0" class="no-results">
<el-empty description="没有找到匹配的模型" />
</div>
<div v-else class="models-grid">
<div
v-for="model in displayedModels"
:key="model.id"
class="model-card"
:class="{ selected: model.id === modelID }"
@click="selectModel(model)"
>
<div class="model-card-header">
<span class="model-name" :title="model.name">{{ model.name }}</span>
<el-tag size="small" :type="getTagType(model.power)" style="flex-shrink: 0">
{{ model.power > 0 ? `${model.power}算力` : '免费' }}
</el-tag>
</div>
<div class="model-description" :title="model.desc || '暂无描述'">
{{ model.desc || '暂无描述' }}
</div>
<!-- 暂时屏蔽此信息展示或许用户不想展示此信息 -->
<div class="model-metadata">
<div class="model-detail">
<div>响应: {{ model.max_tokens }}</div>
<div>上下文: {{ model.max_context }}</div>
</div>
</div>
</div>
</div>
</div>
</el-popover>
<div class="flex-center ml-2">
<el-dropdown :hide-on-click="false" trigger="click">
<span class="setting"><i class="iconfont icon-plugin"></i></span>
<template #dropdown>
<el-dropdown-menu class="tools-dropdown">
<el-checkbox-group v-model="toolSelected">
<el-dropdown-item v-for="item in tools" :key="item.id">
<el-checkbox :value="item.id" :label="item.label" />
<el-tooltip :content="item.description" placement="right">
<el-icon><InfoFilled /></el-icon>
</el-tooltip>
</el-dropdown-item>
</el-checkbox-group>
</el-dropdown-menu>
</template>
</el-dropdown>
<span class="setting" @click="showChatSetting = true">
<i class="iconfont icon-config"></i>
</span>
</div>
</div>
<div class="flex justify-center">
<div id="container" :style="{ height: mainWinHeight + 'px' }">
<div class="chat-box" id="chat-box" :style="{ height: chatBoxHeight + 'px' }">
<div v-if="showHello">
<welcome @send="autofillPrompt" />
</div>
<div v-for="item in chatData" :key="item.id" v-else>
<chat-prompt
v-if="item.type === 'prompt'"
:data="item"
:list-style="listStyle"
@edit="editUserPrompt"
/>
<chat-reply
v-else-if="item.type === 'reply'"
:data="item"
@regen="reGenerate"
:read-only="false"
:list-style="listStyle"
/>
</div>
<back-top :right="30" :bottom="155" />
</div>
<!-- end chat box -->
<div class="input-box">
<div class="input-box-inner">
<div class="input-body">
<div ref="textHeightRef" class="hide-div">{{ prompt }}</div>
<div class="input-border">
<div class="input-inner">
<div class="file-list" v-if="files.length > 0">
<file-list :files="files" @remove-file="removeFile" />
</div>
<textarea
ref="inputRef"
class="prompt-input"
:rows="row"
v-model="prompt"
@keydown="onInput"
@input="onInput"
placeholder="按 Enter 键发送消息,使用 Shift + Enter 换行"
autofocus
>
</textarea>
</div>
<div class="flex-between">
<div class="flex little-btns">
<!-- <span class="tool-item-btn" @click="realtimeChat">
<el-tooltip
class="box-item"
effect="dark"
:content="
'实时语音对话,每次消耗' + config.advance_voice_power + '算力'
"
>
<i class="iconfont icon-mic-bold"></i>
</el-tooltip>
</span> -->
<span class="tool-item-btn">
<el-tooltip class="box-item" effect="dark" content="上传附件">
<file-select
:user-id="loginUser && loginUser.id"
@selected="insertFile"
/>
</el-tooltip>
</span>
</div>
<div class="flex little-btns">
<span class="send-btn tool-item-btn">
<el-button type="info" v-if="isGenerating" @click="stopGenerate" plain>
<el-icon>
<VideoPause />
</el-icon>
</el-button>
<el-button
@click="sendMessage()"
style="color: #754ff6"
:disabled="isGenerating"
v-else
>
<el-tooltip class="box-item" effect="dark" content="发送">
<el-icon><Promotion /></el-icon>
</el-tooltip>
</el-button>
</span>
</div>
</div>
</div>
</div>
</div>
<!-- end input box -->
</div>
</div>
<!-- end container -->
</div>
<!-- end loading -->
</div>
</el-main>
</el-container>
<ChatSetting :show="showChatSetting" @hide="showChatSetting = false" />
<el-dialog v-model="showConversationDialog" title="实时语音通话" :fullscreen="true">
<div v-loading="!frameLoaded">
<iframe
style="width: 100%; height: calc(100vh - 100px); border: none"
:src="voiceChatUrl"
@load="frameLoaded = true"
allow="microphone *;camera *;"
></iframe>
</div>
</el-dialog>
</div>
</template>
<script setup>
import BackTop from '@/components/BackTop.vue'
import ChatPrompt from '@/components/ChatPrompt.vue'
import ChatReply from '@/components/ChatReply.vue'
import ChatSetting from '@/components/ChatSetting.vue'
import FileList from '@/components/FileList.vue'
import FileSelect from '@/components/FileSelect.vue'
import Welcome from '@/components/Welcome.vue'
import { checkSession, getClientId, getSystemInfo } from '@/store/cache'
import { useSharedStore } from '@/store/sharedata'
import { closeLoading, showLoading, showMessageError, showMessageInfo } from '@/utils/dialog'
import { httpGet, httpPost } from '@/utils/http'
import { isMobile, randString, removeArrayItem, UUID } from '@/utils/libs'
import {
Delete,
Edit,
InfoFilled,
More,
Promotion,
Search,
Share,
VideoPause,
} from '@element-plus/icons-vue'
import { fetchEventSource } from '@microsoft/fetch-event-source'
import Clipboard from 'clipboard'
import { ElMessage, ElMessageBox } from 'element-plus'
import 'highlight.js/styles/a11y-dark.css'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { getUserToken } from '../store/session'
import { substr } from '../utils/libs'
const title = ref('GeekAI-智能助手')
const logo = ref('')
const models = ref([])
const modelID = ref(0)
const chatData = ref([])
const allChats = ref([]) // 会话列表
const chatList = ref(allChats.value)
const mainWinHeight = ref(0) // 主窗口高度
const chatBoxHeight = ref(0) // 聊天内容框高度
const chatListHeight = ref(0) // 聊天列表高度
const loading = ref(false)
const loginUser = ref(null)
const roles = ref([])
const router = useRouter()
const roleId = ref(0)
const chatId = ref()
const newChatItem = ref(null)
const isLogin = ref(false)
const showHello = ref(true)
const inputRef = ref(null)
const textHeightRef = ref(null)
const store = useSharedStore()
const row = ref(1)
const showChatSetting = ref(false)
const listStyle = ref(store.chatListStyle)
const config = ref({ advance_voice_power: 0 })
const voiceChatUrl = ref('')
const modelSearchKeyword = ref('') // 模型搜索关键词
const selectedCategory = ref('')
const modelCategories = ref([])
const groupedModels = ref([])
const activeCategory = ref('') // 当前激活的分类标签
const showFreeModelsOnly = ref(false) // 是否只显示免费模型
const tools = ref([])
const toolSelected = ref([])
const stream = ref(store.chatStream)
const modelSelectorRef = ref(null)
// 过滤后的模型列表
const filteredModels = computed(() => {
if (!modelSearchKeyword.value && !showFreeModelsOnly.value && !activeCategory.value) {
return models.value
}
return models.value.filter((model) => {
// 搜索关键词匹配
const matchesSearch =
!modelSearchKeyword.value ||
model.name.toLowerCase().includes(modelSearchKeyword.value.toLowerCase()) ||
(model.description &&
model.description.toLowerCase().includes(modelSearchKeyword.value.toLowerCase()))
// 分类匹配
const matchesCategory = !activeCategory.value || model.tag === activeCategory.value
// 免费模型匹配
const matchesFree = !showFreeModelsOnly.value || model.power <= 0
return matchesSearch && matchesCategory && matchesFree
})
})
// 最终展示的模型列表
const displayedModels = computed(() => {
return filteredModels.value
})
// 切换是否只显示免费模型
const toggleFreeModels = () => {
showFreeModelsOnly.value = !showFreeModelsOnly.value
if (showFreeModelsOnly.value) {
activeCategory.value = ''
}
}
// 提取所有模型分类
const updateModelCategories = () => {
const categories = new Set()
models.value.forEach((model) => {
if (model.tag) {
categories.add(model.tag)
}
})
modelCategories.value = Array.from(categories)
}
// 按分类对模型进行分组
const updateGroupedModels = () => {
const filtered = filteredModels.value
// 如果已经指定分类,则只显示该分类
if (selectedCategory.value) {
groupedModels.value = [
{
category: selectedCategory.value,
models: filtered,
},
]
return
}
// 否则按分类分组展示
const groups = {}
filtered.forEach((model) => {
const category = model.tag || '未分类'
if (!groups[category]) {
groups[category] = []
}
groups[category].push(model)
})
groupedModels.value = Object.keys(groups).map((category) => ({
category,
models: groups[category],
}))
// 对分组进行排序(未分类放最后)
groupedModels.value.sort((a, b) => {
if (a.category === '未分类') return 1
if (b.category === '未分类') return -1
return a.category.localeCompare(b.category)
})
}
// 当筛选条件变化时更新分组
watch([filteredModels, selectedCategory], () => {
updateGroupedModels()
})
// 监听模型数据变化,更新分类列表
watch(
() => models.value,
() => {
updateModelCategories()
updateGroupedModels()
},
{ deep: true }
)
// 获取选中的模型名称
const getSelectedModelName = () => {
const model = getSelectedModel()
return model ? model.name : '选择模型'
}
// 获取选中的模型
const getSelectedModel = () => {
return models.value.find((model) => model.id === modelID.value)
}
// 选择模型
const selectModel = (model) => {
modelID.value = model.id
modelSelectorRef.value.hide()
_newChat()
}
// 根据算力获取标签类型
const getTagType = (power) => {
const powerNum = Number(power)
if (powerNum <= 5) return 'info'
if (powerNum <= 15) return 'warning'
return 'danger'
}
watch(
() => store.chatListStyle,
(newValue) => {
listStyle.value = newValue
}
)
watch(
() => store.chatStream,
(newValue) => {
stream.value = newValue
}
)
if (isMobile()) {
router.push('/mobile/chat')
}
// 初始化角色ID参数
if (router.currentRoute.value.query.role_id) {
roleId.value = parseInt(router.currentRoute.value.query.role_id)
}
// 初始化 ChatID
chatId.value = router.currentRoute.value.params.id
if (!chatId.value) {
chatId.value = UUID()
} else {
// 查询对话信息
httpGet('/api/chat/detail', { chat_id: chatId.value })
.then((res) => {
document.title = res.data.title
roleId.value = res.data.role_id
modelID.value = res.data.model_id
})
.catch((e) => {
console.error('获取对话信息失败:' + e.message)
})
}
// 获取系统配置
getSystemInfo()
.then((res) => {
config.value = res.data
title.value = config.value.title
logo.value = res.data.bar_logo
})
.catch((e) => {
ElMessage.error('获取系统配置失败:' + e.message)
})
// 获取工具函数
httpGet('/api/function/list')
.then((res) => {
tools.value = res.data
})
.catch((e) => {
showMessageError('获取工具函数失败:' + e.message)
})
const prompt = ref('')
const isGenerating = ref(false)
const lineBuffer = ref('') // 输出缓冲行
const isNewMsg = ref(true)
const abortController = ref(null)
onMounted(() => {
resizeElement()
initData()
const clipboard = new Clipboard('.copy-reply, .copy-code-btn')
clipboard.on('success', () => {
ElMessage.success('复制成功!')
})
clipboard.on('error', () => {
ElMessage.error('复制失败!')
})
window.onresize = () => resizeElement()
})
// 初始化数据
const initData = async () => {
try {
// 获取角色列表
const roleRes = await httpGet('/api/app/list')
roles.value = roleRes.data
if (roles.value.length > 0 && !roleId.value) {
roleId.value = roles.value[0].id
}
// 获取模型列表
const modelRes = await httpGet('/api/model/list')
models.value = modelRes.data
if (models.value.length > 0) {
modelID.value = models.value[0].id
}
// 获取用户信息
const user = await checkSession()
loginUser.value = user
isLogin.value = true
// 获取聊天列表
const chatRes = await httpGet('/api/chat/list')
allChats.value = chatRes.data
chatList.value = allChats.value
if (chatId.value) {
loadChatHistory(chatId.value)
}
} catch (error) {
if (error.response?.status === 401) {
isLogin.value = false
} else {
console.warn('初始化数据失败:' + error.message)
}
}
}
abortController.value = new AbortController()
// 发送 SSE 请求
const sendSSERequest = async (message) => {
isGenerating.value = true
try {
await fetchEventSource('/api/chat/message', {
method: 'POST',
headers: {
Authorization: getUserToken(),
},
body: JSON.stringify(message),
openWhenHidden: true,
// 重试机制,避免连接断开后一直重试
retry: 3000,
// 设置重试延迟为0确保不重试
retryDelay: 3000,
// 设置最大重试次数为0
maxRetries: 3,
signal: abortController.value.signal,
onopen(response) {
if (response.ok && response.status === 200) {
console.log('SSE connection opened')
} else {
console.error('SSE connection failed', response)
isGenerating.value = false
}
},
onmessage(msg) {
try {
const data = JSON.parse(msg.data)
if (data.type === 'error') {
const reply = chatData.value[chatData.value.length - 1]
if (reply) {
reply['content'].text = `<div class="text-red-500 p-3 rounded-md">${data.body}</div>`
}
isGenerating.value = false
return
}
if (data.type === 'end') {
isGenerating.value = false
lineBuffer.value = '' // 清空缓冲
// 获取 token
const reply = chatData.value[chatData.value.length - 1]
httpPost('/api/chat/tokens', {
text: '',
model: getModelValue(modelID.value),
chat_id: chatId.value,
})
.then((res) => {
reply['created_at'] = new Date().getTime()
reply['tokens'] = res.data
// 将聊天框的滚动条滑动到最底部
nextTick(() => {
document
.getElementById('chat-box')
.scrollTo(0, document.getElementById('chat-box').scrollHeight)
})
})
.catch(() => {})
isNewMsg.value = true
tmpChatTitle.value = message.prompt
console.log('chatData.value', chatData.value)
// 判断 chatlist 中指定的 chat_id 是否存在
const chat = chatList.value.find((chat) => chat.chat_id === chatId.value)
if (!chat) {
const _role = getRoleById(roleId.value)
chatList.value.unshift({
chat_id: chatId.value,
title: substr(message.prompt, 15),
role_id: roleId.value,
model_id: modelID.value,
icon: _role.icon,
created_at: new Date().getTime(),
updated_at: new Date().getTime(),
})
}
return
}
if (data.type === 'text') {
if (isNewMsg.value) {
isNewMsg.value = false
lineBuffer.value = data.body
const reply = chatData.value[chatData.value.length - 1]
if (reply) {
reply['content'].text = lineBuffer.value
}
} else {
lineBuffer.value += data.body
const reply = chatData.value[chatData.value.length - 1]
if (reply) {
reply['content'].text = lineBuffer.value
}
}
}
// 回答完毕,更新完整的消息内容
if (data.type === 'complete') {
chatData.value[chatData.value.length - 1] = data.body
}
// 将聊天框的滚动条滑动到最底部
nextTick(() => {
document
.getElementById('chat-box')
.scrollTo(0, document.getElementById('chat-box').scrollHeight)
localStorage.setItem('chat_id', chatId.value)
})
} catch (error) {
console.error('Error processing message:', error)
isGenerating.value = false
ElMessage.error('消息处理出错,请重试')
}
},
onerror(err) {
console.error('SSE Error:', err)
try {
abortController.value && abortController.value.abort()
} catch (e) {
console.error('AbortController abort error:', e)
}
isGenerating.value = false
// ElMessage.error('连接已断开,发生错误:' + err.message)
const reply = chatData.value[chatData.value.length - 1]
if (reply) {
reply['content'].text = `<div class="text-red-500 p-3 rounded-md">${err.message}</div>`
}
},
onclose() {
console.log('SSE connection closed')
isGenerating.value = false
},
})
} catch (error) {
console.error('Failed to send message:', error)
isGenerating.value = false
ElMessage.error('发送消息失败,请重试')
}
}
// 发送消息
const sendMessage = (messageId = 0) => {
if (!isLogin.value) {
console.log('未登录')
store.setShowLoginDialog(true)
return
}
if (isGenerating.value) {
ElMessage.warning('AI 正在作答中,请稍后...')
return
}
if (prompt.value === '') {
showMessageError('请输入要发送的消息!')
return false
}
// 追加消息
chatData.value.push({
type: 'prompt',
id: 0,
icon: loginUser.value.avatar,
content: {
text: prompt.value,
files: files.value,
},
model: getModelValue(modelID.value),
created_at: new Date().getTime() / 1000,
})
// 添加空回复消息
const _role = getRoleById(roleId.value)
chatData.value.push({
chat_id: chatId,
role_id: roleId.value,
type: 'reply',
id: randString(32),
icon: _role['icon'],
content: {
text: '',
files: [],
},
})
nextTick(() => {
document
.getElementById('chat-box')
.scrollTo(0, document.getElementById('chat-box').scrollHeight)
})
showHello.value = false
// 异步发送 SSE 请求
sendSSERequest({
user_id: loginUser.value.id,
role_id: roleId.value,
model_id: modelID.value,
chat_id: chatId.value,
prompt: prompt.value,
tools: toolSelected.value,
stream: stream.value,
files: files.value,
last_msg_id: messageId || 0,
})
prompt.value = ''
files.value = []
row.value = 1
}
const getRoleById = function (rid) {
for (let i = 0; i < roles.value.length; i++) {
if (roles.value[i]['id'] === rid) {
return roles.value[i]
}
}
return null
}
const resizeElement = function () {
chatListHeight.value = window.innerHeight - 240
mainWinHeight.value = window.innerHeight - 50
chatBoxHeight.value = window.innerHeight - 101 - 82 - 38
}
const _newChat = () => {
if (isLogin.value) {
chatId.value = UUID()
newChat()
}
}
const disableModel = ref(false)
// 新建会话
const newChat = () => {
if (!isLogin.value) {
store.setShowLoginDialog(true)
return
}
const role = getRoleById(roleId.value)
showHello.value = role.key === 'gpt'
// if the role bind a model, disable model change
disableModel.value = false
if (role.model_id > 0) {
modelID.value = role.model_id
disableModel.value = true
}
// 已有新开的会话
if (newChatItem.value !== null && newChatItem.value['role_id'] === roles.value[0]['role_id']) {
return
}
// 获取当前聊天角色图标
let icon = ''
roles.value.forEach((item) => {
if (item['id'] === roleId.value) {
icon = item['icon']
}
})
newChatItem.value = {
chat_id: '',
icon: icon,
role_id: roleId.value,
model_id: modelID.value,
title: '',
edit: false,
removing: false,
}
isGenerating.value = false
loadChatHistory(chatId.value)
router.push(`/chat/${chatId.value}`)
}
// 切换会话
const loadChat = function (chat) {
if (!isLogin.value) {
store.setShowLoginDialog(true)
return
}
if (chatId.value === chat.chat_id) {
return
}
newChatItem.value = null
roleId.value = chat.role_id
modelID.value = chat.model_id
chatId.value = chat.chat_id
isGenerating.value = false
loadChatHistory(chatId.value)
router.push(`/chat/${chatId.value}`)
}
// 编辑会话标题
const tmpChatTitle = ref('')
const editChatTitle = (chat) => {
chat.edit = true
tmpChatTitle.value = chat.title
nextTick(() => {
document.getElementById('chat-' + chat.chat_id).focus()
})
}
const titleKeydown = (e, chat) => {
if (e.keyCode === 13) {
e.stopPropagation()
editConfirm(chat)
}
}
const stopPropagation = (e) => {
e.stopPropagation()
}
// 确认修改
const editConfirm = function (chat) {
if (tmpChatTitle.value === '') {
return ElMessage.error('请输入会话标题!')
}
if (!chat.chat_id) {
return ElMessage.error('对话 ID 为空,请刷新页面再试!')
}
if (tmpChatTitle.value === chat.title) {
chat.edit = false
return
}
httpPost('/api/chat/update', {
chat_id: chat.chat_id,
title: tmpChatTitle.value,
})
.then(() => {
chat.title = tmpChatTitle.value
chat.edit = false
})
.catch((e) => {
ElMessage.error('操作失败:' + e.message)
})
}
// 删除会话
const removeChat = function (chat) {
ElMessageBox.confirm(`该操作会删除"${chat.title}"`, '删除聊天', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
httpGet('/api/chat/remove?chat_id=' + chat.chat_id)
.then(() => {
chatList.value = removeArrayItem(chatList.value, chat, function (e1, e2) {
return e1.id === e2.id
})
// 重置会话
_newChat()
})
.catch((e) => {
ElMessage.error('操作失败:' + e.message)
})
})
.catch(() => {})
}
const onInput = (e) => {
// 根据输入的内容自动计算输入框的行数
const lineHeight = parseFloat(window.getComputedStyle(inputRef.value).lineHeight)
textHeightRef.value.style.width = inputRef.value.clientWidth + 'px' // 设定宽度和 textarea 相同
const lines = Math.floor(textHeightRef.value.clientHeight / lineHeight)
inputRef.value.scrollTo(0, inputRef.value.scrollHeight)
if (prompt.value.length < 10) {
row.value = 1
} else if (lines <= 7) {
row.value = lines
} else {
row.value = 7
}
// 输入回车自动提交
if (e.keyCode === 13) {
// Shift + Enter 换行
if (e.shiftKey) {
return
}
e.preventDefault()
sendMessage()
}
}
// 自动填充 prompt
const autofillPrompt = (text) => {
prompt.value = text
inputRef.value.focus()
sendMessage()
}
const clearAllChats = function () {
ElMessageBox.confirm('清除所有对话?此操作不可撤销!', '警告', {
confirmButtonText: '删除对话',
cancelButtonText: '取消',
dangerouslyUseHTMLString: true,
showClose: true,
closeOnClickModal: false,
center: false,
})
.then(() => {
httpGet('/api/chat/clear')
.then(() => {
ElMessage.success('操作成功!')
chatData.value = []
chatList.value = []
newChat()
})
.catch((e) => {
ElMessage.error('操作失败:' + e.message)
})
})
.catch(() => {})
}
const loadChatHistory = function (chatId) {
chatData.value = []
loading.value = true
httpGet('/api/chat/history?chat_id=' + chatId)
.then((res) => {
loading.value = false
const data = res.data
if ((!data || data.length === 0) && chatData.value.length === 0) {
// 加载打招呼信息
const _role = getRoleById(roleId.value)
chatData.value.push({
chat_id: chatId,
role_id: roleId.value,
type: 'reply',
id: 0,
icon: _role['icon'],
isHello: true,
content: {
text: _role['hello_msg'],
files: [],
},
})
return
}
showHello.value = false
for (let i = 0; i < data.length; i++) {
if (data[i].type === 'reply' && i > 0) {
data[i].prompt = data[i - 1].content
}
chatData.value.push(data[i])
}
nextTick(() => {
document
.getElementById('chat-box')
.scrollTo(0, document.getElementById('chat-box').scrollHeight)
})
})
.catch((e) => {
// TODO: 显示重新加载按钮
ElMessage.error('加载聊天记录失败:' + e.message)
})
}
// 停止生成
const stopGenerate = function () {
if (abortController.value) {
abortController.value.abort()
isGenerating.value = false
httpGet('/api/chat/stop?session_id=' + getClientId())
.then(() => {
showMessageInfo('会话已中断')
})
.catch((e) => {
showMessageError('中断对话失败:' + e.message)
})
}
}
// 重新生成
const reGenerate = function (messageId) {
// 恢复发送按钮状态
if (isGenerating.value) {
ElMessage.warning('AI 正在作答中,请稍后...')
return
}
console.log('messageId', messageId)
console.log('chatData.value', chatData.value)
// 判断 messageId 是整数
if (messageId !== '' && isNaN(messageId)) {
ElMessage.warning('消息 ID 不合法,无法重新生成')
return
}
chatData.value = chatData.value.filter((item) => item.id < messageId && !item.isHello)
const userPrompt = chatData.value.pop()
prompt.value = userPrompt.content.text
sendMessage(messageId)
// 将光标定位到输入框并聚焦
nextTick(() => {
if (inputRef.value) {
inputRef.value.focus()
// 触发输入事件以更新文本高度
onInput({ keyCode: null })
}
})
}
// 编辑用户消息
const editUserPrompt = function (messageId) {
// 找到要编辑的消息及其索引
let messageIndex = -1
let messageContent = ''
for (let i = 0; i < chatData.value.length; i++) {
if (chatData.value[i].id === messageId) {
messageIndex = i
messageContent = chatData.value[i].content
break
}
}
if (messageIndex === -1) return
// 弹出编辑对话框
ElMessageBox.prompt('', '编辑消息', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: messageContent,
inputType: 'textarea',
customClass: 'edit-prompt-dialog',
roundButton: true,
})
.then(({ value }) => {
if (value.trim() === '') {
ElMessage.warning('消息内容不能为空')
return
}
// 更新用户消息
chatData.value[messageIndex].content = value
// 移除该消息之后的所有消息
chatData.value = chatData.value.slice(0, messageIndex + 1)
// 添加空回复消息
const _role = getRoleById(roleId.value)
chatData.value.push({
chat_id: chatId,
role_id: roleId.value,
type: 'reply',
id: randString(32),
icon: _role['icon'],
content: '',
})
disableInput(false)
// 发送编辑后的消息
store.socket.conn.send(
JSON.stringify({
channel: 'chat',
type: 'text',
body: {
role_id: roleId.value,
model_id: modelID.value,
chat_id: chatId.value,
content: value,
tools: toolSelected.value,
stream: stream.value,
edit_message: true,
},
})
)
})
.catch(() => {
// 取消编辑
})
}
const chatName = ref('')
// 搜索会话
const searchChat = function (e) {
if (chatName.value === '') {
chatList.value = allChats.value
return
}
if (e.keyCode === 13) {
const items = []
for (let i = 0; i < allChats.value.length; i++) {
if (allChats.value[i].title.toLowerCase().indexOf(chatName.value.toLowerCase()) !== -1) {
items.push(allChats.value[i])
}
}
chatList.value = items
}
}
// 导出会话
const shareChat = (chat) => {
if (!chat.chat_id) {
return ElMessage.error('请先选中一个会话')
}
const url = location.protocol + '//' + location.host + '/chat/export?chat_id=' + chat.chat_id
window.open(url, '_blank')
}
const getModelValue = (model_id) => {
for (let i = 0; i < models.value.length; i++) {
if (models.value[i].id === model_id) {
return models.value[i].value
}
}
return ''
}
const files = ref([])
// 插入文件
const insertFile = (file) => {
files.value.push(file)
}
const removeFile = (file) => {
files.value = removeArrayItem(files.value, file, (v1, v2) => v1.url === v2.url)
}
// 实时语音对话
const showConversationDialog = ref(false)
// const conversationRef = ref(null);
// const dialogHeight = ref(window.innerHeight - 75);
const frameLoaded = ref(false)
const realtimeChat = () => {
if (!isLogin.value) {
store.setShowLoginDialog(true)
return
}
showLoading('正在连接...')
httpPost('/api/realtime/voice')
.then((res) => {
voiceChatUrl.value = res.data
showConversationDialog.value = true
closeLoading()
})
.catch((e) => {
showMessageError('连接失败:' + e.message)
closeLoading()
})
}
// const hangUp = () => {
// showConversationDialog.value = false;
// conversationRef.value.hangUp();
// };
</script>
<style scoped lang="scss">
@use '@/assets/css/chat-plus.scss' as *;
</style>
<style lang="scss">
@use '@/assets/css/markdown/vue.css' as *;
@use 'sass:color';
.input-container {
.el-textarea {
.el-textarea__inner {
padding-right: 40px;
}
}
}
.model-selector-popover {
max-width: 820px !important;
}
.el-popper.model-selector-popover {
left: 50% !important;
transform: translateX(-50%) !important;
}
.model-selector-container {
padding: 16px;
.model-search {
margin-bottom: 15px;
display: flex;
align-items: center;
}
.category-tabs {
display: flex;
flex-wrap: wrap;
border-bottom: 1px solid #e4e7ed;
margin-bottom: 16px;
.category-tab {
padding: 8px 16px;
cursor: pointer;
margin-right: 8px;
margin-bottom: -1px;
font-size: 14px;
color: #606266;
transition: all 0.2s;
border-bottom: 2px solid transparent;
&:hover {
color: #409eff;
}
&.active {
color: #409eff;
border-bottom-color: #409eff;
font-weight: 500;
}
&.reset-filter {
color: #f56c6c;
margin-left: auto;
&:hover {
color: color.adjust(#f56c6c, $lightness: -10%);
}
}
}
}
.no-results {
padding: 30px;
text-align: center;
}
.models-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
max-height: 450px;
overflow-y: auto;
padding: 4px 4px 16px 4px;
}
.model-card {
border: 1px solid #dcdfe6;
border-radius: 6px;
padding: 14px;
cursor: pointer;
transition: all 0.25s ease;
height: 100%;
display: flex;
flex-direction: column;
min-width: 0; /* 防止内容溢出 */
&:hover {
border-color: #409eff;
transform: translateY(-2px);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
&.selected {
border-color: #409eff;
background-color: #ecf5ff;
}
.model-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
.model-name {
font-weight: bold;
word-break: break-word;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.3;
max-width: 170px;
margin-right: 8px;
}
}
.model-description {
font-size: 12px;
color: #606266;
margin-bottom: 10px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.4;
flex-grow: 1;
}
//.model-metadata {
// display: flex;
// flex-direction: column;
// margin-top: auto;
//}
.model-detail {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #909399;
}
}
}
.adaptive-width-button {
min-width: 180px;
max-width: 350px;
width: auto !important;
padding-left: 15px;
padding-right: 15px;
}
.selected-model-display {
display: flex;
align-items: center;
justify-content: center;
.model-name-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 280px;
}
}
.customer-service-content {
text-align: center;
padding: 10px 0;
.service-tip {
font-size: 16px;
color: #303133;
margin-bottom: 15px;
}
.qrcode-image {
width: 200px;
height: 200px;
margin: 0 auto;
}
.service-note {
font-size: 14px;
color: #909399;
margin-top: 15px;
}
}
.customer-service-btn {
margin-left: 8px;
}
</style>