chat.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  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. // animate response to make it looks smooth
  165. function animateResponseText() {
  166. if (finished || controller.signal.aborted) {
  167. responseText += remainText;
  168. console.log("[Response Animation] finished");
  169. if (responseText?.length === 0) {
  170. options.onError?.(new Error("empty response from server"));
  171. }
  172. return;
  173. }
  174. if (remainText.length > 0) {
  175. const fetchCount = Math.max(1, Math.round(remainText.length / 60));
  176. const fetchText = remainText.slice(0, fetchCount);
  177. responseText += fetchText;
  178. remainText = remainText.slice(fetchCount);
  179. options.onUpdate?.(responseText, fetchText);
  180. }
  181. requestAnimationFrame(animateResponseText);
  182. }
  183. // start animaion
  184. animateResponseText();
  185. const finish = () => {
  186. if (!finished) {
  187. if (!running && runTools.length > 0) {
  188. const toolCallMessage = {
  189. role: "assistant",
  190. tool_calls: [...runTools],
  191. };
  192. running = true;
  193. runTools.splice(0, runTools.length); // empty runTools
  194. return Promise.all(
  195. toolCallMessage.tool_calls.map((tool) => {
  196. options?.onBeforeTool?.(tool);
  197. return Promise.resolve(
  198. // @ts-ignore
  199. funcs[tool.function.name](
  200. // @ts-ignore
  201. tool?.function?.arguments
  202. ? JSON.parse(tool?.function?.arguments)
  203. : {},
  204. ),
  205. )
  206. .then((res) => {
  207. let content = res.data || res?.statusText;
  208. if (res.status >= 300) {
  209. return Promise.reject(content);
  210. }
  211. return content;
  212. })
  213. .then((content) => {
  214. options?.onAfterTool?.({
  215. ...tool,
  216. content,
  217. isError: false,
  218. });
  219. return content;
  220. })
  221. .catch((e) => {
  222. options?.onAfterTool?.({
  223. ...tool,
  224. isError: true,
  225. errorMsg: e.toString(),
  226. });
  227. return e.toString();
  228. })
  229. .then((content) => ({
  230. name: tool.function.name,
  231. role: "tool",
  232. content,
  233. tool_call_id: tool.id,
  234. }));
  235. }),
  236. ).then((toolCallResult) => {
  237. processToolMessage(requestPayload, toolCallMessage, toolCallResult);
  238. setTimeout(() => {
  239. // call again
  240. console.debug("[ChatAPI] restart");
  241. running = false;
  242. chatApi(chatPath, headers, requestPayload, tools); // call fetchEventSource
  243. }, 60);
  244. });
  245. return;
  246. }
  247. if (running) {
  248. return;
  249. }
  250. console.debug("[ChatAPI] end");
  251. finished = true;
  252. options.onFinish(responseText + remainText);
  253. }
  254. };
  255. controller.signal.onabort = finish;
  256. function chatApi(
  257. chatPath: string,
  258. headers: any,
  259. requestPayload: any,
  260. tools: any,
  261. ) {
  262. const chatPayload = {
  263. method: "POST",
  264. body: JSON.stringify({
  265. ...requestPayload,
  266. tools: tools && tools.length ? tools : undefined,
  267. }),
  268. signal: controller.signal,
  269. headers,
  270. };
  271. const requestTimeoutId = setTimeout(
  272. () => controller.abort(),
  273. REQUEST_TIMEOUT_MS,
  274. );
  275. fetchEventSource(chatPath, {
  276. fetch: tauriFetch as any,
  277. ...chatPayload,
  278. async onopen(res) {
  279. clearTimeout(requestTimeoutId);
  280. const contentType = res.headers.get("content-type");
  281. console.log("[Request] response content type: ", contentType);
  282. if (contentType?.startsWith("text/plain")) {
  283. responseText = await res.clone().text();
  284. return finish();
  285. }
  286. if (
  287. !res.ok ||
  288. !res.headers
  289. .get("content-type")
  290. ?.startsWith(EventStreamContentType) ||
  291. res.status !== 200
  292. ) {
  293. const responseTexts = [responseText];
  294. let extraInfo = await res.clone().text();
  295. try {
  296. const resJson = await res.clone().json();
  297. extraInfo = prettyObject(resJson);
  298. } catch {}
  299. if (res.status === 401) {
  300. responseTexts.push(Locale.Error.Unauthorized);
  301. }
  302. if (extraInfo) {
  303. responseTexts.push(extraInfo);
  304. }
  305. responseText = responseTexts.join("\n\n");
  306. return finish();
  307. }
  308. },
  309. onmessage(msg) {
  310. if (msg.data === "[DONE]" || finished) {
  311. return finish();
  312. }
  313. const text = msg.data;
  314. try {
  315. const chunk = parseSSE(msg.data, runTools);
  316. if (chunk) {
  317. remainText += chunk;
  318. }
  319. } catch (e) {
  320. console.error("[Request] parse error", text, msg, e);
  321. }
  322. },
  323. onclose() {
  324. finish();
  325. },
  326. onerror(e) {
  327. options?.onError?.(e);
  328. throw e;
  329. },
  330. openWhenHidden: true,
  331. });
  332. }
  333. console.debug("[ChatAPI] start");
  334. chatApi(chatPath, headers, requestPayload, tools); // call fetchEventSource
  335. }