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

1509 lines
36 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-app">
<!-- 历史会话侧边栏 -->
<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>
<el-card class="session-card" shadow="never">
<template #header>
<div class="card-header">
<el-icon><ChatDotRound /></el-icon>
<span>当前会话</span>
</div>
</template>
<div class="current-session">
<el-tooltip :content="sessionName" placement="top">
<div class="session-name">{{ getShortenedName(sessionName) || '新会话' }}</div>
</el-tooltip>
</div>
</el-card>
<el-card class="session-card history-card" shadow="never">
<template #header>
<div class="card-header">
<el-icon><Clock /></el-icon>
<span>历史会话</span>
</div>
</template>
<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">
<span class="history-name">{{ getShortenedName(session.Name) }}</span>
</el-tooltip>
</div>
</div>
</el-scrollbar>
</el-card>
</div>
<!-- 折叠按钮 -->
<div class="toggle-sidebar" @click="showSession">
<el-icon v-if="sessionIsShow"><DArrowLeft /></el-icon>
<el-icon v-else><DArrowRight /></el-icon>
</div>
<!-- 聊天区域 -->
<div class="chat-container">
<!-- 顶部工具栏 -->
<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>
<!-- 消息列表 -->
<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>
</div>
<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>
</div>
<!-- 加载状态 -->
<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>
<div class="typing-text">正在思考... ({{ currentAIMessage.length }} 字符)</div>
</div>
</div>
</div>
</div>
</div>
<!-- 输入区域 -->
<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>
<div class="input-area">
<div class="input-tools">
<el-tooltip content="选择文件" placement="top">
<el-button type="text" :icon="FolderOpened" @click="handleSelectFileVisible" />
</el-tooltip>
</div>
<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>
</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>
<el-button type="primary" @click="handleSelectFileConfirm">确认添加</el-button>
</div>
</div>
</el-dialog>
<!-- 上传文件对话框 -->
<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>
<el-button type="primary" @click="HandleTextToDocFile">确认创建</el-button>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts" name="gen-chat">
import { ref, onMounted, onUnmounted, reactive, nextTick, watch } from "vue";
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";
import { GetMessageService } from "@/api/im";
import { FindUserFileService } from "@/api/file";
import { Model } from "@/types/model";
import { UserUISettings } from '@/types/user';
import { File, fileUrl } from "@/types/file";
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";
interface Message {
role: "user" | "assistant";
content: string;
finished?: boolean;
}
interface FileMessage {
file_content: File;
file_type: string | null;
}
interface GenerationMessage {
text: string;
file_content: FileMessage[];
}
const md = new MarkdownIt();
md.use(markdownItHighlightjs, {
hljs,
auto: true,
code: true,
});
md.renderer.rules.image = function (tokens, idx, options, env, self) {
const token = tokens[idx];
token.attrSet('width', '400');
token.attrSet('style', 'max-width: 100%; height: auto; border-radius: 8px;');
return self.renderToken(tokens, idx, options, env, self);
};
md.use(markdownItKatex);
md.use(mermaidPlugin);
const historySessions = ref<Session[]>([]);
const loading = ref(false);
const canStop = ref(false);
const isUserScrolling = ref<boolean>(false);
const messages = reactive<Message[]>([]);
const inputMessage = ref("");
const currentAIMessage = ref("");
const sessionID = ref(0);
const messagesContainer = ref<HTMLDivElement | null>(null);
const sessionIsShow = ref(true);
const sessionName = ref("");
const ModelList = ref<Model[]>([]);
const selectModel = ref(0);
const temperature = ref(0.8);
const topP = ref(0.9);
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);
const userUIconfigInfo = ref<UserUISettings>({} as UserUISettings);
const historyMsgHtml = ref([]);
// 监听需要保存的配置
watch(
[selectModel, temperature, topP, sessionID],
() => {
updateUserUIconfigInfo();
}
);
const renderMarkdown = (message: Message, index: number) => {
if (message.finished === false) {
return message.content;
}
if (historyMsgHtml.value[index]) {
return historyMsgHtml.value[index];
}
const html = md.render(message.content);
historyMsgHtml.value.push(html);
return html;
};
const scrollToBottom = () => {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
}
});
};
const copyCode = (code: string) => {
navigator.clipboard.writeText(code).then(() => {
ElMessage.success("代码已复制到剪贴板");
});
};
const removeFile = (index: number) => {
selectedFiles.value.splice(index, 1);
};
const handleSelectFileVisible = async () => {
await getFileListData();
selectFileVisible.value = true;
};
const handleUploadFileClose = async () => {
uploadFileVisible.value = false;
await getFileListData();
};
const handleMessageTextToDOCClose = async () => {
textToDocFileVisible.value = false;
};
const uploadMessageFile = () => {
uploadFileVisible.value = true;
};
const handleSelectFileConfirm = () => {
selectFileVisible.value = false;
};
const doButtonD = () => {
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("复制成功");
});
});
});
};
// 处理键盘事件
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
onMounted(async () => {
userUIconfigInfo.value = JSON.parse(
localStorage.getItem("userUIconfigInfo") || "{}"
);
messagesContainer.value = document.querySelector(".chat-messages");
// 页面加载时获取历史会话
await fetchHistorySessions();
});
// 获取历史会话
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);
}
};
onUnmounted(() => {
// 清理工作可以在这里添加
});
const doReceiveMessageSSE = (event: any) => {
let msg: WSMessage = JSON.parse(event.data.replace("data: ", ""));
const existingMessage = messages.find(
(m) => m.role === "assistant" && !m.finished
);
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;
canStop.value = false;
currentAIMessage.value = "";
doButtonD();
}
scrollToBottom();
};
const updateUserUIconfigInfo = () => {
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("保存成功");
}
});
};
const sendMessage = async () => {
if (loading.value) {
ElMessage.info("正在停止生成...");
return;
}
if (inputMessage.value.trim() === "" && selectedFiles.value.length === 0) {
ElMessage.warning("消息不能为空");
return;
}
await nextTick();
sendMessageWithFileUseSSE();
};
const sendMessageWithFileUseSSE = async () => {
checkTokenIsValidAndRefresh();
let url = "https://pm.ljsea.top/im/chat_completion";
let headers = { "token": localStorage.getItem("token") || "" };
let end_msg: any = {
msg: inputMessage.value,
type: "null",
function: "gen-ai-chat",
session_id: sessionID.value,
model_id: selectModel.value,
temperature: temperature.value,
top_p: topP.value,
};
if (selectedFiles.value.length > 0) {
let file_contents: FileMessage[] = [];
for (let i = 0; i < selectedFiles.value.length; i++) {
let file: File = selectedFiles.value[i];
let file_type = file.UserFileName.match(/\.(jpg|jpeg|png|gif|bmp|webp)$/i) ? "image_file" : "text_file";
file_contents.push({
file_content: file,
file_type: file_type,
});
}
let msg: GenerationMessage = {
text: inputMessage.value,
file_content: file_contents,
};
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);
}
scrollToBottom();
fetchEventSource(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers,
},
body: JSON.stringify(end_msg),
openWhenHidden: true,
onmessage: (event) => {
try {
doReceiveMessageSSE(event);
} catch (e) {
console.error('Failed to parse SSE data:', e);
}
},
onerror: (error) => {
ElMessage.error("发送失败! 请刷新页面重试");
loading.value = false;
canStop.value = false;
},
onclose: () => {
console.log('SSE connection closed');
},
});
};
const loadSession = async (session_id: number) => {
sessionID.value = session_id;
messages.length = 0;
historyMsgHtml.value.length = 0;
sessionName.value = historySessions.value.find(
(session) => session.ID === session_id
)?.Name || "";
await getMessage(session_id);
scrollToBottom();
doButtonD();
};
const clearCurrent = () => {
sessionID.value = 0;
messages.length = 0;
historyMsgHtml.value.length = 0;
sessionName.value = "新会话";
ElMessage.success("新会话已创建! 可以开始聊天了");
};
const showSession = async () => {
if (!sessionIsShow.value) {
await fetchHistorySessions();
}
sessionIsShow.value = !sessionIsShow.value;
};
const getShortenedName = (name: string) => {
if (!name) return "";
if (name.length > 20) {
return name.slice(0, 20) + "...";
}
return name;
};
const getMessage = async (session_id: number) => {
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];
let pMsgContent = "";
if (msg.Status === 3) {
let file_msg: GenerationMessage = JSON.parse(msg.Msg);
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`;
} else {
pMsgContent += `📎 文件:[${fc.file_content.UserFileName}](${fileUrl}${fc.file_content.file_store_name})\n\n`;
}
}
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',
height: '500px',
width: '100%',
cache: { enable: false },
value: textToDocFileContent.value,
});
};
const copyMessage = (content: string) => {
navigator.clipboard.writeText(content).then(() => {
ElMessage.success("复制成功");
}).catch(() => {
ElMessage.error("复制失败");
});
};
const checkTokenIsValidAndRefresh = () => {
let last_refresh = localStorage.getItem("refresh_time");
let now = Date.now();
let isValid = false;
if (last_refresh) {
let diff = now - parseInt(last_refresh);
if (diff < 60 * 60 * 1000) {
isValid = true;
}
}
if (!isValid) {
GetModelListByFunctionName();
}
};
const GetModelListByFunctionName = async () => {
let req = {
function: "gen-ai-chat",
token: localStorage.getItem("token"),
};
try {
let result = await FindModelListByFunctionName(req);
if (result["code"] === 0) {
ModelList.value = result.data;
if (userUIconfigInfo.value.gen_ai_function?.model_id) {
selectModel.value = userUIconfigInfo.value.gen_ai_function.model_id;
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) {
getMessage(sessionID.value);
}
} else if (ModelList.value.length > 0) {
selectModel.value = ModelList.value[0].ID;
}
} else {
ElMessage.error(result["msg"]);
}
} catch (e) {
console.log(e);
}
};
GetModelListByFunctionName();
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"]);
}
};
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) {
ElMessage.success("文件已加入创建队列,请在文件列表中查看");
textToDocFileVisible.value = false;
} else {
ElMessage.error(result["msg"]);
}
};
</script>
<style scoped>
.chat-app {
display: flex;
height: 100%;
background-color: #f7f8fa;
}
/* 侧边栏 */
.history-sessions {
width: 280px;
height: 100%;
display: flex;
flex-direction: column;
background: #fff;
border-right: 1px solid #e5e6eb;
transition: all 0.3s ease;
}
.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;
}
/* 折叠按钮 */
.toggle-sidebar {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
cursor: pointer;
color: #86909c;
transition: all 0.2s;
background: #fff;
border-right: 1px solid #e5e6eb;
}
.toggle-sidebar:hover {
color: #4e5969;
background: #f7f8fa;
}
/* 聊天容器 */
.chat-container {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
}
/* 顶部工具栏 */
.chat-header {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 24px;
background: #fff;
border-bottom: 1px solid #e5e6eb;
}
.model-select {
width: 300px;
}
/* 消息列表 */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 24px;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #86909c;
}
.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;
display: flex;
flex-direction: column;
gap: 24px;
}
.message-item {
display: flex;
gap: 12px;
}
.message-item.user {
flex-direction: row-reverse;
}
.message-avatar {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.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;
}
.message-content {
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;
}
.message-item.user .message-content {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
.message-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s;
}
.message-item:hover .message-actions {
opacity: 1;
}
/* 打字指示器 */
.typing-indicator {
display: flex;
gap: 6px;
padding: 8px 0;
}
.typing-indicator span {
width: 8px;
height: 8px;
border-radius: 50%;
background: #86909c;
animation: typing 1.4s infinite;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%, 60%, 100% {
transform: translateY(0);
opacity: 0.4;
}
30% {
transform: translateY(-6px);
opacity: 1;
}
}
.typing-text {
color: #86909c;
font-size: 14px;
}
/* 输入区域 */
.chat-input-wrapper {
padding: 16px 24px;
background: #fff;
border-top: 1px solid #e5e6eb;
}
.selected-files {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.file-tag {
display: flex;
align-items: center;
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;
}
.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;
}
.file-list {
max-height: 400px;
overflow-y: auto;
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;
}
.file-icon {
color: #86909c;
}
.file-name {
color: #1d2129;
}
.footer-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-top: 1px solid #e5e6eb;
}
.selected-count {
color: #86909c;
}
/* 文档对话框 */
.doc-dialog :deep(.el-dialog__body) {
padding: 0;
}
.doc-header {
padding: 16px 20px;
border-bottom: 1px solid #e5e6eb;
}
.doc-name-input {
width: 400px;
}
.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 {
padding: 8px;
}
.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 {
margin-bottom: 20px;
}
.param-label {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
color: #4e5969;
font-size: 14px;
}
.tip-icon {
color: #86909c;
cursor: help;
}
.param-control {
display: flex;
align-items: center;
gap: 12px;
}
.param-control .el-slider {
flex: 1;
}
.param-value {
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;
}
.chat-messages::-webkit-scrollbar-thumb,
.history-scroll::-webkit-scrollbar-thumb,
.file-list::-webkit-scrollbar-thumb {
background: #c9cdd4;
border-radius: 3px;
}
.chat-messages::-webkit-scrollbar-thumb:hover,
.history-scroll::-webkit-scrollbar-thumb:hover,
.file-list::-webkit-scrollbar-thumb:hover {
background: #86909c;
}
</style>