Improve WebUI streaming and Python tooling

This commit is contained in:
武阳
2026-05-06 20:37:17 +08:00
parent a0da3c2423
commit 9ca675be0d
13 changed files with 930 additions and 147 deletions
+163 -10
View File
@@ -26,6 +26,22 @@ type ClawChatResponse = {
detail?: string;
};
type ClawRuntimeEvent = {
type?: string;
tool_name?: string;
tool_call_id?: string;
arguments?: unknown;
assistant_content?: string;
metadata?: Record<string, unknown>;
ok?: boolean;
delta?: string;
};
type ClawStreamItem =
| { kind: "event"; event?: ClawRuntimeEvent }
| { kind: "result"; payload?: ClawChatResponse }
| { kind: "error"; error?: string; detail?: string; status_code?: number };
type ChatRequestBody = {
id?: string;
messages: UIMessage[];
@@ -75,6 +91,7 @@ export async function POST(req: Request) {
const stream = createUIMessageStream({
execute: async ({ writer }) => {
const streamState = { textStarted: false, textStreamed: false };
writer.write({ type: "start" });
writer.write({ type: "start-step" });
@@ -85,12 +102,14 @@ export async function POST(req: Request) {
id: "reasoning-1",
delta: "思考中,正在判断是否需要调用工具。",
});
const payload = await callClawBackend(
const payload = await callClawBackendStream(
userPrompt,
runtimeContext,
account.id,
sessionId,
resumeId,
writer,
streamState,
);
const elapsedMs = payload.elapsed_ms ?? Date.now() - startedAt;
const text = formatClawResponse(payload);
@@ -102,9 +121,14 @@ export async function POST(req: Request) {
});
writer.write({ type: "reasoning-end", id: "reasoning-1" });
writeToolTrace(writer, payload.transcript);
writer.write({ type: "text-start", id: "text-1" });
writer.write({ type: "text-delta", id: "text-1", delta: text });
writer.write({ type: "text-end", id: "text-1" });
if (!streamState.textStreamed) {
writer.write({ type: "text-start", id: "text-1" });
writer.write({ type: "text-delta", id: "text-1", delta: text });
writer.write({ type: "text-end", id: "text-1" });
} else if (streamState.textStarted) {
writer.write({ type: "text-end", id: "text-1" });
streamState.textStarted = false;
}
writer.write({ type: "finish-step" });
writer.write({
type: "finish",
@@ -124,8 +148,13 @@ export async function POST(req: Request) {
}
function formatDuration(ms: number) {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
const safeMs = Math.max(0, Math.round(ms));
if (safeMs < 1000) return `${safeMs}ms`;
const totalSeconds = Math.round(safeMs / 1000);
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return seconds > 0 ? `${minutes}min${seconds}s` : `${minutes}min`;
}
function writeToolTrace(
@@ -276,15 +305,17 @@ function safeFilename(value: string) {
return value.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 120) || "attachment";
}
async function callClawBackend(
async function callClawBackendStream(
prompt: string,
runtimeContext: string,
accountId: string,
sessionId: string,
resumeSessionId?: string,
writer?: UIMessageStreamWriter<UIMessage>,
streamState?: { textStarted: boolean; textStreamed: boolean },
): Promise<ClawChatResponse> {
try {
const response = await fetch(`${CLAW_API_URL}/api/chat`, {
const response = await fetch(`${CLAW_API_URL}/api/chat/stream`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
@@ -295,8 +326,10 @@ async function callClawBackend(
...(resumeSessionId ? { resume_session_id: resumeSessionId } : {}),
}),
});
const payload = (await response.json()) as ClawChatResponse;
if (!response.ok) {
const payload = (await response
.json()
.catch(() => ({}))) as ClawChatResponse;
return {
error:
payload.detail ??
@@ -304,7 +337,9 @@ async function callClawBackend(
`Claw backend returned ${response.status}`,
};
}
return payload;
if (!response.body)
return { error: "Claw backend returned an empty stream" };
return await consumeClawStream(response.body, writer, streamState);
} catch (err) {
return {
error:
@@ -315,6 +350,124 @@ async function callClawBackend(
}
}
async function consumeClawStream(
body: ReadableStream<Uint8Array>,
writer?: UIMessageStreamWriter<UIMessage>,
streamState?: { textStarted: boolean; textStreamed: boolean },
) {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let finalPayload: ClawChatResponse | undefined;
while (true) {
const { value, done } = await reader.read();
if (value) {
buffer += decoder.decode(value, { stream: !done });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const payload = parseStreamLine(line);
if (!payload) continue;
if (payload.kind === "event" && payload.event && writer) {
writeRuntimeEvent(writer, payload.event, streamState);
}
if (payload.kind === "result") {
finalPayload = payload.payload;
}
if (payload.kind === "error") {
return {
error:
payload.detail ??
payload.error ??
`Claw backend returned ${payload.status_code ?? 500}`,
};
}
}
}
if (done) break;
}
if (buffer.trim()) {
const payload = parseStreamLine(buffer);
if (payload?.kind === "result") finalPayload = payload.payload;
if (payload?.kind === "error") {
return {
error:
payload.detail ??
payload.error ??
`Claw backend returned ${payload.status_code ?? 500}`,
};
}
}
return (
finalPayload ?? { error: "Claw backend stream ended without a result" }
);
}
function parseStreamLine(line: string): ClawStreamItem | undefined {
const trimmed = line.trim();
if (!trimmed) return undefined;
try {
return JSON.parse(trimmed) as ClawStreamItem;
} catch {
return undefined;
}
}
function writeRuntimeEvent(
writer: UIMessageStreamWriter<UIMessage>,
event: ClawRuntimeEvent,
streamState?: { textStarted: boolean; textStreamed: boolean },
) {
if (event.type === "final_text_start") {
if (!streamState?.textStarted) {
writer.write({ type: "text-start", id: "text-1" });
if (streamState) streamState.textStarted = true;
}
}
if (event.type === "final_text_delta" && event.delta) {
if (!streamState?.textStarted) {
writer.write({ type: "text-start", id: "text-1" });
if (streamState) streamState.textStarted = true;
}
writer.write({ type: "text-delta", id: "text-1", delta: event.delta });
if (streamState) streamState.textStreamed = true;
}
if (event.type === "final_text_end") {
if (streamState?.textStarted) {
writer.write({ type: "text-end", id: "text-1" });
streamState.textStarted = false;
}
}
if (event.type === "tool_start" && event.tool_call_id && event.tool_name) {
writer.write({
type: "tool-input-available",
toolCallId: event.tool_call_id,
toolName: event.tool_name,
input: attachStageNote(event.arguments ?? {}, event.assistant_content),
});
}
if (event.type === "tool_delta" && event.tool_call_id && event.delta) {
writer.write({
type: "reasoning-delta",
id: "reasoning-1",
delta: `\n${event.tool_name ?? "tool"} 输出中...`,
});
}
if (event.type === "tool_result" && event.tool_call_id) {
const preview = event.metadata?.output_preview;
writer.write({
type: "tool-output-available",
toolCallId: event.tool_call_id,
output:
typeof preview === "string"
? preview
: event.ok === false
? "工具执行失败,等待最终结果汇总。"
: "工具调用完成,等待最终结果汇总。",
});
}
}
function formatClawResponse(payload: ClawChatResponse) {
if (payload.error) return payload.error;
return payload.final_output ?? "";
+6 -2
View File
@@ -24,10 +24,14 @@ async function proxyModelsRequest(
if (!account) return Response.json({ models: [] }, { status: 401 });
try {
const response = await fetch(`${CLAW_API_URL}/api/models`, {
const url = new URL(`${CLAW_API_URL}/api/models`);
if (method === "GET") url.searchParams.set("account_id", account.id);
const response = await fetch(url, {
method,
headers: payload ? { "content-type": "application/json" } : undefined,
body: payload ? JSON.stringify(payload) : undefined,
body: payload
? JSON.stringify({ ...payload, account_id: account.id })
: undefined,
cache: "no-store",
});
const body = await response.text();
+3 -1
View File
@@ -6,7 +6,9 @@ export async function GET() {
const account = await getCurrentAccount();
if (!account) return Response.json([], { status: 401 });
const response = await fetch(`${CLAW_API_URL}/api/skills`, {
const url = new URL(`${CLAW_API_URL}/api/skills`);
url.searchParams.set("account_id", account.id);
const response = await fetch(url, {
cache: "no-store",
});
const payload = await response.text();