shared.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. import { From } from '@/types/record';
  2. import { hasVisibleMessageContent } from '@/utils/interrupted-message';
  3. const MessageStateValue = {
  4. Update: 0,
  5. Sending: 1,
  6. Finish: 3,
  7. Stop: 5,
  8. };
  9. export const businessAssistantSuggestions = [
  10. '你能为我提供哪些服务呢',
  11. '高新技术企业的认定',
  12. '青浦区高层次人才有哪些补贴',
  13. ];
  14. export const CANCELLED_SESSION_TEXT = '会话已经取消';
  15. export const THINKING_SCOPE_PLACEHOLDER = '<scope title="正在思考中...">\n</scope>\n';
  16. export const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
  17. export const MAX_FILE_SIZE = 50 * 1024 * 1024;
  18. /**
  19. * 支持的格式 —— 与产品提示文案**一一对应**(用户 2026-09-23 按提示对齐):
  20. * 文件:**Excel(xls/xlsx)、DOCX、文字 PDF**
  21. * 图片:**JPEG、PNG、GIF、WebP**
  22. *
  23. * ⚠️ 改动这里要**同时改三处**,否则会出现"提示说支持、实际被拒":
  24. * ① 本文件的两个扩展名数组 ② PC / Mobile 里 `<input accept="…">`
  25. * ③ 提示文案(用下面的 `*_LABEL` 常量拼,别各写各的)
  26. */
  27. export const SUPPORTED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
  28. export const SUPPORTED_ATTACHMENT_EXTENSIONS = ['xls', 'xlsx', 'docx', 'pdf'];
  29. /** 提示文案里给的格式说明(给人看的写法,与上面的白名单保持同一口径) */
  30. export const SUPPORTED_IMAGE_LABEL = 'JPEG、PNG、GIF、WebP';
  31. export const SUPPORTED_ATTACHMENT_LABEL = 'Excel、DOCX、文字PDF';
  32. export interface BusinessAssistantMessage {
  33. id: string;
  34. role: 'user' | 'ai';
  35. content: string;
  36. from?: From;
  37. state?: number;
  38. history?: boolean;
  39. /**
  40. * F073:本轮正文走**真实增量流式**(协调器 `answer_stream_start` 已到、`finishTask` 前)。
  41. * 渲染层据此关闭打字机改为即时渲染——正文已经是逐片段到达了,再逐字重放等于凭空多等一遍。
  42. * 旧后端轮次、mock 测试流从不置位,行为不变。
  43. */
  44. contentStreaming?: boolean;
  45. questionSource?: any[];
  46. mediaList?: any[];
  47. attachments?: BusinessAssistantMessageAttachment[];
  48. references?: any[];
  49. relatedQuestions?: any[];
  50. }
  51. export const createBusinessAssistantId = () => {
  52. if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
  53. return crypto.randomUUID();
  54. }
  55. return `ba-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  56. };
  57. export interface BusinessAssistantSuggestion {
  58. text: string;
  59. intent?: string;
  60. }
  61. export interface BusinessAssistantInputFileTransmission {
  62. files: string[];
  63. file_pos: number[];
  64. attachments?: BusinessAssistantMessageAttachment[];
  65. }
  66. export interface BusinessAssistantMessageAttachment {
  67. id: string;
  68. name: string;
  69. size: number;
  70. isImage: boolean;
  71. /**
  72. * **可显示地址**(气泡里 `<img src>` 用的那个)—— 图片才有。
  73. * 2026-09-23 换 DMS 后它可能是 `/dms/static/photo/…` 这样的**代理地址**,
  74. * 只有拼接前缀才能取到图,`/static/photo/…` 这种相对路径直接当 src 是取不到的
  75. * (实测:相对路径经 dev server 返回的是 index.html,`<img>` 解不出来 → 图不显示)。
  76. */
  77. url?: string;
  78. /**
  79. * **相对路径**(`/static/photo/…`)—— 这个才是随请求体 `pictures` / `files`
  80. * 发给**后端**的值,别跟 `url` 混(见 `toChatAttachmentUrls`)。
  81. */
  82. fileUrl?: string;
  83. }
  84. export interface BusinessAssistantSession {
  85. id: string;
  86. title: string;
  87. messages: BusinessAssistantMessage[];
  88. viewed?: boolean;
  89. followupSuggestions?: BusinessAssistantSuggestion[];
  90. followupSuggestionsHidden?: boolean;
  91. }
  92. export interface BusinessAssistantUploadedFile {
  93. id: string;
  94. name: string;
  95. size: number;
  96. type?: string;
  97. lastModified?: number;
  98. progress: number;
  99. status: 'uploading' | 'success' | 'error';
  100. isImage: boolean;
  101. url?: string;
  102. fileUrl?: string;
  103. errorMsg?: string;
  104. abort?: () => void;
  105. }
  106. export const createAiMessage = (
  107. content: string,
  108. state = MessageStateValue.Finish,
  109. ): BusinessAssistantMessage => ({
  110. id: createBusinessAssistantId(),
  111. role: 'ai',
  112. content,
  113. from: From.AI,
  114. state,
  115. history: false,
  116. questionSource: [],
  117. mediaList: [],
  118. references: [],
  119. relatedQuestions: [],
  120. });
  121. export const createUserMessage = (
  122. content: string,
  123. attachments: BusinessAssistantMessageAttachment[] = [],
  124. ): BusinessAssistantMessage => ({
  125. id: createBusinessAssistantId(),
  126. role: 'user',
  127. content,
  128. from: From.User,
  129. state: MessageStateValue.Finish,
  130. history: false,
  131. questionSource: [],
  132. mediaList: [],
  133. attachments,
  134. references: [],
  135. relatedQuestions: [],
  136. });
  137. export const getLastAiMessage = (messages: BusinessAssistantMessage[]) => {
  138. const lastMsg = messages[messages.length - 1];
  139. return lastMsg?.role === 'ai' ? lastMsg : null;
  140. };
  141. export const ensurePendingAiMessage = (messages: BusinessAssistantMessage[]) => {
  142. const lastAiMessage = getLastAiMessage(messages);
  143. if (lastAiMessage) {
  144. return lastAiMessage;
  145. }
  146. const aiMessage = createAiMessage('', MessageStateValue.Sending);
  147. messages.push(aiMessage);
  148. return aiMessage;
  149. };
  150. export const ensureThinkingScopeAiMessage = (messages: BusinessAssistantMessage[]) => {
  151. const aiMessage = ensurePendingAiMessage(messages);
  152. if (!String(aiMessage.content || '').trim()) {
  153. aiMessage.content = THINKING_SCOPE_PLACEHOLDER;
  154. }
  155. aiMessage.state = MessageStateValue.Sending;
  156. aiMessage.history = false;
  157. return aiMessage;
  158. };
  159. export const appendAiMessageChunk = (messages: BusinessAssistantMessage[], content: string) => {
  160. const lastAiMessage = getLastAiMessage(messages);
  161. if (lastAiMessage?.state === MessageStateValue.Stop) {
  162. return null;
  163. }
  164. const aiMessage = lastAiMessage || ensurePendingAiMessage(messages);
  165. aiMessage.content += content;
  166. aiMessage.state = MessageStateValue.Sending;
  167. aiMessage.history = false;
  168. return aiMessage;
  169. };
  170. export interface AiMessageChunkBufferOptions {
  171. append: (content: string) => void;
  172. schedule?: (flush: () => void) => number | void;
  173. cancelSchedule?: (handle: number) => void;
  174. onFlush?: () => void;
  175. }
  176. export const createAiMessageChunkBuffer = ({
  177. append,
  178. schedule,
  179. cancelSchedule,
  180. onFlush,
  181. }: AiMessageChunkBufferOptions) => {
  182. let pendingContent = '';
  183. let scheduledHandle: number | null = null;
  184. const defaultSchedule = (flush: () => void) => {
  185. if (typeof requestAnimationFrame === 'function') {
  186. return requestAnimationFrame(flush);
  187. }
  188. return window.setTimeout(flush, 16);
  189. };
  190. const defaultCancelSchedule = (handle: number) => {
  191. if (typeof cancelAnimationFrame === 'function') {
  192. cancelAnimationFrame(handle);
  193. return;
  194. }
  195. window.clearTimeout(handle);
  196. };
  197. const flush = () => {
  198. scheduledHandle = null;
  199. if (!pendingContent) {
  200. return;
  201. }
  202. const content = pendingContent;
  203. pendingContent = '';
  204. append(content);
  205. onFlush?.();
  206. };
  207. const push = (content: string) => {
  208. if (!content) {
  209. return;
  210. }
  211. pendingContent += content;
  212. if (scheduledHandle !== null) {
  213. return;
  214. }
  215. const handle = (schedule || defaultSchedule)(flush);
  216. scheduledHandle = typeof handle === 'number' ? handle : 0;
  217. };
  218. const cancel = () => {
  219. pendingContent = '';
  220. if (scheduledHandle === null) {
  221. return;
  222. }
  223. (cancelSchedule || defaultCancelSchedule)(scheduledHandle);
  224. scheduledHandle = null;
  225. };
  226. return {
  227. push,
  228. flush,
  229. cancel,
  230. };
  231. };
  232. export const finishAiMessage = (messages: BusinessAssistantMessage[]) => {
  233. const lastAiMessage = getLastAiMessage(messages);
  234. if (!lastAiMessage || lastAiMessage.state === MessageStateValue.Stop) {
  235. return lastAiMessage;
  236. }
  237. if (lastAiMessage.state !== MessageStateValue.Finish) {
  238. lastAiMessage.state = MessageStateValue.Finish;
  239. }
  240. return lastAiMessage;
  241. };
  242. export const stopAiMessage = (messages: BusinessAssistantMessage[]) => {
  243. const aiMessage = ensurePendingAiMessage(messages);
  244. aiMessage.state = MessageStateValue.Stop;
  245. aiMessage.history = false;
  246. return aiMessage;
  247. };
  248. export const isPendingLikeState = (state: any) => {
  249. if (typeof state === 'string') {
  250. const normalizedState = state.trim().toLowerCase();
  251. return normalizedState === 'pending' || normalizedState === 'loading';
  252. }
  253. return state === MessageStateValue.Update || state === MessageStateValue.Sending || state === 0 || state === 1;
  254. };
  255. export const normalizeLegacyContent = (content: any) => {
  256. if (typeof content === 'string') {
  257. return content.trim();
  258. }
  259. if (!Array.isArray(content)) {
  260. return null;
  261. }
  262. return (content as Array<{ content?: any }>)
  263. .map((row) => (typeof row?.content === 'string' ? row.content : ''))
  264. .join('')
  265. .trim();
  266. };
  267. export const normalizeSessionHistory = (session: BusinessAssistantSession) => {
  268. if (!session || !Array.isArray(session.messages) || session.messages.length === 0) {
  269. return false;
  270. }
  271. const lastMsg = session.messages[session.messages.length - 1];
  272. if (!lastMsg || lastMsg.role !== 'ai') {
  273. return false;
  274. }
  275. const rawContent = (lastMsg as any).content;
  276. const normalizedContent = normalizeLegacyContent(rawContent);
  277. const hasLegacyPendingRows =
  278. Array.isArray(rawContent) && (rawContent as Array<{ state?: any }>).some((row) => isPendingLikeState(row?.state));
  279. const hasPendingState = isPendingLikeState(lastMsg.state);
  280. const shouldNormalize = hasPendingState || hasLegacyPendingRows || Array.isArray(rawContent);
  281. if (!shouldNormalize) {
  282. return false;
  283. }
  284. let changed = false;
  285. if (normalizedContent !== null && (lastMsg as any).content !== normalizedContent) {
  286. (lastMsg as any).content = normalizedContent;
  287. changed = true;
  288. }
  289. // 判空口径与渲染层统一:`<scope>` 进度块不算可见内容
  290. // (见 utils/interrupted-message.ts 的 hasVisibleMessageContent 说明)
  291. if (!hasVisibleMessageContent(lastMsg.content)) {
  292. lastMsg.content = CANCELLED_SESSION_TEXT;
  293. changed = true;
  294. }
  295. if (lastMsg.state !== MessageStateValue.Finish) {
  296. lastMsg.state = MessageStateValue.Finish;
  297. changed = true;
  298. }
  299. return changed;
  300. };
  301. /**
  302. * 组装**发给后端的 question**:把已上传附件的地址拼在问题后面。
  303. *
  304. * 背景(2026-09-18):新接口 `POST /api/chat` 的请求体**只接受 `{thread_id, question}`
  305. * 两个字段**,多一个就 422,所以旧协议的 `transmission.files / file_pos` 发不出去。
  306. * 经用户拍板走「把 OSS 地址拼进 question 文本」这条**不需要后端配合**的路子。
  307. *
  308. * ⚠️ **只用于发给后端**,不要拿它当本地消息内容 —— 否则用户气泡里会显示一长串 URL。
  309. * 本地展示仍用用户输入的原文。
  310. *
  311. * 格式:原文 + 换行 + 每条 URL 一行(**不加「附件:」之类的字眼**,
  312. * 免得凭空发明一套后端可能不认识的措辞;URL 本身就说明了一切)。
  313. * 若后端要求特定标记,改这一个函数即可。
  314. */
  315. /**
  316. * 多模态附件的两种 URL 列表(`/api/chat` 请求体的 `pictures` / `files` 字段)。
  317. *
  318. * 实测(2026-09-23):
  319. * - 两者都是 **URL 字符串数组**(传对象数组会被 422 `invalid_request` 拒掉)
  320. * - `pictures` 里的图后端**自己去取**(所以必须是可公网访问的地址),
  321. * 实测带上它之后模型确实"看到"了图并能描述内容
  322. */
  323. export interface ChatAttachmentUrls {
  324. /** 图片(后端按视觉输入处理) */
  325. pictures: string[];
  326. /** 非图片文件(pdf/docx…) */
  327. files: string[];
  328. }
  329. /**
  330. * 把上传模块交出来的 transmission 拆成 `pictures` / `files`。
  331. *
  332. * 按 `attachments[].isImage` 分(上传时已经判好),**两个列表都只保留非空 URL**。
  333. * 拿不到 `attachments`(只有 `files`)时,只能全部当普通文件 —— 分不出图片,
  334. * 但至少地址不丢。
  335. *
  336. * ⚠️ **取的是 `fileUrl`(相对路径),不是 `url`(可显示地址)**:
  337. * 换 DMS 之后两个字段分工不同 —— 后端要的是 `/static/photo/…`,
  338. * 而 `url` 是拼了 `/dms` 代理前缀给 `<img>` 用的。
  339. * 2026-09-23 的 bug 正是这里取错:气泡里的图不显示(相对路径解不出来),
  340. * 详见 progress.md 续 28 与 verify-attachment-question.mjs 的【7】。
  341. */
  342. export const toChatAttachmentUrls = (
  343. transmission?: BusinessAssistantInputFileTransmission | null,
  344. ): ChatAttachmentUrls => {
  345. const attachments = Array.isArray(transmission?.attachments) ? transmission?.attachments : [];
  346. const pick = (list: BusinessAssistantMessageAttachment[] | undefined) =>
  347. (list || [])
  348. .map((item) => String(item?.fileUrl ?? item?.url ?? '').trim())
  349. .filter(Boolean);
  350. if (attachments && attachments.length) {
  351. return {
  352. pictures: pick(attachments.filter((item) => item?.isImage)),
  353. files: pick(attachments.filter((item) => !item?.isImage)),
  354. };
  355. }
  356. const fallback = (Array.isArray(transmission?.files) ? transmission?.files : [])
  357. .map((url) => String(url ?? '').trim())
  358. .filter(Boolean);
  359. return { pictures: [], files: fallback };
  360. };
  361. // [已停用] 附件地址拼进 question 文本(方案②)—— 2026-09-23 后端支持了
  362. // `pictures` / `files` 两个多模态字段(方案①),地址不再拼进正文:
  363. // 拼进去会让模型把 URL 当正文内容读,而且本地消息、会话标题、DMS 记录都跟着变脏。
  364. // export const buildQuestionWithAttachments = (
  365. // question: string,
  366. // fileUrls?: Array<string | null | undefined> | null,
  367. // ): string => {
  368. // const urls = (Array.isArray(fileUrls) ? fileUrls : [])
  369. // .map((url) => String(url ?? '').trim())
  370. // .filter(Boolean);
  371. //
  372. // if (!urls.length) {
  373. // return question;
  374. // }
  375. //
  376. // return `${question}\n${urls.join('\n')}`;
  377. // };