Skip to content

Commit d830f38

Browse files
authored
add chat capability (#1)
* add chat capability * fix * fix
1 parent 716c917 commit d830f38

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+4654
-14
lines changed

.vscode/settings.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,7 @@
33
{
44
"mode": "auto"
55
}
6-
]
6+
],
7+
"git.detectSubmodules": false,
8+
"git.autoRepositoryDetection": "openEditors"
79
}

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ Core models: `agents`, `tasks`, `messages` (see `packages/backend/convex/schema.
6666
- Prefer reusable components and functions over duplication
6767
- Keep it simple - avoid over-engineering and premature abstractions
6868
- **Use strong typing** - leverage types from external packages; avoid `any` and type assertions
69+
- **No type suppression** - avoid `@ts-expect-error`, `@ts-ignore`, and `as` casts; fix types properly instead
6970
- Prefer named exports over default exports (except Next.js pages/layouts where required)
7071
- Use `@clawe/ui/components/*` for UI imports
7172
- Use `@/` alias for app-local imports

apps/web/package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
"dependencies": {
2020
"@clawe/backend": "workspace:*",
2121
"@clawe/ui": "workspace:*",
22-
"convex": "^1.21.0",
2322
"@tanstack/react-query": "^5.75.5",
2423
"@tiptap/core": "^3.15.3",
2524
"@tiptap/extension-bubble-menu": "^3.15.3",
@@ -34,22 +33,30 @@
3433
"@tiptap/suggestion": "^3.15.3",
3534
"@xyflow/react": "^12.10.0",
3635
"axios": "^1.13.4",
36+
"convex": "^1.21.0",
3737
"framer-motion": "^12.29.0",
3838
"lucide-react": "^0.562.0",
3939
"next": "16.1.0",
4040
"next-themes": "^0.4.6",
4141
"react": "^19.2.0",
4242
"react-dom": "^19.2.0",
4343
"tippy.js": "^6.3.7",
44+
"ws": "^8.19.0",
4445
"zod": "^4.3.6"
4546
},
4647
"devDependencies": {
4748
"@clawe/eslint-config": "workspace:*",
4849
"@clawe/typescript-config": "workspace:*",
50+
"@testing-library/jest-dom": "^6.9.1",
51+
"@testing-library/react": "^16.3.2",
52+
"@types/jsdom": "^27.0.0",
4953
"@types/node": "^22.15.3",
5054
"@types/react": "19.2.2",
5155
"@types/react-dom": "19.2.2",
56+
"@types/ws": "^8.18.1",
57+
"@vitejs/plugin-react": "^5.1.3",
5258
"eslint": "^9.39.1",
59+
"jsdom": "^28.0.0",
5360
"typescript": "5.9.2",
5461
"vitest": "^4.0.18"
5562
}

apps/web/src/app/(dashboard)/_components/dashboard-sidebar.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import * as React from "react";
44
import { useRouter, usePathname } from "next/navigation";
5-
import { SquareKanban, Bot, Settings } from "lucide-react";
5+
import { SquareKanban, Bot, Settings, MessageSquare } from "lucide-react";
66
import { motion, AnimatePresence } from "framer-motion";
77

88
import { NavMain, type NavItem } from "./nav-main";
@@ -47,6 +47,11 @@ const SidebarNavContent = () => {
4747
url: "/agents",
4848
icon: Bot,
4949
},
50+
{
51+
title: "Chat",
52+
url: "/chat",
53+
icon: MessageSquare,
54+
},
5055
{
5156
title: "Settings",
5257
url: "/settings",
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Chat } from "@/components/chat";
2+
3+
// Use the full agent session key format for consistency with OpenClaw
4+
const SESSION_KEY = "agent:main:main";
5+
6+
export default function ChatPage() {
7+
return <Chat sessionKey={SESSION_KEY} mode="full" className="h-full" />;
8+
}

apps/web/src/app/(dashboard)/layout.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ type DashboardLayoutProps = {
1414
header: React.ReactNode;
1515
};
1616

17-
// Routes that handle their own scrolling (e.g., kanban board)
18-
const isFullHeightRoute = (path: string) => path === "/board";
17+
// Routes that handle their own scrolling (e.g., kanban board, chat)
18+
const isFullHeightRoute = (path: string) =>
19+
path === "/board" || path === "/chat";
1920

2021
const DashboardLayout = ({ children, header }: DashboardLayoutProps) => {
2122
const pathname = usePathname();
@@ -53,7 +54,7 @@ const DashboardLayout = ({ children, header }: DashboardLayoutProps) => {
5354
{header}
5455
</header>
5556
{fullHeight ? (
56-
<main className="flex min-h-0 flex-1 flex-col p-6">{children}</main>
57+
<main className="flex min-h-0 flex-1 flex-col">{children}</main>
5758
) : (
5859
<ScrollArea className="h-full min-h-0 flex-1">
5960
<main className="p-6">{children}</main>
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { NextRequest } from "next/server";
3+
import { POST } from "./route";
4+
5+
// Mock the gateway client
6+
const mockRequest = vi.fn();
7+
const mockConnect = vi.fn();
8+
const mockClose = vi.fn();
9+
10+
vi.mock("@/lib/openclaw/gateway-client", () => ({
11+
createGatewayClient: vi.fn(() => ({
12+
connect: mockConnect,
13+
request: mockRequest,
14+
close: mockClose,
15+
isConnected: vi.fn().mockReturnValue(true),
16+
})),
17+
}));
18+
19+
describe("POST /api/chat/abort", () => {
20+
beforeEach(() => {
21+
vi.clearAllMocks();
22+
mockConnect.mockResolvedValue({ type: "hello-ok", protocol: 3 });
23+
mockRequest.mockResolvedValue({});
24+
});
25+
26+
it("returns 400 when sessionKey is missing", async () => {
27+
const request = new NextRequest("http://localhost/api/chat/abort", {
28+
method: "POST",
29+
body: JSON.stringify({}),
30+
});
31+
32+
const response = await POST(request);
33+
expect(response.status).toBe(400);
34+
35+
const data = await response.json();
36+
expect(data.error).toBe("sessionKey is required");
37+
});
38+
39+
it("returns 400 for invalid JSON", async () => {
40+
const request = new NextRequest("http://localhost/api/chat/abort", {
41+
method: "POST",
42+
body: "invalid json",
43+
});
44+
45+
const response = await POST(request);
46+
expect(response.status).toBe(400);
47+
48+
const data = await response.json();
49+
expect(data.error).toBe("Invalid JSON body");
50+
});
51+
52+
it("aborts without runId", async () => {
53+
const request = new NextRequest("http://localhost/api/chat/abort", {
54+
method: "POST",
55+
body: JSON.stringify({ sessionKey: "test-session" }),
56+
});
57+
58+
const response = await POST(request);
59+
expect(response.status).toBe(200);
60+
61+
const data = await response.json();
62+
expect(data.success).toBe(true);
63+
64+
expect(mockRequest).toHaveBeenCalledWith("chat.abort", {
65+
sessionKey: "test-session",
66+
});
67+
});
68+
69+
it("aborts with runId", async () => {
70+
const request = new NextRequest("http://localhost/api/chat/abort", {
71+
method: "POST",
72+
body: JSON.stringify({
73+
sessionKey: "test-session",
74+
runId: "run-123",
75+
}),
76+
});
77+
78+
const response = await POST(request);
79+
expect(response.status).toBe(200);
80+
81+
expect(mockRequest).toHaveBeenCalledWith("chat.abort", {
82+
sessionKey: "test-session",
83+
runId: "run-123",
84+
});
85+
});
86+
87+
it("returns 500 on gateway error", async () => {
88+
mockRequest.mockRejectedValue(new Error("Abort failed"));
89+
90+
const request = new NextRequest("http://localhost/api/chat/abort", {
91+
method: "POST",
92+
body: JSON.stringify({ sessionKey: "test-session" }),
93+
});
94+
95+
const response = await POST(request);
96+
expect(response.status).toBe(500);
97+
98+
const data = await response.json();
99+
expect(data.error).toBe("Abort failed");
100+
});
101+
102+
it("closes client after request", async () => {
103+
const request = new NextRequest("http://localhost/api/chat/abort", {
104+
method: "POST",
105+
body: JSON.stringify({ sessionKey: "test-session" }),
106+
});
107+
108+
await POST(request);
109+
110+
expect(mockClose).toHaveBeenCalled();
111+
});
112+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { createGatewayClient } from "@/lib/openclaw/gateway-client";
3+
4+
export const runtime = "nodejs";
5+
export const dynamic = "force-dynamic";
6+
7+
type AbortRequestBody = {
8+
sessionKey: string;
9+
runId?: string;
10+
};
11+
12+
/**
13+
* POST /api/chat/abort
14+
* Abort an in-progress chat generation.
15+
*/
16+
export async function POST(request: NextRequest) {
17+
let body: AbortRequestBody;
18+
19+
try {
20+
body = (await request.json()) as AbortRequestBody;
21+
} catch {
22+
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
23+
}
24+
25+
const { sessionKey, runId } = body;
26+
27+
if (!sessionKey || typeof sessionKey !== "string") {
28+
return NextResponse.json(
29+
{ error: "sessionKey is required" },
30+
{ status: 400 },
31+
);
32+
}
33+
34+
const client = createGatewayClient();
35+
36+
try {
37+
await client.connect();
38+
39+
await client.request("chat.abort", {
40+
sessionKey,
41+
...(runId && { runId }),
42+
});
43+
44+
return NextResponse.json({ success: true });
45+
} catch (error) {
46+
const errorMessage =
47+
error instanceof Error ? error.message : "Unknown error";
48+
return NextResponse.json({ error: errorMessage }, { status: 500 });
49+
} finally {
50+
client.close();
51+
}
52+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { NextRequest } from "next/server";
3+
import { GET } from "./route";
4+
5+
// Mock the gateway client
6+
const mockRequest = vi.fn();
7+
const mockConnect = vi.fn();
8+
const mockClose = vi.fn();
9+
10+
vi.mock("@/lib/openclaw/gateway-client", () => ({
11+
createGatewayClient: vi.fn(() => ({
12+
connect: mockConnect,
13+
request: mockRequest,
14+
close: mockClose,
15+
isConnected: vi.fn().mockReturnValue(true),
16+
})),
17+
}));
18+
19+
describe("GET /api/chat/history", () => {
20+
beforeEach(() => {
21+
vi.clearAllMocks();
22+
mockConnect.mockResolvedValue({ type: "hello-ok", protocol: 3 });
23+
mockRequest.mockResolvedValue({
24+
messages: [
25+
{ role: "user", content: [{ type: "text", text: "Hello" }] },
26+
{ role: "assistant", content: [{ type: "text", text: "Hi there!" }] },
27+
],
28+
thinkingLevel: "normal",
29+
});
30+
});
31+
32+
it("returns 400 when sessionKey is missing", async () => {
33+
const request = new NextRequest("http://localhost/api/chat/history");
34+
35+
const response = await GET(request);
36+
expect(response.status).toBe(400);
37+
38+
const data = await response.json();
39+
expect(data.error).toBe("sessionKey query parameter is required");
40+
});
41+
42+
it("returns messages for valid sessionKey", async () => {
43+
const request = new NextRequest(
44+
"http://localhost/api/chat/history?sessionKey=test-session",
45+
);
46+
47+
const response = await GET(request);
48+
expect(response.status).toBe(200);
49+
50+
const data = await response.json();
51+
expect(data.messages).toHaveLength(2);
52+
expect(data.thinkingLevel).toBe("normal");
53+
});
54+
55+
it("uses custom limit when provided", async () => {
56+
const request = new NextRequest(
57+
"http://localhost/api/chat/history?sessionKey=test-session&limit=50",
58+
);
59+
60+
await GET(request);
61+
62+
expect(mockRequest).toHaveBeenCalledWith("chat.history", {
63+
sessionKey: "test-session",
64+
limit: 50,
65+
});
66+
});
67+
68+
it("returns 400 for invalid limit", async () => {
69+
const request = new NextRequest(
70+
"http://localhost/api/chat/history?sessionKey=test-session&limit=9999",
71+
);
72+
73+
const response = await GET(request);
74+
expect(response.status).toBe(400);
75+
76+
const data = await response.json();
77+
expect(data.error).toBe("limit must be between 1 and 1000");
78+
});
79+
80+
it("returns 500 on gateway error", async () => {
81+
mockConnect.mockRejectedValue(new Error("Connection failed"));
82+
83+
const request = new NextRequest(
84+
"http://localhost/api/chat/history?sessionKey=test-session",
85+
);
86+
87+
const response = await GET(request);
88+
expect(response.status).toBe(500);
89+
90+
const data = await response.json();
91+
expect(data.error).toBe("Connection failed");
92+
});
93+
94+
it("closes client after request", async () => {
95+
const request = new NextRequest(
96+
"http://localhost/api/chat/history?sessionKey=test-session",
97+
);
98+
99+
await GET(request);
100+
101+
expect(mockClose).toHaveBeenCalled();
102+
});
103+
});

0 commit comments

Comments
 (0)