Files
tuanwei-admin/src/views/oa/chatgpt/index.vue
T
2026-07-20 11:50:47 +08:00

2032 lines
56 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="chat-page">
<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 `![](${url})`;
})
.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>