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 = '\n\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 里 `` * ③ 提示文案(用下面的 `*_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; /** * **可显示地址**(气泡里 `` 用的那个)—— 图片才有。 * 2026-09-23 换 DMS 后它可能是 `/dms/static/photo/…` 这样的**代理地址**, * 只有拼接前缀才能取到图,`/static/photo/…` 这种相对路径直接当 src 是取不到的 * (实测:相对路径经 dev server 返回的是 index.html,`` 解不出来 → 图不显示)。 */ 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; } // 判空口径与渲染层统一:`` 进度块不算可见内容 // (见 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` 代理前缀给 `` 用的。 * 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 | 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')}`; // };