chat.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  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. const content = JSON.stringify(res.data);
  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?.({ ...tool, isError: true });
  223. return e.toString();
  224. })
  225. .then((content) => ({
  226. role: "tool",
  227. content,
  228. tool_call_id: tool.id,
  229. }));
  230. }),
  231. ).then((toolCallResult) => {
  232. processToolMessage(requestPayload, toolCallMessage, toolCallResult);
  233. setTimeout(() => {
  234. // call again
  235. console.debug("[ChatAPI] restart");
  236. running = false;
  237. chatApi(chatPath, headers, requestPayload, tools); // call fetchEventSource
  238. }, 60);
  239. });
  240. return;
  241. }
  242. if (running) {
  243. return;
  244. }
  245. console.debug("[ChatAPI] end");
  246. finished = true;
  247. options.onFinish(responseText + remainText);
  248. }
  249. };
  250. controller.signal.onabort = finish;
  251. function chatApi(
  252. chatPath: string,
  253. headers: any,
  254. requestPayload: any,
  255. tools: any,
  256. ) {
  257. const chatPayload = {
  258. method: "POST",
  259. body: JSON.stringify({
  260. ...requestPayload,
  261. tools: tools && tools.length ? tools : undefined,
  262. }),
  263. signal: controller.signal,
  264. headers,
  265. };
  266. const requestTimeoutId = setTimeout(
  267. () => controller.abort(),
  268. REQUEST_TIMEOUT_MS,
  269. );
  270. fetchEventSource(chatPath, {
  271. fetch: tauriFetch as any,
  272. ...chatPayload,
  273. async onopen(res) {
  274. clearTimeout(requestTimeoutId);
  275. const contentType = res.headers.get("content-type");
  276. console.log("[Request] response content type: ", contentType);
  277. if (contentType?.startsWith("text/plain")) {
  278. responseText = await res.clone().text();
  279. return finish();
  280. }
  281. if (
  282. !res.ok ||
  283. !res.headers
  284. .get("content-type")
  285. ?.startsWith(EventStreamContentType) ||
  286. res.status !== 200
  287. ) {
  288. const responseTexts = [responseText];
  289. let extraInfo = await res.clone().text();
  290. try {
  291. const resJson = await res.clone().json();
  292. extraInfo = prettyObject(resJson);
  293. } catch {}
  294. if (res.status === 401) {
  295. responseTexts.push(Locale.Error.Unauthorized);
  296. }
  297. if (extraInfo) {
  298. responseTexts.push(extraInfo);
  299. }
  300. responseText = responseTexts.join("\n\n");
  301. return finish();
  302. }
  303. },
  304. onmessage(msg) {
  305. if (msg.data === "[DONE]" || finished) {
  306. return finish();
  307. }
  308. const text = msg.data;
  309. try {
  310. const chunk = parseSSE(msg.data, runTools);
  311. if (chunk) {
  312. remainText += chunk;
  313. }
  314. } catch (e) {
  315. console.error("[Request] parse error", text, msg, e);
  316. }
  317. },
  318. onclose() {
  319. finish();
  320. },
  321. onerror(e) {
  322. options?.onError?.(e);
  323. throw e;
  324. },
  325. openWhenHidden: true,
  326. });
  327. }
  328. console.debug("[ChatAPI] start");
  329. chatApi(chatPath, headers, requestPayload, tools); // call fetchEventSource
  330. }