forked from b2894lxlx/tuanwei-admin
2379 lines
69 KiB
Vue
2379 lines
69 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 === '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 ``;
|
||
})
|
||
.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>
|