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')}`;
// };