| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436 |
- import { From } from '@/types/record';
- import { hasVisibleMessageContent } from '@/utils/interrupted-message';
- const MessageStateValue = {
- Update: 0,
- Sending: 1,
- Finish: 3,
- Stop: 5,
- };
- export const businessAssistantSuggestions = [
- '你能为我提供哪些服务呢',
- '高新技术企业的认定',
- '青浦区高层次人才有哪些补贴',
- ];
- export const CANCELLED_SESSION_TEXT = '会话已经取消';
- export const THINKING_SCOPE_PLACEHOLDER = '<scope title="正在思考中...">\n</scope>\n';
- export const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
- export const MAX_FILE_SIZE = 50 * 1024 * 1024;
- /**
- * 支持的格式 —— 与产品提示文案**一一对应**(用户 2026-09-23 按提示对齐):
- * 文件:**Excel(xls/xlsx)、DOCX、文字 PDF**
- * 图片:**JPEG、PNG、GIF、WebP**
- *
- * ⚠️ 改动这里要**同时改三处**,否则会出现"提示说支持、实际被拒":
- * ① 本文件的两个扩展名数组 ② PC / Mobile 里 `<input accept="…">`
- * ③ 提示文案(用下面的 `*_LABEL` 常量拼,别各写各的)
- */
- export const SUPPORTED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
- export const SUPPORTED_ATTACHMENT_EXTENSIONS = ['xls', 'xlsx', 'docx', 'pdf'];
- /** 提示文案里给的格式说明(给人看的写法,与上面的白名单保持同一口径) */
- export const SUPPORTED_IMAGE_LABEL = 'JPEG、PNG、GIF、WebP';
- export const SUPPORTED_ATTACHMENT_LABEL = 'Excel、DOCX、文字PDF';
- export interface BusinessAssistantMessage {
- id: string;
- role: 'user' | 'ai';
- content: string;
- from?: From;
- state?: number;
- history?: boolean;
- /**
- * F073:本轮正文走**真实增量流式**(协调器 `answer_stream_start` 已到、`finishTask` 前)。
- * 渲染层据此关闭打字机改为即时渲染——正文已经是逐片段到达了,再逐字重放等于凭空多等一遍。
- * 旧后端轮次、mock 测试流从不置位,行为不变。
- */
- contentStreaming?: boolean;
- questionSource?: any[];
- mediaList?: any[];
- attachments?: BusinessAssistantMessageAttachment[];
- references?: any[];
- relatedQuestions?: any[];
- }
- export const createBusinessAssistantId = () => {
- if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
- return crypto.randomUUID();
- }
- return `ba-${Date.now()}-${Math.random().toString(16).slice(2)}`;
- };
- export interface BusinessAssistantSuggestion {
- text: string;
- intent?: string;
- }
- export interface BusinessAssistantInputFileTransmission {
- files: string[];
- file_pos: number[];
- attachments?: BusinessAssistantMessageAttachment[];
- }
- export interface BusinessAssistantMessageAttachment {
- id: string;
- name: string;
- size: number;
- isImage: boolean;
- /**
- * **可显示地址**(气泡里 `<img src>` 用的那个)—— 图片才有。
- * 2026-09-23 换 DMS 后它可能是 `/dms/static/photo/…` 这样的**代理地址**,
- * 只有拼接前缀才能取到图,`/static/photo/…` 这种相对路径直接当 src 是取不到的
- * (实测:相对路径经 dev server 返回的是 index.html,`<img>` 解不出来 → 图不显示)。
- */
- url?: string;
- /**
- * **相对路径**(`/static/photo/…`)—— 这个才是随请求体 `pictures` / `files`
- * 发给**后端**的值,别跟 `url` 混(见 `toChatAttachmentUrls`)。
- */
- fileUrl?: string;
- }
- export interface BusinessAssistantSession {
- id: string;
- title: string;
- messages: BusinessAssistantMessage[];
- viewed?: boolean;
- followupSuggestions?: BusinessAssistantSuggestion[];
- followupSuggestionsHidden?: boolean;
- }
- export interface BusinessAssistantUploadedFile {
- id: string;
- name: string;
- size: number;
- type?: string;
- lastModified?: number;
- progress: number;
- status: 'uploading' | 'success' | 'error';
- isImage: boolean;
- url?: string;
- fileUrl?: string;
- errorMsg?: string;
- abort?: () => void;
- }
- export const createAiMessage = (
- content: string,
- state = MessageStateValue.Finish,
- ): BusinessAssistantMessage => ({
- id: createBusinessAssistantId(),
- role: 'ai',
- content,
- from: From.AI,
- state,
- history: false,
- questionSource: [],
- mediaList: [],
- references: [],
- relatedQuestions: [],
- });
- export const createUserMessage = (
- content: string,
- attachments: BusinessAssistantMessageAttachment[] = [],
- ): BusinessAssistantMessage => ({
- id: createBusinessAssistantId(),
- role: 'user',
- content,
- from: From.User,
- state: MessageStateValue.Finish,
- history: false,
- questionSource: [],
- mediaList: [],
- attachments,
- references: [],
- relatedQuestions: [],
- });
- export const getLastAiMessage = (messages: BusinessAssistantMessage[]) => {
- const lastMsg = messages[messages.length - 1];
- return lastMsg?.role === 'ai' ? lastMsg : null;
- };
- export const ensurePendingAiMessage = (messages: BusinessAssistantMessage[]) => {
- const lastAiMessage = getLastAiMessage(messages);
- if (lastAiMessage) {
- return lastAiMessage;
- }
- const aiMessage = createAiMessage('', MessageStateValue.Sending);
- messages.push(aiMessage);
- return aiMessage;
- };
- export const ensureThinkingScopeAiMessage = (messages: BusinessAssistantMessage[]) => {
- const aiMessage = ensurePendingAiMessage(messages);
- if (!String(aiMessage.content || '').trim()) {
- aiMessage.content = THINKING_SCOPE_PLACEHOLDER;
- }
- aiMessage.state = MessageStateValue.Sending;
- aiMessage.history = false;
- return aiMessage;
- };
- export const appendAiMessageChunk = (messages: BusinessAssistantMessage[], content: string) => {
- const lastAiMessage = getLastAiMessage(messages);
- if (lastAiMessage?.state === MessageStateValue.Stop) {
- return null;
- }
- const aiMessage = lastAiMessage || ensurePendingAiMessage(messages);
- aiMessage.content += content;
- aiMessage.state = MessageStateValue.Sending;
- aiMessage.history = false;
- return aiMessage;
- };
- export interface AiMessageChunkBufferOptions {
- append: (content: string) => void;
- schedule?: (flush: () => void) => number | void;
- cancelSchedule?: (handle: number) => void;
- onFlush?: () => void;
- }
- export const createAiMessageChunkBuffer = ({
- append,
- schedule,
- cancelSchedule,
- onFlush,
- }: AiMessageChunkBufferOptions) => {
- let pendingContent = '';
- let scheduledHandle: number | null = null;
- const defaultSchedule = (flush: () => void) => {
- if (typeof requestAnimationFrame === 'function') {
- return requestAnimationFrame(flush);
- }
- return window.setTimeout(flush, 16);
- };
- const defaultCancelSchedule = (handle: number) => {
- if (typeof cancelAnimationFrame === 'function') {
- cancelAnimationFrame(handle);
- return;
- }
- window.clearTimeout(handle);
- };
- const flush = () => {
- scheduledHandle = null;
- if (!pendingContent) {
- return;
- }
- const content = pendingContent;
- pendingContent = '';
- append(content);
- onFlush?.();
- };
- const push = (content: string) => {
- if (!content) {
- return;
- }
- pendingContent += content;
- if (scheduledHandle !== null) {
- return;
- }
- const handle = (schedule || defaultSchedule)(flush);
- scheduledHandle = typeof handle === 'number' ? handle : 0;
- };
- const cancel = () => {
- pendingContent = '';
- if (scheduledHandle === null) {
- return;
- }
- (cancelSchedule || defaultCancelSchedule)(scheduledHandle);
- scheduledHandle = null;
- };
- return {
- push,
- flush,
- cancel,
- };
- };
- export const finishAiMessage = (messages: BusinessAssistantMessage[]) => {
- const lastAiMessage = getLastAiMessage(messages);
- if (!lastAiMessage || lastAiMessage.state === MessageStateValue.Stop) {
- return lastAiMessage;
- }
- if (lastAiMessage.state !== MessageStateValue.Finish) {
- lastAiMessage.state = MessageStateValue.Finish;
- }
- return lastAiMessage;
- };
- export const stopAiMessage = (messages: BusinessAssistantMessage[]) => {
- const aiMessage = ensurePendingAiMessage(messages);
- aiMessage.state = MessageStateValue.Stop;
- aiMessage.history = false;
- return aiMessage;
- };
- export const isPendingLikeState = (state: any) => {
- if (typeof state === 'string') {
- const normalizedState = state.trim().toLowerCase();
- return normalizedState === 'pending' || normalizedState === 'loading';
- }
- return state === MessageStateValue.Update || state === MessageStateValue.Sending || state === 0 || state === 1;
- };
- export const normalizeLegacyContent = (content: any) => {
- if (typeof content === 'string') {
- return content.trim();
- }
- if (!Array.isArray(content)) {
- return null;
- }
- return (content as Array<{ content?: any }>)
- .map((row) => (typeof row?.content === 'string' ? row.content : ''))
- .join('')
- .trim();
- };
- export const normalizeSessionHistory = (session: BusinessAssistantSession) => {
- if (!session || !Array.isArray(session.messages) || session.messages.length === 0) {
- return false;
- }
- const lastMsg = session.messages[session.messages.length - 1];
- if (!lastMsg || lastMsg.role !== 'ai') {
- return false;
- }
- const rawContent = (lastMsg as any).content;
- const normalizedContent = normalizeLegacyContent(rawContent);
- const hasLegacyPendingRows =
- Array.isArray(rawContent) && (rawContent as Array<{ state?: any }>).some((row) => isPendingLikeState(row?.state));
- const hasPendingState = isPendingLikeState(lastMsg.state);
- const shouldNormalize = hasPendingState || hasLegacyPendingRows || Array.isArray(rawContent);
- if (!shouldNormalize) {
- return false;
- }
- let changed = false;
- if (normalizedContent !== null && (lastMsg as any).content !== normalizedContent) {
- (lastMsg as any).content = normalizedContent;
- changed = true;
- }
- // 判空口径与渲染层统一:`<scope>` 进度块不算可见内容
- // (见 utils/interrupted-message.ts 的 hasVisibleMessageContent 说明)
- if (!hasVisibleMessageContent(lastMsg.content)) {
- lastMsg.content = CANCELLED_SESSION_TEXT;
- changed = true;
- }
- if (lastMsg.state !== MessageStateValue.Finish) {
- lastMsg.state = MessageStateValue.Finish;
- changed = true;
- }
- return changed;
- };
- /**
- * 组装**发给后端的 question**:把已上传附件的地址拼在问题后面。
- *
- * 背景(2026-09-18):新接口 `POST /api/chat` 的请求体**只接受 `{thread_id, question}`
- * 两个字段**,多一个就 422,所以旧协议的 `transmission.files / file_pos` 发不出去。
- * 经用户拍板走「把 OSS 地址拼进 question 文本」这条**不需要后端配合**的路子。
- *
- * ⚠️ **只用于发给后端**,不要拿它当本地消息内容 —— 否则用户气泡里会显示一长串 URL。
- * 本地展示仍用用户输入的原文。
- *
- * 格式:原文 + 换行 + 每条 URL 一行(**不加「附件:」之类的字眼**,
- * 免得凭空发明一套后端可能不认识的措辞;URL 本身就说明了一切)。
- * 若后端要求特定标记,改这一个函数即可。
- */
- /**
- * 多模态附件的两种 URL 列表(`/api/chat` 请求体的 `pictures` / `files` 字段)。
- *
- * 实测(2026-09-23):
- * - 两者都是 **URL 字符串数组**(传对象数组会被 422 `invalid_request` 拒掉)
- * - `pictures` 里的图后端**自己去取**(所以必须是可公网访问的地址),
- * 实测带上它之后模型确实"看到"了图并能描述内容
- */
- export interface ChatAttachmentUrls {
- /** 图片(后端按视觉输入处理) */
- pictures: string[];
- /** 非图片文件(pdf/docx…) */
- files: string[];
- }
- /**
- * 把上传模块交出来的 transmission 拆成 `pictures` / `files`。
- *
- * 按 `attachments[].isImage` 分(上传时已经判好),**两个列表都只保留非空 URL**。
- * 拿不到 `attachments`(只有 `files`)时,只能全部当普通文件 —— 分不出图片,
- * 但至少地址不丢。
- *
- * ⚠️ **取的是 `fileUrl`(相对路径),不是 `url`(可显示地址)**:
- * 换 DMS 之后两个字段分工不同 —— 后端要的是 `/static/photo/…`,
- * 而 `url` 是拼了 `/dms` 代理前缀给 `<img>` 用的。
- * 2026-09-23 的 bug 正是这里取错:气泡里的图不显示(相对路径解不出来),
- * 详见 progress.md 续 28 与 verify-attachment-question.mjs 的【7】。
- */
- export const toChatAttachmentUrls = (
- transmission?: BusinessAssistantInputFileTransmission | null,
- ): ChatAttachmentUrls => {
- const attachments = Array.isArray(transmission?.attachments) ? transmission?.attachments : [];
- const pick = (list: BusinessAssistantMessageAttachment[] | undefined) =>
- (list || [])
- .map((item) => String(item?.fileUrl ?? item?.url ?? '').trim())
- .filter(Boolean);
- if (attachments && attachments.length) {
- return {
- pictures: pick(attachments.filter((item) => item?.isImage)),
- files: pick(attachments.filter((item) => !item?.isImage)),
- };
- }
- const fallback = (Array.isArray(transmission?.files) ? transmission?.files : [])
- .map((url) => String(url ?? '').trim())
- .filter(Boolean);
- return { pictures: [], files: fallback };
- };
- // [已停用] 附件地址拼进 question 文本(方案②)—— 2026-09-23 后端支持了
- // `pictures` / `files` 两个多模态字段(方案①),地址不再拼进正文:
- // 拼进去会让模型把 URL 当正文内容读,而且本地消息、会话标题、DMS 记录都跟着变脏。
- // export const buildQuestionWithAttachments = (
- // question: string,
- // fileUrls?: Array<string | null | undefined> | null,
- // ): string => {
- // const urls = (Array.isArray(fileUrls) ? fileUrls : [])
- // .map((url) => String(url ?? '').trim())
- // .filter(Boolean);
- //
- // if (!urls.length) {
- // return question;
- // }
- //
- // return `${question}\n${urls.join('\n')}`;
- // };
|