chat.ts 9.4 KB

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