Improve WebUI streaming and Python tooling
This commit is contained in:
@@ -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 ?? "";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user