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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user