chat.ts 9.4 KB

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