feat: sync r1 thinking code

This commit is contained in:
Hk-Gosuto
2025-02-07 16:28:12 +08:00
parent a62612c2f0
commit 318801be1d
17 changed files with 1502 additions and 77 deletions

View File

@@ -10,6 +10,7 @@ import {
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) => {
@@ -352,3 +353,262 @@ export function stream(
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;
// 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.trim().length === 0) {
return;
}
// 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
}

View File

@@ -1,4 +1,4 @@
import { DEFAULT_MODELS } from "../constant";
import { DEFAULT_MODELS, ServiceProvider } from "../constant";
import { LLMModel } from "../client/api";
const CustomSeq = {
@@ -202,3 +202,57 @@ export function isModelAvailableInServer(
const modelTable = collectModelTable(DEFAULT_MODELS, customModels);
return modelTable[fullName]?.available === false;
}
/**
* Check if the model name is a GPT-4 related model
*
* @param modelName The name of the model to check
* @returns True if the model is a GPT-4 related model (excluding gpt-4o-mini)
*/
export function isGPT4Model(modelName: string): boolean {
return (
(modelName.startsWith("gpt-4") ||
modelName.startsWith("chatgpt-4o") ||
modelName.startsWith("o1")) &&
!modelName.startsWith("gpt-4o-mini")
);
}
/**
* Checks if a model is not available on any of the specified providers in the server.
*
* @param {string} customModels - A string of custom models, comma-separated.
* @param {string} modelName - The name of the model to check.
* @param {string|string[]} providerNames - A string or array of provider names to check against.
*
* @returns {boolean} True if the model is not available on any of the specified providers, false otherwise.
*/
export function isModelNotavailableInServer(
customModels: string,
modelName: string,
providerNames: string | string[],
): boolean {
// Check DISABLE_GPT4 environment variable
if (
process.env.DISABLE_GPT4 === "1" &&
isGPT4Model(modelName.toLowerCase())
) {
return true;
}
const modelTable = collectModelTable(DEFAULT_MODELS, customModels);
const providerNamesArray = Array.isArray(providerNames)
? providerNames
: [providerNames];
for (const providerName of providerNamesArray) {
// if model provider is bytedance, use model config name to check if not avaliable
if (providerName === ServiceProvider.ByteDance) {
return !Object.values(modelTable).filter((v) => v.name === modelName)?.[0]
?.available;
}
const fullName = `${modelName}@${providerName.toLowerCase()}`;
if (modelTable?.[fullName]?.available === true) return false;
}
return true;
}