Files
tuanwei-admin/src/views/oa/chatgpt/index.vue
T

2379 lines
69 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 === 'writing'" class="ratio-panel">
<span class="ratio-label">文体</span>
<button
v-for="item in WRITING_DOC_TYPE_OPTIONS"
:key="item.value"
class="ratio-option"
type="button"
:class="{ 'is-active': writingDocType === item.value }"
:disabled="loading"
@click="writingDocType = item.value"
>
{{ item.label }}
</button>
<template v-if="writingTemplateMode">
<span class="ratio-label">字数</span>
<button
v-for="item in WRITING_WORD_COUNT_OPTIONS"
:key="item.value"
class="ratio-option"
type="button"
:class="{ 'is-active': writingWordCount === item.value }"
:disabled="loading"
@click="writingWordCount = item.value"
>
{{ item.label }}
</button>
</template>
</div>
<!--
写作参数:只有选了文体(= 走「内容创作与宣传助手」)才有意义。
自由创作时「AI 创意助手」只吃 user_content,这块整块隐藏。
默认收起:写作模式下多数人只写一段话,参数是补充项。
-->
<div
v-if="activeChatMode === 'writing' && writingTemplateMode"
class="param-block"
:class="{ 'is-open': writingParamsOpen }"
>
<div class="param-head">
<span class="param-title">写作参数</span>
<span class="param-hint">活动信息可选填,填了会一并提交</span>
<span v-if="writingParamCount" class="param-count">
已填 {{ writingParamCount }} 项
</span>
<button
class="param-toggle"
type="button"
:disabled="loading"
@click="writingParamsOpen = !writingParamsOpen"
>
{{ writingParamsOpen ? "收起" : "展开" }}
<span class="param-caret" :class="{ 'is-open': writingParamsOpen }">⌄</span>
</button>
</div>
<div v-if="writingParamsOpen" class="param-body">
<div class="param-row">
<span class="param-field-label">活动类型</span>
<button
class="ratio-option"
type="button"
:class="{ 'is-active': !writingParams.eventType }"
:disabled="loading"
@click="writingParams.eventType = ''"
>
不限
</button>
<button
v-for="item in WRITING_EVENT_TYPE_OPTIONS"
:key="item"
class="ratio-option"
type="button"
:class="{ 'is-active': writingParams.eventType === item }"
:disabled="loading"
@click="writingParams.eventType = item"
>
{{ item }}
</button>
</div>
<div class="param-grid">
<label class="param-field">
<span>活动主题</span>
<input
v-model="writingParams.theme"
type="text"
:disabled="loading"
placeholder="例如:春季植树志愿服务"
/>
</label>
<label class="param-field">
<span>活动时间</span>
<input
v-model="writingParams.time"
type="text"
:disabled="loading"
placeholder="例如:2026年4月8日下午"
/>
</label>
<label class="param-field">
<span>活动地点</span>
<input
v-model="writingParams.location"
type="text"
:disabled="loading"
placeholder="例如:校史馆广场"
/>
</label>
<label class="param-field">
<span>参与人员</span>
<input
v-model="writingParams.participants"
type="text"
:disabled="loading"
placeholder="例如:青年志愿者代表共120人"
/>
</label>
</div>
<div class="param-foot">
下方输入框里的内容会作为「亮点内容」提交;活动主题留空时由上游自己拟标题。
</div>
</div>
</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="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, reactive, 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 { runCreativeAssistantStream, runManuscriptGenStream, streamChatMessage } from "@/utils/ai-stream";
import {
WRITING_DOC_TYPE_FREE,
WRITING_DOC_TYPE_OPTIONS,
WRITING_EVENT_TYPE_OPTIONS,
WRITING_WORD_COUNT_OPTIONS
} from "./writing-params";
import { attachHeartbeat, buildWebSocketUrl } from "@/utils/chat-socket";
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 { resolveDisplayText, isFailedPayload, pickDisplayable, FAILED_TEXT } from "@/utils/ai-result";
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");
// 写作模式的显式选择:默认 auto = 不下发这两个字段的值,交给上游 Dify 识别
const writingDocType = ref(WRITING_DOC_TYPE_FREE);
const writingWordCount = ref("auto");
// 「写作参数」面板:只有选了文体(走「内容创作与宣传助手」)时才出现。
// 这些字段都是上游开始节点的可选变量,留空就不下发(后端 buildManuscriptInputs 会跳过空串)。
const writingParamsOpen = ref(false);
const writingParams = reactive({
// 空串 = 不限,不替用户猜活动类型;选了才下发 event_type
eventType: "",
theme: "",
time: "",
location: "",
participants: ""
});
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(() => {
if (loading.value) {
return false;
}
if (content.value.trim().length > 0) {
return true;
}
// 选了文体并填了写作参数时,正文可以先空着:亮点由参数与上游提示词补
return (
activeChatMode.value === "writing" &&
writingTemplateMode.value &&
writingParamCount.value > 0
);
});
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" }
];
// 图片比例选择器已撤掉:上游创意助手开始节点没有比例变量(实测只有 user_content /
// user_files),前端只能把它拼进 user_content —— 那是替上游写 prompt。
// 要恢复这个功能,先在上游开始节点加变量,再按变量契约接上来。
// user_files 本身也已不再上传:开始节点挂着这个变量,但没有任何下游节点消费它(issue 16)。
/** 选中文体 = 套模板走「内容创作与宣传助手」;否则是自由创作走「AI 创意助手」 */
const writingTemplateMode = computed(
() => writingDocType.value !== WRITING_DOC_TYPE_FREE
);
/**
* 写作参数里用户真正填了几项(不含活动类型:它默认「不限」,算进去会让面板
* 一打开就显示「已填 1 项」,反而误导)。
*/
const writingParamCount = computed(
() =>
[writingParams.theme, writingParams.time, writingParams.location, writingParams.participants].filter(
(value) => String(value || "").trim().length > 0
).length
);
const inputPlaceholder = computed(() => {
if (activeChatMode.value === "writing") {
if (writingTemplateMode.value) {
return "写清活动亮点、流程、成果等关键信息,会作为「亮点内容」提交;标题、地点等可在上方「写作参数」里补";
}
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 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 = () => {
attachHeartbeat(socket);
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 = "";
};
// 原本这里有一个 stringifyUnknown(JSON.stringify 整包兜底),已移除:
// 它会把 conversationId / taskId / workflowRunId 等内部标识符渲染给用户。
// 现在统一走 @/utils/ai-result 的 resolveDisplayText(见 issue 10)。
const pickString = (...values: unknown[]) => {
for (const value of values) {
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return "";
};
const buildWritingPayload = (query: string) => {
// 两条路,都不解析自然语言(2026-10-21 改造):
// - 自由创作:原话整段作为 userContent,后端见 docType 为空就转「AI 创意助手」;
// - 选了文体:原话整段作为 highlights,「写作参数」里填了的活动类型 / 主题 / 时间 /
// 地点 / 参与人员原样下发,没填的不带。doc_type / Number 是上游提示词的
// **主控指令**,本地猜会猜错(见 issue 13)。
const content = String(query || "").trim();
if (writingDocType.value === WRITING_DOC_TYPE_FREE) {
return { docType: "", userContent: content };
}
const payload: Record<string, unknown> = {
docType: writingDocType.value,
highlights: content,
files: []
};
const optionalFields: [string, string][] = [
["eventType", writingParams.eventType],
["theme", writingParams.theme],
["time", writingParams.time],
["location", writingParams.location],
["participants", writingParams.participants]
];
optionalFields.forEach(([key, value]) => {
const text = String(value || "").trim();
if (text) {
payload[key] = text;
}
});
if (writingWordCount.value !== "auto") {
payload.number = Number(writingWordCount.value);
}
return payload;
};
/** 没写正文、只填了写作参数时,聊天气泡上给一句说明,不显示空消息 */
const writingRequestSummary = () => {
const parts = [
writingDocType.value,
writingParams.theme || writingParams.location || writingParams.time
].filter((item) => String(item || "").trim().length > 0);
return `(按写作参数生成:${parts.join(" · ")})`;
};
const formatWritingResult = (payload: unknown) => {
if (typeof payload === "string") {
return payload;
}
// 失败态优先:此时 answer 里是失败文案(后端已带 status=failed)。
// 不先判失败就会渲染成「主题 + 生成失败,请稍后重试」(见 issue 10)。
if (isFailedPayload(payload)) {
return FAILED_TEXT;
}
const data = (payload || {}) as Record<string, any>;
const title = pickString(
data.title,
data.theme,
data.name,
data.subject,
data.data?.title
);
// 用 pickDisplayable 而非 pickString:后者会把裸 UUID 当正文
const body = pickDisplayable(
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 ? `${title}\n\n${body}` : body;
}
// 不再 stringifyUnknown(payload):那会把 conversationId / taskId 这类
// 内部标识符整包渲染给用户。
return resolveDisplayText({
payload,
scope: "oa/chatgpt.formatWritingResult",
candidates: []
});
};
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")}`;
};
/**
* 清洗创意助手的 video_message / photo_message。
*
* 这两个字段在上游其实是 **LLM 节点的原始输出**,实测两种形态都不是给用户看的文案:
* ① ` thinking…` 开头的思维链(整段英文推理过程);
* ② `{"cover_prompt":"…","width":1024,"height":1024}` 生图提示词中间产物。
* 直接展示会让聊天气泡里出现一大段英文思考过程或裸 JSON(2026-10-13 实测截图为证)。
* 这里剥离思维链、丢弃提示词 JSON,只有剩下的确实是自然语言时才展示。
*/
const sanitizeCreativeMessage = (value: string) => {
const text = parseAssistantContent(value).content.trim();
if (!text) {
return "";
}
if (text.startsWith("{") && /"(cover_prompt|width|height)"/.test(text)) {
return "";
}
return text;
};
const formatCreativeResult = (payload: unknown) => {
// 失败态优先:此时 outputs 为空、answer 是失败文案,不判失败就会落到
// 整包兜底把内部标识符渲染出去(见 issue 10)。
if (isFailedPayload(payload)) {
return FAILED_TEXT;
}
const outputs = getCreativeOutputs(payload as any);
const script = pickString(outputs.Script_result, outputs.script, outputs.text);
const videoMessage = sanitizeCreativeMessage(pickString(outputs.video_message));
const photoMessage = sanitizeCreativeMessage(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");
// 不再 stringifyUnknown(payload):整包会带出 taskId / workflowRunId 等内部标识符。
// 有可读内容就给内容,否则给通用兜底文案(原报文进日志)。
return resolveDisplayText({
payload,
scope: "oa/chatgpt.formatCreativeResult",
candidates: [text]
});
};
/**
* 图片模式入参。
*
* <p>只下发两样东西:用户原话 {@code user_content},以及 {@code mode=image}。
* 参考素材({@code user_files})已下线:上游出图工具只做文生图,开始节点虽然挂着
* 这个 file-list 变量,但没有任何节点消费它(见 issue 16),传上去只是空等上传。
*
* <p>{@code mode} 不写进 user_content,由后端在 image 模式下统一包装成
* 「生成图片」的意图声明 —— 上游「问题分类器」因此不会把画面描述判成文案分支。
*/
const buildImagePayload = (query: string) => {
return {
user_content: query,
mode: "image"
};
};
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") {
// 稿件生成同样会被链路的 60s 空闲超时掐掉,改走流式(正文边推边显示)
const result = await runManuscriptGenStream(buildWritingPayload(query), signal);
if (typeof (result as any)?.taskId === "string") {
currentTaskId.value = (result as any).taskId;
}
setResult(formatWritingResult(result));
return;
}
if (activeChatMode.value === "image") {
// 出图中途零字节,普通 POST 会被网关按 60s 空闲超时掐成 504,改走流式
const result = await runCreativeAssistantStream(buildImagePayload(query), 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();
// 只填了写作参数、没写正文时,气泡上仍要有一句话,否则是一条空消息
const displayQuery =
query ||
(activeChatMode.value === "writing" && writingTemplateMode.value
? writingRequestSummary()
: query);
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: displayQuery
});
messages.value.push({
id: assistantId,
role: "assistant",
content: ASSISTANT_PENDING_TEXT,
loading: true
});
streamingMessageId.value = assistantId;
content.value = "";
scrollToBottom();
if (activeChatMode.value === "chat") {
// 先试 WebSocket;生产网关在 /api 前丢 Upgrade 头,握手必然失败,
// 这时落到 HTTP 流式端点,帧内容与 WS 完全一致,解析逻辑不用改
const payload = {
query,
conversationId: activeConversationId.value
};
const viaSocket = await connectWebSocket()
.then(() => true)
.catch(() => false);
if (viaSocket) {
await sendMessage(payload, { signal: abortController.signal });
} else {
await streamChatMessage(
payload as Record<string, unknown>,
(raw) => void handleSocketMessage(raw),
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;
}
/* 「写作参数」面板:只在选了文体时出现,默认收起,避免挤占写作输入区 */
.param-block {
margin-bottom: 12px;
padding: 10px 12px;
border: 1px dashed rgba(21, 144, 255, 0.28);
border-radius: 12px;
background: #f9fcff;
transition: border-color 0.2s ease, background 0.2s ease;
}
.param-block.is-open {
border-style: solid;
background: #f5fbff;
}
.param-head {
display: flex;
align-items: center;
gap: 8px;
min-height: 24px;
}
.param-title {
color: #1590ff;
font-size: 13px;
font-weight: 700;
}
.param-hint {
color: #8aa4bd;
font-size: 12px;
}
.param-count {
padding: 1px 8px;
border-radius: 999px;
background: rgba(21, 144, 255, 0.12);
color: #1590ff;
font-size: 12px;
font-weight: 600;
}
.param-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: auto;
padding: 2px 10px;
border: none;
border-radius: 999px;
background: transparent;
color: #1590ff;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.param-toggle:hover:not(:disabled) {
background: rgba(21, 144, 255, 0.1);
}
.param-toggle:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.param-caret {
display: inline-block;
/* 收起时箭头朝下(提示「还有内容在下面」),展开后翻上来 */
transform: rotate(0deg);
transition: transform 0.2s ease;
}
.param-caret.is-open {
transform: rotate(180deg);
}
.param-body {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(21, 144, 255, 0.12);
}
.param-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.param-field-label {
color: #5f7f9f;
font-size: 13px;
font-weight: 600;
}
.param-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
}
.param-field {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.param-field span {
flex: 0 0 auto;
color: #5f7f9f;
font-size: 12px;
font-weight: 600;
}
.param-field input {
flex: 1 1 auto;
min-width: 0;
height: 30px;
padding: 0 10px;
border: 1px solid rgba(21, 144, 255, 0.24);
border-radius: 8px;
background: #fff;
color: #33475b;
font-size: 13px;
outline: none;
}
.param-field input::placeholder {
color: #b6c9da;
}
.param-field input:focus {
border-color: #1590ff;
box-shadow: 0 0 0 2px rgba(21, 144, 255, 0.12);
}
.param-field input:disabled {
background: #f4f7fa;
cursor: not-allowed;
}
.param-foot {
margin-top: 10px;
color: #8aa4bd;
font-size: 12px;
line-height: 1.5;
}
@media (max-width: 820px) {
.param-grid {
grid-template-columns: minmax(0, 1fr);
}
}
.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>