chat.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  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. export function compressImage(file: Blob, maxSize: number): Promise<string> {
  14. return new Promise((resolve, reject) => {
  15. const reader = new FileReader();
  16. reader.onload = (readerEvent: any) => {
  17. const image = new Image();
  18. image.onload = () => {
  19. let canvas = document.createElement("canvas");
  20. let ctx = canvas.getContext("2d");
  21. let width = image.width;
  22. let height = image.height;
  23. let quality = 0.9;
  24. let dataUrl;
  25. do {
  26. canvas.width = width;
  27. canvas.height = height;
  28. ctx?.clearRect(0, 0, canvas.width, canvas.height);
  29. ctx?.drawImage(image, 0, 0, width, height);
  30. dataUrl = canvas.toDataURL("image/jpeg", quality);
  31. if (dataUrl.length < maxSize) break;
  32. if (quality > 0.5) {
  33. // Prioritize quality reduction
  34. quality -= 0.1;
  35. } else {
  36. // Then reduce the size
  37. width *= 0.9;
  38. height *= 0.9;
  39. }
  40. } while (dataUrl.length > maxSize);
  41. resolve(dataUrl);
  42. };
  43. image.onerror = reject;
  44. image.src = readerEvent.target.result;
  45. };
  46. reader.onerror = reject;
  47. if (file.type.includes("heic")) {
  48. try {
  49. const heic2any = require("heic2any");
  50. heic2any({ blob: file, toType: "image/jpeg" })
  51. .then((blob: Blob) => {
  52. reader.readAsDataURL(blob);
  53. })
  54. .catch((e: any) => {
  55. reject(e);
  56. });
  57. } catch (e) {
  58. reject(e);
  59. }
  60. }
  61. reader.readAsDataURL(file);
  62. });
  63. }
  64. export async function preProcessImageContent(
  65. content: RequestMessage["content"],
  66. ) {
  67. if (typeof content === "string") {
  68. return content;
  69. }
  70. const result = [];
  71. for (const part of content) {
  72. if (part?.type == "image_url" && part?.image_url?.url) {
  73. try {
  74. const url = await cacheImageToBase64Image(part?.image_url?.url);
  75. result.push({ type: part.type, image_url: { url } });
  76. } catch (error) {
  77. console.error("Error processing image URL:", error);
  78. }
  79. } else {
  80. result.push({ ...part });
  81. }
  82. }
  83. return result;
  84. }
  85. const imageCaches: Record<string, string> = {};
  86. export function cacheImageToBase64Image(imageUrl: string) {
  87. if (imageUrl.includes(CACHE_URL_PREFIX)) {
  88. if (!imageCaches[imageUrl]) {
  89. const reader = new FileReader();
  90. return fetch(imageUrl, {
  91. method: "GET",
  92. mode: "cors",
  93. credentials: "include",
  94. })
  95. .then((res) => res.blob())
  96. .then(
  97. async (blob) =>
  98. (imageCaches[imageUrl] = await compressImage(blob, 256 * 1024)),
  99. ); // compressImage
  100. }
  101. return Promise.resolve(imageCaches[imageUrl]);
  102. }
  103. return Promise.resolve(imageUrl);
  104. }
  105. export function base64Image2Blob(base64Data: string, contentType: string) {
  106. const byteCharacters = atob(base64Data);
  107. const byteNumbers = new Array(byteCharacters.length);
  108. for (let i = 0; i < byteCharacters.length; i++) {
  109. byteNumbers[i] = byteCharacters.charCodeAt(i);
  110. }
  111. const byteArray = new Uint8Array(byteNumbers);
  112. return new Blob([byteArray], { type: contentType });
  113. }
  114. export function uploadImage(file: Blob): Promise<string> {
  115. if (!window._SW_ENABLED) {
  116. // if serviceWorker register error, using compressImage
  117. return compressImage(file, 256 * 1024);
  118. }
  119. const body = new FormData();
  120. body.append("file", file);
  121. return fetch(UPLOAD_URL, {
  122. method: "post",
  123. body,
  124. mode: "cors",
  125. credentials: "include",
  126. })
  127. .then((res) => res.json())
  128. .then((res) => {
  129. console.log("res", res);
  130. if (res?.code == 0 && res?.data) {
  131. return res?.data;
  132. }
  133. throw Error(`upload Error: ${res?.msg}`);
  134. });
  135. }
  136. export function removeImage(imageUrl: string) {
  137. return fetch(imageUrl, {
  138. method: "DELETE",
  139. mode: "cors",
  140. credentials: "include",
  141. });
  142. }
  143. export function stream(
  144. chatPath: string,
  145. requestPayload: any,
  146. headers: any,
  147. tools: any[],
  148. funcs: Record<string, Function>,
  149. controller: AbortController,
  150. parseSSE: (text: string, runTools: any[]) => string | undefined,
  151. processToolMessage: (
  152. requestPayload: any,
  153. toolCallMessage: any,
  154. toolCallResult: any[],
  155. ) => void,
  156. options: any,
  157. ) {
  158. let responseText = "";
  159. let remainText = "";
  160. let finished = false;
  161. let running = false;
  162. let runTools: any[] = [];
  163. // animate response to make it looks smooth
  164. function animateResponseText() {
  165. if (finished || controller.signal.aborted) {
  166. responseText += remainText;
  167. console.log("[Response Animation] finished");
  168. if (responseText?.length === 0) {
  169. options.onError?.(new Error("empty response from server"));
  170. }
  171. return;
  172. }
  173. if (remainText.length > 0) {
  174. const fetchCount = Math.max(1, Math.round(remainText.length / 60));
  175. const fetchText = remainText.slice(0, fetchCount);
  176. responseText += fetchText;
  177. remainText = remainText.slice(fetchCount);
  178. options.onUpdate?.(responseText, fetchText);
  179. }
  180. requestAnimationFrame(animateResponseText);
  181. }
  182. // start animaion
  183. animateResponseText();
  184. const finish = () => {
  185. if (!finished) {
  186. if (!running && runTools.length > 0) {
  187. const toolCallMessage = {
  188. role: "assistant",
  189. tool_calls: [...runTools],
  190. };
  191. running = true;
  192. runTools.splice(0, runTools.length); // empty runTools
  193. return Promise.all(
  194. toolCallMessage.tool_calls.map((tool) => {
  195. options?.onBeforeTool?.(tool);
  196. return Promise.resolve(
  197. // @ts-ignore
  198. funcs[tool.function.name](
  199. // @ts-ignore
  200. tool?.function?.arguments
  201. ? JSON.parse(tool?.function?.arguments)
  202. : {},
  203. ),
  204. )
  205. .then((res) => {
  206. const content = JSON.stringify(res.data);
  207. if (res.status >= 300) {
  208. return Promise.reject(content);
  209. }
  210. return content;
  211. })
  212. .then((content) => {
  213. options?.onAfterTool?.({
  214. ...tool,
  215. content,
  216. isError: false,
  217. });
  218. return content;
  219. })
  220. .catch((e) => {
  221. options?.onAfterTool?.({ ...tool, isError: true });
  222. return e.toString();
  223. })
  224. .then((content) => ({
  225. role: "tool",
  226. content,
  227. tool_call_id: tool.id,
  228. }));
  229. }),
  230. ).then((toolCallResult) => {
  231. processToolMessage(requestPayload, toolCallMessage, toolCallResult);
  232. setTimeout(() => {
  233. // call again
  234. console.debug("[ChatAPI] restart");
  235. running = false;
  236. chatApi(chatPath, headers, requestPayload, tools); // call fetchEventSource
  237. }, 60);
  238. });
  239. return;
  240. }
  241. if (running) {
  242. return;
  243. }
  244. console.debug("[ChatAPI] end");
  245. finished = true;
  246. options.onFinish(responseText + remainText);
  247. }
  248. };
  249. controller.signal.onabort = finish;
  250. function chatApi(
  251. chatPath: string,
  252. headers: any,
  253. requestPayload: any,
  254. tools: any,
  255. ) {
  256. const chatPayload = {
  257. method: "POST",
  258. body: JSON.stringify({
  259. ...requestPayload,
  260. tools: tools && tools.length ? tools : undefined,
  261. }),
  262. signal: controller.signal,
  263. headers,
  264. };
  265. const requestTimeoutId = setTimeout(
  266. () => controller.abort(),
  267. REQUEST_TIMEOUT_MS,
  268. );
  269. fetchEventSource(chatPath, {
  270. ...chatPayload,
  271. async onopen(res) {
  272. clearTimeout(requestTimeoutId);
  273. const contentType = res.headers.get("content-type");
  274. console.log("[Request] response content type: ", contentType);
  275. if (contentType?.startsWith("text/plain")) {
  276. responseText = await res.clone().text();
  277. return finish();
  278. }
  279. if (
  280. !res.ok ||
  281. !res.headers
  282. .get("content-type")
  283. ?.startsWith(EventStreamContentType) ||
  284. res.status !== 200
  285. ) {
  286. const responseTexts = [responseText];
  287. let extraInfo = await res.clone().text();
  288. try {
  289. const resJson = await res.clone().json();
  290. extraInfo = prettyObject(resJson);
  291. } catch {}
  292. if (res.status === 401) {
  293. responseTexts.push(Locale.Error.Unauthorized);
  294. }
  295. if (extraInfo) {
  296. responseTexts.push(extraInfo);
  297. }
  298. responseText = responseTexts.join("\n\n");
  299. return finish();
  300. }
  301. },
  302. onmessage(msg) {
  303. if (msg.data === "[DONE]" || finished) {
  304. return finish();
  305. }
  306. const text = msg.data;
  307. try {
  308. const chunk = parseSSE(msg.data, runTools);
  309. if (chunk) {
  310. remainText += chunk;
  311. }
  312. } catch (e) {
  313. console.error("[Request] parse error", text, msg, e);
  314. }
  315. },
  316. onclose() {
  317. finish();
  318. },
  319. onerror(e) {
  320. options?.onError?.(e);
  321. throw e;
  322. },
  323. openWhenHidden: true,
  324. });
  325. }
  326. console.debug("[ChatAPI] start");
  327. chatApi(chatPath, headers, requestPayload, tools); // call fetchEventSource
  328. }