chat.ts 9.9 KB

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