1509 lines
36 KiB
Vue
1509 lines
36 KiB
Vue
<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 += `\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 += `\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>
|