forked from b2894lxlx/tuanwei-admin
2032 lines
56 KiB
Vue
2032 lines
56 KiB
Vue
<template>
|
||
<div class="chat-page">
|
||
<aside class="chat-sidebar">
|
||
<div class="brand-block">
|
||
<div class="brand-logo">
|
||
<img src="@/assets/v2_t9kowd.png" alt="广西医科大学团委" />
|
||
</div>
|
||
<div class="brand-text">
|
||
<div class="brand-title">AI团宝</div>
|
||
<div class="brand-subtitle">广西医科大学团委智能化平台</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
class="new-chat-btn"
|
||
type="button"
|
||
:disabled="loading"
|
||
@click="startNewChat"
|
||
>
|
||
<span class="plus-icon" aria-hidden="true"></span>
|
||
开启新对话
|
||
</button>
|
||
|
||
<div class="history-block">
|
||
<template v-if="historySections.length">
|
||
<template v-for="section in historySections" :key="section.label">
|
||
<div class="history-label">{{ section.label }}</div>
|
||
<div
|
||
v-for="item in section.items"
|
||
:key="getHistoryKey(item)"
|
||
class="history-item"
|
||
:class="{ 'is-active': activeHistoryKey === getHistoryKey(item) }"
|
||
>
|
||
<button
|
||
class="history-item-main"
|
||
type="button"
|
||
:disabled="loading"
|
||
@click="selectChat(item)"
|
||
>
|
||
{{ getHistoryTitle(item) }}
|
||
</button>
|
||
<button
|
||
class="history-delete-btn"
|
||
type="button"
|
||
:disabled="loading || deletingChatId === item.id"
|
||
@click.stop="removeChat(item)"
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path
|
||
d="M5 7h14"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="1.6"
|
||
stroke-linecap="round"
|
||
/>
|
||
<path
|
||
d="M10 4h4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="1.6"
|
||
stroke-linecap="round"
|
||
/>
|
||
<path
|
||
d="M8 7v10a2 2 0 002 2h4a2 2 0 002-2V7"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="1.6"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</template>
|
||
</template>
|
||
<div v-else class="history-empty">暂无历史对话</div>
|
||
</div>
|
||
|
||
<div class="sidebar-footer">
|
||
<p class="user-name">{{ userName }}</p>
|
||
<div class="footer-actions">
|
||
<button class="admin-btn" type="button" @click="goAdmin"
|
||
>进入后台
|
||
</button
|
||
>
|
||
<button class="logout-btn" type="button" @click="onLogout"
|
||
>退出登录
|
||
</button
|
||
>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<main class="chat-main">
|
||
<div class="chat-main-header">{{ chatHeaderText }}</div>
|
||
|
||
<div class="chat-body" ref="chatBodyRef">
|
||
<template v-if="messages.length">
|
||
<div
|
||
v-for="item in messages"
|
||
:key="item.id"
|
||
class="chat-row"
|
||
:class="isUserMessage(item) ? 'is-user' : 'is-assistant'"
|
||
>
|
||
<div class="chat-bubble" :class="{ 'is-pending': item.loading }">
|
||
<template v-if="isUserMessage(item)">
|
||
{{ item.content }}
|
||
</template>
|
||
<template v-else>
|
||
<div
|
||
v-if="shouldShowAssistantThinking(item)"
|
||
class="assistant-thinking"
|
||
>
|
||
<span class="thinking-dot" aria-hidden="true"></span>
|
||
<span>{{ ASSISTANT_PENDING_TEXT }}</span>
|
||
</div>
|
||
<div
|
||
v-if="
|
||
item.loading &&
|
||
hasAssistantDisplayContent(item.content)
|
||
"
|
||
class="assistant-streaming-text"
|
||
>
|
||
{{ getAssistantDisplayContent(item.content) }}
|
||
</div>
|
||
<div
|
||
v-else-if="getAssistantImageUrl(item.content)"
|
||
class="assistant-image-wrap"
|
||
>
|
||
<a-image
|
||
:src="getAssistantImageUrl(item.content)"
|
||
class="assistant-image"
|
||
/>
|
||
</div>
|
||
<ByteMdViewer
|
||
v-else-if="hasAssistantDisplayContent(item.content)"
|
||
:key="`${item.id}-${getAssistantDisplayContent(item.content).length}`"
|
||
class="assistant-markdown"
|
||
:value="getAssistantDisplayContent(item.content)"
|
||
:plugins="plugins"
|
||
/>
|
||
</template>
|
||
</div>
|
||
<div class="bubble-actions">
|
||
<button
|
||
class="icon-btn"
|
||
type="button"
|
||
@click="copyText(getMessageCopyContent(item))"
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<rect
|
||
x="7"
|
||
y="7"
|
||
width="12"
|
||
height="12"
|
||
rx="2"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="1.6"
|
||
/>
|
||
<rect
|
||
x="3"
|
||
y="3"
|
||
width="12"
|
||
height="12"
|
||
rx="2"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="1.6"
|
||
opacity="0.6"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
<button
|
||
v-if="isUserMessage(item)"
|
||
class="icon-btn"
|
||
type="button"
|
||
@click="fillContent(item.content || '')"
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path
|
||
d="M4 20h4l10-10-4-4L4 16v4z"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="1.6"
|
||
/>
|
||
<path
|
||
d="M13 6l4 4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="1.6"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<div class="chat-empty">
|
||
你好,我是 AI 团委助手。请输入你的问题,我会通过实时消息为你回复。
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<div class="assistant-input">
|
||
<div class="assistant-title">
|
||
<img src="@/assets/v2_t9kowd.png" alt="团务智能问答助手" />
|
||
<span>{{ assistantTitle }}</span>
|
||
</div>
|
||
<div class="input-shell">
|
||
<div v-if="activeChatMode === 'image'" class="ratio-panel">
|
||
<span class="ratio-label">图片比例</span>
|
||
<button
|
||
v-for="item in imageRatioOptions"
|
||
:key="item.value"
|
||
class="ratio-option"
|
||
type="button"
|
||
:class="{ 'is-active': imageRatio === item.value }"
|
||
:disabled="loading"
|
||
@click="imageRatio = item.value"
|
||
>
|
||
{{ item.label }}
|
||
</button>
|
||
</div>
|
||
<textarea
|
||
v-model="content"
|
||
:placeholder="inputPlaceholder"
|
||
:disabled="loading"
|
||
@keydown.enter.exact.prevent="onSend"
|
||
></textarea>
|
||
<div class="input-actions">
|
||
<div class="mode-tags">
|
||
<button
|
||
v-for="item in chatModeOptions"
|
||
:key="item.value"
|
||
class="mode-tag"
|
||
type="button"
|
||
:class="{ 'is-active': activeChatMode === item.value }"
|
||
:disabled="loading"
|
||
@click="activeChatMode = item.value"
|
||
>
|
||
{{ item.label }}
|
||
</button>
|
||
</div>
|
||
<div class="send-actions">
|
||
<button class="icon-btn" type="button">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<rect
|
||
x="4"
|
||
y="6"
|
||
width="16"
|
||
height="12"
|
||
rx="2"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="1.6"
|
||
/>
|
||
<circle cx="9" cy="10" r="1.5" fill="currentColor" />
|
||
<path
|
||
d="M5 16l4-4 4 4 3-3 3 3"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="1.6"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
<button class="icon-btn" type="button">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path
|
||
d="M7 12l6-6a3 3 0 114 4l-7 7a4 4 0 11-6-6l7-7"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="1.6"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
<button
|
||
class="send-btn"
|
||
:class="{ 'is-stop': loading }"
|
||
type="button"
|
||
:disabled="loading ? stopping : !canSend"
|
||
@click="loading ? onStopGenerate() : onSend()"
|
||
>{{ loading ? (stopping ? "停止中" : "停止") : "发送" }}
|
||
</button
|
||
>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import "bytemd/dist/index.min.css";
|
||
import dayjs from "dayjs";
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||
import { useRouter } from "vue-router";
|
||
import "github-markdown-css/github-markdown-light.css";
|
||
import gfm from "@bytemd/plugin-gfm";
|
||
import zh_HansGfm from "@bytemd/plugin-gfm/locales/zh_Hans.json";
|
||
import highlight from "@bytemd/plugin-highlight-ssr";
|
||
import "highlight.js/styles/default.css";
|
||
import { message } from "ant-design-vue";
|
||
import ByteMdViewer from "@/components/ByteMdViewer/index.vue";
|
||
import { listAiChatHistory } from "@/api/ai/aiChatHistory";
|
||
import type { AiChatHistory } from "@/api/ai/aiChatHistory/model";
|
||
import { listAiChatList, removeAiChatList } from "@/api/ai/aiChatList";
|
||
import type { AiChatList } from "@/api/ai/aiChatList/model";
|
||
import { sendMessage, stopMessage } from "@/api/oa/chatgpt";
|
||
import { manuscriptGen } from "@/api/ai/manuscriptGen";
|
||
import { creativeAssistant } from "@/api/ai/creativeAssistant";
|
||
import { API_BASE_URL } from "@/config/setting";
|
||
import type { Menu } from "@/api/system/menu/model";
|
||
import { useUserStore } from "@/store/modules/user";
|
||
import { logout } from "@/utils/page-tab-util";
|
||
import { copyText } from "@/utils/common";
|
||
import { getToken } from "@/utils/token-util";
|
||
|
||
type ChatRole = "user" | "assistant";
|
||
type ChatMode = "chat" | "writing" | "image";
|
||
|
||
interface ChatMessageItem {
|
||
id: string;
|
||
role: ChatRole;
|
||
content: string;
|
||
loading?: boolean;
|
||
}
|
||
|
||
interface HistorySection {
|
||
label: string;
|
||
items: AiChatList[];
|
||
}
|
||
|
||
interface AuthorityTreeItem extends Menu {
|
||
children?: AuthorityTreeItem[];
|
||
}
|
||
|
||
const plugins = ref([
|
||
gfm({
|
||
locale: zh_HansGfm
|
||
}),
|
||
highlight()
|
||
]);
|
||
|
||
const ASSISTANT_PENDING_TEXT = "正在思考中...";
|
||
const THINK_OPEN_TAG = "<think>";
|
||
const THINK_CLOSE_TAG = "</think>";
|
||
const loading = ref(false);
|
||
const stopping = ref(false);
|
||
const content = ref("");
|
||
const activeChatMode = ref<ChatMode>("chat");
|
||
const imageRatio = ref("1:1");
|
||
const wsConnected = ref(false);
|
||
const deletingChatId = ref<number>();
|
||
const messages = ref<ChatMessageItem[]>([]);
|
||
const historyList = ref<AiChatList[]>([]);
|
||
const chatBodyRef = ref<HTMLElement | null>(null);
|
||
const activeChatId = ref<number>();
|
||
const activeConversationId = ref<string>();
|
||
const streamingMessageId = ref<string>();
|
||
const currentTaskId = ref<string>();
|
||
const currentAbortController = ref<AbortController | null>(null);
|
||
const webSocketRef = ref<WebSocket | null>(null);
|
||
const wsConnectPromiseRef = ref<Promise<void> | null>(null);
|
||
const userStore = useUserStore();
|
||
const { push } = useRouter();
|
||
const loginUser = computed(() => userStore.info ?? {});
|
||
console.log('loginUser', loginUser)
|
||
const userName = computed(
|
||
() =>
|
||
loginUser.value.nickname ||
|
||
loginUser.value.realName ||
|
||
(loginUser.value.userId ? `用户${loginUser.value.userId}` : "未登录")
|
||
);
|
||
|
||
const canSend = computed(
|
||
() => content.value.trim().length > 0 && !loading.value
|
||
);
|
||
|
||
const isCanceledError = (error: any) =>
|
||
error?.code === "ERR_CANCELED" ||
|
||
error?.name === "CanceledError" ||
|
||
error?.message === "canceled";
|
||
|
||
const chatModeOptions: { label: string; value: ChatMode }[] = [
|
||
{ label: "聊天", value: "chat" },
|
||
{ label: "写作", value: "writing" },
|
||
{ label: "图片", value: "image" }
|
||
];
|
||
|
||
const imageRatioOptions = [
|
||
{ label: "1:1", value: "1:1" },
|
||
{ label: "4:3", value: "4:3" },
|
||
{ label: "3:4", value: "3:4" },
|
||
{ label: "16:9", value: "16:9" },
|
||
{ label: "9:16", value: "9:16" }
|
||
];
|
||
|
||
const inputPlaceholder = computed(() => {
|
||
if (activeChatMode.value === "writing") {
|
||
return "请输入写作需求,例如新闻稿、推文文案、小论文、发言稿,也可以补充标题、受众、字数和风格";
|
||
}
|
||
if (activeChatMode.value === "image") {
|
||
return "请输入图片生成需求,例如校园活动海报、公众号头图、志愿服务插画,并选择图片比例";
|
||
}
|
||
return "说说你想问的问题";
|
||
});
|
||
|
||
const assistantTitle = computed(() => {
|
||
if (activeChatMode.value === "writing") {
|
||
return "AI 写作助手";
|
||
}
|
||
if (activeChatMode.value === "image") {
|
||
return "AI 图片生成助手";
|
||
}
|
||
return "团务智能问答助手";
|
||
});
|
||
|
||
const chatHeaderText = computed(() => {
|
||
if (loading.value) {
|
||
return wsConnected.value ? "AI助手在线答疑" : "AI助手连接中...";
|
||
}
|
||
return "AI助手待命中";
|
||
});
|
||
|
||
const activeHistoryKey = computed(() => {
|
||
if (activeChatId.value) {
|
||
return `chat-${activeChatId.value}`;
|
||
}
|
||
if (activeConversationId.value) {
|
||
return `conversation-${activeConversationId.value}`;
|
||
}
|
||
return "";
|
||
});
|
||
|
||
const historySections = computed<HistorySection[]>(() => {
|
||
const groupMap = new Map<string, AiChatList[]>();
|
||
[...historyList.value]
|
||
.sort((a, b) => {
|
||
const left = dayjs(b.updateTime || b.createTime || 0).valueOf();
|
||
const right = dayjs(a.updateTime || a.createTime || 0).valueOf();
|
||
return left - right;
|
||
})
|
||
.forEach((item) => {
|
||
const label = getHistoryDateLabel(item.createTime || item.updateTime);
|
||
if (!groupMap.has(label)) {
|
||
groupMap.set(label, []);
|
||
}
|
||
groupMap.get(label)?.push(item);
|
||
});
|
||
|
||
return Array.from(groupMap.entries()).map(([label, items]) => ({
|
||
label,
|
||
items
|
||
}));
|
||
});
|
||
|
||
const isUserMessage = (item: ChatMessageItem) => item.role === "user";
|
||
|
||
const createMessageId = (prefix: string) =>
|
||
`${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||
|
||
const parseAssistantContent = (value: string) => {
|
||
if (!value || value === ASSISTANT_PENDING_TEXT) {
|
||
return {
|
||
content: "",
|
||
thinking: value === ASSISTANT_PENDING_TEXT
|
||
};
|
||
}
|
||
|
||
let cursor = 0;
|
||
let content = "";
|
||
let thinking = false;
|
||
|
||
while (cursor < value.length) {
|
||
if (!thinking) {
|
||
const openIndex = value.indexOf(THINK_OPEN_TAG, cursor);
|
||
const closeIndex = value.indexOf(THINK_CLOSE_TAG, cursor);
|
||
|
||
if (closeIndex !== -1 && (openIndex === -1 || closeIndex < openIndex)) {
|
||
cursor = closeIndex + THINK_CLOSE_TAG.length;
|
||
continue;
|
||
}
|
||
if (openIndex === -1) {
|
||
content += value.slice(cursor);
|
||
break;
|
||
}
|
||
content += value.slice(cursor, openIndex);
|
||
cursor = openIndex + THINK_OPEN_TAG.length;
|
||
thinking = true;
|
||
continue;
|
||
}
|
||
|
||
const closeIndex = value.indexOf(THINK_CLOSE_TAG, cursor);
|
||
if (closeIndex === -1) {
|
||
break;
|
||
}
|
||
cursor = closeIndex + THINK_CLOSE_TAG.length;
|
||
thinking = false;
|
||
}
|
||
|
||
return {
|
||
content,
|
||
thinking
|
||
};
|
||
};
|
||
|
||
const scrollToBottom = () => {
|
||
nextTick(() => {
|
||
const el = chatBodyRef.value;
|
||
if (el) {
|
||
el.scrollTop = el.scrollHeight;
|
||
}
|
||
});
|
||
};
|
||
|
||
const fillContent = (value: string) => {
|
||
content.value = value;
|
||
};
|
||
|
||
const getAssistantDisplayContent = (value: string) =>
|
||
parseAssistantContent(value).content;
|
||
|
||
const hasAssistantDisplayContent = (value: string) =>
|
||
getAssistantDisplayContent(value).trim().length > 0;
|
||
|
||
const getAssistantImageUrl = (value: string) => {
|
||
const content = getAssistantDisplayContent(value).trim();
|
||
if (!content) {
|
||
return "";
|
||
}
|
||
if (/^(https?:\/\/|data:image\/)/i.test(content) && /\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/i.test(content.split("\n")[0]) || /^data:image\//i.test(content)) {
|
||
return content;
|
||
}
|
||
const markdownImageMatch = content.match(/^!\[[^\]]*]\((.+?)\)$/);
|
||
if (markdownImageMatch?.[1]) {
|
||
return markdownImageMatch[1];
|
||
}
|
||
return "";
|
||
};
|
||
|
||
const shouldShowAssistantThinking = (item: ChatMessageItem) => {
|
||
if (item.content === ASSISTANT_PENDING_TEXT) {
|
||
return true;
|
||
}
|
||
if (!item.loading) {
|
||
return false;
|
||
}
|
||
return parseAssistantContent(item.content).thinking;
|
||
};
|
||
|
||
const getMessageCopyContent = (item: ChatMessageItem) =>
|
||
isUserMessage(item)
|
||
? item.content || ""
|
||
: getAssistantDisplayContent(item.content).trim();
|
||
|
||
const getHistoryKey = (item: AiChatList) => {
|
||
if (item.id) {
|
||
return `chat-${item.id}`;
|
||
}
|
||
return `conversation-${
|
||
item.conversationId || item.createTime || Math.random()
|
||
}`;
|
||
};
|
||
|
||
const getHistoryTitle = (item: AiChatList) => {
|
||
const title = item.title?.trim();
|
||
if (title) {
|
||
return title;
|
||
}
|
||
if (item.conversationId) {
|
||
return `会话 ${item.conversationId.slice(0, 8)}`;
|
||
}
|
||
return "未命名对话";
|
||
};
|
||
|
||
const getHistoryDateLabel = (value?: string) => {
|
||
if (!value) {
|
||
return "更早";
|
||
}
|
||
const date = dayjs(value);
|
||
if (date.isSame(dayjs(), "day")) {
|
||
return "今天";
|
||
}
|
||
if (date.isSame(dayjs().subtract(1, "day"), "day")) {
|
||
return "昨天";
|
||
}
|
||
return date.format("YYYY年M月D日");
|
||
};
|
||
|
||
const sortAuthorityTree = (list: AuthorityTreeItem[]) => {
|
||
return [...list]
|
||
.sort((a, b) => (a.sortNumber ?? Number.MAX_SAFE_INTEGER) - (b.sortNumber ?? Number.MAX_SAFE_INTEGER))
|
||
.map((item) => ({
|
||
...item,
|
||
children: item.children?.length ? sortAuthorityTree(item.children) : []
|
||
}));
|
||
};
|
||
|
||
const buildAuthorityTree = (authorities?: Menu[]) => {
|
||
const menuList = (authorities ?? [])
|
||
.filter((item) => item.menuType !== 1 && item.menuId != null)
|
||
.map((item) => ({
|
||
...item,
|
||
children: [] as AuthorityTreeItem[]
|
||
}));
|
||
const menuMap = new Map<number, AuthorityTreeItem>();
|
||
|
||
menuList.forEach((item) => {
|
||
if (item.menuId != null) {
|
||
menuMap.set(item.menuId, item);
|
||
}
|
||
});
|
||
|
||
const tree: AuthorityTreeItem[] = [];
|
||
menuList.forEach((item) => {
|
||
const parentId = item.parentId ?? 0;
|
||
if (parentId === 0) {
|
||
tree.push(item);
|
||
return;
|
||
}
|
||
const parent = menuMap.get(parentId);
|
||
if (parent) {
|
||
parent.children?.push(item);
|
||
} else {
|
||
tree.push(item);
|
||
}
|
||
});
|
||
|
||
return sortAuthorityTree(tree);
|
||
};
|
||
|
||
const findFirstChildMenuPath = (menus: AuthorityTreeItem[]) => {
|
||
for (const item of menus) {
|
||
if (!item.children?.length) {
|
||
continue;
|
||
}
|
||
for (const child of item.children) {
|
||
if (child.path) {
|
||
return child.path;
|
||
}
|
||
if (child.children?.length) {
|
||
const descendantPath = findFirstChildMenuPath([child]);
|
||
if (descendantPath) {
|
||
return descendantPath;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return "";
|
||
};
|
||
|
||
const buildWebSocketUrl = (userId: number) => {
|
||
if (/^https?:\/\//.test(API_BASE_URL)) {
|
||
const url = new URL(API_BASE_URL);
|
||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||
url.pathname = url.pathname.replace(/\/api\/?$/, "");
|
||
return `${url.toString().replace(/\/$/, "")}/chat/${userId}`;
|
||
}
|
||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||
return `${protocol}//${window.location.host}/chat/${userId}`;
|
||
};
|
||
|
||
const formatHistoryMessages = (list: AiChatHistory[]) => {
|
||
const result: ChatMessageItem[] = [];
|
||
[...list]
|
||
.sort(
|
||
(a, b) =>
|
||
dayjs(a.createTime || 0).valueOf() -
|
||
dayjs(b.createTime || 0).valueOf()
|
||
)
|
||
.forEach((item) => {
|
||
if (item.content) {
|
||
result.push({
|
||
id: createMessageId("user"),
|
||
role: "user",
|
||
content: item.content
|
||
});
|
||
}
|
||
if (item.reply) {
|
||
result.push({
|
||
id: createMessageId("assistant"),
|
||
role: "assistant",
|
||
content: item.reply
|
||
});
|
||
}
|
||
});
|
||
return result;
|
||
};
|
||
|
||
const syncActiveChat = () => {
|
||
if (!activeConversationId.value) {
|
||
return;
|
||
}
|
||
const current = historyList.value.find(
|
||
(item) => item.conversationId === activeConversationId.value
|
||
);
|
||
if (current?.id) {
|
||
activeChatId.value = current.id;
|
||
}
|
||
};
|
||
|
||
const appendAssistantChunk = (answer: string, conversationId?: string) => {
|
||
if (conversationId) {
|
||
activeConversationId.value = conversationId;
|
||
}
|
||
if (!streamingMessageId.value) {
|
||
const messageId = createMessageId("assistant");
|
||
streamingMessageId.value = messageId;
|
||
messages.value.push({
|
||
id: messageId,
|
||
role: "assistant",
|
||
content: answer,
|
||
loading: true
|
||
});
|
||
scrollToBottom();
|
||
return;
|
||
}
|
||
const target = messages.value.find(
|
||
(item) => item.id === streamingMessageId.value
|
||
);
|
||
if (!target) {
|
||
return;
|
||
}
|
||
target.content =
|
||
target.content === ASSISTANT_PENDING_TEXT
|
||
? answer
|
||
: `${target.content}${answer}`;
|
||
scrollToBottom();
|
||
};
|
||
|
||
const finishAssistantMessage = async (conversationId?: string) => {
|
||
if (conversationId) {
|
||
activeConversationId.value = conversationId;
|
||
}
|
||
const target = messages.value.find(
|
||
(item) => item.id === streamingMessageId.value
|
||
);
|
||
if (target) {
|
||
target.loading = false;
|
||
if (target.content === ASSISTANT_PENDING_TEXT) {
|
||
target.content = "";
|
||
}
|
||
}
|
||
streamingMessageId.value = undefined;
|
||
currentTaskId.value = undefined;
|
||
currentAbortController.value = null;
|
||
stopping.value = false;
|
||
loading.value = false;
|
||
await loadHistoryList(false);
|
||
syncActiveChat();
|
||
};
|
||
|
||
const markCurrentMessageStopped = () => {
|
||
const target = messages.value.find(
|
||
(item) => item.id === streamingMessageId.value
|
||
);
|
||
if (target) {
|
||
target.loading = false;
|
||
const visibleContent = getAssistantDisplayContent(target.content).trim();
|
||
target.content = visibleContent
|
||
? `${visibleContent}\n\n(已停止生成)`
|
||
: "已停止生成。";
|
||
}
|
||
streamingMessageId.value = undefined;
|
||
currentTaskId.value = undefined;
|
||
currentAbortController.value = null;
|
||
loading.value = false;
|
||
stopping.value = false;
|
||
disconnectWebSocket();
|
||
scrollToBottom();
|
||
};
|
||
|
||
const disconnectWebSocket = () => {
|
||
wsConnectPromiseRef.value = null;
|
||
wsConnected.value = false;
|
||
const socket = webSocketRef.value;
|
||
webSocketRef.value = null;
|
||
if (
|
||
socket &&
|
||
(socket.readyState === WebSocket.OPEN ||
|
||
socket.readyState === WebSocket.CONNECTING)
|
||
) {
|
||
socket.close();
|
||
}
|
||
};
|
||
|
||
const handleSocketMessage = async (raw: string) => {
|
||
if (!raw) {
|
||
return;
|
||
}
|
||
try {
|
||
const data = JSON.parse(raw);
|
||
if (typeof data?.taskId === "string" && data.taskId && data.taskId !== "0") {
|
||
currentTaskId.value = data.taskId;
|
||
}
|
||
if (data?.answer === "__END__") {
|
||
await finishAssistantMessage(data.conversationId);
|
||
disconnectWebSocket();
|
||
return;
|
||
}
|
||
if (data?.loading === "true") {
|
||
return;
|
||
}
|
||
const notice = data?.notice || data?.answer || "";
|
||
if (notice) {
|
||
appendAssistantChunk(notice, data.conversationId);
|
||
}
|
||
} catch (e) {
|
||
console.warn("websocket message parse failed", e);
|
||
}
|
||
};
|
||
|
||
const connectWebSocket = async () => {
|
||
const userId = loginUser.value.userId;
|
||
if (!userId) {
|
||
return Promise.reject(new Error("未获取到当前登录用户"));
|
||
}
|
||
const currentSocket = webSocketRef.value;
|
||
if (currentSocket?.readyState === WebSocket.OPEN && wsConnected.value) {
|
||
return;
|
||
}
|
||
if (
|
||
(currentSocket?.readyState === WebSocket.OPEN ||
|
||
currentSocket?.readyState === WebSocket.CONNECTING) &&
|
||
wsConnectPromiseRef.value
|
||
) {
|
||
return wsConnectPromiseRef.value;
|
||
}
|
||
let cleanupPending = () => undefined;
|
||
const connectPromise = new Promise<void>((resolve, reject) => {
|
||
let settled = false;
|
||
const socket = new WebSocket(buildWebSocketUrl(userId));
|
||
cleanupPending = () => {
|
||
if (wsConnectPromiseRef.value === connectPromise) {
|
||
wsConnectPromiseRef.value = null;
|
||
}
|
||
};
|
||
socket.onopen = () => {
|
||
wsConnected.value = false;
|
||
};
|
||
socket.onmessage = (event) => {
|
||
const raw = String(event.data || "");
|
||
if (raw === "连接成功") {
|
||
wsConnected.value = true;
|
||
if (!settled) {
|
||
settled = true;
|
||
cleanupPending();
|
||
resolve();
|
||
}
|
||
return;
|
||
}
|
||
void handleSocketMessage(raw);
|
||
};
|
||
socket.onclose = () => {
|
||
wsConnected.value = false;
|
||
if (webSocketRef.value === socket) {
|
||
webSocketRef.value = null;
|
||
}
|
||
if (!settled) {
|
||
settled = true;
|
||
cleanupPending();
|
||
reject(new Error("聊天连接已关闭"));
|
||
return;
|
||
}
|
||
cleanupPending();
|
||
};
|
||
socket.onerror = () => {
|
||
wsConnected.value = false;
|
||
if (!settled) {
|
||
settled = true;
|
||
cleanupPending();
|
||
reject(new Error("聊天连接失败"));
|
||
}
|
||
};
|
||
webSocketRef.value = socket;
|
||
});
|
||
wsConnectPromiseRef.value = connectPromise;
|
||
return connectPromise;
|
||
};
|
||
|
||
const loadHistoryList = async (autoSelect = true) => {
|
||
const userId = loginUser.value.userId;
|
||
if (!userId) {
|
||
historyList.value = [];
|
||
return;
|
||
}
|
||
const response = await listAiChatList({ userId });
|
||
historyList.value = response || [];
|
||
syncActiveChat();
|
||
if (
|
||
autoSelect &&
|
||
!activeChatId.value &&
|
||
!activeConversationId.value &&
|
||
historyList.value.length
|
||
) {
|
||
await selectChat(historyList.value[0], true);
|
||
}
|
||
};
|
||
|
||
const loadChatHistory = async (item: AiChatList) => {
|
||
const userId = loginUser.value.userId;
|
||
const response = await listAiChatHistory({
|
||
chatId: item.id,
|
||
conversationId: item.conversationId,
|
||
userId
|
||
});
|
||
messages.value = formatHistoryMessages(response || []);
|
||
scrollToBottom();
|
||
};
|
||
|
||
const selectChat = async (item: AiChatList, silent = false) => {
|
||
if (loading.value && !silent) {
|
||
message.warning("当前正在生成回复,请稍后再切换会话");
|
||
return;
|
||
}
|
||
activeChatId.value = item.id;
|
||
activeConversationId.value = item.conversationId;
|
||
await loadChatHistory(item);
|
||
};
|
||
|
||
const startNewChat = () => {
|
||
if (loading.value) {
|
||
message.warning("当前正在生成回复,请稍后再开启新对话");
|
||
return;
|
||
}
|
||
disconnectWebSocket();
|
||
activeChatId.value = undefined;
|
||
activeConversationId.value = undefined;
|
||
streamingMessageId.value = undefined;
|
||
messages.value = [];
|
||
content.value = "";
|
||
};
|
||
|
||
const stringifyUnknown = (value: unknown) => {
|
||
try {
|
||
return JSON.stringify(value, null, 2);
|
||
} catch (e) {
|
||
return String(value ?? "");
|
||
}
|
||
};
|
||
|
||
const pickString = (...values: unknown[]) => {
|
||
for (const value of values) {
|
||
if (typeof value === "string" && value.trim()) {
|
||
return value.trim();
|
||
}
|
||
}
|
||
return "";
|
||
};
|
||
|
||
const parseKeyValueInput = (value: string) => {
|
||
const result: Record<string, string> = {};
|
||
value
|
||
.split(/\n+/)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean)
|
||
.forEach((line) => {
|
||
const match = line.match(/^([^::]+)[::]\s*(.*)$/);
|
||
if (match) {
|
||
result[match[1].trim()] = match[2].trim();
|
||
}
|
||
});
|
||
return result;
|
||
};
|
||
|
||
const buildWritingPayload = (query: string) => {
|
||
const fields = parseKeyValueInput(query);
|
||
return {
|
||
docType: fields["稿件类型"] || fields["文体"] || fields["类型"] || "新闻稿",
|
||
eventType: fields["活动类型"] || fields["场景"] || "校园内容",
|
||
theme: fields["稿件标题"] || fields["标题"] || fields["主题"] || query,
|
||
time: fields["活动时间"] || fields["时间"] || "",
|
||
location: fields["活动地点"] || fields["地点"] || "",
|
||
participants: fields["参与人员"] || fields["受众"] || fields["人员"] || "",
|
||
highlights: fields["亮点内容"] || fields["要点"] || fields["亮点"] || fields["内容"] || query,
|
||
number: Number(fields["建议篇幅"] || fields["字数"] || 800),
|
||
files: []
|
||
};
|
||
};
|
||
|
||
const formatWritingResult = (payload: unknown, query: string) => {
|
||
if (typeof payload === "string") {
|
||
return payload;
|
||
}
|
||
const data = (payload || {}) as Record<string, any>;
|
||
const title = pickString(
|
||
data.title,
|
||
data.theme,
|
||
data.name,
|
||
data.subject,
|
||
data.data?.title
|
||
);
|
||
const body = pickString(
|
||
data.content,
|
||
data.text,
|
||
data.result,
|
||
data.output,
|
||
data.answer,
|
||
data.manuscript,
|
||
data.article,
|
||
data.message,
|
||
data.data?.content,
|
||
data.data?.text,
|
||
data.data?.result,
|
||
data.data?.output,
|
||
data.choices?.[0]?.message?.content
|
||
);
|
||
if (title || body) {
|
||
return `${title || buildWritingPayload(query).theme}\n\n${body}`;
|
||
}
|
||
return stringifyUnknown(payload);
|
||
};
|
||
|
||
const getCreativeOutputs = (payload: any) => {
|
||
if (payload?.outputs && typeof payload.outputs === "object") {
|
||
return payload.outputs;
|
||
}
|
||
if (payload?.data?.outputs && typeof payload.data.outputs === "object") {
|
||
return payload.data.outputs;
|
||
}
|
||
return payload || {};
|
||
};
|
||
|
||
const getCreativeFileUrl = (item: any) =>
|
||
String(
|
||
item?.url ||
|
||
item?.remote_url ||
|
||
item?.downloadUrl ||
|
||
item?.download_url ||
|
||
item?.path ||
|
||
""
|
||
).trim();
|
||
|
||
const getCreativeFileName = (item: any, fallback: string) =>
|
||
String(item?.name || item?.filename || fallback).trim();
|
||
|
||
const isCreativeImageFile = (item: any) => {
|
||
const url = getCreativeFileUrl(item).toLowerCase();
|
||
const contentType = String(
|
||
item?.contentType || item?.content_type || item?.mime_type || ""
|
||
).toLowerCase();
|
||
const fileType = String(item?.type || "").toLowerCase();
|
||
const extension = String(item?.extension || "").toLowerCase();
|
||
return (
|
||
contentType.startsWith("image/") ||
|
||
fileType === "image" ||
|
||
/\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/.test(url) ||
|
||
[".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"].includes(
|
||
extension
|
||
)
|
||
);
|
||
};
|
||
|
||
const formatFileList = (title: string, list: any[] = []) => {
|
||
if (!list.length) {
|
||
return "";
|
||
}
|
||
return `\n\n### ${title}\n${list
|
||
.map((item, index) => {
|
||
const url = getCreativeFileUrl(item);
|
||
const name = getCreativeFileName(item, `结果文件${index + 1}`);
|
||
if (!url) {
|
||
return "";
|
||
}
|
||
return `${index + 1}. [${name}](${url})`;
|
||
})
|
||
.filter(Boolean)
|
||
.join("\n")}`;
|
||
};
|
||
|
||
const formatImageList = (title: string, list: any[] = []) => {
|
||
if (!list.length) {
|
||
return "";
|
||
}
|
||
const lines = list
|
||
.map((item) => {
|
||
const url = getCreativeFileUrl(item);
|
||
if (!url) {
|
||
return "";
|
||
}
|
||
return ``;
|
||
})
|
||
.filter(Boolean);
|
||
if (!lines.length) {
|
||
return "";
|
||
}
|
||
return `\n\n### ${title}\n${lines.join("\n\n")}`;
|
||
};
|
||
|
||
const formatCreativeResult = (payload: unknown) => {
|
||
const outputs = getCreativeOutputs(payload as any);
|
||
const script = pickString(outputs.Script_result, outputs.script, outputs.text);
|
||
const videoMessage = pickString(outputs.video_message);
|
||
const photoMessage = pickString(outputs.photo_message);
|
||
const photoFiles = Array.isArray(outputs.photo_files)
|
||
? outputs.photo_files
|
||
: Array.isArray(outputs.photo_file)
|
||
? outputs.photo_file
|
||
: [];
|
||
const imageFiles = photoFiles.filter((item) => isCreativeImageFile(item));
|
||
const otherPhotoFiles = photoFiles.filter((item) => !imageFiles.includes(item));
|
||
const files = [
|
||
formatFileList("视频生成结果", outputs.video_result || []),
|
||
formatImageList("图像 / 排版结果", imageFiles),
|
||
formatFileList("图像 / 排版结果文件", otherPhotoFiles)
|
||
].join("");
|
||
const text = [
|
||
videoMessage,
|
||
photoMessage,
|
||
script ? `### 脚本创作结果\n${script}` : "",
|
||
files
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n\n");
|
||
return text || stringifyUnknown(payload);
|
||
};
|
||
|
||
const buildImagePrompt = (query: string) => {
|
||
return [
|
||
query,
|
||
`图片比例:${imageRatio.value}`,
|
||
"请直接生成图片,优先输出图片文件或图片链接。"
|
||
].join("\n");
|
||
};
|
||
|
||
const sendModeMessage = async (
|
||
query: string,
|
||
assistantId: string,
|
||
signal?: AbortSignal
|
||
) => {
|
||
const target = messages.value.find((item) => item.id === assistantId);
|
||
const setResult = (value: string) => {
|
||
if (target) {
|
||
target.content = value || "抱歉,我无法生成你要求的内容。";
|
||
target.loading = false;
|
||
}
|
||
};
|
||
if (activeChatMode.value === "writing") {
|
||
const result = await manuscriptGen(buildWritingPayload(query), { signal });
|
||
if (typeof (result as any)?.taskId === "string") {
|
||
currentTaskId.value = (result as any).taskId;
|
||
}
|
||
setResult(formatWritingResult(result, query));
|
||
return;
|
||
}
|
||
if (activeChatMode.value === "image") {
|
||
const imagePrompt = buildImagePrompt(query);
|
||
const result = await creativeAssistant({
|
||
user_content: imagePrompt,
|
||
user_contentString: imagePrompt,
|
||
user_files: []
|
||
}, { signal });
|
||
if (typeof (result as any)?.taskId === "string") {
|
||
currentTaskId.value = (result as any).taskId;
|
||
}
|
||
setResult(formatCreativeResult(result));
|
||
}
|
||
};
|
||
|
||
const removeChat = async (item: AiChatList) => {
|
||
if (loading.value) {
|
||
message.warning("当前正在生成回复,请稍后再删除会话");
|
||
return;
|
||
}
|
||
if (!item.id) {
|
||
message.error("当前会话缺少标识,无法删除");
|
||
return;
|
||
}
|
||
if (!window.confirm(`确定删除“${getHistoryTitle(item)}”吗?`)) {
|
||
return;
|
||
}
|
||
deletingChatId.value = item.id;
|
||
try {
|
||
const isActive =
|
||
activeChatId.value === item.id ||
|
||
activeConversationId.value === item.conversationId;
|
||
await removeAiChatList(item.id);
|
||
await loadHistoryList(false);
|
||
if (isActive) {
|
||
const nextChat = historyList.value[0];
|
||
if (nextChat) {
|
||
activeChatId.value = nextChat.id;
|
||
activeConversationId.value = nextChat.conversationId;
|
||
await loadChatHistory(nextChat);
|
||
} else {
|
||
startNewChat();
|
||
}
|
||
}
|
||
message.success("删除成功");
|
||
} catch (e: any) {
|
||
message.error(e?.message || "删除失败");
|
||
} finally {
|
||
deletingChatId.value = undefined;
|
||
}
|
||
};
|
||
|
||
const goAdmin = async () => {
|
||
if (!getToken()) {
|
||
push({
|
||
path: "/login",
|
||
query: {
|
||
from: "/"
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
const authorityTree = buildAuthorityTree(loginUser.value.authorities);
|
||
console.log('authorityTree', authorityTree)
|
||
const firstPath = findFirstChildMenuPath(authorityTree);
|
||
console.log('firstPath', firstPath)
|
||
|
||
if (!firstPath) {
|
||
message.warning("当前用户没有可进入的后台菜单");
|
||
push("/");
|
||
return;
|
||
}
|
||
push(firstPath);
|
||
};
|
||
|
||
const onLogout = () => {
|
||
logout(false);
|
||
};
|
||
|
||
const onStopGenerate = async () => {
|
||
if (!loading.value || stopping.value) {
|
||
return;
|
||
}
|
||
stopping.value = true;
|
||
try {
|
||
await stopMessage({
|
||
taskId: currentTaskId.value,
|
||
mode: activeChatMode.value
|
||
});
|
||
currentAbortController.value?.abort();
|
||
markCurrentMessageStopped();
|
||
message.success("已停止生成");
|
||
} catch (e: any) {
|
||
stopping.value = false;
|
||
message.error(e?.message || "停止失败");
|
||
}
|
||
};
|
||
|
||
const onSend = async () => {
|
||
if (!canSend.value) {
|
||
return;
|
||
}
|
||
if (!loginUser.value.userId) {
|
||
message.error("未获取到当前登录用户");
|
||
return;
|
||
}
|
||
const query = content.value.trim();
|
||
loading.value = true;
|
||
stopping.value = false;
|
||
currentTaskId.value = undefined;
|
||
const abortController = new AbortController();
|
||
currentAbortController.value = abortController;
|
||
try {
|
||
const assistantId = createMessageId("assistant");
|
||
messages.value.push({
|
||
id: createMessageId("user"),
|
||
role: "user",
|
||
content: query
|
||
});
|
||
messages.value.push({
|
||
id: assistantId,
|
||
role: "assistant",
|
||
content: ASSISTANT_PENDING_TEXT,
|
||
loading: true
|
||
});
|
||
streamingMessageId.value = assistantId;
|
||
content.value = "";
|
||
scrollToBottom();
|
||
if (activeChatMode.value === "chat") {
|
||
await connectWebSocket();
|
||
await sendMessage({
|
||
query,
|
||
conversationId: activeConversationId.value
|
||
}, {
|
||
signal: abortController.signal
|
||
});
|
||
} else {
|
||
await sendModeMessage(query, assistantId, abortController.signal);
|
||
streamingMessageId.value = undefined;
|
||
loading.value = false;
|
||
stopping.value = false;
|
||
currentTaskId.value = undefined;
|
||
currentAbortController.value = null;
|
||
scrollToBottom();
|
||
}
|
||
} catch (e: any) {
|
||
if (isCanceledError(e)) {
|
||
return;
|
||
}
|
||
const target = streamingMessageId.value
|
||
? messages.value.find((item) => item.id === streamingMessageId.value)
|
||
: undefined;
|
||
if (target) {
|
||
target.loading = false;
|
||
target.content = "出错了,请稍后再试。";
|
||
}
|
||
streamingMessageId.value = undefined;
|
||
currentTaskId.value = undefined;
|
||
currentAbortController.value = null;
|
||
loading.value = false;
|
||
stopping.value = false;
|
||
disconnectWebSocket();
|
||
message.error(e?.message || "发送失败");
|
||
}
|
||
};
|
||
|
||
onMounted(async () => {
|
||
try {
|
||
if (!loginUser.value.userId) {
|
||
await userStore.fetchUserInfo();
|
||
}
|
||
await loadHistoryList();
|
||
} catch (e: any) {
|
||
message.error(e?.message || "初始化聊天页失败");
|
||
}
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
currentAbortController.value?.abort();
|
||
disconnectWebSocket();
|
||
});
|
||
</script>
|
||
|
||
<script lang="ts">
|
||
export default {
|
||
name: "ChatGPT"
|
||
};
|
||
</script>
|
||
|
||
<style lang="less" scoped>
|
||
:deep(.ele-body) {
|
||
padding: 0 !important;
|
||
background: transparent;
|
||
}
|
||
|
||
:deep(.markdown-body) {
|
||
background: transparent;
|
||
color: inherit;
|
||
font-size: 14px;
|
||
line-height: 1.75;
|
||
}
|
||
|
||
:deep(.markdown-body p) {
|
||
margin: 0 0 8px;
|
||
}
|
||
|
||
:deep(.markdown-body p:last-child) {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
:deep(.markdown-body ul) {
|
||
margin: 6px 0 8px;
|
||
padding-left: 18px;
|
||
}
|
||
|
||
.chat-page {
|
||
height: 100%;
|
||
min-height: 0;
|
||
display: flex;
|
||
overflow: hidden;
|
||
font-family: 'Alibaba PuHuiTi', 'HarmonyOS Sans SC', 'Noto Sans SC',
|
||
'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||
color: #3f2d25;
|
||
background: #fff9f6;
|
||
}
|
||
|
||
.chat-sidebar {
|
||
width: 320px;
|
||
background: #e8f4ff;
|
||
padding: 28px 24px 24px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
border-right: 1px solid #c7e8ff;
|
||
}
|
||
|
||
.brand-block {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.brand-logo {
|
||
width: 44px;
|
||
height: 44px;
|
||
border-radius: 12px;
|
||
background: #fff;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-shadow: 0 8px 16px rgba(21, 144, 255, 0.12);
|
||
}
|
||
|
||
.brand-logo img {
|
||
width: 34px;
|
||
height: 34px;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.brand-title {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: #1590ff;
|
||
}
|
||
|
||
.brand-subtitle {
|
||
margin-top: 2px;
|
||
font-size: 12px;
|
||
color: #5f7f9f;
|
||
}
|
||
|
||
.new-chat-btn {
|
||
margin-top: 22px;
|
||
height: 44px;
|
||
border-radius: 999px;
|
||
border: 1.5px solid #1590ff;
|
||
background: #fff;
|
||
color: #1590ff;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 10px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.new-chat-btn:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.plus-icon {
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 50%;
|
||
border: 1.5px solid #1590ff;
|
||
position: relative;
|
||
}
|
||
|
||
.plus-icon::before,
|
||
.plus-icon::after {
|
||
content: '';
|
||
position: absolute;
|
||
background: #1590ff;
|
||
}
|
||
|
||
.plus-icon::before {
|
||
width: 9px;
|
||
height: 1.6px;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
}
|
||
|
||
.plus-icon::after {
|
||
width: 1.6px;
|
||
height: 9px;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
}
|
||
|
||
.history-block {
|
||
margin-top: 32px;
|
||
height: calc(100vh - 260px);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
padding-right: 4px;
|
||
|
||
&::-webkit-scrollbar {
|
||
width: 4px;
|
||
}
|
||
|
||
&::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
&::-webkit-scrollbar-thumb {
|
||
background: rgba(21, 144, 255, 0.3);
|
||
border-radius: 999px;
|
||
}
|
||
|
||
&::-webkit-scrollbar-thumb:hover {
|
||
background: rgba(21, 144, 255, 0.6);
|
||
}
|
||
}
|
||
|
||
.history-label {
|
||
font-size: 12px;
|
||
color: #5f7f9f;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.history-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
border-radius: 12px;
|
||
transition: background-color 0.2s ease;
|
||
}
|
||
|
||
.history-item-main {
|
||
flex: 1;
|
||
min-width: 0;
|
||
text-align: left;
|
||
background: transparent;
|
||
border: none;
|
||
color: #1f2d3d;
|
||
font-size: 14px;
|
||
padding: 8px 0;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.history-item-main:disabled,
|
||
.history-delete-btn:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.history-item.is-active {
|
||
padding: 0 10px;
|
||
background: rgba(21, 144, 255, 0.08);
|
||
}
|
||
|
||
.history-item.is-active .history-item-main {
|
||
color: #1590ff;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.history-delete-btn {
|
||
width: 26px;
|
||
height: 26px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
background: transparent;
|
||
color: #6aaeff;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.history-delete-btn svg {
|
||
width: 16px;
|
||
height: 16px;
|
||
}
|
||
|
||
.history-delete-btn:hover {
|
||
color: #1590ff;
|
||
background: rgba(21, 144, 255, 0.08);
|
||
}
|
||
|
||
.history-empty {
|
||
font-size: 13px;
|
||
color: #5f7f9f;
|
||
}
|
||
|
||
.sidebar-footer {
|
||
margin-top: auto;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
flex-direction: column;
|
||
justify-content: flex-start;
|
||
gap: 12px;
|
||
}
|
||
|
||
.footer-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.user-name {
|
||
font-size: 14px;
|
||
color: #1590ff;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.admin-btn {
|
||
border-radius: 999px;
|
||
border: 1.5px solid #1590ff;
|
||
background: linear-gradient(135deg, #1590ff, #32bfff);
|
||
color: #fff;
|
||
padding: 4px 14px;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.logout-btn {
|
||
border-radius: 999px;
|
||
border: 1.5px solid #1590ff;
|
||
background: #fff;
|
||
color: #1590ff;
|
||
padding: 4px 16px;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.chat-main {
|
||
flex: 1;
|
||
min-height: 0;
|
||
position: relative;
|
||
padding: 30px 60px 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
background: linear-gradient(180deg, #f5fbff 0%, #e6f7ff 100%);
|
||
}
|
||
|
||
.chat-main::before {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
background: radial-gradient(620px 620px at 85% 40%,
|
||
rgba(21, 144, 255, 0.18) 0%,
|
||
rgba(21, 144, 255, 0) 65%),
|
||
radial-gradient(520px 520px at 70% 85%,
|
||
rgba(50, 191, 255, 0.16) 0%,
|
||
rgba(50, 191, 255, 0) 70%);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.chat-main-header {
|
||
flex-shrink: 0;
|
||
text-align: center;
|
||
color: #5f7f9f;
|
||
font-size: 15px;
|
||
letter-spacing: 1px;
|
||
margin-bottom: 24px;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
.chat-body {
|
||
flex: 1 1 0;
|
||
height: 0;
|
||
overflow-y: auto;
|
||
min-height: 0;
|
||
padding: 0 10px 24px 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 22px;
|
||
position: relative;
|
||
z-index: 1;
|
||
width: 800px;
|
||
margin: 0 auto;
|
||
overscroll-behavior: contain;
|
||
}
|
||
|
||
.chat-row {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.chat-row.is-user {
|
||
align-items: flex-end;
|
||
}
|
||
|
||
.chat-row.is-assistant {
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.chat-bubble {
|
||
max-width: 620px;
|
||
font-size: 14px;
|
||
color: #1f2d3d;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.chat-row.is-user .chat-bubble {
|
||
padding: 10px 20px;
|
||
border-radius: 999px;
|
||
border: 1.5px solid #1590ff;
|
||
color: #1590ff;
|
||
background: #fff;
|
||
font-weight: 600;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.chat-row.is-assistant .chat-bubble {
|
||
padding: 0;
|
||
border: none;
|
||
background: transparent;
|
||
}
|
||
|
||
.chat-bubble.is-pending {
|
||
color: #5f7f9f;
|
||
}
|
||
|
||
.assistant-streaming-text {
|
||
max-width: 640px;
|
||
color: #1f2d3d;
|
||
line-height: 1.8;
|
||
font-size: 14px;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.assistant-thinking {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
color: #5f7f9f;
|
||
font-size: 14px;
|
||
line-height: 1.8;
|
||
}
|
||
|
||
.thinking-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background: currentColor;
|
||
opacity: 0.75;
|
||
animation: thinkingPulse 1.2s ease-in-out infinite;
|
||
}
|
||
|
||
.assistant-markdown {
|
||
max-width: 640px;
|
||
}
|
||
|
||
.assistant-image-wrap {
|
||
max-width: 640px;
|
||
}
|
||
|
||
.assistant-image {
|
||
max-width: 100%;
|
||
}
|
||
|
||
.bubble-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
color: #1590ff;
|
||
}
|
||
|
||
.icon-btn {
|
||
width: 28px;
|
||
height: 28px;
|
||
border: none;
|
||
background: transparent;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: inherit;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.icon-btn svg {
|
||
width: 20px;
|
||
height: 20px;
|
||
}
|
||
|
||
.assistant-text {
|
||
max-width: 640px;
|
||
color: #1f2d3d;
|
||
line-height: 1.8;
|
||
font-size: 14px;
|
||
}
|
||
|
||
@keyframes thinkingPulse {
|
||
0%,
|
||
100% {
|
||
transform: scale(0.8);
|
||
opacity: 0.45;
|
||
}
|
||
50% {
|
||
transform: scale(1);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
.chat-empty {
|
||
margin: auto;
|
||
max-width: 520px;
|
||
text-align: center;
|
||
color: #5f7f9f;
|
||
font-size: 15px;
|
||
line-height: 1.9;
|
||
}
|
||
|
||
.assistant-text p {
|
||
margin: 0 0 10px;
|
||
}
|
||
|
||
.assistant-list {
|
||
display: grid;
|
||
gap: 6px;
|
||
margin-bottom: 10px;
|
||
color: #1f2d3d;
|
||
}
|
||
|
||
.assistant-article {
|
||
max-width: 820px;
|
||
color: #2f2a27;
|
||
line-height: 1.9;
|
||
font-size: 15px;
|
||
}
|
||
|
||
.assistant-article p {
|
||
margin: 0 0 14px;
|
||
}
|
||
|
||
.assistant-divider {
|
||
height: 2px;
|
||
background: #2d7ff9;
|
||
margin: 10px 0 18px;
|
||
}
|
||
|
||
.assistant-title {
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
color: #1f1f1f;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.assistant-subtitle {
|
||
color: #5e5e5e;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.assistant-section {
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.assistant-bullets {
|
||
margin: 0;
|
||
padding-left: 18px;
|
||
color: #1f1f1f;
|
||
}
|
||
|
||
.assistant-bullets li {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.assistant-bullets li::marker {
|
||
color: #2d7ff9;
|
||
}
|
||
|
||
.assistant-bullets .assistant-alpha {
|
||
list-style: none;
|
||
padding-left: 0;
|
||
margin-left: -18px;
|
||
}
|
||
|
||
.assistant-input {
|
||
flex-shrink: 0;
|
||
z-index: 1;
|
||
margin-top: auto;
|
||
padding: 16px 0 24px;
|
||
}
|
||
|
||
.assistant-title {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 10px;
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: #1590ff;
|
||
}
|
||
|
||
.assistant-title img {
|
||
width: 34px;
|
||
height: 34px;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.input-shell {
|
||
margin: 14px auto 0;
|
||
border-radius: 18px;
|
||
border: 1.5px solid #1590ff;
|
||
background: #fff;
|
||
padding: 16px 16px 12px;
|
||
width: 800px;
|
||
}
|
||
|
||
.ratio-panel {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-bottom: 12px;
|
||
padding-bottom: 12px;
|
||
border-bottom: 1px solid rgba(21, 144, 255, 0.12);
|
||
}
|
||
|
||
.ratio-label {
|
||
color: #5f7f9f;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.ratio-option {
|
||
min-width: 48px;
|
||
height: 28px;
|
||
border: 1px solid rgba(21, 144, 255, 0.28);
|
||
border-radius: 999px;
|
||
background: #f5fbff;
|
||
color: #1590ff;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.ratio-option.is-active {
|
||
border-color: #1590ff;
|
||
background: #1590ff;
|
||
color: #fff;
|
||
}
|
||
|
||
.ratio-option:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.input-shell textarea {
|
||
width: 100%;
|
||
height: 120px;
|
||
border: none;
|
||
outline: none;
|
||
resize: none;
|
||
font-size: 14px;
|
||
color: #6a5b50;
|
||
background: transparent;
|
||
}
|
||
|
||
.input-shell textarea::placeholder {
|
||
color: #c5b1a6;
|
||
}
|
||
|
||
.input-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
color: #1590ff;
|
||
}
|
||
|
||
.mode-tags {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.mode-tag {
|
||
border: 1px solid rgba(21, 144, 255, 0.28);
|
||
background: #f5fbff;
|
||
color: #1590ff;
|
||
border-radius: 999px;
|
||
padding: 4px 10px;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.mode-tag.is-active {
|
||
background: #1590ff;
|
||
color: #fff;
|
||
border-color: #1590ff;
|
||
}
|
||
|
||
.mode-tag:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.send-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.send-btn {
|
||
border-radius: 999px;
|
||
border: none;
|
||
background: #1590ff;
|
||
color: #fff;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
padding: 6px 18px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.send-btn.is-stop {
|
||
background: #f05252;
|
||
}
|
||
|
||
.send-btn:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
@media screen and (max-width: 1200px) {
|
||
.chat-page {
|
||
flex-direction: column;
|
||
height: 100%;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.chat-sidebar {
|
||
width: 100%;
|
||
border-right: none;
|
||
border-bottom: 1px solid #c7e8ff;
|
||
}
|
||
|
||
.chat-main {
|
||
padding: 24px 24px 0;
|
||
}
|
||
}
|
||
</style>
|