chat.ts 10 KB

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