Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
"test:watch": "vitest"
},
"dependencies": {
"@ai-sdk/openai": "^3.0.26",
"@ai-sdk/react": "^3.0.79",
"@clawe/backend": "workspace:*",
"@clawe/shared": "workspace:*",
"@clawe/ui": "workspace:*",
Expand All @@ -33,6 +35,7 @@
"@tiptap/starter-kit": "^3.15.3",
"@tiptap/suggestion": "^3.15.3",
"@xyflow/react": "^12.10.0",
"ai": "^6.0.77",
"axios": "^1.13.4",
"convex": "^1.21.0",
"framer-motion": "^12.29.0",
Expand Down
58 changes: 27 additions & 31 deletions apps/web/src/app/api/chat/route.spec.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { POST } from "./route";

// Mock the gateway client
vi.mock("@clawe/shared/openclaw", () => ({
createGatewayClient: vi.fn(() => ({
connect: vi.fn().mockResolvedValue({ type: "hello-ok", protocol: 3 }),
request: vi.fn().mockResolvedValue({}),
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
// Mock the AI SDK
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => ({
chat: vi.fn(() => ({ modelId: "openclaw" })),
})),
}));

vi.mock("ai", () => ({
streamText: vi.fn(() => ({
toTextStreamResponse: vi.fn(
() =>
new Response("Hello", {
headers: { "Content-Type": "text/plain" },
}),
),
})),
GatewayClient: vi.fn(),
}));

describe("POST /api/chat", () => {
Expand All @@ -19,9 +25,9 @@ describe("POST /api/chat", () => {
});

it("returns 400 when sessionKey is missing", async () => {
const request = new NextRequest("http://localhost/api/chat", {
const request = new Request("http://localhost/api/chat", {
method: "POST",
body: JSON.stringify({ message: "Hello" }),
body: JSON.stringify({ messages: [{ role: "user", content: "Hello" }] }),
});

const response = await POST(request);
Expand All @@ -31,35 +37,25 @@ describe("POST /api/chat", () => {
expect(data.error).toBe("sessionKey is required");
});

it("returns SSE stream with correct headers", async () => {
const request = new NextRequest("http://localhost/api/chat", {
it("returns 400 when messages is missing", async () => {
const request = new Request("http://localhost/api/chat", {
method: "POST",
body: JSON.stringify({
sessionKey: "test-session",
message: "Hello",
}),
body: JSON.stringify({ sessionKey: "test-session" }),
});

const response = await POST(request);
expect(response.status).toBe(400);

expect(response.headers.get("Content-Type")).toBe("text/event-stream");
expect(response.headers.get("Cache-Control")).toBe("no-cache");
expect(response.headers.get("Connection")).toBe("keep-alive");
const data = await response.json();
expect(data.error).toBe("messages is required");
});

it("handles attachments in request body", async () => {
const request = new NextRequest("http://localhost/api/chat", {
it("returns stream response with valid request", async () => {
const request = new Request("http://localhost/api/chat", {
method: "POST",
body: JSON.stringify({
sessionKey: "test-session",
message: "Check this image",
attachments: [
{
type: "image",
mimeType: "image/png",
content: "base64data",
},
],
messages: [{ role: "user", content: "Hello" }],
}),
});

Expand All @@ -68,7 +64,7 @@ describe("POST /api/chat", () => {
});

it("returns 500 on invalid JSON", async () => {
const request = new NextRequest("http://localhost/api/chat", {
const request = new Request("http://localhost/api/chat", {
method: "POST",
body: "invalid json",
});
Expand Down
151 changes: 27 additions & 124 deletions apps/web/src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,20 @@
import { NextRequest } from "next/server";
import { GatewayClient, createGatewayClient } from "@clawe/shared/openclaw";
import type {
ChatEvent,
ChatSendParams,
ChatAttachment,
} from "@clawe/shared/openclaw";
import { createOpenAI } from "@ai-sdk/openai";
import { streamText } from "ai";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

type ChatRequestBody = {
sessionKey: string;
message: string;
attachments?: Array<{
type: "image";
mimeType: string;
content: string;
}>;
};
const openclawUrl = process.env.OPENCLAW_URL || "http://localhost:18789";
const openclawToken = process.env.OPENCLAW_TOKEN || "";

/**
* POST /api/chat
* Send a chat message and stream the response via SSE.
* Proxy chat requests to OpenClaw's OpenAI-compatible endpoint.
*/
export async function POST(request: NextRequest) {
const encoder = new TextEncoder();

export async function POST(request: Request) {
try {
const body = (await request.json()) as ChatRequestBody;
const { sessionKey, message, attachments } = body;
const body = await request.json();
const { messages, sessionKey } = body;

if (!sessionKey || typeof sessionKey !== "string") {
return new Response(JSON.stringify({ error: "sessionKey is required" }), {
Expand All @@ -37,115 +23,32 @@ export async function POST(request: NextRequest) {
});
}

// Create SSE stream
const stream = new ReadableStream({
async start(controller) {
const sendEvent = (event: string, data: unknown) => {
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
controller.enqueue(encoder.encode(payload));
};

let client: GatewayClient | null = null;

try {
// Create gateway client with chat event handler
client = createGatewayClient({
onChatEvent: (chatEvent: ChatEvent) => {
if (chatEvent.sessionKey !== sessionKey) {
return;
}

switch (chatEvent.state) {
case "delta":
sendEvent("delta", {
runId: chatEvent.runId,
message: chatEvent.message,
seq: chatEvent.seq,
});
break;
case "final":
sendEvent("final", {
runId: chatEvent.runId,
message: chatEvent.message,
usage: chatEvent.usage,
stopReason: chatEvent.stopReason,
});
break;
case "aborted":
sendEvent("aborted", {
runId: chatEvent.runId,
});
break;
case "error":
sendEvent("error", {
runId: chatEvent.runId,
message: chatEvent.errorMessage,
});
break;
}
},
onClose: () => {
controller.close();
},
onError: (error) => {
sendEvent("error", { message: error.message });
controller.close();
},
});

// Connect to gateway
await client.connect();
sendEvent("connected", { sessionKey });

// Generate idempotency key
const idempotencyKey = `chat_${Date.now()}_${Math.random().toString(36).slice(2)}`;

// Prepare attachments
const apiAttachments: ChatAttachment[] | undefined = attachments?.map(
(att) => ({
type: "image" as const,
mimeType: att.mimeType,
content: att.content,
}),
);

// Send chat message
const params: ChatSendParams = {
sessionKey,
message,
deliver: false,
idempotencyKey,
attachments: apiAttachments,
};

await client.request("chat.send", params);

// Keep connection open for events
// The stream will close when final/error/aborted event is received
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
sendEvent("error", { message: errorMessage });
client?.close();
controller.close();
}
if (!messages || !Array.isArray(messages)) {
return new Response(JSON.stringify({ error: "messages is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}

// Handle client disconnect
request.signal.addEventListener("abort", () => {
client?.close();
controller.close();
});
},
// Create OpenAI-compatible client pointing to OpenClaw
const openclaw = createOpenAI({
baseURL: `${openclawUrl}/v1`,
apiKey: openclawToken,
});

return new Response(stream, {
// Stream response using Vercel AI SDK
// Use .chat() to force Chat Completions API instead of Responses API
const result = streamText({
model: openclaw.chat("openclaw"),
messages,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-OpenClaw-Session-Key": sessionKey,
},
});

return result.toTextStreamResponse();
} catch (error) {
console.error("[chat] Error:", error);
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
return new Response(JSON.stringify({ error: errorMessage }), {
Expand Down
Loading