sawAdmin/src/views/system/gen-chat.vue

1509 lines
36 KiB
Vue
Raw Normal View History

<template>
2025-03-26 18:08:51 +08:00
<div class="chat-app">
<!-- 历史会话侧边栏 -->
2026-05-12 23:33:55 +08:00
<div class="history-sessions" :class="{ collapsed: !sessionIsShow }">
<div class="session-header">
<el-button type="primary" @click="clearCurrent" class="new-session-btn">
<el-icon><Plus /></el-icon>
新会话
</el-button>
</div>
2026-05-12 23:33:55 +08:00
<el-card class="session-card" shadow="never">
2025-03-26 18:08:51 +08:00
<template #header>
2026-05-12 23:33:55 +08:00
<div class="card-header">
<el-icon><ChatDotRound /></el-icon>
<span>当前会话</span>
</div>
2025-09-23 20:35:33 +08:00
</template>
2026-05-12 23:33:55 +08:00
<div class="current-session">
<el-tooltip :content="sessionName" placement="top">
<div class="session-name">{{ getShortenedName(sessionName) || '新会话' }}</div>
</el-tooltip>
</div>
2025-03-26 18:08:51 +08:00
</el-card>
2026-05-12 23:33:55 +08:00
<el-card class="session-card history-card" shadow="never">
2025-03-26 18:08:51 +08:00
<template #header>
2026-05-12 23:33:55 +08:00
<div class="card-header">
<el-icon><Clock /></el-icon>
<span>历史会话</span>
</div>
2025-03-26 18:08:51 +08:00
</template>
2026-05-12 23:33:55 +08:00
<el-scrollbar class="history-scroll">
<div class="history-list">
<div
v-for="(session, index) in historySessions"
:key="index"
class="history-item"
:class="{ active: session.ID === sessionID }"
@click="loadSession(session.ID)"
>
<el-tooltip :content="session.Name" placement="top">
2026-05-12 23:33:55 +08:00
<span class="history-name">{{ getShortenedName(session.Name) }}</span>
</el-tooltip>
2026-05-12 23:33:55 +08:00
</div>
</div>
2025-03-26 18:08:51 +08:00
</el-scrollbar>
</el-card>
</div>
2026-05-12 23:33:55 +08:00
<!-- 折叠按钮 -->
<div class="toggle-sidebar" @click="showSession">
<el-icon v-if="sessionIsShow"><DArrowLeft /></el-icon>
<el-icon v-else><DArrowRight /></el-icon>
</div>
2025-03-26 18:08:51 +08:00
2026-05-12 23:33:55 +08:00
<!-- 聊天区域 -->
2025-03-26 18:08:51 +08:00
<div class="chat-container">
2026-05-12 23:33:55 +08:00
<!-- 顶部工具栏 -->
<div class="chat-header">
<el-select v-model="selectModel" placeholder="选择模型" class="model-select">
<el-option v-for="item in ModelList" :key="item.ID" :label="item.Type + ' - ' + item.Description" :value="item.ID" />
</el-select>
<el-dropdown trigger="click" class="params-dropdown">
<el-button link>
<el-icon><Setting /></el-icon>
模型参数
<el-icon><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<div class="dropdown-content">
<div class="model-params">
<div class="params-tip">
<el-icon><InfoFilled /></el-icon>
<span>建议仅调整 temperature top_p 其中之一</span>
</div>
<!-- 温度参数 -->
<div class="param-item">
<div class="param-label">
<span>温度 (Temperature)</span>
<el-tooltip effect="dark" placement="top" content="采样温度控制生成随机性0: 保守2: 随机)">
<el-icon class="tip-icon"><QuestionFilled /></el-icon>
</el-tooltip>
</div>
<div class="param-control">
<el-slider v-model="temperature" :min="0" :max="2" :step="0.1" :show-tooltip="false" />
<span class="param-value">{{ temperature }}</span>
</div>
</div>
<!-- Top P 参数 -->
<div class="param-item">
<div class="param-label">
<span>Top P</span>
<el-tooltip effect="dark" placement="top" content="限制候选词范围0: 严格1: 宽松)">
<el-icon class="tip-icon"><QuestionFilled /></el-icon>
</el-tooltip>
</div>
<div class="param-control">
<el-slider v-model="topP" :min="0" :max="1" :step="0.1" :show-tooltip="false" />
<span class="param-value">{{ topP }}</span>
</div>
</div>
</div>
</div>
</template>
</el-dropdown>
</div>
2025-03-26 18:08:51 +08:00
<!-- 消息列表 -->
2026-05-12 23:33:55 +08:00
<div class="chat-messages" ref="messagesContainer">
<!-- 空状态 -->
<div v-if="messages.length === 0" class="empty-state">
<div class="empty-icon">
<el-icon :size="80"><ChatLineRound /></el-icon>
2025-03-26 18:08:51 +08:00
</div>
2026-05-12 23:33:55 +08:00
<div class="empty-text">开始一段对话吧</div>
<div class="empty-hint">输入消息或选择文件 AI 进行交流</div>
</div>
<!-- 消息列表 -->
<div v-else class="messages-list">
<div v-for="(message, index) in messages" :key="index" :class="['message-item', message.role]">
<div class="message-avatar">
<el-icon v-if="message.role === 'assistant'" class="ai-icon"><Promotion /></el-icon>
<el-icon v-else class="user-icon"><User /></el-icon>
</div>
<div class="message-wrapper">
<div class="message-content">
<div v-html="renderMarkdown(message, index)" class="markdown-body"></div>
</div>
<div class="message-actions">
<el-tooltip content="复制" placement="top">
<el-button type="text" :icon="DocumentCopy" @click="copyMessage(message.content)" />
</el-tooltip>
<el-tooltip content="保存为文件" placement="top">
<el-button type="text" :icon="Document" @click="MessageTextToDoc(message.content)" />
</el-tooltip>
</div>
</div>
2025-03-26 18:08:51 +08:00
</div>
2026-05-12 23:33:55 +08:00
<!-- 加载状态 -->
<div v-if="loading" class="message-item assistant">
<div class="message-avatar">
<el-icon class="ai-icon"><Promotion /></el-icon>
</div>
<div class="message-wrapper">
<div class="message-content">
<div class="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
2026-05-12 23:33:55 +08:00
<div class="typing-text">正在思考... ({{ currentAIMessage.length }} 字符)</div>
</div>
</div>
</div>
</div>
</div>
2026-05-12 23:33:55 +08:00
<!-- 输入区域 -->
<div class="chat-input-wrapper">
<!-- 已选文件 -->
<div v-if="selectedFiles.length > 0" class="selected-files">
<el-tag
v-for="(file, index) in selectedFiles"
:key="index"
closable
@close="removeFile(index)"
class="file-tag"
>
<el-icon><Document /></el-icon>
{{ file.UserFileName }}
</el-tag>
</div>
2026-05-12 23:33:55 +08:00
<div class="input-area">
<div class="input-tools">
<el-tooltip content="选择文件" placement="top">
<el-button type="text" :icon="FolderOpened" @click="handleSelectFileVisible" />
</el-tooltip>
2026-05-12 23:33:55 +08:00
</div>
2025-04-01 14:40:20 +08:00
2026-05-12 23:33:55 +08:00
<el-input
v-model="inputMessage"
type="textarea"
placeholder="输入消息... (Enter 发送Shift+Enter 换行)"
@keydown="handleKeydown"
class="chat-textarea"
:autosize="{ minRows: 1, maxRows: 6 }"
/>
<el-button
type="primary"
:icon="loading ? VideoPause : Check"
@click="sendMessage"
class="send-btn"
>
{{ loading ? '停止' : '发送' }}
</el-button>
</div>
2026-05-12 23:33:55 +08:00
</div>
</div>
<!-- 文件对话框 -->
<el-dialog v-model="selectFileVisible" title="从上传文件中选择" width="50%" class="file-dialog">
<div class="dialog-search">
<el-input placeholder="搜索文件" v-model="searchFileQuery" prefix-icon="Search" clearable />
<el-button type="primary" @click="uploadMessageFile" class="upload-btn">
<el-icon><Upload /></el-icon>
上传文件
</el-button>
</div>
<div class="file-list">
<el-checkbox-group v-model="selectedFiles">
<div v-for="(item, index) in filteredFiles" :key="index" class="file-item">
<el-checkbox :label="item" />
<div class="file-info">
<el-icon class="file-icon">
<Picture v-if="item.UploadType === 'image'" />
<Document v-else />
</el-icon>
<span class="file-name">{{ item.UserFileName }}</span>
</div>
</div>
</el-checkbox-group>
</div>
<div class="footer-bar">
<span class="selected-count">已选 {{ selectedFiles.length }} 个文件</span>
<div>
<el-button @click="selectFileVisible = false">取消</el-button>
2026-05-12 23:33:55 +08:00
<el-button type="primary" @click="handleSelectFileConfirm">确认添加</el-button>
</div>
2026-05-12 23:33:55 +08:00
</div>
</el-dialog>
<!-- 上传文件对话框 -->
2026-05-12 23:33:55 +08:00
<el-dialog title="上传文件" v-model="uploadFileVisible" width="50%" :before-close="handleUploadFileClose">
<UploadFile />
</el-dialog>
<!-- 文本创建文件对话 -->
<el-dialog title="文本创建文件" v-model="textToDocFileVisible" width="70%" class="doc-dialog" :before-close="handleMessageTextToDOCClose">
<div class="doc-header">
<el-input v-model="textToDocFileName" placeholder="输入文件名..." class="doc-name-input">
<template #append>
<el-select v-model="selectFileDocType" placeholder="类型">
<el-option label="docx" value="docx" />
<el-option label="txt" value="txt" />
<el-option label="md" value="md" />
</el-select>
</template>
</el-input>
</div>
<div ref="vditorRef" class="vditor-container"></div>
<div class="doc-footer">
<el-button @click="textToDocFileVisible = false">取消</el-button>
2026-05-12 23:33:55 +08:00
<el-button type="primary" @click="HandleTextToDocFile">确认创建</el-button>
</div>
</el-dialog>
2025-04-01 14:40:20 +08:00
</div>
2025-03-25 19:51:06 +08:00
</template>
2026-04-03 20:58:10 +08:00
<script setup lang="ts" name="gen-chat">
2025-09-23 20:35:33 +08:00
import { ref, onMounted, onUnmounted, reactive, nextTick, watch } from "vue";
2026-05-12 23:33:55 +08:00
import {
Check,
DocumentCopy,
Document,
Plus,
ChatDotRound,
Clock,
DArrowLeft,
DArrowRight,
Setting,
ArrowDown,
QuestionFilled,
ChatLineRound,
Promotion,
User,
FolderOpened,
VideoPause,
Upload,
Picture,
InfoFilled,
} from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import MarkdownIt from "markdown-it";
import hljs from "highlight.js";
import "highlight.js/styles/github.css";
import markdownItHighlightjs from "markdown-it-highlightjs";
import markdownItKatex from "markdown-it-katex";
import mermaidPlugin from "@agoose77/markdown-it-mermaid";
import { fetchEventSource } from '@microsoft/fetch-event-source';
import "katex/dist/katex.min.css";
import Vditor from 'vditor';
import 'vditor/dist/index.css';
import { WSMessage, GenMessage } from "@/types/im";
2025-03-25 19:51:06 +08:00
import { GetMessageService } from "@/api/im";
2025-04-01 14:40:20 +08:00
import { FindUserFileService } from "@/api/file";
import { Model } from "@/types/model";
2025-09-23 20:35:33 +08:00
import { UserUISettings } from '@/types/user';
import { File, fileUrl } from "@/types/file";
2025-03-26 18:08:51 +08:00
import { Session } from "@/types/session";
import { FindSessionService } from "@/api/session";
import { SetMessageTextToDocService } from "@/api/tool";
import UploadFile from "@/components/upload-file.vue";
import { updateUserUIconfigInfoService } from "@/api/user";
import { FindModelListByFunctionName } from "@/api/function";
2025-03-25 19:51:06 +08:00
interface Message {
role: "user" | "assistant";
content: string;
finished?: boolean;
}
2026-05-12 23:33:55 +08:00
interface FileMessage {
2026-05-12 23:33:55 +08:00
file_content: File;
file_type: string | null;
}
interface GenerationMessage {
text: string;
file_content: FileMessage[];
}
2025-03-26 18:08:51 +08:00
const md = new MarkdownIt();
2025-03-27 17:33:35 +08:00
md.use(markdownItHighlightjs, {
hljs,
auto: true,
2025-03-27 17:33:35 +08:00
code: true,
});
md.renderer.rules.image = function (tokens, idx, options, env, self) {
const token = tokens[idx];
2025-09-23 20:35:33 +08:00
token.attrSet('width', '400');
2026-05-12 23:33:55 +08:00
token.attrSet('style', 'max-width: 100%; height: auto; border-radius: 8px;');
return self.renderToken(tokens, idx, options, env, self);
};
2025-03-27 18:49:14 +08:00
md.use(markdownItKatex);
md.use(mermaidPlugin);
2025-03-26 18:08:51 +08:00
const historySessions = ref<Session[]>([]);
2025-03-25 19:51:06 +08:00
const loading = ref(false);
2026-05-12 23:33:55 +08:00
const canStop = ref(false);
2025-03-25 19:51:06 +08:00
const isUserScrolling = ref<boolean>(false);
2026-05-12 23:33:55 +08:00
const messages = reactive<Message[]>([]);
2025-03-25 19:51:06 +08:00
const inputMessage = ref("");
const currentAIMessage = ref("");
const sessionID = ref(0);
const messagesContainer = ref<HTMLDivElement | null>(null);
2026-05-12 23:33:55 +08:00
const sessionIsShow = ref(true);
2025-03-26 18:08:51 +08:00
const sessionName = ref("");
const ModelList = ref<Model[]>([]);
const selectModel = ref(0);
const temperature = ref(0.8);
const topP = ref(0.9);
2026-05-12 23:33:55 +08:00
const selectedFiles = ref<File[]>([]);
const selectFileVisible = ref(false);
const searchFileQuery = ref("");
const filteredFiles = ref<File[]>([]);
const uploadFileVisible = ref(false);
const textToDocFileVisible = ref(false);
const selectFileDocType = ref("docx");
const textToDocFileName = ref("");
const textToDocFileContent = ref("");
const vditor = ref();
const vditorRef = ref(null);
2026-05-12 23:33:55 +08:00
const userUIconfigInfo = ref<UserUISettings>({} as UserUISettings);
const historyMsgHtml = ref([]);
2026-05-12 23:33:55 +08:00
// 监听需要保存的配置
watch(
2025-09-23 20:35:33 +08:00
[selectModel, temperature, topP, sessionID],
2026-05-12 23:33:55 +08:00
() => {
updateUserUIconfigInfo();
2025-09-23 20:35:33 +08:00
}
2026-05-12 23:33:55 +08:00
);
2025-03-25 19:51:06 +08:00
2025-09-23 20:35:33 +08:00
const renderMarkdown = (message: Message, index: number) => {
2026-05-12 23:33:55 +08:00
if (message.finished === false) {
return message.content;
}
2025-09-23 20:35:33 +08:00
if (historyMsgHtml.value[index]) {
return historyMsgHtml.value[index];
}
const html = md.render(message.content);
historyMsgHtml.value.push(html);
return html;
2025-03-25 19:51:06 +08:00
};
const scrollToBottom = () => {
2026-05-12 23:33:55 +08:00
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
}
});
2025-03-25 19:51:06 +08:00
};
const copyCode = (code: string) => {
navigator.clipboard.writeText(code).then(() => {
ElMessage.success("代码已复制到剪贴板");
});
};
2026-05-12 23:33:55 +08:00
2025-04-01 14:40:20 +08:00
const removeFile = (index: number) => {
selectedFiles.value.splice(index, 1);
};
const handleSelectFileVisible = async () => {
2026-05-12 23:33:55 +08:00
await getFileListData();
selectFileVisible.value = true;
2025-04-01 14:40:20 +08:00
};
const handleUploadFileClose = async () => {
2026-05-12 23:33:55 +08:00
uploadFileVisible.value = false;
await getFileListData();
};
const handleMessageTextToDOCClose = async () => {
2026-05-12 23:33:55 +08:00
textToDocFileVisible.value = false;
2025-04-01 14:40:20 +08:00
};
const uploadMessageFile = () => {
2026-05-12 23:33:55 +08:00
uploadFileVisible.value = true;
};
2025-04-01 14:40:20 +08:00
const handleSelectFileConfirm = () => {
2026-05-12 23:33:55 +08:00
selectFileVisible.value = false;
2025-04-01 14:40:20 +08:00
};
const doButtonD = () => {
2026-05-12 23:33:55 +08:00
nextTick(() => {
const codeBlocks = document.querySelectorAll<HTMLElement>(".markdown-body pre");
codeBlocks.forEach((pre) => {
if (pre.querySelector(".code-header")) return;
const code = pre.querySelector("code");
const lang = code?.className.match(/language-(\w+)/)?.[1] || "code";
const header = document.createElement("div");
header.className = "code-header";
header.innerHTML = `
<span class="code-lang">${lang}</span>
<button class="copy-btn" title="复制代码">
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</button>
`;
pre.style.position = "relative";
pre.style.marginTop = "0";
pre.insertBefore(header, pre.firstChild);
header.querySelector(".copy-btn")?.addEventListener("click", () => {
copyCode(code?.textContent || "");
ElMessage.success("复制成功");
});
});
});
};
2026-05-12 23:33:55 +08:00
// 处理键盘事件
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
2026-05-12 23:33:55 +08:00
onMounted(async () => {
userUIconfigInfo.value = JSON.parse(
localStorage.getItem("userUIconfigInfo") || "{}"
);
2025-05-21 20:00:58 +08:00
messagesContainer.value = document.querySelector(".chat-messages");
2026-05-12 23:33:55 +08:00
// 页面加载时获取历史会话
await fetchHistorySessions();
2025-03-25 19:51:06 +08:00
});
2026-05-12 23:33:55 +08:00
// 获取历史会话
const fetchHistorySessions = async () => {
try {
let req = {
token: localStorage.getItem("token"),
type: "UserID",
session_type: 1,
};
let result = await FindSessionService(req);
historySessions.value = result.data;
} catch (e) {
console.error("获取历史会话失败:", e);
2025-09-23 20:35:33 +08:00
}
2026-05-12 23:33:55 +08:00
};
2025-09-23 20:35:33 +08:00
2026-05-12 23:33:55 +08:00
onUnmounted(() => {
// 清理工作可以在这里添加
});
2025-04-27 13:09:57 +08:00
2026-05-12 23:33:55 +08:00
const doReceiveMessageSSE = (event: any) => {
2025-09-23 20:35:33 +08:00
let msg: WSMessage = JSON.parse(event.data.replace("data: ", ""));
const existingMessage = messages.find(
2026-05-12 23:33:55 +08:00
(m) => m.role === "assistant" && !m.finished
2025-09-23 20:35:33 +08:00
);
if (existingMessage) {
existingMessage.content += msg.msg.msg.response;
} else {
messages.push({
role: "assistant",
content: msg.msg.msg.response,
finished: false,
});
}
sessionID.value = msg.session_id;
currentAIMessage.value += msg.msg.msg.response;
if (msg.msg.msg.done) {
const assistantMessage = messages[messages.length - 1];
assistantMessage.finished = true;
loading.value = false;
2026-05-12 23:33:55 +08:00
canStop.value = false;
2025-09-23 20:35:33 +08:00
currentAIMessage.value = "";
doButtonD();
}
2026-05-12 23:33:55 +08:00
scrollToBottom();
};
const updateUserUIconfigInfo = () => {
2025-09-23 20:35:33 +08:00
let req: UserUISettings = JSON.parse(localStorage.getItem("userUIconfigInfo") || "{}");
if (req.user_id == 0) {
req.user_id = parseInt(localStorage.getItem("user_id") || "0");
} else {
req.gen_ai_function.model_id = selectModel.value;
req.gen_ai_function.temperature = temperature.value;
req.gen_ai_function.top_p = topP.value;
req.gen_ai_function.session_id = sessionID.value;
}
updateUserUIconfigInfoService(req, localStorage.getItem("token")).then((res) => {
if (res["code"] === 0) {
console.log("保存成功");
}
2025-09-23 20:35:33 +08:00
});
2026-05-12 23:33:55 +08:00
};
2025-09-23 20:35:33 +08:00
2025-05-21 20:00:58 +08:00
const sendMessage = async () => {
2026-05-12 23:33:55 +08:00
if (loading.value) {
ElMessage.info("正在停止生成...");
return;
}
if (inputMessage.value.trim() === "" && selectedFiles.value.length === 0) {
ElMessage.warning("消息不能为空");
2025-05-21 20:00:58 +08:00
return;
}
await nextTick();
2025-09-23 20:35:33 +08:00
sendMessageWithFileUseSSE();
2025-03-26 18:08:51 +08:00
};
2025-09-23 20:35:33 +08:00
const sendMessageWithFileUseSSE = async () => {
checkTokenIsValidAndRefresh();
let url = "https://pm.ljsea.top/im/chat_completion";
2026-05-12 23:33:55 +08:00
let headers = { "token": localStorage.getItem("token") || "" };
let end_msg: any = {
2025-09-23 20:35:33 +08:00
msg: inputMessage.value,
type: "null",
function: "gen-ai-chat",
session_id: sessionID.value,
model_id: selectModel.value,
temperature: temperature.value,
top_p: topP.value,
};
2026-05-12 23:33:55 +08:00
2025-09-23 20:35:33 +08:00
if (selectedFiles.value.length > 0) {
2026-05-12 23:33:55 +08:00
let file_contents: FileMessage[] = [];
2025-09-23 20:35:33 +08:00
for (let i = 0; i < selectedFiles.value.length; i++) {
let file: File = selectedFiles.value[i];
2026-05-12 23:33:55 +08:00
let file_type = file.UserFileName.match(/\.(jpg|jpeg|png|gif|bmp|webp)$/i) ? "image_file" : "text_file";
file_contents.push({
2025-09-23 20:35:33 +08:00
file_content: file,
file_type: file_type,
2026-05-12 23:33:55 +08:00
});
2025-09-23 20:35:33 +08:00
}
let msg: GenerationMessage = {
text: inputMessage.value,
file_content: file_contents,
};
2026-05-12 23:33:55 +08:00
end_msg.msg = JSON.stringify(msg);
end_msg.is_file = true;
}
let pMsgContent = "";
if (end_msg.is_file) {
let file_msg: GenerationMessage = JSON.parse(end_msg.msg);
for (let i = 0; i < file_msg.file_content.length; i++) {
const fc = file_msg.file_content[i];
if (fc.file_type === "image_file") {
pMsgContent += `![${fc.file_content.UserFileName}](${fileUrl}${fc.file_content.file_store_name})\n\n`;
} else {
pMsgContent += `📎 文件:[${fc.file_content.UserFileName}](${fileUrl}${fc.file_content.file_store_name})\n\n`;
}
}
pMsgContent += file_msg.text;
} else {
pMsgContent = end_msg.msg;
}
messages.push({ role: "user", content: pMsgContent, finished: true });
inputMessage.value = "";
selectedFiles.value = [];
loading.value = true;
canStop.value = true;
if (sessionID.value === 0) {
sessionName.value = pMsgContent.slice(0, 50);
2025-09-23 20:35:33 +08:00
}
2026-05-12 23:33:55 +08:00
scrollToBottom();
2025-09-23 20:35:33 +08:00
fetchEventSource(url, {
method: 'POST',
headers: {
2026-05-12 23:33:55 +08:00
'Content-Type': 'application/json',
...headers,
2025-09-23 20:35:33 +08:00
},
2026-05-12 23:33:55 +08:00
body: JSON.stringify(end_msg),
openWhenHidden: true,
2025-09-23 20:35:33 +08:00
onmessage: (event) => {
try {
doReceiveMessageSSE(event);
} catch (e) {
console.error('Failed to parse SSE data:', e);
}
},
onerror: (error) => {
2026-05-12 23:33:55 +08:00
ElMessage.error("发送失败! 请刷新页面重试");
loading.value = false;
canStop.value = false;
2025-09-23 20:35:33 +08:00
},
onclose: () => {
console.log('SSE connection closed');
},
});
};
2025-03-26 18:08:51 +08:00
const loadSession = async (session_id: number) => {
sessionID.value = session_id;
2026-05-12 23:33:55 +08:00
messages.length = 0;
historyMsgHtml.value.length = 0;
2025-03-26 18:08:51 +08:00
sessionName.value = historySessions.value.find(
2026-05-12 23:33:55 +08:00
(session) => session.ID === session_id
)?.Name || "";
2025-03-26 18:08:51 +08:00
await getMessage(session_id);
scrollToBottom();
2025-03-27 17:33:35 +08:00
doButtonD();
2025-03-26 18:08:51 +08:00
};
const clearCurrent = () => {
sessionID.value = 0;
2026-05-12 23:33:55 +08:00
messages.length = 0;
historyMsgHtml.value.length = 0;
2025-03-26 18:08:51 +08:00
sessionName.value = "新会话";
2026-05-12 23:33:55 +08:00
ElMessage.success("新会话已创建! 可以开始聊天了");
2025-03-25 19:51:06 +08:00
};
2025-03-26 18:08:51 +08:00
const showSession = async () => {
2026-05-12 23:33:55 +08:00
if (!sessionIsShow.value) {
await fetchHistorySessions();
}
2025-03-26 18:08:51 +08:00
sessionIsShow.value = !sessionIsShow.value;
};
2026-05-12 23:33:55 +08:00
2025-03-26 18:08:51 +08:00
const getShortenedName = (name: string) => {
2026-05-12 23:33:55 +08:00
if (!name) return "";
if (name.length > 20) {
return name.slice(0, 20) + "...";
2025-03-26 18:08:51 +08:00
}
return name;
};
2025-03-25 19:51:06 +08:00
2025-03-26 18:08:51 +08:00
const getMessage = async (session_id: number) => {
2026-05-12 23:33:55 +08:00
historyMsgHtml.value.length = 0;
let result: any = {};
try {
let req = {
token: localStorage.getItem("token"),
session_id: session_id,
};
result = await GetMessageService(req);
if (result["code"] === 0) {
let data = result["data"];
for (let i = 0; i < data.length; i++) {
if (data[i]["Type"] === 3) {
let msg: GenMessage = data[i];
2025-09-23 20:35:33 +08:00
let pMsgContent = "";
2026-05-12 23:33:55 +08:00
if (msg.Status === 3) {
let file_msg: GenerationMessage = JSON.parse(msg.Msg);
2026-05-12 23:33:55 +08:00
for (let j = 0; j < file_msg.file_content.length; j++) {
const fc = file_msg.file_content[j];
if (fc.file_type === "image_file") {
pMsgContent += `![${fc.file_content.UserFileName}](${fileUrl}${fc.file_content.file_store_name})\n\n`;
2025-09-23 20:35:33 +08:00
} else {
2026-05-12 23:33:55 +08:00
pMsgContent += `📎 文件:[${fc.file_content.UserFileName}](${fileUrl}${fc.file_content.file_store_name})\n\n`;
2025-09-23 20:35:33 +08:00
}
}
2026-05-12 23:33:55 +08:00
pMsgContent += file_msg.text;
} else {
pMsgContent = msg.Msg;
}
messages.push({
role: "user",
content: pMsgContent,
finished: true,
});
} else if (data[i]["Type"] === 4) {
messages.push({
role: "assistant",
content: data[i]["Msg"],
finished: true,
});
}
}
}
} catch (e) {
console.log(e);
}
};
const MessageTextToDoc = async (content: string) => {
textToDocFileContent.value = content;
textToDocFileVisible.value = true;
await nextTick();
vditor.value = new Vditor(vditorRef.value, {
mode: 'sv',
2026-05-12 23:33:55 +08:00
height: '500px',
width: '100%',
cache: { enable: false },
value: textToDocFileContent.value,
});
};
const copyMessage = (content: string) => {
2026-05-12 23:33:55 +08:00
navigator.clipboard.writeText(content).then(() => {
ElMessage.success("复制成功");
}).catch(() => {
ElMessage.error("复制失败");
});
};
2025-09-23 20:35:33 +08:00
const checkTokenIsValidAndRefresh = () => {
2026-05-12 23:33:55 +08:00
let last_refresh = localStorage.getItem("refresh_time");
2025-09-23 20:35:33 +08:00
let now = Date.now();
let isValid = false;
if (last_refresh) {
let diff = now - parseInt(last_refresh);
if (diff < 60 * 60 * 1000) {
isValid = true;
}
}
2026-05-12 23:33:55 +08:00
if (!isValid) {
2025-09-23 20:35:33 +08:00
GetModelListByFunctionName();
}
};
const GetModelListByFunctionName = async () => {
let req = {
function: "gen-ai-chat",
token: localStorage.getItem("token"),
};
try {
let result = await FindModelListByFunctionName(req);
if (result["code"] === 0) {
2026-05-12 23:33:55 +08:00
ModelList.value = result.data;
if (userUIconfigInfo.value.gen_ai_function?.model_id) {
2025-09-23 20:35:33 +08:00
selectModel.value = userUIconfigInfo.value.gen_ai_function.model_id;
2026-05-12 23:33:55 +08:00
temperature.value = userUIconfigInfo.value.gen_ai_function.temperature || 0.8;
topP.value = userUIconfigInfo.value.gen_ai_function.top_p || 0.9;
sessionID.value = userUIconfigInfo.value.gen_ai_function.session_id || 0;
if (sessionID.value !== 0) {
2025-09-23 20:35:33 +08:00
getMessage(sessionID.value);
}
2026-05-12 23:33:55 +08:00
} else if (ModelList.value.length > 0) {
selectModel.value = ModelList.value[0].ID;
}
} else {
ElMessage.error(result["msg"]);
}
} catch (e) {
console.log(e);
}
};
GetModelListByFunctionName();
2025-04-01 14:40:20 +08:00
const getFileListData = async () => {
let req = {
token: localStorage.getItem("token"),
type: "all",
};
let result = await FindUserFileService(req);
if (result["code"] === 0) {
filteredFiles.value = result["data"];
} else {
ElMessage.error(result["msg"]);
}
2025-04-01 14:40:20 +08:00
};
const HandleTextToDocFile = async () => {
if (textToDocFileName.value.trim() === "") {
ElMessage.warning("文件名不能为空");
return;
}
let req = {
token: localStorage.getItem("token"),
file_name: textToDocFileName.value,
text: vditor.value.getValue(),
file_type: selectFileDocType.value,
};
let result = await SetMessageTextToDocService(req);
if (result["code"] === 0) {
2026-05-12 23:33:55 +08:00
ElMessage.success("文件已加入创建队列,请在文件列表中查看");
textToDocFileVisible.value = false;
} else {
ElMessage.error(result["msg"]);
}
};
2025-03-25 19:51:06 +08:00
</script>
2026-05-12 23:33:55 +08:00
2025-03-25 19:51:06 +08:00
<style scoped>
2025-03-26 18:08:51 +08:00
.chat-app {
display: flex;
height: 100%;
2026-05-12 23:33:55 +08:00
background-color: #f7f8fa;
2025-03-26 18:08:51 +08:00
}
2026-05-12 23:33:55 +08:00
/* 侧边栏 */
2025-03-26 18:08:51 +08:00
.history-sessions {
2026-05-12 23:33:55 +08:00
width: 280px;
2025-03-26 18:08:51 +08:00
height: 100%;
2026-05-12 23:33:55 +08:00
display: flex;
flex-direction: column;
background: #fff;
border-right: 1px solid #e5e6eb;
transition: all 0.3s ease;
2025-03-26 18:08:51 +08:00
}
2026-05-12 23:33:55 +08:00
.history-sessions.collapsed {
width: 0;
overflow: hidden;
border-right: none;
}
.session-header {
padding: 16px;
border-bottom: 1px solid #e5e6eb;
}
.new-session-btn {
width: 100%;
}
.session-card {
margin: 12px;
border-radius: 8px;
}
.session-card :deep(.el-card__header) {
padding: 12px 16px;
background: #f7f8fa;
border-bottom: none;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: #1d2129;
}
.current-session {
padding: 8px 0;
}
.session-name {
padding: 8px 12px;
background: #f2f3f5;
border-radius: 6px;
color: #4e5969;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-card {
flex: 1;
display: flex;
flex-direction: column;
margin-top: 0;
}
.history-scroll {
flex: 1;
min-height: 0;
}
.history-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.history-item {
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
color: #4e5969;
font-size: 14px;
}
.history-item:hover {
background: #f2f3f5;
}
.history-item.active {
background: #e8f3ff;
color: #1677ff;
}
.history-name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
2025-03-26 18:08:51 +08:00
}
2026-05-12 23:33:55 +08:00
/* 折叠按钮 */
.toggle-sidebar {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
2025-03-26 18:08:51 +08:00
cursor: pointer;
2026-05-12 23:33:55 +08:00
color: #86909c;
transition: all 0.2s;
background: #fff;
border-right: 1px solid #e5e6eb;
2025-03-26 18:08:51 +08:00
}
2026-05-12 23:33:55 +08:00
.toggle-sidebar:hover {
color: #4e5969;
background: #f7f8fa;
2025-03-26 18:08:51 +08:00
}
2026-05-12 23:33:55 +08:00
/* 聊天容器 */
2025-03-25 19:51:06 +08:00
.chat-container {
2025-03-26 18:08:51 +08:00
flex: 1;
2025-03-25 19:51:06 +08:00
display: flex;
flex-direction: column;
height: 100%;
2026-05-12 23:33:55 +08:00
min-width: 0;
2025-03-25 19:51:06 +08:00
}
2026-05-12 23:33:55 +08:00
/* 顶部工具栏 */
.chat-header {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 24px;
background: #fff;
border-bottom: 1px solid #e5e6eb;
}
.model-select {
width: 300px;
}
/* 消息列表 */
2025-03-25 19:51:06 +08:00
.chat-messages {
flex: 1;
2025-09-23 20:35:33 +08:00
overflow-y: auto;
2026-05-12 23:33:55 +08:00
padding: 24px;
}
/* 空状态 */
.empty-state {
2025-03-25 19:51:06 +08:00
display: flex;
flex-direction: column;
2026-05-12 23:33:55 +08:00
align-items: center;
justify-content: center;
height: 100%;
color: #86909c;
2025-03-25 19:51:06 +08:00
}
2026-05-12 23:33:55 +08:00
.empty-icon {
color: #c9cdd4;
margin-bottom: 16px;
}
.empty-text {
font-size: 20px;
font-weight: 600;
color: #1d2129;
margin-bottom: 8px;
}
.empty-hint {
font-size: 14px;
color: #86909c;
}
/* 消息列表 */
.messages-list {
max-width: 1000px;
margin: 0 auto;
2025-03-25 19:51:06 +08:00
display: flex;
flex-direction: column;
2026-05-12 23:33:55 +08:00
gap: 24px;
2025-03-25 19:51:06 +08:00
}
2026-05-12 23:33:55 +08:00
.message-item {
2025-03-25 19:51:06 +08:00
display: flex;
2026-05-12 23:33:55 +08:00
gap: 12px;
2025-03-25 19:51:06 +08:00
}
2026-05-12 23:33:55 +08:00
.message-item.user {
flex-direction: row-reverse;
2025-03-25 19:51:06 +08:00
}
.message-avatar {
2026-05-12 23:33:55 +08:00
flex-shrink: 0;
2025-03-25 19:51:06 +08:00
width: 40px;
2026-05-12 23:33:55 +08:00
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
2025-03-25 19:51:06 +08:00
}
2026-05-12 23:33:55 +08:00
.ai-icon {
width: 100%;
height: 100%;
padding: 8px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border-radius: 8px;
box-sizing: border-box;
}
.user-icon {
width: 100%;
height: 100%;
padding: 8px;
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
color: #fff;
border-radius: 8px;
box-sizing: border-box;
}
.message-wrapper {
max-width: calc(100% - 80px);
display: flex;
flex-direction: column;
gap: 8px;
}
.message-item.user .message-wrapper {
align-items: flex-end;
2025-03-25 19:51:06 +08:00
}
.message-content {
2026-05-12 23:33:55 +08:00
padding: 16px 20px;
border-radius: 12px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
line-height: 1.6;
word-break: break-word;
2025-03-25 19:51:06 +08:00
}
2026-05-12 23:33:55 +08:00
.message-item.user .message-content {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
2025-03-25 19:51:06 +08:00
}
2026-05-12 23:33:55 +08:00
.message-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s;
2025-03-25 19:51:06 +08:00
}
2026-05-12 23:33:55 +08:00
.message-item:hover .message-actions {
opacity: 1;
2025-03-25 19:51:06 +08:00
}
2026-05-12 23:33:55 +08:00
/* 打字指示器 */
.typing-indicator {
display: flex;
gap: 6px;
padding: 8px 0;
2025-03-25 19:51:06 +08:00
}
2025-03-26 18:08:51 +08:00
2026-05-12 23:33:55 +08:00
.typing-indicator span {
width: 8px;
height: 8px;
border-radius: 50%;
background: #86909c;
animation: typing 1.4s infinite;
2025-03-26 18:08:51 +08:00
}
2025-09-23 20:35:33 +08:00
2026-05-12 23:33:55 +08:00
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
2025-03-26 18:08:51 +08:00
}
2026-05-12 23:33:55 +08:00
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
2025-03-26 18:08:51 +08:00
}
2026-05-12 23:33:55 +08:00
@keyframes typing {
0%, 60%, 100% {
transform: translateY(0);
opacity: 0.4;
}
30% {
transform: translateY(-6px);
opacity: 1;
}
2025-03-26 18:08:51 +08:00
}
2026-05-12 23:33:55 +08:00
.typing-text {
color: #86909c;
font-size: 14px;
2025-03-26 18:08:51 +08:00
}
2026-05-12 23:33:55 +08:00
/* 输入区域 */
.chat-input-wrapper {
padding: 16px 24px;
background: #fff;
border-top: 1px solid #e5e6eb;
2025-03-26 18:08:51 +08:00
}
2026-05-12 23:33:55 +08:00
.selected-files {
2025-03-27 17:33:35 +08:00
display: flex;
2026-05-12 23:33:55 +08:00
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
2025-03-27 17:33:35 +08:00
}
2026-05-12 23:33:55 +08:00
.file-tag {
2025-03-27 17:33:35 +08:00
display: flex;
align-items: center;
2026-05-12 23:33:55 +08:00
gap: 4px;
}
.input-area {
max-width: 1000px;
margin: 0 auto;
display: flex;
gap: 12px;
align-items: flex-end;
}
.input-tools {
display: flex;
gap: 4px;
padding-bottom: 8px;
}
.chat-textarea {
flex: 1;
}
2025-03-27 17:33:35 +08:00
2026-05-12 23:33:55 +08:00
.chat-textarea :deep(.el-textarea__inner) {
border-radius: 12px;
padding: 12px 16px;
resize: none;
}
.send-btn {
padding: 12px 24px;
border-radius: 10px;
font-weight: 500;
}
/* 文件对话框 */
.file-dialog :deep(.el-dialog__body) {
padding: 0;
}
.dialog-search {
display: flex;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid #e5e6eb;
}
.dialog-search .el-input {
flex: 1;
2025-03-27 17:33:35 +08:00
}
2025-04-01 14:40:20 +08:00
.file-list {
max-height: 400px;
overflow-y: auto;
2026-05-12 23:33:55 +08:00
padding: 12px 0;
}
.file-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 20px;
cursor: pointer;
transition: background 0.2s;
}
.file-item:hover {
background: #f7f8fa;
}
.file-info {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
2025-04-01 14:40:20 +08:00
}
2025-09-23 20:35:33 +08:00
2025-04-01 14:40:20 +08:00
.file-icon {
2026-05-12 23:33:55 +08:00
color: #86909c;
2025-04-01 14:40:20 +08:00
}
2025-09-23 20:35:33 +08:00
2026-05-12 23:33:55 +08:00
.file-name {
color: #1d2129;
2025-04-01 14:40:20 +08:00
}
2025-09-23 20:35:33 +08:00
2025-04-01 14:40:20 +08:00
.footer-bar {
display: flex;
justify-content: space-between;
align-items: center;
2026-05-12 23:33:55 +08:00
padding: 16px 20px;
border-top: 1px solid #e5e6eb;
2025-04-01 14:40:20 +08:00
}
2025-09-23 20:35:33 +08:00
2025-04-01 14:40:20 +08:00
.selected-count {
2026-05-12 23:33:55 +08:00
color: #86909c;
2025-04-01 14:40:20 +08:00
}
2025-09-23 20:35:33 +08:00
2026-05-12 23:33:55 +08:00
/* 文档对话框 */
.doc-dialog :deep(.el-dialog__body) {
padding: 0;
2025-04-01 14:40:20 +08:00
}
2026-05-12 23:33:55 +08:00
.doc-header {
padding: 16px 20px;
border-bottom: 1px solid #e5e6eb;
}
2026-05-12 23:33:55 +08:00
.doc-name-input {
width: 400px;
2026-05-12 23:33:55 +08:00
}
.vditor-container {
min-height: 500px;
}
.doc-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 20px;
border-top: 1px solid #e5e6eb;
}
/* 参数下拉 */
.dropdown-content {
width: 360px;
padding: 8px;
}
.model-params {
2026-05-12 23:33:55 +08:00
padding: 8px;
}
2025-09-23 20:35:33 +08:00
2026-05-12 23:33:55 +08:00
.params-tip {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
margin-bottom: 12px;
background: #e8f3ff;
border-radius: 6px;
color: #1677ff;
font-size: 13px;
}
.param-item {
2026-05-12 23:33:55 +08:00
margin-bottom: 20px;
}
.param-label {
display: flex;
align-items: center;
2026-05-12 23:33:55 +08:00
gap: 6px;
margin-bottom: 8px;
2026-05-12 23:33:55 +08:00
color: #4e5969;
font-size: 14px;
}
.tip-icon {
2026-05-12 23:33:55 +08:00
color: #86909c;
cursor: help;
}
2026-05-12 23:33:55 +08:00
.param-control {
display: flex;
align-items: center;
gap: 12px;
}
.param-control .el-slider {
flex: 1;
}
.param-value {
2026-05-12 23:33:55 +08:00
min-width: 40px;
text-align: right;
font-weight: 600;
color: #1677ff;
}
</style>
<style>
/* Markdown 样式 */
.markdown-body {
font-size: 15px;
line-height: 1.7;
}
.markdown-body p {
margin: 8px 0;
}
.markdown-body pre {
margin: 16px 0;
border-radius: 8px;
overflow: hidden;
}
.markdown-body code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 14px;
}
.markdown-body pre code {
display: block;
padding: 16px;
padding-top: 12px;
overflow-x: auto;
}
.markdown-body :not(pre) > code {
background: #f2f3f5;
padding: 2px 6px;
border-radius: 4px;
color: #d63384;
}
.code-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: #1e1e1e;
border-bottom: 1px solid #333;
}
.code-lang {
color: #888;
font-size: 12px;
text-transform: uppercase;
}
.copy-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
background: transparent;
border: none;
color: #888;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
}
.copy-btn:hover {
color: #fff;
background: #333;
}
.markdown-body blockquote {
margin: 12px 0;
padding: 8px 16px;
border-left: 4px solid #667eea;
background: #f7f8fa;
color: #4e5969;
border-radius: 0 6px 6px 0;
}
.markdown-body ul,
.markdown-body ol {
padding-left: 24px;
margin: 12px 0;
}
.markdown-body li {
margin: 4px 0;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
margin: 16px 0 8px;
font-weight: 600;
color: #1d2129;
}
.markdown-body h1 {
font-size: 24px;
border-bottom: 1px solid #e5e6eb;
padding-bottom: 8px;
}
.markdown-body h2 {
font-size: 20px;
}
.markdown-body h3 {
font-size: 18px;
}
.markdown-body a {
color: #667eea;
text-decoration: none;
}
.markdown-body a:hover {
text-decoration: underline;
}
.markdown-body table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
}
.markdown-body th,
.markdown-body td {
border: 1px solid #e5e6eb;
padding: 8px 12px;
text-align: left;
}
.markdown-body th {
background: #f7f8fa;
font-weight: 600;
}
.message-item.user .markdown-body :not(pre) > code {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
.message-item.user .markdown-body a {
color: #fff;
text-decoration: underline;
}
/* 滚动条样式 */
.chat-messages::-webkit-scrollbar,
.history-scroll::-webkit-scrollbar,
.file-list::-webkit-scrollbar {
width: 6px;
}
.chat-messages::-webkit-scrollbar-track,
.history-scroll::-webkit-scrollbar-track,
.file-list::-webkit-scrollbar-track {
background: transparent;
}
2026-05-12 23:33:55 +08:00
.chat-messages::-webkit-scrollbar-thumb,
.history-scroll::-webkit-scrollbar-thumb,
.file-list::-webkit-scrollbar-thumb {
background: #c9cdd4;
border-radius: 3px;
}
2026-05-12 23:33:55 +08:00
.chat-messages::-webkit-scrollbar-thumb:hover,
.history-scroll::-webkit-scrollbar-thumb:hover,
.file-list::-webkit-scrollbar-thumb:hover {
background: #86909c;
}
2025-03-25 19:51:06 +08:00
</style>