Files
geekai/web/src/views/ChatPlus.vue
2025-05-28 22:55:53 +08:00

1593 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.description || '暂无描述'">
{{ model.description || '暂无描述' }}
</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 键发送消息,使用 Ctrl + 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">
<!-- showStopGenerate -->
<el-button
type="info"
v-if="showStopGenerate"
@click="stopGenerate"
plain
>
<el-icon>
<VideoPause />
</el-icon>
</el-button>
<el-button @click="sendMessage" style="color: #754ff6" 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>
<el-dialog v-model="showNotice" :show-close="true" class="notice-dialog" title="网站公告">
<div class="notice">
<div v-html="notice"></div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="notShow" type="primary">我知道了不再显示</el-button>
</span>
</template>
</el-dialog>
<ChatSetting :show="showChatSetting" @hide="showChatSetting = false" />
<!-- <el-dialog
v-model="showConversationDialog"
title="实时语音通话"
:before-close="hangUp"
>
<realtime-conversation
@close="showConversationDialog = false"
ref="conversationRef"
:height="dialogHeight + 'px'"
/>
</el-dialog> -->
<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 } 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 MarkdownIt from 'markdown-it'
import emoji from 'markdown-it-emoji'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { getUserToken } from '../store/session'
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 showNotice = ref(false)
const notice = ref('')
const noticeKey = ref('SYSTEM_NOTICE')
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.category === 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.category) {
categories.add(model.category)
}
})
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.category || '未分类'
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)
})
const md = new MarkdownIt({
breaks: true,
html: true,
linkify: true,
typographer: true,
}).use(emoji)
// 获取系统公告
httpGet('/api/config/get?key=notice')
.then((res) => {
try {
notice.value = md.render(res.data['content'])
const oldNotice = localStorage.getItem(noticeKey.value)
// 如果公告有更新,则显示公告
if (oldNotice !== notice.value && notice.value.length > 10) {
showNotice.value = true
}
} catch (e) {
console.warn(e)
}
})
.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 showStopGenerate = ref(false) // 停止生成
const lineBuffer = ref('') // 输出缓冲行
const canSend = ref(true)
const isNewMsg = ref(true)
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 = 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)
}
}
}
// 发送 SSE 请求
const sendSSERequest = async (message) => {
try {
await fetchEventSource('/api/chat/message', {
method: 'POST',
headers: {
Authorization: getUserToken(),
},
body: JSON.stringify(message),
openWhenHidden: true,
onopen(response) {
if (response.ok && response.status === 200) {
console.log('SSE connection opened')
} else {
throw new Error(`Failed to open SSE connection: ${response.status}`)
}
},
onmessage(msg) {
try {
const data = JSON.parse(msg.data)
if (data.type === 'error') {
ElMessage.error(data.body)
enableInput()
return
}
if (data.type === 'end') {
enableInput()
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
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
}
}
}
// 将聊天框的滚动条滑动到最底部
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)
enableInput()
ElMessage.error('消息处理出错,请重试')
}
},
onerror(err) {
console.error('SSE Error:', err)
enableInput()
ElMessage.error('连接已断开,请重试')
},
onclose() {
console.log('SSE connection closed')
enableInput()
},
})
} catch (error) {
console.error('Failed to send message:', error)
enableInput()
ElMessage.error('发送消息失败,请重试')
}
}
// 发送消息
const sendMessage = (messageId) => {
if (!isLogin.value) {
console.log('未登录')
store.setShowLoginDialog(true)
return
}
if (canSend.value === false) {
ElMessage.warning('AI 正在作答中,请稍后...')
return
}
if (prompt.value.trim().length === 0 || canSend.value === false) {
showMessageError('请输入要发送的消息!')
return false
}
// 追加消息
chatData.value.push({
type: 'prompt',
id: randString(32),
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
disableInput(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,
})
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,
}
showStopGenerate.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
showStopGenerate.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 disableInput = (force) => {
canSend.value = false
showStopGenerate.value = !force
}
const enableInput = () => {
canSend.value = true
showStopGenerate.value = false
}
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) {
if (e.ctrlKey) {
// Ctrl + Enter 换行
prompt.value += '\n'
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: randString(32),
icon: _role['icon'],
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 () {
showStopGenerate.value = false
httpGet('/api/chat/stop?session_id=' + getClientId()).then(() => {
enableInput()
})
}
// 重新生成
const reGenerate = function (messageId) {
// 恢复发送按钮状态
canSend.value = true
showStopGenerate.value = false
console.log(messageId)
chatData.value = chatData.value.filter((item) => item.id < messageId)
// 保存用户消息内容,填入输入框
const userPrompt = chatData.value[chatData.value.length - 1].content.text
// 删除用户消息
const lastMessage = chatData.value.pop()
// 填入输入框
prompt.value = userPrompt
sendMessage(lastMessage.id)
// 将光标定位到输入框并聚焦
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 notShow = () => {
localStorage.setItem(noticeKey.value, notice.value)
showNotice.value = false
}
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="stylus">
@import '../assets/css/chat-plus.styl'
</style>
<style lang="stylus">
@import '../assets/css/markdown/vue.css';
.notice-dialog {
.el-dialog__header {
padding-bottom 0
}
.el-dialog__body {
padding 0 20px
h2 {
margin: 20px 0 15px 0;
}
ol, ul {
padding-left 10px
}
ol {
list-style decimal-leading-zero
padding-left 20px
}
ul {
list-style inside
}
}
}
.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: darken(#F56C6C, 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>