543 lines
15 KiB
TypeScript
543 lines
15 KiB
TypeScript
import { mkdir, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import {
|
|
createUIMessageStream,
|
|
createUIMessageStreamResponse,
|
|
type UIMessage,
|
|
type UIMessageStreamWriter,
|
|
} from "ai";
|
|
import {
|
|
accountSessionInputRoot,
|
|
accountSessionOutputRoot,
|
|
getCurrentAccount,
|
|
} from "@/lib/claw-auth";
|
|
|
|
export const runtime = "nodejs";
|
|
export const maxDuration = 300;
|
|
|
|
type ClawChatResponse = {
|
|
final_output?: string;
|
|
session_id?: string;
|
|
tool_calls?: number;
|
|
stop_reason?: string;
|
|
elapsed_ms?: number;
|
|
usage?: UsageSummary;
|
|
transcript?: ClawTranscriptEntry[];
|
|
error?: string;
|
|
detail?: string;
|
|
};
|
|
|
|
type ClawRuntimeEvent = {
|
|
type?: string;
|
|
run_id?: string;
|
|
tool_name?: string;
|
|
tool_call_id?: string;
|
|
arguments?: unknown;
|
|
assistant_content?: string;
|
|
metadata?: Record<string, unknown>;
|
|
ok?: boolean;
|
|
delta?: string;
|
|
elapsed_ms?: number;
|
|
};
|
|
|
|
type ClawStreamItem =
|
|
| { kind: "event"; event?: ClawRuntimeEvent }
|
|
| { kind: "result"; payload?: ClawChatResponse }
|
|
| { kind: "error"; error?: string; detail?: string; status_code?: number };
|
|
|
|
type StreamState = {
|
|
textStarted: boolean;
|
|
textStreamed: boolean;
|
|
phase: "waiting" | "queued" | "running" | "done";
|
|
runId?: string;
|
|
};
|
|
|
|
type ChatRequestBody = {
|
|
id?: string;
|
|
messages: UIMessage[];
|
|
resumeSessionId?: string;
|
|
};
|
|
|
|
type ClawTranscriptEntry = {
|
|
role?: string;
|
|
content?: string;
|
|
tool_call_id?: string;
|
|
tool_calls?: ClawToolCall[];
|
|
};
|
|
|
|
type ClawToolCall = {
|
|
id?: string;
|
|
name?: string;
|
|
arguments?: unknown;
|
|
function?: {
|
|
name?: string;
|
|
arguments?: unknown;
|
|
};
|
|
};
|
|
|
|
type UsageSummary = {
|
|
input_tokens?: number;
|
|
output_tokens?: number;
|
|
total_tokens?: number;
|
|
reasoning_tokens?: number;
|
|
};
|
|
|
|
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
|
|
|
|
export async function POST(req: Request) {
|
|
const account = await getCurrentAccount();
|
|
if (!account) return new Response("请先登录账号", { status: 401 });
|
|
|
|
const { id, messages, resumeSessionId }: ChatRequestBody = await req.json();
|
|
const resumeId = resumeSessionId ?? getLastSessionId(messages);
|
|
const sessionId = safeSessionId(resumeId ?? id ?? crypto.randomUUID());
|
|
await ensureSessionDirectories(account.id, sessionId);
|
|
const userPrompt = await getLastUserText(messages, account.id, sessionId);
|
|
|
|
if (!userPrompt) {
|
|
return new Response("Prompt is empty", { status: 400 });
|
|
}
|
|
const runtimeContext = renderSessionRuntimeContext(account.id, sessionId);
|
|
|
|
const stream = createUIMessageStream({
|
|
execute: async ({ writer }) => {
|
|
const streamState: StreamState = {
|
|
textStarted: false,
|
|
textStreamed: false,
|
|
phase: "waiting",
|
|
};
|
|
writer.write({ type: "start" });
|
|
writer.write({ type: "start-step" });
|
|
|
|
try {
|
|
const startedAt = Date.now();
|
|
writer.write({ type: "reasoning-start", id: "reasoning-1" });
|
|
writer.write({
|
|
type: "reasoning-delta",
|
|
id: "reasoning-1",
|
|
delta: "请求已发送,等待后端开始处理。",
|
|
});
|
|
const payload = await callClawBackendStream(
|
|
userPrompt,
|
|
runtimeContext,
|
|
account.id,
|
|
sessionId,
|
|
resumeId,
|
|
writer,
|
|
streamState,
|
|
);
|
|
const elapsedMs = payload.elapsed_ms ?? Date.now() - startedAt;
|
|
const text = formatClawResponse(payload);
|
|
|
|
writer.write({
|
|
type: "reasoning-delta",
|
|
id: "reasoning-1",
|
|
delta: `\n思考并执行完成,用时 ${formatDuration(elapsedMs)}。`,
|
|
});
|
|
writer.write({ type: "reasoning-end", id: "reasoning-1" });
|
|
writeToolTrace(writer, payload.transcript);
|
|
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",
|
|
finishReason: payload.error ? "error" : "stop",
|
|
messageMetadata: {
|
|
sessionId: payload.session_id,
|
|
toolCalls: payload.tool_calls,
|
|
stopReason: payload.stop_reason,
|
|
elapsedMs,
|
|
usage: payload.usage,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
const text =
|
|
err instanceof Error && err.name === "AbortError"
|
|
? "请求已取消。"
|
|
: err instanceof Error
|
|
? err.message
|
|
: "请求失败。";
|
|
writer.write({ type: "reasoning-end", id: "reasoning-1" });
|
|
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" });
|
|
writer.write({ type: "finish-step" });
|
|
writer.write({
|
|
type: "finish",
|
|
finishReason: "error",
|
|
});
|
|
}
|
|
},
|
|
});
|
|
|
|
return createUIMessageStreamResponse({ stream });
|
|
}
|
|
|
|
function formatDuration(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 writeToolTrace(
|
|
writer: UIMessageStreamWriter<UIMessage>,
|
|
transcript?: ClawTranscriptEntry[],
|
|
) {
|
|
if (!transcript?.length) return;
|
|
|
|
const toolResults = new Map<string, string>();
|
|
for (const entry of transcript) {
|
|
if (entry.role === "tool" && entry.tool_call_id) {
|
|
toolResults.set(entry.tool_call_id, entry.content ?? "");
|
|
}
|
|
}
|
|
|
|
for (const entry of transcript) {
|
|
if (!entry.tool_calls?.length) continue;
|
|
const stageNote = entry.role === "assistant" ? entry.content?.trim() : "";
|
|
for (const call of entry.tool_calls) {
|
|
const toolCallId = call.id;
|
|
const toolName = call.function?.name ?? call.name;
|
|
if (!toolCallId || !toolName) continue;
|
|
|
|
writer.write({
|
|
type: "tool-input-available",
|
|
toolCallId,
|
|
toolName,
|
|
input: attachStageNote(
|
|
parseToolInput(call.function?.arguments ?? call.arguments),
|
|
stageNote,
|
|
),
|
|
});
|
|
|
|
if (toolResults.has(toolCallId)) {
|
|
writer.write({
|
|
type: "tool-output-available",
|
|
toolCallId,
|
|
output: toolResults.get(toolCallId),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function attachStageNote(input: unknown, stageNote?: string) {
|
|
const note = stageNote?.trim();
|
|
if (!note) return input;
|
|
if (input && typeof input === "object" && !Array.isArray(input)) {
|
|
return { ...input, __claw_stage_note: note };
|
|
}
|
|
return { value: input, __claw_stage_note: note };
|
|
}
|
|
|
|
function parseToolInput(value: unknown) {
|
|
if (typeof value !== "string") return value ?? {};
|
|
try {
|
|
return JSON.parse(value);
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
async function getLastUserText(
|
|
messages: UIMessage[],
|
|
accountId: string,
|
|
threadId?: string,
|
|
) {
|
|
const lastUserMessage = [...messages]
|
|
.reverse()
|
|
.find((msg) => msg.role === "user");
|
|
if (!lastUserMessage) return "";
|
|
|
|
const chunks: string[] = [];
|
|
for (const part of lastUserMessage.parts) {
|
|
if (part.type === "text") chunks.push(part.text);
|
|
if (part.type === "file") {
|
|
chunks.push(
|
|
await saveFilePart(part, accountId, threadId ?? lastUserMessage.id),
|
|
);
|
|
}
|
|
}
|
|
|
|
return chunks.filter(Boolean).join("\n\n").trim();
|
|
}
|
|
|
|
async function ensureSessionDirectories(accountId: string, sessionId: string) {
|
|
await Promise.all([
|
|
mkdir(accountSessionInputRoot(accountId, sessionId), { recursive: true }),
|
|
mkdir(accountSessionOutputRoot(accountId, sessionId), { recursive: true }),
|
|
]);
|
|
}
|
|
|
|
function renderSessionRuntimeContext(accountId: string, sessionId: string) {
|
|
return [
|
|
"[当前会话目录]",
|
|
`- 输入目录: ${accountSessionInputRoot(accountId, sessionId)}`,
|
|
`- 输出目录: ${accountSessionOutputRoot(accountId, sessionId)}`,
|
|
"如需保存本轮任务产物,请优先写入输出目录。",
|
|
].join("\n\n");
|
|
}
|
|
|
|
function getLastSessionId(messages: UIMessage[]) {
|
|
for (const message of [...messages].reverse()) {
|
|
if (message.role !== "assistant") continue;
|
|
const metadata = message.metadata;
|
|
if (metadata && typeof metadata === "object" && "sessionId" in metadata) {
|
|
const sessionId = (metadata as { sessionId?: unknown }).sessionId;
|
|
if (typeof sessionId === "string" && sessionId) return sessionId;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
async function saveFilePart(
|
|
part: Extract<UIMessage["parts"][number], { type: "file" }>,
|
|
accountId: string,
|
|
threadId: string,
|
|
) {
|
|
const filename = safeFilename(part.filename ?? "attachment");
|
|
if (!part.url.startsWith("data:")) {
|
|
return `[文件] ${filename}\n- 类型: ${part.mediaType}\n- URL: ${part.url}`;
|
|
}
|
|
|
|
const match = part.url.match(/^data:([^;,]+)?(;base64)?,(.*)$/);
|
|
if (!match) {
|
|
return `[文件] ${filename}\n- 类型: ${part.mediaType}\n- 状态: 无法解析 data URL`;
|
|
}
|
|
|
|
const isBase64 = Boolean(match[2]);
|
|
const body = match[3] ?? "";
|
|
const bytes = isBase64
|
|
? Buffer.from(body, "base64")
|
|
: Buffer.from(decodeURIComponent(body), "utf8");
|
|
const uploadDir = accountSessionInputRoot(accountId, safeFilename(threadId));
|
|
await mkdir(uploadDir, { recursive: true });
|
|
const filePath = path.join(uploadDir, `${Date.now()}-${filename}`);
|
|
await writeFile(filePath, bytes);
|
|
|
|
return [
|
|
`[文件已上传] ${filename}`,
|
|
`- 类型: ${part.mediaType}`,
|
|
`- 本地路径: ${filePath}`,
|
|
"请在需要读取文件内容时使用 read_file 工具读取该本地路径。",
|
|
].join("\n");
|
|
}
|
|
|
|
function safeFilename(value: string) {
|
|
return value.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 120) || "attachment";
|
|
}
|
|
|
|
async function callClawBackendStream(
|
|
prompt: string,
|
|
runtimeContext: string,
|
|
accountId: string,
|
|
sessionId: string,
|
|
resumeSessionId?: string,
|
|
writer?: UIMessageStreamWriter<UIMessage>,
|
|
streamState?: StreamState,
|
|
): Promise<ClawChatResponse> {
|
|
try {
|
|
const response = await fetch(`${CLAW_API_URL}/api/chat/stream`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
prompt,
|
|
runtime_context: runtimeContext,
|
|
account_id: accountId,
|
|
session_id: sessionId,
|
|
...(resumeSessionId ? { resume_session_id: resumeSessionId } : {}),
|
|
}),
|
|
});
|
|
if (!response.ok) {
|
|
const payload = (await response
|
|
.json()
|
|
.catch(() => ({}))) as ClawChatResponse;
|
|
return {
|
|
error:
|
|
payload.detail ??
|
|
payload.error ??
|
|
`Claw backend returned ${response.status}`,
|
|
};
|
|
}
|
|
if (!response.body)
|
|
return { error: "Claw backend returned an empty stream" };
|
|
return await consumeClawStream(response.body, writer, streamState);
|
|
} catch (err) {
|
|
return {
|
|
error:
|
|
err instanceof Error
|
|
? `Unable to reach Claw backend: ${err.message}`
|
|
: "Unable to reach Claw backend",
|
|
};
|
|
}
|
|
}
|
|
|
|
async function consumeClawStream(
|
|
body: ReadableStream<Uint8Array>,
|
|
writer?: UIMessageStreamWriter<UIMessage>,
|
|
streamState?: StreamState,
|
|
) {
|
|
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?: StreamState,
|
|
) {
|
|
if (event.type === "run_queued") {
|
|
if (streamState) streamState.phase = "queued";
|
|
if (streamState && event.run_id) streamState.runId = event.run_id;
|
|
writer.write({
|
|
type: "reasoning-delta",
|
|
id: "reasoning-1",
|
|
delta: "\n当前会话已有任务在执行,本轮正在排队。",
|
|
});
|
|
}
|
|
if (event.type === "run_started") {
|
|
if (streamState) streamState.phase = "running";
|
|
if (streamState && event.run_id) streamState.runId = event.run_id;
|
|
writer.write({
|
|
type: "reasoning-delta",
|
|
id: "reasoning-1",
|
|
delta: "\n后端已开始执行本轮任务。",
|
|
});
|
|
}
|
|
if (event.type === "server_heartbeat") {
|
|
const elapsed =
|
|
typeof event.elapsed_ms === "number"
|
|
? `,已等待 ${formatDuration(event.elapsed_ms)}`
|
|
: "";
|
|
const phase = streamState?.phase === "queued" ? "仍在排队" : "后端仍在处理";
|
|
writer.write({
|
|
type: "reasoning-delta",
|
|
id: "reasoning-1",
|
|
delta: `\n${phase}${elapsed}。`,
|
|
});
|
|
}
|
|
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 ?? "";
|
|
}
|
|
|
|
function safeSessionId(value: string) {
|
|
return (
|
|
value.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 120) || crypto.randomUUID()
|
|
);
|
|
}
|