Spaces:
Sleeping
Sleeping
import { | |
CACHE_URL_PREFIX, | |
UPLOAD_URL, | |
REQUEST_TIMEOUT_MS, | |
} from "@/app/constant"; | |
import { RequestMessage } from "@/app/client/api"; | |
import Locale from "@/app/locales"; | |
import { | |
EventStreamContentType, | |
fetchEventSource, | |
} from "@fortaine/fetch-event-source"; | |
import { prettyObject } from "./format"; | |
import { fetch as tauriFetch } from "./stream"; | |
export function compressImage(file: Blob, maxSize: number): Promise<string> { | |
return new Promise((resolve, reject) => { | |
const reader = new FileReader(); | |
reader.onload = (readerEvent: any) => { | |
const image = new Image(); | |
image.onload = () => { | |
let canvas = document.createElement("canvas"); | |
let ctx = canvas.getContext("2d"); | |
let width = image.width; | |
let height = image.height; | |
let quality = 0.9; | |
let dataUrl; | |
do { | |
canvas.width = width; | |
canvas.height = height; | |
ctx?.clearRect(0, 0, canvas.width, canvas.height); | |
ctx?.drawImage(image, 0, 0, width, height); | |
dataUrl = canvas.toDataURL("image/jpeg", quality); | |
if (dataUrl.length < maxSize) break; | |
if (quality > 0.5) { | |
// Prioritize quality reduction | |
quality -= 0.1; | |
} else { | |
// Then reduce the size | |
width *= 0.9; | |
height *= 0.9; | |
} | |
} while (dataUrl.length > maxSize); | |
resolve(dataUrl); | |
}; | |
image.onerror = reject; | |
image.src = readerEvent.target.result; | |
}; | |
reader.onerror = reject; | |
if (file.type.includes("heic")) { | |
try { | |
const heic2any = require("heic2any"); | |
heic2any({ blob: file, toType: "image/jpeg" }) | |
.then((blob: Blob) => { | |
reader.readAsDataURL(blob); | |
}) | |
.catch((e: any) => { | |
reject(e); | |
}); | |
} catch (e) { | |
reject(e); | |
} | |
} | |
reader.readAsDataURL(file); | |
}); | |
} | |
export async function preProcessImageContent( | |
content: RequestMessage["content"], | |
) { | |
if (typeof content === "string") { | |
return content; | |
} | |
const result = []; | |
for (const part of content) { | |
if (part?.type == "image_url" && part?.image_url?.url) { | |
try { | |
const url = await cacheImageToBase64Image(part?.image_url?.url); | |
result.push({ type: part.type, image_url: { url } }); | |
} catch (error) { | |
console.error("Error processing image URL:", error); | |
} | |
} else { | |
result.push({ ...part }); | |
} | |
} | |
return result; | |
} | |
const imageCaches: Record<string, string> = {}; | |
export function cacheImageToBase64Image(imageUrl: string) { | |
if (imageUrl.includes(CACHE_URL_PREFIX)) { | |
if (!imageCaches[imageUrl]) { | |
const reader = new FileReader(); | |
return fetch(imageUrl, { | |
method: "GET", | |
mode: "cors", | |
credentials: "include", | |
}) | |
.then((res) => res.blob()) | |
.then( | |
async (blob) => | |
(imageCaches[imageUrl] = await compressImage(blob, 256 * 1024)), | |
); // compressImage | |
} | |
return Promise.resolve(imageCaches[imageUrl]); | |
} | |
return Promise.resolve(imageUrl); | |
} | |
export function base64Image2Blob(base64Data: string, contentType: string) { | |
const byteCharacters = atob(base64Data); | |
const byteNumbers = new Array(byteCharacters.length); | |
for (let i = 0; i < byteCharacters.length; i++) { | |
byteNumbers[i] = byteCharacters.charCodeAt(i); | |
} | |
const byteArray = new Uint8Array(byteNumbers); | |
return new Blob([byteArray], { type: contentType }); | |
} | |
export function uploadImage(file: Blob): Promise<string> { | |
if (!window._SW_ENABLED) { | |
// if serviceWorker register error, using compressImage | |
return compressImage(file, 256 * 1024); | |
} | |
const body = new FormData(); | |
body.append("file", file); | |
return fetch(UPLOAD_URL, { | |
method: "post", | |
body, | |
mode: "cors", | |
credentials: "include", | |
}) | |
.then((res) => res.json()) | |
.then((res) => { | |
// console.log("res", res); | |
if (res?.code == 0 && res?.data) { | |
return res?.data; | |
} | |
throw Error(`upload Error: ${res?.msg}`); | |
}); | |
} | |
export function removeImage(imageUrl: string) { | |
return fetch(imageUrl, { | |
method: "DELETE", | |
mode: "cors", | |
credentials: "include", | |
}); | |
} | |
export function stream( | |
chatPath: string, | |
requestPayload: any, | |
headers: any, | |
tools: any[], | |
funcs: Record<string, Function>, | |
controller: AbortController, | |
parseSSE: (text: string, runTools: any[]) => string | undefined, | |
processToolMessage: ( | |
requestPayload: any, | |
toolCallMessage: any, | |
toolCallResult: any[], | |
) => void, | |
options: any, | |
) { | |
let responseText = ""; | |
let remainText = ""; | |
let finished = false; | |
let running = false; | |
let runTools: any[] = []; | |
let responseRes: Response; | |
// animate response to make it looks smooth | |
function animateResponseText() { | |
if (finished || controller.signal.aborted) { | |
responseText += remainText; | |
console.log("[Response Animation] finished"); | |
if (responseText?.length === 0) { | |
options.onError?.(new Error("empty response from server")); | |
} | |
return; | |
} | |
if (remainText.length > 0) { | |
const fetchCount = Math.max(1, Math.round(remainText.length / 60)); | |
const fetchText = remainText.slice(0, fetchCount); | |
responseText += fetchText; | |
remainText = remainText.slice(fetchCount); | |
options.onUpdate?.(responseText, fetchText); | |
} | |
requestAnimationFrame(animateResponseText); | |
} | |
// start animaion | |
animateResponseText(); | |
const finish = () => { | |
if (!finished) { | |
if (!running && runTools.length > 0) { | |
const toolCallMessage = { | |
role: "assistant", | |
tool_calls: [...runTools], | |
}; | |
running = true; | |
runTools.splice(0, runTools.length); // empty runTools | |
return Promise.all( | |
toolCallMessage.tool_calls.map((tool) => { | |
options?.onBeforeTool?.(tool); | |
return Promise.resolve( | |
// @ts-ignore | |
funcs[tool.function.name]( | |
// @ts-ignore | |
tool?.function?.arguments | |
? JSON.parse(tool?.function?.arguments) | |
: {}, | |
), | |
) | |
.then((res) => { | |
let content = res.data || res?.statusText; | |
// hotfix #5614 | |
content = | |
typeof content === "string" | |
? content | |
: JSON.stringify(content); | |
if (res.status >= 300) { | |
return Promise.reject(content); | |
} | |
return content; | |
}) | |
.then((content) => { | |
options?.onAfterTool?.({ | |
...tool, | |
content, | |
isError: false, | |
}); | |
return content; | |
}) | |
.catch((e) => { | |
options?.onAfterTool?.({ | |
...tool, | |
isError: true, | |
errorMsg: e.toString(), | |
}); | |
return e.toString(); | |
}) | |
.then((content) => ({ | |
name: tool.function.name, | |
role: "tool", | |
content, | |
tool_call_id: tool.id, | |
})); | |
}), | |
).then((toolCallResult) => { | |
processToolMessage(requestPayload, toolCallMessage, toolCallResult); | |
setTimeout(() => { | |
// call again | |
console.debug("[ChatAPI] restart"); | |
running = false; | |
chatApi(chatPath, headers, requestPayload, tools); // call fetchEventSource | |
}, 60); | |
}); | |
return; | |
} | |
if (running) { | |
return; | |
} | |
console.debug("[ChatAPI] end"); | |
finished = true; | |
options.onFinish(responseText + remainText, responseRes); // 将res传递给onFinish | |
} | |
}; | |
controller.signal.onabort = finish; | |
function chatApi( | |
chatPath: string, | |
headers: any, | |
requestPayload: any, | |
tools: any, | |
) { | |
const chatPayload = { | |
method: "POST", | |
body: JSON.stringify({ | |
...requestPayload, | |
tools: tools && tools.length ? tools : undefined, | |
}), | |
signal: controller.signal, | |
headers, | |
}; | |
const requestTimeoutId = setTimeout( | |
() => controller.abort(), | |
REQUEST_TIMEOUT_MS, | |
); | |
fetchEventSource(chatPath, { | |
fetch: tauriFetch as any, | |
...chatPayload, | |
async onopen(res) { | |
clearTimeout(requestTimeoutId); | |
const contentType = res.headers.get("content-type"); | |
console.log("[Request] response content type: ", contentType); | |
responseRes = res; | |
if (contentType?.startsWith("text/plain")) { | |
responseText = await res.clone().text(); | |
return finish(); | |
} | |
if ( | |
!res.ok || | |
!res.headers | |
.get("content-type") | |
?.startsWith(EventStreamContentType) || | |
res.status !== 200 | |
) { | |
const responseTexts = [responseText]; | |
let extraInfo = await res.clone().text(); | |
try { | |
const resJson = await res.clone().json(); | |
extraInfo = prettyObject(resJson); | |
} catch {} | |
if (res.status === 401) { | |
responseTexts.push(Locale.Error.Unauthorized); | |
} | |
if (extraInfo) { | |
responseTexts.push(extraInfo); | |
} | |
responseText = responseTexts.join("\n\n"); | |
return finish(); | |
} | |
}, | |
onmessage(msg) { | |
if (msg.data === "[DONE]" || finished) { | |
return finish(); | |
} | |
const text = msg.data; | |
// Skip empty messages | |
if (!text || text.trim().length === 0) { | |
return; | |
} | |
try { | |
const chunk = parseSSE(text, runTools); | |
if (chunk) { | |
remainText += chunk; | |
} | |
} catch (e) { | |
console.error("[Request] parse error", text, msg, e); | |
} | |
}, | |
onclose() { | |
finish(); | |
}, | |
onerror(e) { | |
options?.onError?.(e); | |
throw e; | |
}, | |
openWhenHidden: true, | |
}); | |
} | |
console.debug("[ChatAPI] start"); | |
chatApi(chatPath, headers, requestPayload, tools); // call fetchEventSource | |
} | |
export function streamWithThink( | |
chatPath: string, | |
requestPayload: any, | |
headers: any, | |
tools: any[], | |
funcs: Record<string, Function>, | |
controller: AbortController, | |
parseSSE: ( | |
text: string, | |
runTools: any[], | |
) => { | |
isThinking: boolean; | |
content: string | undefined; | |
}, | |
processToolMessage: ( | |
requestPayload: any, | |
toolCallMessage: any, | |
toolCallResult: any[], | |
) => void, | |
options: any, | |
) { | |
let responseText = ""; | |
let remainText = ""; | |
let finished = false; | |
let running = false; | |
let runTools: any[] = []; | |
let responseRes: Response; | |
let isInThinkingMode = false; | |
let lastIsThinking = false; | |
let lastIsThinkingTagged = false; //between <think> and </think> tags | |
// animate response to make it looks smooth | |
function animateResponseText() { | |
if (finished || controller.signal.aborted) { | |
responseText += remainText; | |
console.log("[Response Animation] finished"); | |
if (responseText?.length === 0) { | |
options.onError?.(new Error("empty response from server")); | |
} | |
return; | |
} | |
if (remainText.length > 0) { | |
const fetchCount = Math.max(1, Math.round(remainText.length / 60)); | |
const fetchText = remainText.slice(0, fetchCount); | |
responseText += fetchText; | |
remainText = remainText.slice(fetchCount); | |
options.onUpdate?.(responseText, fetchText); | |
} | |
requestAnimationFrame(animateResponseText); | |
} | |
// start animaion | |
animateResponseText(); | |
const finish = () => { | |
if (!finished) { | |
if (!running && runTools.length > 0) { | |
const toolCallMessage = { | |
role: "assistant", | |
tool_calls: [...runTools], | |
}; | |
running = true; | |
runTools.splice(0, runTools.length); // empty runTools | |
return Promise.all( | |
toolCallMessage.tool_calls.map((tool) => { | |
options?.onBeforeTool?.(tool); | |
return Promise.resolve( | |
// @ts-ignore | |
funcs[tool.function.name]( | |
// @ts-ignore | |
tool?.function?.arguments | |
? JSON.parse(tool?.function?.arguments) | |
: {}, | |
), | |
) | |
.then((res) => { | |
let content = res.data || res?.statusText; | |
// hotfix #5614 | |
content = | |
typeof content === "string" | |
? content | |
: JSON.stringify(content); | |
if (res.status >= 300) { | |
return Promise.reject(content); | |
} | |
return content; | |
}) | |
.then((content) => { | |
options?.onAfterTool?.({ | |
...tool, | |
content, | |
isError: false, | |
}); | |
return content; | |
}) | |
.catch((e) => { | |
options?.onAfterTool?.({ | |
...tool, | |
isError: true, | |
errorMsg: e.toString(), | |
}); | |
return e.toString(); | |
}) | |
.then((content) => ({ | |
name: tool.function.name, | |
role: "tool", | |
content, | |
tool_call_id: tool.id, | |
})); | |
}), | |
).then((toolCallResult) => { | |
processToolMessage(requestPayload, toolCallMessage, toolCallResult); | |
setTimeout(() => { | |
// call again | |
console.debug("[ChatAPI] restart"); | |
running = false; | |
chatApi(chatPath, headers, requestPayload, tools); // call fetchEventSource | |
}, 60); | |
}); | |
return; | |
} | |
if (running) { | |
return; | |
} | |
console.debug("[ChatAPI] end"); | |
finished = true; | |
options.onFinish(responseText + remainText, responseRes); | |
} | |
}; | |
controller.signal.onabort = finish; | |
function chatApi( | |
chatPath: string, | |
headers: any, | |
requestPayload: any, | |
tools: any, | |
) { | |
const chatPayload = { | |
method: "POST", | |
body: JSON.stringify({ | |
...requestPayload, | |
tools: tools && tools.length ? tools : undefined, | |
}), | |
signal: controller.signal, | |
headers, | |
}; | |
const requestTimeoutId = setTimeout( | |
() => controller.abort(), | |
REQUEST_TIMEOUT_MS, | |
); | |
fetchEventSource(chatPath, { | |
fetch: tauriFetch as any, | |
...chatPayload, | |
async onopen(res) { | |
clearTimeout(requestTimeoutId); | |
const contentType = res.headers.get("content-type"); | |
console.log("[Request] response content type: ", contentType); | |
responseRes = res; | |
if (contentType?.startsWith("text/plain")) { | |
responseText = await res.clone().text(); | |
return finish(); | |
} | |
if ( | |
!res.ok || | |
!res.headers | |
.get("content-type") | |
?.startsWith(EventStreamContentType) || | |
res.status !== 200 | |
) { | |
const responseTexts = [responseText]; | |
let extraInfo = await res.clone().text(); | |
try { | |
const resJson = await res.clone().json(); | |
extraInfo = prettyObject(resJson); | |
} catch {} | |
if (res.status === 401) { | |
responseTexts.push(Locale.Error.Unauthorized); | |
} | |
if (extraInfo) { | |
responseTexts.push(extraInfo); | |
} | |
responseText = responseTexts.join("\n\n"); | |
return finish(); | |
} | |
}, | |
onmessage(msg) { | |
if (msg.data === "[DONE]" || finished) { | |
return finish(); | |
} | |
const text = msg.data; | |
// Skip empty messages | |
if (!text || text.trim().length === 0) { | |
return; | |
} | |
try { | |
const chunk = parseSSE(text, runTools); | |
// Skip if content is empty | |
if (!chunk?.content || chunk.content.length === 0) { | |
return; | |
} | |
// deal with <think> and </think> tags start | |
if (!chunk.isThinking) { | |
if (chunk.content.startsWith("<think>")) { | |
chunk.isThinking = true; | |
chunk.content = chunk.content.slice(7).trim(); | |
lastIsThinkingTagged = true; | |
} else if (chunk.content.endsWith("</think>")) { | |
chunk.isThinking = false; | |
chunk.content = chunk.content.slice(0, -8).trim(); | |
lastIsThinkingTagged = false; | |
} else if (lastIsThinkingTagged) { | |
chunk.isThinking = true; | |
} | |
} | |
// deal with <think> and </think> tags start | |
// Check if thinking mode changed | |
const isThinkingChanged = lastIsThinking !== chunk.isThinking; | |
lastIsThinking = chunk.isThinking; | |
if (chunk.isThinking) { | |
// If in thinking mode | |
if (!isInThinkingMode || isThinkingChanged) { | |
// If this is a new thinking block or mode changed, add prefix | |
isInThinkingMode = true; | |
if (remainText.length > 0) { | |
remainText += "\n"; | |
} | |
remainText += "> " + chunk.content; | |
} else { | |
// Handle newlines in thinking content | |
if (chunk.content.includes("\n\n")) { | |
const lines = chunk.content.split("\n\n"); | |
remainText += lines.join("\n\n> "); | |
} else { | |
remainText += chunk.content; | |
} | |
} | |
} else { | |
// If in normal mode | |
if (isInThinkingMode || isThinkingChanged) { | |
// If switching from thinking mode to normal mode | |
isInThinkingMode = false; | |
remainText += "\n\n" + chunk.content; | |
} else { | |
remainText += chunk.content; | |
} | |
} | |
} catch (e) { | |
console.error("[Request] parse error", text, msg, e); | |
// Don't throw error for parse failures, just log them | |
} | |
}, | |
onclose() { | |
finish(); | |
}, | |
onerror(e) { | |
options?.onError?.(e); | |
throw e; | |
}, | |
openWhenHidden: true, | |
}); | |
} | |
console.debug("[ChatAPI] start"); | |
chatApi(chatPath, headers, requestPayload, tools); // call fetchEventSource | |
} | |