chat.ts 19 KB

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