chat.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667
  1. import {
  2. CACHE_URL_PREFIX,
  3. UPLOAD_URL,
  4. REQUEST_TIMEOUT_MS,
  5. } from "@/app/constant";
  6. import { MultimodalContent, RequestMessage } from "@/app/client/api";
  7. import Locale from "@/app/locales";
  8. import {
  9. EventStreamContentType,
  10. fetchEventSource,
  11. } from "@fortaine/fetch-event-source";
  12. import { prettyObject } from "./format";
  13. import { fetch as tauriFetch } from "./stream";
  14. export function compressImage(file: Blob, maxSize: number): Promise<string> {
  15. return new Promise((resolve, reject) => {
  16. const reader = new FileReader();
  17. reader.onload = (readerEvent: any) => {
  18. const image = new Image();
  19. image.onload = () => {
  20. let canvas = document.createElement("canvas");
  21. let ctx = canvas.getContext("2d");
  22. let width = image.width;
  23. let height = image.height;
  24. let quality = 0.9;
  25. let dataUrl;
  26. do {
  27. canvas.width = width;
  28. canvas.height = height;
  29. ctx?.clearRect(0, 0, canvas.width, canvas.height);
  30. ctx?.drawImage(image, 0, 0, width, height);
  31. dataUrl = canvas.toDataURL("image/jpeg", quality);
  32. if (dataUrl.length < maxSize) break;
  33. if (quality > 0.5) {
  34. // Prioritize quality reduction
  35. quality -= 0.1;
  36. } else {
  37. // Then reduce the size
  38. width *= 0.9;
  39. height *= 0.9;
  40. }
  41. } while (dataUrl.length > maxSize);
  42. resolve(dataUrl);
  43. };
  44. image.onerror = reject;
  45. image.src = readerEvent.target.result;
  46. };
  47. reader.onerror = reject;
  48. if (file.type.includes("heic")) {
  49. try {
  50. const heic2any = require("heic2any");
  51. heic2any({ blob: file, toType: "image/jpeg" })
  52. .then((blob: Blob) => {
  53. reader.readAsDataURL(blob);
  54. })
  55. .catch((e: any) => {
  56. reject(e);
  57. });
  58. } catch (e) {
  59. reject(e);
  60. }
  61. }
  62. reader.readAsDataURL(file);
  63. });
  64. }
  65. export async function preProcessImageContentBase(
  66. content: RequestMessage["content"],
  67. transformImageUrl: (url: string) => Promise<{ [key: string]: any }>,
  68. ) {
  69. if (typeof content === "string") {
  70. return content;
  71. }
  72. const result = [];
  73. for (const part of content) {
  74. if (part?.type == "image_url" && part?.image_url?.url) {
  75. try {
  76. const url = await cacheImageToBase64Image(part?.image_url?.url);
  77. result.push(await transformImageUrl(url));
  78. } catch (error) {
  79. console.error("Error processing image URL:", error);
  80. }
  81. } else {
  82. result.push({ ...part });
  83. }
  84. }
  85. return result;
  86. }
  87. export async function preProcessImageContent(
  88. content: RequestMessage["content"],
  89. ) {
  90. return preProcessImageContentBase(content, async (url) => ({
  91. type: "image_url",
  92. image_url: { url },
  93. })) as Promise<MultimodalContent[] | string>;
  94. }
  95. export async function preProcessImageContentForAlibabaDashScope(
  96. content: RequestMessage["content"],
  97. ) {
  98. return preProcessImageContentBase(content, async (url) => ({
  99. image: url,
  100. }));
  101. }
  102. const imageCaches: Record<string, string> = {};
  103. export function cacheImageToBase64Image(imageUrl: string) {
  104. if (imageUrl.includes(CACHE_URL_PREFIX)) {
  105. if (!imageCaches[imageUrl]) {
  106. const reader = new FileReader();
  107. return fetch(imageUrl, {
  108. method: "GET",
  109. mode: "cors",
  110. credentials: "include",
  111. })
  112. .then((res) => res.blob())
  113. .then(
  114. async (blob) =>
  115. (imageCaches[imageUrl] = await compressImage(blob, 256 * 1024)),
  116. ); // compressImage
  117. }
  118. return Promise.resolve(imageCaches[imageUrl]);
  119. }
  120. return Promise.resolve(imageUrl);
  121. }
  122. export function base64Image2Blob(base64Data: string, contentType: string) {
  123. const byteCharacters = atob(base64Data);
  124. const byteNumbers = new Array(byteCharacters.length);
  125. for (let i = 0; i < byteCharacters.length; i++) {
  126. byteNumbers[i] = byteCharacters.charCodeAt(i);
  127. }
  128. const byteArray = new Uint8Array(byteNumbers);
  129. return new Blob([byteArray], { type: contentType });
  130. }
  131. export function uploadImage(file: Blob): Promise<string> {
  132. if (!window._SW_ENABLED) {
  133. // if serviceWorker register error, using compressImage
  134. return compressImage(file, 256 * 1024);
  135. }
  136. const body = new FormData();
  137. body.append("file", file);
  138. return fetch(UPLOAD_URL, {
  139. method: "post",
  140. body,
  141. mode: "cors",
  142. credentials: "include",
  143. })
  144. .then((res) => res.json())
  145. .then((res) => {
  146. // console.log("res", res);
  147. if (res?.code == 0 && res?.data) {
  148. return res?.data;
  149. }
  150. throw Error(`upload Error: ${res?.msg}`);
  151. });
  152. }
  153. export function removeImage(imageUrl: string) {
  154. return fetch(imageUrl, {
  155. method: "DELETE",
  156. mode: "cors",
  157. credentials: "include",
  158. });
  159. }
  160. export function stream(
  161. chatPath: string,
  162. requestPayload: any,
  163. headers: any,
  164. tools: any[],
  165. funcs: Record<string, Function>,
  166. controller: AbortController,
  167. parseSSE: (text: string, runTools: any[]) => string | undefined,
  168. processToolMessage: (
  169. requestPayload: any,
  170. toolCallMessage: any,
  171. toolCallResult: any[],
  172. ) => void,
  173. options: any,
  174. ) {
  175. let responseText = "";
  176. let remainText = "";
  177. let finished = false;
  178. let running = false;
  179. let runTools: any[] = [];
  180. let responseRes: Response;
  181. // animate response to make it looks smooth
  182. function animateResponseText() {
  183. if (finished || controller.signal.aborted) {
  184. responseText += remainText;
  185. console.log("[Response Animation] finished");
  186. if (responseText?.length === 0) {
  187. options.onError?.(new Error("empty response from server"));
  188. }
  189. return;
  190. }
  191. if (remainText.length > 0) {
  192. const fetchCount = Math.max(1, Math.round(remainText.length / 60));
  193. const fetchText = remainText.slice(0, fetchCount);
  194. responseText += fetchText;
  195. remainText = remainText.slice(fetchCount);
  196. options.onUpdate?.(responseText, fetchText);
  197. }
  198. requestAnimationFrame(animateResponseText);
  199. }
  200. // start animaion
  201. animateResponseText();
  202. const finish = () => {
  203. if (!finished) {
  204. if (!running && runTools.length > 0) {
  205. const toolCallMessage = {
  206. role: "assistant",
  207. tool_calls: [...runTools],
  208. };
  209. running = true;
  210. runTools.splice(0, runTools.length); // empty runTools
  211. return Promise.all(
  212. toolCallMessage.tool_calls.map((tool) => {
  213. options?.onBeforeTool?.(tool);
  214. return Promise.resolve(
  215. // @ts-ignore
  216. funcs[tool.function.name](
  217. // @ts-ignore
  218. tool?.function?.arguments
  219. ? JSON.parse(tool?.function?.arguments)
  220. : {},
  221. ),
  222. )
  223. .then((res) => {
  224. let content = res.data || res?.statusText;
  225. // hotfix #5614
  226. content =
  227. typeof content === "string"
  228. ? content
  229. : JSON.stringify(content);
  230. if (res.status >= 300) {
  231. return Promise.reject(content);
  232. }
  233. return content;
  234. })
  235. .then((content) => {
  236. options?.onAfterTool?.({
  237. ...tool,
  238. content,
  239. isError: false,
  240. });
  241. return content;
  242. })
  243. .catch((e) => {
  244. options?.onAfterTool?.({
  245. ...tool,
  246. isError: true,
  247. errorMsg: e.toString(),
  248. });
  249. return e.toString();
  250. })
  251. .then((content) => ({
  252. name: tool.function.name,
  253. role: "tool",
  254. content,
  255. tool_call_id: tool.id,
  256. }));
  257. }),
  258. ).then((toolCallResult) => {
  259. processToolMessage(requestPayload, toolCallMessage, toolCallResult);
  260. setTimeout(() => {
  261. // call again
  262. console.debug("[ChatAPI] restart");
  263. running = false;
  264. chatApi(chatPath, headers, requestPayload, tools); // call fetchEventSource
  265. }, 60);
  266. });
  267. return;
  268. }
  269. if (running) {
  270. return;
  271. }
  272. console.debug("[ChatAPI] end");
  273. finished = true;
  274. options.onFinish(responseText + remainText, responseRes); // 将res传递给onFinish
  275. }
  276. };
  277. controller.signal.onabort = finish;
  278. function chatApi(
  279. chatPath: string,
  280. headers: any,
  281. requestPayload: any,
  282. tools: any,
  283. ) {
  284. const chatPayload = {
  285. method: "POST",
  286. body: JSON.stringify({
  287. ...requestPayload,
  288. tools: tools && tools.length ? tools : undefined,
  289. }),
  290. signal: controller.signal,
  291. headers,
  292. };
  293. const requestTimeoutId = setTimeout(
  294. () => controller.abort(),
  295. REQUEST_TIMEOUT_MS,
  296. );
  297. fetchEventSource(chatPath, {
  298. fetch: tauriFetch as any,
  299. ...chatPayload,
  300. async onopen(res) {
  301. clearTimeout(requestTimeoutId);
  302. const contentType = res.headers.get("content-type");
  303. console.log("[Request] response content type: ", contentType);
  304. responseRes = res;
  305. if (contentType?.startsWith("text/plain")) {
  306. responseText = await res.clone().text();
  307. return finish();
  308. }
  309. if (
  310. !res.ok ||
  311. !res.headers
  312. .get("content-type")
  313. ?.startsWith(EventStreamContentType) ||
  314. res.status !== 200
  315. ) {
  316. const responseTexts = [responseText];
  317. let extraInfo = await res.clone().text();
  318. try {
  319. const resJson = await res.clone().json();
  320. extraInfo = prettyObject(resJson);
  321. } catch {}
  322. if (res.status === 401) {
  323. responseTexts.push(Locale.Error.Unauthorized);
  324. }
  325. if (extraInfo) {
  326. responseTexts.push(extraInfo);
  327. }
  328. responseText = responseTexts.join("\n\n");
  329. return finish();
  330. }
  331. },
  332. onmessage(msg) {
  333. if (msg.data === "[DONE]" || finished) {
  334. return finish();
  335. }
  336. const text = msg.data;
  337. // Skip empty messages
  338. if (!text || text.trim().length === 0) {
  339. return;
  340. }
  341. try {
  342. const chunk = parseSSE(text, runTools);
  343. if (chunk) {
  344. remainText += chunk;
  345. }
  346. } catch (e) {
  347. console.error("[Request] parse error", text, msg, e);
  348. }
  349. },
  350. onclose() {
  351. finish();
  352. },
  353. onerror(e) {
  354. options?.onError?.(e);
  355. throw e;
  356. },
  357. openWhenHidden: true,
  358. });
  359. }
  360. console.debug("[ChatAPI] start");
  361. chatApi(chatPath, headers, requestPayload, tools); // call fetchEventSource
  362. }
  363. export function streamWithThink(
  364. chatPath: string,
  365. requestPayload: any,
  366. headers: any,
  367. tools: any[],
  368. funcs: Record<string, Function>,
  369. controller: AbortController,
  370. parseSSE: (
  371. text: string,
  372. runTools: any[],
  373. ) => {
  374. isThinking: boolean;
  375. content: string | undefined;
  376. },
  377. processToolMessage: (
  378. requestPayload: any,
  379. toolCallMessage: any,
  380. toolCallResult: any[],
  381. ) => void,
  382. options: any,
  383. ) {
  384. let responseText = "";
  385. let remainText = "";
  386. let finished = false;
  387. let running = false;
  388. let runTools: any[] = [];
  389. let responseRes: Response;
  390. let isInThinkingMode = false;
  391. let lastIsThinking = false;
  392. let lastIsThinkingTagged = false; //between <think> and </think> tags
  393. // animate response to make it looks smooth
  394. function animateResponseText() {
  395. if (finished || controller.signal.aborted) {
  396. responseText += remainText;
  397. console.log("[Response Animation] finished");
  398. if (responseText?.length === 0) {
  399. options.onError?.(new Error("empty response from server"));
  400. }
  401. return;
  402. }
  403. if (remainText.length > 0) {
  404. const fetchCount = Math.max(1, Math.round(remainText.length / 60));
  405. const fetchText = remainText.slice(0, fetchCount);
  406. responseText += fetchText;
  407. remainText = remainText.slice(fetchCount);
  408. options.onUpdate?.(responseText, fetchText);
  409. }
  410. requestAnimationFrame(animateResponseText);
  411. }
  412. // start animaion
  413. animateResponseText();
  414. const finish = () => {
  415. if (!finished) {
  416. if (!running && runTools.length > 0) {
  417. const toolCallMessage = {
  418. role: "assistant",
  419. tool_calls: [...runTools],
  420. };
  421. running = true;
  422. runTools.splice(0, runTools.length); // empty runTools
  423. return Promise.all(
  424. toolCallMessage.tool_calls.map((tool) => {
  425. options?.onBeforeTool?.(tool);
  426. return Promise.resolve(
  427. // @ts-ignore
  428. funcs[tool.function.name](
  429. // @ts-ignore
  430. tool?.function?.arguments
  431. ? JSON.parse(tool?.function?.arguments)
  432. : {},
  433. ),
  434. )
  435. .then((res) => {
  436. let content = res.data || res?.statusText;
  437. // hotfix #5614
  438. content =
  439. typeof content === "string"
  440. ? content
  441. : JSON.stringify(content);
  442. if (res.status >= 300) {
  443. return Promise.reject(content);
  444. }
  445. return content;
  446. })
  447. .then((content) => {
  448. options?.onAfterTool?.({
  449. ...tool,
  450. content,
  451. isError: false,
  452. });
  453. return content;
  454. })
  455. .catch((e) => {
  456. options?.onAfterTool?.({
  457. ...tool,
  458. isError: true,
  459. errorMsg: e.toString(),
  460. });
  461. return e.toString();
  462. })
  463. .then((content) => ({
  464. name: tool.function.name,
  465. role: "tool",
  466. content,
  467. tool_call_id: tool.id,
  468. }));
  469. }),
  470. ).then((toolCallResult) => {
  471. processToolMessage(requestPayload, toolCallMessage, toolCallResult);
  472. setTimeout(() => {
  473. // call again
  474. console.debug("[ChatAPI] restart");
  475. running = false;
  476. chatApi(chatPath, headers, requestPayload, tools); // call fetchEventSource
  477. }, 60);
  478. });
  479. return;
  480. }
  481. if (running) {
  482. return;
  483. }
  484. console.debug("[ChatAPI] end");
  485. finished = true;
  486. options.onFinish(responseText + remainText, responseRes);
  487. }
  488. };
  489. controller.signal.onabort = finish;
  490. function chatApi(
  491. chatPath: string,
  492. headers: any,
  493. requestPayload: any,
  494. tools: any,
  495. ) {
  496. const chatPayload = {
  497. method: "POST",
  498. body: JSON.stringify({
  499. ...requestPayload,
  500. tools: tools && tools.length ? tools : undefined,
  501. }),
  502. signal: controller.signal,
  503. headers,
  504. };
  505. const requestTimeoutId = setTimeout(
  506. () => controller.abort(),
  507. REQUEST_TIMEOUT_MS,
  508. );
  509. fetchEventSource(chatPath, {
  510. fetch: tauriFetch as any,
  511. ...chatPayload,
  512. async onopen(res) {
  513. clearTimeout(requestTimeoutId);
  514. const contentType = res.headers.get("content-type");
  515. console.log("[Request] response content type: ", contentType);
  516. responseRes = res;
  517. if (contentType?.startsWith("text/plain")) {
  518. responseText = await res.clone().text();
  519. return finish();
  520. }
  521. if (
  522. !res.ok ||
  523. !res.headers
  524. .get("content-type")
  525. ?.startsWith(EventStreamContentType) ||
  526. res.status !== 200
  527. ) {
  528. const responseTexts = [responseText];
  529. let extraInfo = await res.clone().text();
  530. try {
  531. const resJson = await res.clone().json();
  532. extraInfo = prettyObject(resJson);
  533. } catch {}
  534. if (res.status === 401) {
  535. responseTexts.push(Locale.Error.Unauthorized);
  536. }
  537. if (extraInfo) {
  538. responseTexts.push(extraInfo);
  539. }
  540. responseText = responseTexts.join("\n\n");
  541. return finish();
  542. }
  543. },
  544. onmessage(msg) {
  545. if (msg.data === "[DONE]" || finished) {
  546. return finish();
  547. }
  548. const text = msg.data;
  549. // Skip empty messages
  550. if (!text || text.trim().length === 0) {
  551. return;
  552. }
  553. try {
  554. const chunk = parseSSE(text, runTools);
  555. // Skip if content is empty
  556. if (!chunk?.content || chunk.content.length === 0) {
  557. return;
  558. }
  559. // deal with <think> and </think> tags start
  560. if (!chunk.isThinking) {
  561. if (chunk.content.startsWith("<think>")) {
  562. chunk.isThinking = true;
  563. chunk.content = chunk.content.slice(7).trim();
  564. lastIsThinkingTagged = true;
  565. } else if (chunk.content.endsWith("</think>")) {
  566. chunk.isThinking = false;
  567. chunk.content = chunk.content.slice(0, -8).trim();
  568. lastIsThinkingTagged = false;
  569. } else if (lastIsThinkingTagged) {
  570. chunk.isThinking = true;
  571. }
  572. }
  573. // deal with <think> and </think> tags start
  574. // Check if thinking mode changed
  575. const isThinkingChanged = lastIsThinking !== chunk.isThinking;
  576. lastIsThinking = chunk.isThinking;
  577. if (chunk.isThinking) {
  578. // If in thinking mode
  579. if (!isInThinkingMode || isThinkingChanged) {
  580. // If this is a new thinking block or mode changed, add prefix
  581. isInThinkingMode = true;
  582. if (remainText.length > 0) {
  583. remainText += "\n";
  584. }
  585. remainText += "> " + chunk.content;
  586. } else {
  587. // Handle newlines in thinking content
  588. if (chunk.content.includes("\n\n")) {
  589. const lines = chunk.content.split("\n\n");
  590. remainText += lines.join("\n\n> ");
  591. } else {
  592. remainText += chunk.content;
  593. }
  594. }
  595. } else {
  596. // If in normal mode
  597. if (isInThinkingMode || isThinkingChanged) {
  598. // If switching from thinking mode to normal mode
  599. isInThinkingMode = false;
  600. remainText += "\n\n" + chunk.content;
  601. } else {
  602. remainText += chunk.content;
  603. }
  604. }
  605. } catch (e) {
  606. console.error("[Request] parse error", text, msg, e);
  607. // Don't throw error for parse failures, just log them
  608. }
  609. },
  610. onclose() {
  611. finish();
  612. },
  613. onerror(e) {
  614. options?.onError?.(e);
  615. throw e;
  616. },
  617. openWhenHidden: true,
  618. });
  619. }
  620. console.debug("[ChatAPI] start");
  621. chatApi(chatPath, headers, requestPayload, tools); // call fetchEventSource
  622. }