chat.ts 18 KB

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