feat(agent): implement streaming responses for online Agent and enhance tool execution feedback

This commit is contained in:
HouYunFei
2026-06-16 16:16:04 +08:00
parent 7893bd72dd
commit 27bd6f004b
4 changed files with 146 additions and 38 deletions
+116 -31
View File
@@ -63,6 +63,7 @@ type ResponseApiPayload = {
code?: number;
msg?: string;
};
type ResponseStreamState = { buffer: string; text: string; payload?: ResponseApiPayload; error?: string };
type ImageApiResponse = {
data?: Array<Record<string, unknown>>;
@@ -263,6 +264,108 @@ function parseToolResponse(payload: ResponseApiPayload): ToolResponseResult {
return { content, toolCalls };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function responseErrorMessage(value: unknown) {
if (!isRecord(value)) return "";
const error = isRecord(value.error) ? value.error : undefined;
const response = isRecord(value.response) ? value.response : undefined;
const responseError = response && isRecord(response.error) ? response.error : undefined;
return stringValue(value.msg) || stringValue(error?.message) || stringValue(responseError?.message);
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : "";
}
function validateResponsePayload(payload: ResponseApiPayload) {
if (typeof payload.code === "number" && payload.code !== 0) throw new Error(payload.msg || "请求失败");
if (payload.error?.message) throw new Error(payload.error.message);
}
async function readFetchError(response: Response, fallback: string) {
const text = await response.text();
if (!text) return readStatusError(response.status, fallback);
try {
return responseErrorMessage(JSON.parse(text)) || readStatusError(response.status, fallback);
} catch {
return text.slice(0, 300) || readStatusError(response.status, fallback);
}
}
function consumeResponseStreamBlock(block: string, state: ResponseStreamState, onDelta?: (text: string) => void) {
const data = block
.split(/\r?\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).replace(/^ /, ""))
.join("\n")
.trim();
if (!data || data === "[DONE]") return;
const event = JSON.parse(data) as Record<string, unknown>;
const type = stringValue(event.type);
const errorMessage = responseErrorMessage(event);
if (errorMessage) state.error = errorMessage;
if (type === "response.output_text.delta" && typeof event.delta === "string") {
state.text += event.delta;
onDelta?.(state.text);
}
if (type === "response.output_text.done" && !state.text && typeof event.text === "string") {
state.text = event.text;
onDelta?.(state.text);
}
if (type === "response.completed" && isRecord(event.response)) {
state.payload = event.response as ResponseApiPayload;
} else if (Array.isArray(event.output)) {
state.payload = event as ResponseApiPayload;
}
}
function consumeResponseStreamText(state: ResponseStreamState, text: string, onDelta?: (text: string) => void, flush = false) {
state.buffer += text;
for (;;) {
const match = state.buffer.match(/\r?\n\r?\n/);
if (!match) break;
consumeResponseStreamBlock(state.buffer.slice(0, match.index), state, onDelta);
state.buffer = state.buffer.slice(match.index + match[0].length);
}
if (flush && state.buffer.trim()) {
consumeResponseStreamBlock(state.buffer, state, onDelta);
state.buffer = "";
}
}
async function requestStreamingResponse(config: AiConfig, body: Record<string, unknown>, onDelta?: (text: string) => void): Promise<ToolResponseResult> {
const response = await fetch(aiApiUrl(config, "/responses"), {
method: "POST",
headers: { ...aiHeaders(config, "application/json"), Accept: "text/event-stream" },
body: JSON.stringify({ ...body, stream: true }),
});
if (!response.ok) throw new Error(await readFetchError(response, "请求失败"));
if (!response.body) {
const payload = (await response.json()) as ResponseApiPayload;
validateResponsePayload(payload);
return parseToolResponse(payload);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
const state: ResponseStreamState = { buffer: "", text: "" };
for (;;) {
const { done, value } = await reader.read();
if (done) break;
consumeResponseStreamText(state, decoder.decode(value, { stream: true }), onDelta);
if (state.error) throw new Error(state.error);
}
consumeResponseStreamText(state, decoder.decode(), onDelta, true);
if (state.error) throw new Error(state.error);
if (!state.payload) return { content: state.text, toolCalls: [] };
validateResponsePayload(state.payload);
const result = parseToolResponse(state.payload);
return { ...result, content: state.text || result.content };
}
export async function requestGeneration(config: AiConfig, prompt: string) {
const requestConfig = resolveModelRequestConfig(config, config.model || config.imageModel);
const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
@@ -325,45 +428,27 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
export async function requestImageQuestion(config: AiConfig, messages: AiTextMessage[], onDelta: (text: string) => void) {
const requestConfig = resolveModelRequestConfig(config, config.model || config.textModel);
try {
const response = await axios.post<ResponseApiPayload>(
aiApiUrl(requestConfig, "/responses"),
{
model: requestConfig.model,
input: toResponseInput(withSystemMessage(requestConfig, messages)),
},
{
headers: aiHeaders(requestConfig, "application/json"),
},
);
if (typeof response.data.code === "number" && response.data.code !== 0) throw new Error(response.data.msg || "请求失败");
if (response.data.error?.message) throw new Error(response.data.error.message);
const answer = parseToolResponse(response.data).content || "没有返回内容";
onDelta(answer);
const answer = (await requestStreamingResponse(requestConfig, {
model: requestConfig.model,
input: toResponseInput(withSystemMessage(requestConfig, messages)),
}, onDelta)).content || "没有返回内容";
if (answer === "没有返回内容") onDelta(answer);
return answer;
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}
}
export async function requestToolResponse(config: AiConfig, messages: ResponseInputMessage[], tools: ResponseFunctionTool[], toolChoice: ToolChoice = "auto"): Promise<ToolResponseResult> {
export async function requestToolResponse(config: AiConfig, messages: ResponseInputMessage[], tools: ResponseFunctionTool[], toolChoice: ToolChoice = "auto", onDelta?: (text: string) => void): Promise<ToolResponseResult> {
const requestConfig = resolveModelRequestConfig(config, config.model || config.textModel);
try {
const response = await axios.post<ResponseApiPayload>(
aiApiUrl(requestConfig, "/responses"),
{
model: requestConfig.model,
input: toResponseInput(withSystemMessage(requestConfig, messages)),
tools: tools.map(toResponseTool),
tool_choice: toolChoice,
parallel_tool_calls: false,
},
{
headers: aiHeaders(requestConfig, "application/json"),
},
);
if (typeof response.data.code === "number" && response.data.code !== 0) throw new Error(response.data.msg || "请求失败");
if (response.data.error?.message) throw new Error(response.data.error.message);
return parseToolResponse(response.data);
return await requestStreamingResponse(requestConfig, {
model: requestConfig.model,
input: toResponseInput(withSystemMessage(requestConfig, messages)),
tools: tools.map(toResponseTool),
tool_choice: toolChoice,
parallel_tool_calls: false,
}, onDelta);
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}