Files
tuanwei-admin/src/views/oa/chatgpt/index.vue
T
weicw-dshsa 8ca7a00afb feat(ai): 图片模式接入 file-list 参考素材上传(issue 14)
上游「AI 创意助手」开始节点有 file-list 变量 user_files(最多 5 个,允许
image/video/document/audio),但 web 端图片模式此前完全没有附件入口 —— 发送键左侧
那两个图标按钮没有绑定任何事件,是纯装饰。

- 聊天页图片模式新增「参考素材」区:添加附件(隐藏 input[type=file])、已选列表可
  逐个移除、上限 5 个(对齐上游 max_length),按 MIME/扩展名判定
  image/video/audio/document,发送时组装 file-list 契约
  {transfer_method:"local_file", type, upload_file_id, url:""};上传中禁止发送
- uploadAiFile(file, app?) 支持应用代号;gxmu/creativeAssistant 表单页改传
  creativeAssistant —— 该页用同一个接口上传却不指定应用,附件必然报
  Invalid upload file(既有缺陷,本次一并修掉)
- CreativeAssistantUploadFile.upload_file_id 由 number 改为 string | number:
  它实际是 Dify /files/upload 返回的 uuid 字符串,原声明与实现不符
- formatCreativeResult 清洗 video_message / photo_message:剥离  thinking 思维链、
  丢弃 {"cover_prompt":…,"width":…} 中间产物 JSON,不再把上游 LLM 的原始输出
  当正文展示(截图实测过:气泡里出现整段英文思考过程或裸 JSON)
- 删除发送键左侧两个无事件的装饰性图标按钮(.icon-btn 样式仍被消息操作按钮使用,保留)
2026-10-13 19:57:57 +08:00

2279 lines
65 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
v-if="showAdminEntry"
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>
<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>
</div>
<div v-if="activeChatMode === 'image'" class="ratio-panel">
<span class="ratio-label">图片比例</span>
<button
v-for="item in imageRatioOptions"
:key="item.value"
class="ratio-option"
type="button"
:class="{ 'is-active': imageRatio === item.value }"
:disabled="loading"
@click="imageRatio = item.value"
>
{{ item.label }}
</button>
</div>
<div v-if="activeChatMode === 'image'" class="ratio-panel attach-panel">
<span class="ratio-label">参考素材</span>
<button
class="ratio-option"
type="button"
:disabled="loading || uploadingFiles || imageFiles.length >= IMAGE_FILE_LIMIT"
@click="pickImageFiles"
>
{{ uploadingFiles ? "上传中..." : "添加附件" }}
</button>
<span class="attach-hint">
最多 {{ IMAGE_FILE_LIMIT }} 个,支持图片 / 视频 / 音频 / 文档
</span>
<ul v-if="imageFiles.length" class="attach-list">
<li
v-for="(item, index) in imageFiles"
:key="item.id"
class="attach-item"
>
<span class="attach-name" :title="item.name">{{ item.name }}</span>
<button
class="attach-remove"
type="button"
:disabled="loading"
@click="removeImageFile(index)"
>
移除
</button>
</li>
</ul>
<input
ref="fileInputRef"
class="attach-input"
type="file"
multiple
@change="onImageFilesPicked"
/>
</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, ref } from "vue";
import { useRouter } from "vue-router";
import "github-markdown-css/github-markdown-light.css";
import gfm from "@bytemd/plugin-gfm";
import zh_HansGfm from "@bytemd/plugin-gfm/locales/zh_Hans.json";
import highlight from "@bytemd/plugin-highlight-ssr";
import "highlight.js/styles/default.css";
import { message } from "ant-design-vue";
import ByteMdViewer from "@/components/ByteMdViewer/index.vue";
import { listAiChatHistory } from "@/api/ai/aiChatHistory";
import type { AiChatHistory } from "@/api/ai/aiChatHistory/model";
import { listAiChatList, removeAiChatList } from "@/api/ai/aiChatList";
import type { AiChatList } from "@/api/ai/aiChatList/model";
import { sendMessage, stopMessage } from "@/api/oa/chatgpt";
import { manuscriptGen } from "@/api/ai/manuscriptGen";
import {
parseWritingRequest,
WRITING_DOC_TYPE_OPTIONS,
WRITING_WORD_COUNT_OPTIONS
} from "./writing-params";
import type { WritingOverrides } from "./writing-params";
import { creativeAssistant, uploadAiFile } from "@/api/ai/creativeAssistant";
import type { CreativeAssistantUploadFile } from "@/api/ai/creativeAssistant/model";
import { API_BASE_URL } from "@/config/setting";
import type { Menu } from "@/api/system/menu/model";
import { useUserStore } from "@/store/modules/user";
import { logout } from "@/utils/page-tab-util";
import { copyText } from "@/utils/common";
import { resolveDisplayText, isFailedPayload, pickDisplayable, FAILED_TEXT } from "@/utils/ai-result";
import { getToken } from "@/utils/token-util";
import { isOnlyStudent } from "@/utils/permission";
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[];
}
/** 图片模式的参考素材(上传成功后保留 Dify 返回的 file id 与应用内类型) */
interface CreativeAttachment {
id: string;
name: string;
type: CreativeAssistantUploadFile["type"];
}
interface AuthorityTreeItem extends Menu {
children?: AuthorityTreeItem[];
}
const plugins = ref([
gfm({
locale: zh_HansGfm
}),
highlight()
]);
const ASSISTANT_PENDING_TEXT = "正在思考中...";
const THINK_OPEN_TAG = "<think>";
const THINK_CLOSE_TAG = "</think>";
const loading = ref(false);
const stopping = ref(false);
const content = ref("");
const activeChatMode = ref<ChatMode>("chat");
const imageRatio = ref("1:1");
/**
* 图片模式的参考素材,对应 Dify「AI 创意助手」开始节点的 file-list 变量 `user_files`。
*
* 两个硬约束(都来自上游 `/parameters` 实测):
* 1. `max_length = 5`,最多 5 个;
* 2. **必须用创意助手自己的应用凭证上传** —— Dify 的 /files/upload 按应用鉴权,
* 用别的应用(如 chat)的 Key 上传,工作流引用该 id 会直接报 `Invalid upload file`。
*/
const imageFiles = ref<CreativeAttachment[]>([]);
const uploadingFiles = ref(false);
const fileInputRef = ref<HTMLInputElement>();
// 写作模式的显式选择:默认 auto,即由 writing-params 从用户输入里自动识别
const writingDocType = ref("auto");
const writingWordCount = ref("auto");
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 showAdminEntry = computed(() => !isOnlyStudent());
const canSend = computed(
() => content.value.trim().length > 0 && !loading.value && !uploadingFiles.value
);
const isCanceledError = (error: any) =>
error?.code === "ERR_CANCELED" ||
error?.name === "CanceledError" ||
error?.message === "canceled";
const chatModeOptions: { label: string; value: ChatMode }[] = [
{ label: "聊天", value: "chat" },
{ label: "写作", value: "writing" },
{ label: "图片", value: "image" }
];
const imageRatioOptions = [
{ label: "1:1", value: "1:1" },
{ label: "4:3", value: "4:3" },
{ label: "3:4", value: "3:4" },
{ label: "16:9", value: "16:9" },
{ label: "9:16", value: "9:16" }
];
/** 参考素材上限,与 Dify 开始节点 user_files 的 max_length 一致 */
const IMAGE_FILE_LIMIT = 5;
const IMAGE_FILE_EXTS = ["PNG", "JPG", "JPEG", "GIF", "WEBP", "BMP", "SVG"];
const VIDEO_FILE_EXTS = ["MP4", "MOV", "MPEG", "MPGA", "AVI", "M4V"];
const AUDIO_FILE_EXTS = ["MP3", "M4A", "WAV", "AMR", "AAC", "OGG", "WEBM"];
const inputPlaceholder = computed(() => {
if (activeChatMode.value === "writing") {
return "直接描述需求即可,例如:帮我写一篇300字关于迎新晚会的推文,风格活泼,面向大一新生(也可在下方直接指定文体和字数)";
}
if (activeChatMode.value === "image") {
return "请输入图片生成需求,例如校园活动海报、公众号头图、志愿服务插画,并选择图片比例";
}
return "说说你想问的问题";
});
const assistantTitle = computed(() => {
if (activeChatMode.value === "writing") {
return "AI 写作助手";
}
if (activeChatMode.value === "image") {
return "AI 图片生成助手";
}
return "团务智能问答助手";
});
const chatHeaderText = computed(() => {
if (loading.value) {
return wsConnected.value ? "AI助手在线答疑" : "AI助手连接中...";
}
return "AI助手待命中";
});
const activeHistoryKey = computed(() => {
if (activeChatId.value) {
return `chat-${activeChatId.value}`;
}
if (activeConversationId.value) {
return `conversation-${activeConversationId.value}`;
}
return "";
});
const historySections = computed<HistorySection[]>(() => {
const groupMap = new Map<string, AiChatList[]>();
[...historyList.value]
.sort((a, b) => {
const left = dayjs(b.updateTime || b.createTime || 0).valueOf();
const right = dayjs(a.updateTime || a.createTime || 0).valueOf();
return left - right;
})
.forEach((item) => {
const label = getHistoryDateLabel(item.createTime || item.updateTime);
if (!groupMap.has(label)) {
groupMap.set(label, []);
}
groupMap.get(label)?.push(item);
});
return Array.from(groupMap.entries()).map(([label, items]) => ({
label,
items
}));
});
const isUserMessage = (item: ChatMessageItem) => item.role === "user";
const createMessageId = (prefix: string) =>
`${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const parseAssistantContent = (value: string) => {
if (!value || value === ASSISTANT_PENDING_TEXT) {
return {
content: "",
thinking: value === ASSISTANT_PENDING_TEXT
};
}
let cursor = 0;
let content = "";
let thinking = false;
while (cursor < value.length) {
if (!thinking) {
const openIndex = value.indexOf(THINK_OPEN_TAG, cursor);
const closeIndex = value.indexOf(THINK_CLOSE_TAG, cursor);
if (closeIndex !== -1 && (openIndex === -1 || closeIndex < openIndex)) {
cursor = closeIndex + THINK_CLOSE_TAG.length;
continue;
}
if (openIndex === -1) {
content += value.slice(cursor);
break;
}
content += value.slice(cursor, openIndex);
cursor = openIndex + THINK_OPEN_TAG.length;
thinking = true;
continue;
}
const closeIndex = value.indexOf(THINK_CLOSE_TAG, cursor);
if (closeIndex === -1) {
break;
}
cursor = closeIndex + THINK_CLOSE_TAG.length;
thinking = false;
}
return {
content,
thinking
};
};
const scrollToBottom = () => {
nextTick(() => {
const el = chatBodyRef.value;
if (el) {
el.scrollTop = el.scrollHeight;
}
});
};
const fillContent = (value: string) => {
content.value = value;
};
const getAssistantDisplayContent = (value: string) =>
parseAssistantContent(value).content;
const hasAssistantDisplayContent = (value: string) =>
getAssistantDisplayContent(value).trim().length > 0;
const getAssistantImageUrl = (value: string) => {
const content = getAssistantDisplayContent(value).trim();
if (!content) {
return "";
}
if (/^(https?:\/\/|data:image\/)/i.test(content) && /\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/i.test(content.split("\n")[0]) || /^data:image\//i.test(content)) {
return content;
}
const markdownImageMatch = content.match(/^!\[[^\]]*]\((.+?)\)$/);
if (markdownImageMatch?.[1]) {
return markdownImageMatch[1];
}
return "";
};
const shouldShowAssistantThinking = (item: ChatMessageItem) => {
if (item.content === ASSISTANT_PENDING_TEXT) {
return true;
}
if (!item.loading) {
return false;
}
return parseAssistantContent(item.content).thinking;
};
const getMessageCopyContent = (item: ChatMessageItem) =>
isUserMessage(item)
? item.content || ""
: getAssistantDisplayContent(item.content).trim();
const getHistoryKey = (item: AiChatList) => {
if (item.id) {
return `chat-${item.id}`;
}
return `conversation-${
item.conversationId || item.createTime || Math.random()
}`;
};
const getHistoryTitle = (item: AiChatList) => {
const title = item.title?.trim();
if (title) {
return title;
}
if (item.conversationId) {
return `会话 ${item.conversationId.slice(0, 8)}`;
}
return "未命名对话";
};
const getHistoryDateLabel = (value?: string) => {
if (!value) {
return "更早";
}
const date = dayjs(value);
if (date.isSame(dayjs(), "day")) {
return "今天";
}
if (date.isSame(dayjs().subtract(1, "day"), "day")) {
return "昨天";
}
return date.format("YYYY年M月D日");
};
const sortAuthorityTree = (list: AuthorityTreeItem[]) => {
return [...list]
.sort((a, b) => (a.sortNumber ?? Number.MAX_SAFE_INTEGER) - (b.sortNumber ?? Number.MAX_SAFE_INTEGER))
.map((item) => ({
...item,
children: item.children?.length ? sortAuthorityTree(item.children) : []
}));
};
const buildAuthorityTree = (authorities?: Menu[]) => {
const menuList = (authorities ?? [])
.filter((item) => item.menuType !== 1 && item.menuId != null)
.map((item) => ({
...item,
children: [] as AuthorityTreeItem[]
}));
const menuMap = new Map<number, AuthorityTreeItem>();
menuList.forEach((item) => {
if (item.menuId != null) {
menuMap.set(item.menuId, item);
}
});
const tree: AuthorityTreeItem[] = [];
menuList.forEach((item) => {
const parentId = item.parentId ?? 0;
if (parentId === 0) {
tree.push(item);
return;
}
const parent = menuMap.get(parentId);
if (parent) {
parent.children?.push(item);
} else {
tree.push(item);
}
});
return sortAuthorityTree(tree);
};
const findFirstChildMenuPath = (menus: AuthorityTreeItem[]) => {
for (const item of menus) {
if (!item.children?.length) {
continue;
}
for (const child of item.children) {
if (child.path) {
return child.path;
}
if (child.children?.length) {
const descendantPath = findFirstChildMenuPath([child]);
if (descendantPath) {
return descendantPath;
}
}
}
}
return "";
};
const buildWebSocketUrl = (userId: number) => {
if (/^https?:\/\//.test(API_BASE_URL)) {
const url = new URL(API_BASE_URL);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
url.pathname = url.pathname.replace(/\/api\/?$/, "");
return `${url.toString().replace(/\/$/, "")}/chat/${userId}`;
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${window.location.host}/chat/${userId}`;
};
const formatHistoryMessages = (list: AiChatHistory[]) => {
const result: ChatMessageItem[] = [];
[...list]
.sort(
(a, b) =>
dayjs(a.createTime || 0).valueOf() -
dayjs(b.createTime || 0).valueOf()
)
.forEach((item) => {
if (item.content) {
result.push({
id: createMessageId("user"),
role: "user",
content: item.content
});
}
if (item.reply) {
result.push({
id: createMessageId("assistant"),
role: "assistant",
content: item.reply
});
}
});
return result;
};
const syncActiveChat = () => {
if (!activeConversationId.value) {
return;
}
const current = historyList.value.find(
(item) => item.conversationId === activeConversationId.value
);
if (current?.id) {
activeChatId.value = current.id;
}
};
const appendAssistantChunk = (answer: string, conversationId?: string) => {
if (conversationId) {
activeConversationId.value = conversationId;
}
if (!streamingMessageId.value) {
const messageId = createMessageId("assistant");
streamingMessageId.value = messageId;
messages.value.push({
id: messageId,
role: "assistant",
content: answer,
loading: true
});
scrollToBottom();
return;
}
const target = messages.value.find(
(item) => item.id === streamingMessageId.value
);
if (!target) {
return;
}
target.content =
target.content === ASSISTANT_PENDING_TEXT
? answer
: `${target.content}${answer}`;
scrollToBottom();
};
const finishAssistantMessage = async (conversationId?: string) => {
if (conversationId) {
activeConversationId.value = conversationId;
}
const target = messages.value.find(
(item) => item.id === streamingMessageId.value
);
if (target) {
target.loading = false;
if (target.content === ASSISTANT_PENDING_TEXT) {
target.content = "";
}
}
streamingMessageId.value = undefined;
currentTaskId.value = undefined;
currentAbortController.value = null;
stopping.value = false;
loading.value = false;
await loadHistoryList(false);
syncActiveChat();
};
const markCurrentMessageStopped = () => {
const target = messages.value.find(
(item) => item.id === streamingMessageId.value
);
if (target) {
target.loading = false;
const visibleContent = getAssistantDisplayContent(target.content).trim();
target.content = visibleContent
? `${visibleContent}\n\n(已停止生成)`
: "已停止生成。";
}
streamingMessageId.value = undefined;
currentTaskId.value = undefined;
currentAbortController.value = null;
loading.value = false;
stopping.value = false;
disconnectWebSocket();
scrollToBottom();
};
const disconnectWebSocket = () => {
wsConnectPromiseRef.value = null;
wsConnected.value = false;
const socket = webSocketRef.value;
webSocketRef.value = null;
if (
socket &&
(socket.readyState === WebSocket.OPEN ||
socket.readyState === WebSocket.CONNECTING)
) {
socket.close();
}
};
const handleSocketMessage = async (raw: string) => {
if (!raw) {
return;
}
try {
const data = JSON.parse(raw);
if (typeof data?.taskId === "string" && data.taskId && data.taskId !== "0") {
currentTaskId.value = data.taskId;
}
if (data?.answer === "__END__") {
await finishAssistantMessage(data.conversationId);
disconnectWebSocket();
return;
}
if (data?.loading === "true") {
return;
}
const notice = data?.notice || data?.answer || "";
if (notice) {
appendAssistantChunk(notice, data.conversationId);
}
} catch (e) {
console.warn("websocket message parse failed", e);
}
};
const connectWebSocket = async () => {
const userId = loginUser.value.userId;
if (!userId) {
return Promise.reject(new Error("未获取到当前登录用户"));
}
const currentSocket = webSocketRef.value;
if (currentSocket?.readyState === WebSocket.OPEN && wsConnected.value) {
return;
}
if (
(currentSocket?.readyState === WebSocket.OPEN ||
currentSocket?.readyState === WebSocket.CONNECTING) &&
wsConnectPromiseRef.value
) {
return wsConnectPromiseRef.value;
}
let cleanupPending = () => undefined;
const connectPromise = new Promise<void>((resolve, reject) => {
let settled = false;
const socket = new WebSocket(buildWebSocketUrl(userId));
cleanupPending = () => {
if (wsConnectPromiseRef.value === connectPromise) {
wsConnectPromiseRef.value = null;
}
};
socket.onopen = () => {
wsConnected.value = false;
};
socket.onmessage = (event) => {
const raw = String(event.data || "");
if (raw === "连接成功") {
wsConnected.value = true;
if (!settled) {
settled = true;
cleanupPending();
resolve();
}
return;
}
void handleSocketMessage(raw);
};
socket.onclose = () => {
wsConnected.value = false;
if (webSocketRef.value === socket) {
webSocketRef.value = null;
}
if (!settled) {
settled = true;
cleanupPending();
reject(new Error("聊天连接已关闭"));
return;
}
cleanupPending();
};
socket.onerror = () => {
wsConnected.value = false;
if (!settled) {
settled = true;
cleanupPending();
reject(new Error("聊天连接失败"));
}
};
webSocketRef.value = socket;
});
wsConnectPromiseRef.value = connectPromise;
return connectPromise;
};
const loadHistoryList = async (autoSelect = true) => {
const userId = loginUser.value.userId;
if (!userId) {
historyList.value = [];
return;
}
const response = await listAiChatList({ userId });
historyList.value = response || [];
syncActiveChat();
if (
autoSelect &&
!activeChatId.value &&
!activeConversationId.value &&
historyList.value.length
) {
await selectChat(historyList.value[0], true);
}
};
const loadChatHistory = async (item: AiChatList) => {
const userId = loginUser.value.userId;
const response = await listAiChatHistory({
chatId: item.id,
conversationId: item.conversationId,
userId
});
messages.value = formatHistoryMessages(response || []);
scrollToBottom();
};
const selectChat = async (item: AiChatList, silent = false) => {
if (loading.value && !silent) {
message.warning("当前正在生成回复,请稍后再切换会话");
return;
}
activeChatId.value = item.id;
activeConversationId.value = item.conversationId;
await loadChatHistory(item);
};
const startNewChat = () => {
if (loading.value) {
message.warning("当前正在生成回复,请稍后再开启新对话");
return;
}
disconnectWebSocket();
activeChatId.value = undefined;
activeConversationId.value = undefined;
streamingMessageId.value = undefined;
messages.value = [];
content.value = "";
};
// 原本这里有一个 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) => {
// 界面上显式选了文体/字数就用显式值,否则由 writing-params 从自然语言里识别。
// 过去这里没有表单,一律回落 doc_type=新闻稿 / Number=800,而上游提示词把这两项
// 当作主控指令,会直接压掉用户原话里的诉求(实测见 .scratch/ai-assistant/probe/)。
const overrides: WritingOverrides = {};
if (writingDocType.value !== "auto") {
overrides.docType = writingDocType.value;
}
if (writingWordCount.value !== "auto") {
overrides.number = Number(writingWordCount.value);
}
return {
...parseWritingRequest(query, overrides),
files: []
};
};
const formatWritingResult = (payload: unknown, query: string) => {
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 || buildWritingPayload(query).theme}\n\n${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]
});
};
const buildImagePrompt = (query: string) => {
return [
query,
`图片比例:${imageRatio.value}`,
"请直接生成图片,优先输出图片文件或图片链接。"
].join("\n");
};
/** 按 MIME 或扩展名判定 Dify file-list 要求的 type(image/video/audio/document) */
const resolveUploadFileType = (
contentType: string,
fileName: string
): CreativeAssistantUploadFile["type"] => {
const mime = String(contentType || "").toLowerCase();
if (mime.startsWith("image/")) {
return "image";
}
if (mime.startsWith("video/")) {
return "video";
}
if (mime.startsWith("audio/")) {
return "audio";
}
const ext = String(fileName || "").split(".").pop()?.toUpperCase() || "";
if (IMAGE_FILE_EXTS.includes(ext)) {
return "image";
}
if (VIDEO_FILE_EXTS.includes(ext)) {
return "video";
}
if (AUDIO_FILE_EXTS.includes(ext)) {
return "audio";
}
return "document";
};
const pickImageFiles = () => {
if (loading.value || uploadingFiles.value) {
return;
}
fileInputRef.value?.click();
};
const removeImageFile = (index: number) => {
imageFiles.value.splice(index, 1);
};
const onImageFilesPicked = async (event: Event) => {
const input = event.target as HTMLInputElement;
const picked = Array.from(input.files || []);
// 清空 value,否则连续选同一个文件不会再触发 change
input.value = "";
if (!picked.length) {
return;
}
const remain = IMAGE_FILE_LIMIT - imageFiles.value.length;
if (remain <= 0) {
message.warning(`参考素材最多 ${IMAGE_FILE_LIMIT} 个`);
return;
}
const selected = picked.slice(0, remain);
if (picked.length > remain) {
message.warning(`参考素材最多 ${IMAGE_FILE_LIMIT} 个,本次只上传前 ${remain} 个`);
}
uploadingFiles.value = true;
try {
for (const file of selected) {
// app 必须传 creativeAssistant:Dify 按应用鉴权上传文件,
// 用别的应用凭证上传的 id,工作流会报 Invalid upload file。
const record = await uploadAiFile(file, "creativeAssistant");
imageFiles.value.push({
id: String(record.id),
name: record.name || file.name,
type: resolveUploadFileType(
record.contentType || file.type,
record.name || file.name
)
});
}
message.success("参考素材已上传");
} catch (e: any) {
message.error(e?.message || "参考素材上传失败");
} finally {
uploadingFiles.value = false;
}
};
/** 图片模式入参:user_content 一段文本 + user_files(Dify file-list 变量) */
const buildImagePayload = (query: string) => {
const userFiles: CreativeAssistantUploadFile[] = imageFiles.value.map((item) => ({
transfer_method: "local_file",
type: item.type,
upload_file_id: item.id,
url: ""
}));
return {
user_content: buildImagePrompt(query),
user_files: userFiles
};
};
const sendModeMessage = async (
query: string,
assistantId: string,
signal?: AbortSignal
) => {
const target = messages.value.find((item) => item.id === assistantId);
const setResult = (value: string) => {
if (target) {
target.content = value || "抱歉,我无法生成你要求的内容。";
target.loading = false;
}
};
if (activeChatMode.value === "writing") {
const result = await manuscriptGen(buildWritingPayload(query), { signal });
if (typeof (result as any)?.taskId === "string") {
currentTaskId.value = (result as any).taskId;
}
setResult(formatWritingResult(result, query));
return;
}
if (activeChatMode.value === "image") {
const result = await creativeAssistant(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 (uploadingFiles.value) {
message.warning("参考素材正在上传,请稍候");
return;
}
if (!canSend.value) {
return;
}
if (!loginUser.value.userId) {
message.error("未获取到当前登录用户");
return;
}
const query = content.value.trim();
loading.value = true;
stopping.value = false;
currentTaskId.value = undefined;
const abortController = new AbortController();
currentAbortController.value = abortController;
try {
const assistantId = createMessageId("assistant");
messages.value.push({
id: createMessageId("user"),
role: "user",
content: query
});
messages.value.push({
id: assistantId,
role: "assistant",
content: ASSISTANT_PENDING_TEXT,
loading: true
});
streamingMessageId.value = assistantId;
content.value = "";
scrollToBottom();
if (activeChatMode.value === "chat") {
await connectWebSocket();
await sendMessage({
query,
conversationId: activeConversationId.value
}, {
signal: abortController.signal
});
} else {
await sendModeMessage(query, assistantId, abortController.signal);
streamingMessageId.value = undefined;
loading.value = false;
stopping.value = false;
currentTaskId.value = undefined;
currentAbortController.value = null;
scrollToBottom();
}
} catch (e: any) {
if (isCanceledError(e)) {
return;
}
const target = streamingMessageId.value
? messages.value.find((item) => item.id === streamingMessageId.value)
: undefined;
if (target) {
target.loading = false;
target.content = "出错了,请稍后再试。";
}
streamingMessageId.value = undefined;
currentTaskId.value = undefined;
currentAbortController.value = null;
loading.value = false;
stopping.value = false;
disconnectWebSocket();
message.error(e?.message || "发送失败");
}
};
onMounted(async () => {
try {
if (!loginUser.value.userId) {
await userStore.fetchUserInfo();
}
await loadHistoryList();
} catch (e: any) {
message.error(e?.message || "初始化聊天页失败");
}
});
onBeforeUnmount(() => {
currentAbortController.value?.abort();
disconnectWebSocket();
});
</script>
<script lang="ts">
export default {
name: "ChatGPT"
};
</script>
<style lang="less" scoped>
:deep(.ele-body) {
padding: 0 !important;
background: transparent;
}
:deep(.markdown-body) {
background: transparent;
color: inherit;
font-size: 14px;
line-height: 1.75;
}
:deep(.markdown-body p) {
margin: 0 0 8px;
}
:deep(.markdown-body p:last-child) {
margin-bottom: 0;
}
:deep(.markdown-body ul) {
margin: 6px 0 8px;
padding-left: 18px;
}
.chat-page {
height: 100%;
min-height: 0;
display: flex;
overflow: hidden;
font-family: 'Alibaba PuHuiTi', 'HarmonyOS Sans SC', 'Noto Sans SC',
'PingFang SC', 'Microsoft YaHei', sans-serif;
color: #3f2d25;
background: #fff9f6;
}
.chat-sidebar {
width: 320px;
background: #e8f4ff;
padding: 28px 24px 24px;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
border-right: 1px solid #c7e8ff;
}
.brand-block {
display: flex;
align-items: center;
gap: 12px;
}
.brand-logo {
width: 44px;
height: 44px;
border-radius: 12px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 16px rgba(21, 144, 255, 0.12);
}
.brand-logo img {
width: 34px;
height: 34px;
object-fit: contain;
}
.brand-title {
font-size: 18px;
font-weight: 700;
color: #1590ff;
}
.brand-subtitle {
margin-top: 2px;
font-size: 12px;
color: #5f7f9f;
}
.new-chat-btn {
margin-top: 22px;
height: 44px;
border-radius: 999px;
border: 1.5px solid #1590ff;
background: #fff;
color: #1590ff;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
cursor: pointer;
}
.new-chat-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.plus-icon {
width: 18px;
height: 18px;
border-radius: 50%;
border: 1.5px solid #1590ff;
position: relative;
}
.plus-icon::before,
.plus-icon::after {
content: '';
position: absolute;
background: #1590ff;
}
.plus-icon::before {
width: 9px;
height: 1.6px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.plus-icon::after {
width: 1.6px;
height: 9px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.history-block {
margin-top: 32px;
height: calc(100vh - 260px);
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto;
overflow-x: hidden;
padding-right: 4px;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(21, 144, 255, 0.3);
border-radius: 999px;
}
&::-webkit-scrollbar-thumb:hover {
background: rgba(21, 144, 255, 0.6);
}
}
.history-label {
font-size: 12px;
color: #5f7f9f;
margin-top: 10px;
}
.history-item {
display: flex;
align-items: center;
gap: 8px;
border-radius: 12px;
transition: background-color 0.2s ease;
}
.history-item-main {
flex: 1;
min-width: 0;
text-align: left;
background: transparent;
border: none;
color: #1f2d3d;
font-size: 14px;
padding: 8px 0;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.history-item-main:disabled,
.history-delete-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.history-item.is-active {
padding: 0 10px;
background: rgba(21, 144, 255, 0.08);
}
.history-item.is-active .history-item-main {
color: #1590ff;
font-weight: 600;
}
.history-delete-btn {
width: 26px;
height: 26px;
border: none;
border-radius: 8px;
background: transparent;
color: #6aaeff;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
cursor: pointer;
}
.history-delete-btn svg {
width: 16px;
height: 16px;
}
.history-delete-btn:hover {
color: #1590ff;
background: rgba(21, 144, 255, 0.08);
}
.history-empty {
font-size: 13px;
color: #5f7f9f;
}
.sidebar-footer {
margin-top: auto;
display: flex;
align-items: flex-start;
flex-direction: column;
justify-content: flex-start;
gap: 12px;
}
.footer-actions {
display: flex;
align-items: center;
gap: 8px;
}
.user-name {
font-size: 14px;
color: #1590ff;
font-weight: 600;
}
.admin-btn {
border-radius: 999px;
border: 1.5px solid #1590ff;
background: linear-gradient(135deg, #1590ff, #32bfff);
color: #fff;
padding: 4px 14px;
font-size: 12px;
cursor: pointer;
}
.logout-btn {
border-radius: 999px;
border: 1.5px solid #1590ff;
background: #fff;
color: #1590ff;
padding: 4px 16px;
font-size: 12px;
cursor: pointer;
}
.chat-main {
flex: 1;
min-height: 0;
position: relative;
padding: 30px 60px 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: linear-gradient(180deg, #f5fbff 0%, #e6f7ff 100%);
}
.chat-main::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(620px 620px at 85% 40%,
rgba(21, 144, 255, 0.18) 0%,
rgba(21, 144, 255, 0) 65%),
radial-gradient(520px 520px at 70% 85%,
rgba(50, 191, 255, 0.16) 0%,
rgba(50, 191, 255, 0) 70%);
pointer-events: none;
}
.chat-main-header {
flex-shrink: 0;
text-align: center;
color: #5f7f9f;
font-size: 15px;
letter-spacing: 1px;
margin-bottom: 24px;
position: relative;
z-index: 1;
}
.chat-body {
flex: 1 1 0;
height: 0;
overflow-y: auto;
min-height: 0;
padding: 0 10px 24px 0;
display: flex;
flex-direction: column;
gap: 22px;
position: relative;
z-index: 1;
width: 800px;
margin: 0 auto;
overscroll-behavior: contain;
}
.chat-row {
display: flex;
flex-direction: column;
gap: 6px;
}
.chat-row.is-user {
align-items: flex-end;
}
.chat-row.is-assistant {
align-items: flex-start;
}
.chat-bubble {
max-width: 620px;
font-size: 14px;
color: #1f2d3d;
word-break: break-word;
}
.chat-row.is-user .chat-bubble {
padding: 10px 20px;
border-radius: 999px;
border: 1.5px solid #1590ff;
color: #1590ff;
background: #fff;
font-weight: 600;
white-space: pre-wrap;
}
.chat-row.is-assistant .chat-bubble {
padding: 0;
border: none;
background: transparent;
}
.chat-bubble.is-pending {
color: #5f7f9f;
}
.assistant-streaming-text {
max-width: 640px;
color: #1f2d3d;
line-height: 1.8;
font-size: 14px;
white-space: pre-wrap;
word-break: break-word;
}
.assistant-thinking {
display: inline-flex;
align-items: center;
gap: 8px;
color: #5f7f9f;
font-size: 14px;
line-height: 1.8;
}
.thinking-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
opacity: 0.75;
animation: thinkingPulse 1.2s ease-in-out infinite;
}
.assistant-markdown {
max-width: 640px;
}
.assistant-image-wrap {
max-width: 640px;
}
.assistant-image {
max-width: 100%;
}
.bubble-actions {
display: flex;
gap: 10px;
color: #1590ff;
}
.icon-btn {
width: 28px;
height: 28px;
border: none;
background: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
color: inherit;
cursor: pointer;
}
.icon-btn svg {
width: 20px;
height: 20px;
}
.assistant-text {
max-width: 640px;
color: #1f2d3d;
line-height: 1.8;
font-size: 14px;
}
@keyframes thinkingPulse {
0%,
100% {
transform: scale(0.8);
opacity: 0.45;
}
50% {
transform: scale(1);
opacity: 1;
}
}
.chat-empty {
margin: auto;
max-width: 520px;
text-align: center;
color: #5f7f9f;
font-size: 15px;
line-height: 1.9;
}
.assistant-text p {
margin: 0 0 10px;
}
.assistant-list {
display: grid;
gap: 6px;
margin-bottom: 10px;
color: #1f2d3d;
}
.assistant-article {
max-width: 820px;
color: #2f2a27;
line-height: 1.9;
font-size: 15px;
}
.assistant-article p {
margin: 0 0 14px;
}
.assistant-divider {
height: 2px;
background: #2d7ff9;
margin: 10px 0 18px;
}
.assistant-title {
font-size: 20px;
font-weight: 700;
color: #1f1f1f;
margin-bottom: 6px;
}
.assistant-subtitle {
color: #5e5e5e;
margin-bottom: 16px;
}
.assistant-section {
font-size: 16px;
font-weight: 700;
margin-bottom: 10px;
}
.assistant-bullets {
margin: 0;
padding-left: 18px;
color: #1f1f1f;
}
.assistant-bullets li {
margin-bottom: 8px;
}
.assistant-bullets li::marker {
color: #2d7ff9;
}
.assistant-bullets .assistant-alpha {
list-style: none;
padding-left: 0;
margin-left: -18px;
}
.assistant-input {
flex-shrink: 0;
z-index: 1;
margin-top: auto;
padding: 16px 0 24px;
}
.assistant-title {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-size: 18px;
font-weight: 700;
color: #1590ff;
}
.assistant-title img {
width: 34px;
height: 34px;
object-fit: contain;
}
.input-shell {
margin: 14px auto 0;
border-radius: 18px;
border: 1.5px solid #1590ff;
background: #fff;
padding: 16px 16px 12px;
width: 800px;
}
.ratio-panel {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(21, 144, 255, 0.12);
}
.ratio-label {
color: #5f7f9f;
font-size: 13px;
font-weight: 600;
}
.ratio-option {
min-width: 48px;
height: 28px;
border: 1px solid rgba(21, 144, 255, 0.28);
border-radius: 999px;
background: #f5fbff;
color: #1590ff;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.ratio-option.is-active {
border-color: #1590ff;
background: #1590ff;
color: #fff;
}
.ratio-option:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.attach-hint {
color: #8aa4bd;
font-size: 12px;
}
.attach-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
width: 100%;
margin: 0;
padding: 0;
list-style: none;
}
.attach-item {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 4px 8px;
border: 1px solid rgba(21, 144, 255, 0.28);
border-radius: 8px;
background: #f5fbff;
color: #1590ff;
font-size: 12px;
}
.attach-name {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attach-remove {
padding: 0;
border: none;
background: transparent;
color: #8aa4bd;
font-size: 12px;
cursor: pointer;
}
.attach-remove:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.attach-input {
display: none;
}
.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>