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();
+98 -12
View File
@@ -1061,15 +1061,30 @@ const ActivityChainSummary: FC<{
partIndex: number | undefined;
}> = ({ messageId, partIndex }) => {
const { openItem } = useActivityPanel();
const startedAtRef = useRef(Date.now());
const [elapsedMs, setElapsedMs] = useState(0);
const label = useAuiState((s) => {
const message = s.message;
return summarizeActivityChainLabel(message.content, message.status?.type);
return summarizeActivityChainLabel(
message.content,
message.status?.type,
elapsedMs,
);
});
const active = useAuiState((s) => {
const message = s.message;
return message.status?.type === "running";
});
useEffect(() => {
if (!active) return;
setElapsedMs(Date.now() - startedAtRef.current);
const timer = window.setInterval(() => {
setElapsedMs(Date.now() - startedAtRef.current);
}, 1000);
return () => window.clearInterval(timer);
}, [active]);
return (
<button
type="button"
@@ -1085,14 +1100,22 @@ const ActivityChainSummary: FC<{
);
};
type ActivitySummaryPart = {
type: string;
text?: string;
result?: unknown;
args?: unknown;
toolName?: string;
};
function summarizeActivityChainLabel(
content: readonly { type: string; text?: string; result?: unknown }[],
content: readonly ActivitySummaryPart[],
status: string | undefined,
elapsedMs = 0,
) {
const toolCount = content.filter((part) => part.type === "tool-call").length;
const runningToolCount = content.filter(
(part) => part.type === "tool-call" && part.result === undefined,
).length;
const toolParts = content.filter((part) => part.type === "tool-call");
const runningTool = toolParts.findLast((part) => part.result === undefined);
const activeTool = runningTool ?? toolParts.at(-1);
const reasoningText =
content.find((part) => part.type === "reasoning")?.text ?? "";
const lastReasoningLine = reasoningText
@@ -1101,19 +1124,64 @@ function summarizeActivityChainLabel(
.map((line) => line.trim())
.filter(Boolean)
.at(-1);
const stageNote = activeTool ? getToolStageNote(activeTool.args) : "";
const activeDetail =
stageNote ||
(activeTool?.toolName ? `调用 ${activeTool.toolName}` : "") ||
(isGenericActivityLine(lastReasoningLine) ? "" : lastReasoningLine);
if (status === "running") {
return runningToolCount > 0 ? "调用工具中" : "思考中";
const elapsed = `用时 ${formatActivityDuration(elapsedMs)}`;
if (activeDetail) {
return `${compactActivityLabel(activeDetail, 15)}${elapsed}`;
}
return `思考中,${elapsed}`;
}
const label = compactActivityLabel(lastReasoningLine || "思考完成");
return toolCount > 0 ? `${label} · 调用了 ${toolCount} 个工具` : label;
const label = compactActivityLabel(
activeDetail || lastReasoningLine || "思考完成",
15,
);
const finalDuration = extractDurationLabel(lastReasoningLine);
const elapsed = finalDuration || formatActivityDuration(elapsedMs);
return `${label},用时 ${elapsed}`;
}
function compactActivityLabel(label: string) {
function getToolStageNote(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
const note = (value as Record<string, unknown>).__claw_stage_note;
return typeof note === "string" ? note.trim() : "";
}
function isGenericActivityLine(value?: string) {
if (!value) return true;
return (
value.startsWith("思考中") ||
value.startsWith("思考并执行完成") ||
value.endsWith("输出中...")
);
}
function extractDurationLabel(value?: string) {
if (!value) return "";
const match = value.match(/用时\s*([^。,.\s]+)/);
return match?.[1] ?? "";
}
function formatActivityDuration(ms: number) {
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 compactActivityLabel(label: string, maxLength = 15) {
const normalized = label.replace(/\s+/g, " ").trim();
if (normalized.length <= 36) return normalized;
return `${normalized.slice(0, 35)}`;
if (normalized.length <= maxLength) return normalized;
return `${normalized.slice(0, Math.max(1, maxLength - 1))}`;
}
const AssistantActionBar: FC = () => {
@@ -1251,10 +1319,13 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
const [localText, setLocalText] = useState(storeText);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isComposingRef = useRef(false);
const justSubmittedRef = useRef(false);
const isDisabled = runtimeDisabled || disabled;
useEffect(() => {
if (!isComposingRef.current) {
if (justSubmittedRef.current && storeText) return;
if (!storeText) justSubmittedRef.current = false;
setLocalText(storeText);
}
}, [storeText]);
@@ -1277,6 +1348,19 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
}, [autoFocus, isDisabled]);
useEffect(() => {
const clearAfterSubmit = () => {
justSubmittedRef.current = true;
setLocalText("");
};
const unsubscribeComposer = aui.on("composer.send", clearAfterSubmit);
const unsubscribeRun = aui.on("thread.runStart", clearAfterSubmit);
return () => {
unsubscribeComposer();
unsubscribeRun();
};
}, [aui]);
const syncComposerText = useCallback(
(value: string) => {
if (!aui.composer().getState().isEditing) return;
@@ -1324,6 +1408,7 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
event.preventDefault();
syncComposerText(localText);
aui.composer().send();
justSubmittedRef.current = true;
setLocalText("");
};
@@ -1336,6 +1421,7 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
onChange={(event) => {
onChange?.(event);
const nextText = event.currentTarget.value;
justSubmittedRef.current = false;
setLocalText(nextText);
const isNativeComposing =
(event.nativeEvent as { isComposing?: boolean }).isComposing === true;