📦 NEW: nextjs chat

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
kumarabhirup 2026-02-06 15:28:35 -08:00
parent 9fa5c96dd4
commit 3568fa09ff
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
23 changed files with 1914 additions and 30 deletions

4
.gitignore vendored
View File

@ -69,5 +69,9 @@ IDENTITY.md
USER.md
.tgz
# Next.js
**/.next/
next-env.d.ts
# local tooling
.serena/

View File

@ -0,0 +1,149 @@
import { createUIMessageStream, type UIMessage } from "ai";
import { spawn } from "node:child_process";
import { resolve } from "node:path";
import { createInterface } from "node:readline";
// Allow streaming responses up to 10 minutes
export const maxDuration = 600;
/** Resolve the repo root (two levels up from apps/web/) */
function repoRoot(): string {
return resolve(process.cwd(), "..", "..");
}
type NdjsonEvent = {
event: string;
runId?: string;
stream?: string;
data?: Record<string, unknown>;
seq?: number;
ts?: number;
sessionKey?: string;
status?: string;
result?: {
payloads?: Array<{ text?: string; mediaUrl?: string | null }>;
meta?: Record<string, unknown>;
};
};
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
// Extract the latest user message text
const lastUserMessage = messages.filter((m) => m.role === "user").pop();
const userText =
lastUserMessage?.parts
?.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text)
.join("\n") ?? "";
if (!userText.trim()) {
return new Response("No message provided", { status: 400 });
}
const root = repoRoot();
const scriptPath = resolve(root, "scripts", "run-node.mjs");
const stream = createUIMessageStream({
async execute({ writer }) {
const textPartId = `text-${Date.now()}`;
let started = false;
await new Promise<void>((resolvePromise, rejectPromise) => {
const child = spawn(
"node",
[scriptPath, "agent", "--agent", "main", "--message", userText, "--stream-json"],
{
cwd: root,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
},
);
const rl = createInterface({ input: child.stdout });
rl.on("line", (line: string) => {
if (!line.trim()) return;
let event: NdjsonEvent;
try {
event = JSON.parse(line) as NdjsonEvent;
} catch {
return; // skip non-JSON lines (e.g. banner)
}
// Handle assistant text deltas
if (event.event === "agent" && event.stream === "assistant") {
const delta =
typeof event.data?.delta === "string" ? event.data.delta : undefined;
if (delta) {
if (!started) {
writer.write({ type: "text-start", id: textPartId });
started = true;
}
writer.write({ type: "text-delta", id: textPartId, delta });
}
}
// Handle lifecycle end
if (
event.event === "agent" &&
event.stream === "lifecycle" &&
event.data?.phase === "end"
) {
if (started) {
writer.write({ type: "text-end", id: textPartId });
}
}
});
child.on("close", (code) => {
// If we never started text, emit an empty response
if (!started) {
writer.write({ type: "text-start", id: textPartId });
writer.write({
type: "text-delta",
id: textPartId,
delta: "(No response from agent)",
});
writer.write({ type: "text-end", id: textPartId });
}
if (code !== 0 && code !== null) {
// Non-zero exit but we already streamed what we could
}
resolvePromise();
});
child.on("error", (err) => {
if (!started) {
writer.write({ type: "text-start", id: textPartId });
writer.write({
type: "text-delta",
id: textPartId,
delta: `Error starting agent: ${err.message}`,
});
writer.write({ type: "text-end", id: textPartId });
}
resolvePromise();
});
// Log stderr for debugging
child.stderr?.on("data", (chunk: Buffer) => {
console.error("[openclaw stderr]", chunk.toString());
});
});
},
onError: (error) => {
const message = error instanceof Error ? error.message : String(error);
return `Agent error: ${message}`;
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}

View File

@ -0,0 +1,63 @@
import { readFileSync, readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
export const dynamic = "force-dynamic";
type MemoryFile = {
name: string;
path: string;
sizeBytes: number;
};
export async function GET() {
const workspaceDir = join(homedir(), ".openclaw", "workspace");
let mainMemory: string | null = null;
const dailyLogs: MemoryFile[] = [];
// Read main MEMORY.md
for (const filename of ["MEMORY.md", "memory.md"]) {
const memPath = join(workspaceDir, filename);
if (existsSync(memPath)) {
try {
mainMemory = readFileSync(memPath, "utf-8");
} catch {
// skip unreadable
}
break;
}
}
// Scan daily log files
const memoryDir = join(workspaceDir, "memory");
if (existsSync(memoryDir)) {
try {
const entries = readdirSync(memoryDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
const filePath = join(memoryDir, entry.name);
try {
const content = readFileSync(filePath, "utf-8");
dailyLogs.push({
name: entry.name,
path: filePath,
sizeBytes: Buffer.byteLength(content, "utf-8"),
});
} catch {
// skip
}
}
} catch {
// dir unreadable
}
}
// Sort daily logs by name (date-based filenames sort chronologically)
dailyLogs.sort((a, b) => b.name.localeCompare(a.name));
return Response.json({
mainMemory,
dailyLogs,
workspaceDir,
});
}

View File

@ -0,0 +1,96 @@
import { readFileSync, readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
export const dynamic = "force-dynamic";
type SessionEntry = {
sessionId: string;
updatedAt: number;
label?: string;
displayName?: string;
channel?: string;
model?: string;
modelProvider?: string;
thinkingLevel?: string;
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
contextTokens?: number;
compactionCount?: number;
};
type SessionRow = {
key: string;
sessionId: string;
updatedAt: number;
label?: string;
displayName?: string;
channel?: string;
model?: string;
modelProvider?: string;
thinkingLevel?: string;
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
contextTokens?: number;
};
function resolveOpenClawDir(): string {
return join(homedir(), ".openclaw");
}
export async function GET() {
const openclawDir = resolveOpenClawDir();
const agentsDir = join(openclawDir, "agents");
if (!existsSync(agentsDir)) {
return Response.json({ agents: [], sessions: [] });
}
const allSessions: SessionRow[] = [];
const agentIds: string[] = [];
try {
const entries = readdirSync(agentsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
agentIds.push(entry.name);
const storePath = join(agentsDir, entry.name, "sessions", "sessions.json");
if (!existsSync(storePath)) continue;
try {
const raw = readFileSync(storePath, "utf-8");
const store = JSON.parse(raw) as Record<string, SessionEntry>;
for (const [key, session] of Object.entries(store)) {
if (!session || typeof session !== "object") continue;
allSessions.push({
key,
sessionId: session.sessionId,
updatedAt: session.updatedAt,
label: session.label,
displayName: session.displayName,
channel: session.channel,
model: session.model,
modelProvider: session.modelProvider,
thinkingLevel: session.thinkingLevel,
inputTokens: session.inputTokens,
outputTokens: session.outputTokens,
totalTokens: session.totalTokens,
contextTokens: session.contextTokens,
});
}
} catch {
// skip unreadable store files
}
}
} catch {
// agents dir unreadable
}
// Sort by updatedAt descending
allSessions.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
return Response.json({ agents: agentIds, sessions: allSessions });
}

View File

@ -0,0 +1,85 @@
import { readFileSync, readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
export const dynamic = "force-dynamic";
type SkillEntry = {
name: string;
description: string;
emoji?: string;
source: string;
filePath: string;
};
/** Parse YAML frontmatter from a SKILL.md file (lightweight, no deps). */
function parseSkillFrontmatter(content: string): {
name?: string;
description?: string;
emoji?: string;
} {
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
if (!match) return {};
const yaml = match[1];
const result: Record<string, string> = {};
for (const line of yaml.split("\n")) {
const kv = line.match(/^(\w+)\s*:\s*(.+)/);
if (kv) {
result[kv[1]] = kv[2].replace(/^["']|["']$/g, "").trim();
}
}
return {
name: result.name,
description: result.description,
emoji: result.emoji,
};
}
function scanSkillDir(dir: string, source: string): SkillEntry[] {
const skills: SkillEntry[] = [];
if (!existsSync(dir)) return skills;
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillMdPath = join(dir, entry.name, "SKILL.md");
if (!existsSync(skillMdPath)) continue;
try {
const content = readFileSync(skillMdPath, "utf-8");
const meta = parseSkillFrontmatter(content);
skills.push({
name: meta.name ?? entry.name,
description: meta.description ?? "",
emoji: meta.emoji,
source,
filePath: skillMdPath,
});
} catch {
// skip unreadable skill files
}
}
} catch {
// dir unreadable
}
return skills;
}
export async function GET() {
const home = homedir();
const openclawDir = join(home, ".openclaw");
const managedSkills = scanSkillDir(join(openclawDir, "skills"), "managed");
const workspaceSkills = scanSkillDir(
join(openclawDir, "workspace", "skills"),
"workspace",
);
const allSkills = [...workspaceSkills, ...managedSkills];
allSkills.sort((a, b) => a.name.localeCompare(b.name));
return Response.json({ skills: allSkills });
}

View File

@ -0,0 +1,56 @@
"use client";
import type { UIMessage } from "ai";
export function ChatMessage({ message }: { message: UIMessage }) {
const isUser = message.role === "user";
return (
<div className={`flex gap-3 py-4 ${isUser ? "justify-end" : "justify-start"}`}>
{!isUser && (
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-[var(--color-accent)] flex items-center justify-center text-white text-sm font-bold">
O
</div>
)}
<div
className={`max-w-[75%] rounded-2xl px-4 py-3 ${
isUser
? "bg-[var(--color-accent)] text-white"
: "bg-[var(--color-surface)] text-[var(--color-text)]"
}`}
>
{message.parts.map((part, index) => {
if (part.type === "text") {
return (
<div key={index} className="whitespace-pre-wrap text-[15px] leading-relaxed">
{part.text}
</div>
);
}
if (part.type.startsWith("tool-")) {
const toolPart = part as { type: string; toolCallId: string; state?: string; title?: string };
return (
<div
key={index}
className="text-xs text-[var(--color-text-muted)] mt-2 px-2 py-1 bg-[var(--color-bg)] rounded font-mono"
>
Tool: {toolPart.title ?? toolPart.toolCallId}
{toolPart.state === "result" && (
<span className="ml-2 text-green-400">done</span>
)}
</div>
);
}
return null;
})}
</div>
{isUser && (
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-[var(--color-border)] flex items-center justify-center text-[var(--color-text-muted)] text-sm font-bold">
U
</div>
)}
</div>
);
}

View File

@ -0,0 +1,322 @@
"use client";
import { useEffect, useState } from "react";
// --- Types ---
type SessionRow = {
key: string;
sessionId: string;
updatedAt: number;
label?: string;
displayName?: string;
channel?: string;
model?: string;
modelProvider?: string;
thinkingLevel?: string;
totalTokens?: number;
};
type SkillEntry = {
name: string;
description: string;
emoji?: string;
source: string;
};
type MemoryFile = {
name: string;
sizeBytes: number;
};
type SidebarSection = "sessions" | "skills" | "memories";
// --- Helpers ---
function timeAgo(ts: number): string {
const diff = Date.now() - ts;
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function formatTokens(n?: number): string {
if (n == null) return "";
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return String(n);
}
// --- Section Components ---
function SessionsSection({ sessions }: { sessions: SessionRow[] }) {
if (sessions.length === 0) {
return <p className="text-sm text-[var(--color-text-muted)] px-3">No sessions found.</p>;
}
return (
<div className="space-y-1">
{sessions.map((s) => (
<div
key={s.key}
className="px-3 py-2 rounded-lg hover:bg-[var(--color-surface-hover)] cursor-default transition-colors"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium truncate flex-1 mr-2">
{s.label ?? s.displayName ?? s.key}
</span>
{s.updatedAt && (
<span className="text-xs text-[var(--color-text-muted)] flex-shrink-0">
{timeAgo(s.updatedAt)}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
{s.channel && (
<span className="text-xs text-[var(--color-text-muted)]">{s.channel}</span>
)}
{s.model && (
<span className="text-xs text-[var(--color-text-muted)] truncate">
{s.model}
</span>
)}
{s.totalTokens != null && s.totalTokens > 0 && (
<span className="text-xs text-[var(--color-text-muted)]">
{formatTokens(s.totalTokens)} tok
</span>
)}
</div>
</div>
))}
</div>
);
}
function SkillsSection({ skills }: { skills: SkillEntry[] }) {
if (skills.length === 0) {
return <p className="text-sm text-[var(--color-text-muted)] px-3">No skills found.</p>;
}
return (
<div className="space-y-1">
{skills.map((skill) => (
<div
key={`${skill.source}:${skill.name}`}
className="px-3 py-2 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors"
>
<div className="flex items-center gap-2">
{skill.emoji && <span className="text-base">{skill.emoji}</span>}
<span className="text-sm font-medium">{skill.name}</span>
<span className="text-xs text-[var(--color-text-muted)] ml-auto">{skill.source}</span>
</div>
{skill.description && (
<p className="text-xs text-[var(--color-text-muted)] mt-0.5 line-clamp-2">
{skill.description}
</p>
)}
</div>
))}
</div>
);
}
function MemoriesSection({
mainMemory,
dailyLogs,
}: {
mainMemory: string | null;
dailyLogs: MemoryFile[];
}) {
const [expanded, setExpanded] = useState(false);
return (
<div className="space-y-2">
{mainMemory ? (
<div className="px-3">
<button
onClick={() => setExpanded(!expanded)}
className="text-xs text-[var(--color-accent)] hover:text-[var(--color-accent-hover)] mb-1"
>
{expanded ? "Collapse" : "Show"} MEMORY.md ({mainMemory.length} chars)
</button>
{expanded && (
<pre className="text-xs text-[var(--color-text-muted)] bg-[var(--color-bg)] rounded p-2 overflow-auto max-h-64 whitespace-pre-wrap">
{mainMemory}
</pre>
)}
</div>
) : (
<p className="text-sm text-[var(--color-text-muted)] px-3">No MEMORY.md found.</p>
)}
{dailyLogs.length > 0 && (
<div className="px-3">
<p className="text-xs text-[var(--color-text-muted)] mb-1">
Daily logs ({dailyLogs.length})
</p>
<div className="space-y-0.5">
{dailyLogs.slice(0, 10).map((log) => (
<div
key={log.name}
className="text-xs text-[var(--color-text-muted)] flex justify-between"
>
<span>{log.name}</span>
<span>{(log.sizeBytes / 1024).toFixed(1)}kb</span>
</div>
))}
{dailyLogs.length > 10 && (
<p className="text-xs text-[var(--color-text-muted)]">
...and {dailyLogs.length - 10} more
</p>
)}
</div>
</div>
)}
</div>
);
}
// --- Collapsible Header ---
function SectionHeader({
title,
count,
isOpen,
onToggle,
}: {
title: string;
count?: number;
isOpen: boolean;
onToggle: () => void;
}) {
return (
<button
onClick={onToggle}
className="w-full flex items-center justify-between px-3 py-2 text-sm font-semibold text-[var(--color-text)] hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors"
>
<span>
{title}
{count != null && (
<span className="ml-1.5 text-xs text-[var(--color-text-muted)] font-normal">
({count})
</span>
)}
</span>
<svg
className={`w-4 h-4 text-[var(--color-text-muted)] transition-transform ${isOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
);
}
// --- Main Sidebar ---
export function Sidebar() {
const [openSections, setOpenSections] = useState<Set<SidebarSection>>(
new Set(["sessions"]),
);
const [sessions, setSessions] = useState<SessionRow[]>([]);
const [skills, setSkills] = useState<SkillEntry[]>([]);
const [mainMemory, setMainMemory] = useState<string | null>(null);
const [dailyLogs, setDailyLogs] = useState<MemoryFile[]>([]);
const [loading, setLoading] = useState(true);
const toggleSection = (section: SidebarSection) => {
setOpenSections((prev) => {
const next = new Set(prev);
if (next.has(section)) next.delete(section);
else next.add(section);
return next;
});
};
useEffect(() => {
async function load() {
setLoading(true);
try {
const [sessionsRes, skillsRes, memoriesRes] = await Promise.all([
fetch("/api/sessions").then((r) => r.json()),
fetch("/api/skills").then((r) => r.json()),
fetch("/api/memories").then((r) => r.json()),
]);
setSessions(sessionsRes.sessions ?? []);
setSkills(skillsRes.skills ?? []);
setMainMemory(memoriesRes.mainMemory ?? null);
setDailyLogs(memoriesRes.dailyLogs ?? []);
} catch (err) {
console.error("Failed to load sidebar data:", err);
} finally {
setLoading(false);
}
}
load();
}, []);
return (
<aside className="w-72 h-screen flex flex-col bg-[var(--color-surface)] border-r border-[var(--color-border)] overflow-hidden">
{/* Header */}
<div className="px-4 py-4 border-b border-[var(--color-border)]">
<h1 className="text-base font-bold flex items-center gap-2">
<span className="text-xl">🦞</span>
<span>OpenClaw</span>
</h1>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto py-2 space-y-1">
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="w-5 h-5 border-2 border-[var(--color-border)] border-t-[var(--color-accent)] rounded-full animate-spin" />
</div>
) : (
<>
{/* Sessions */}
<div>
<SectionHeader
title="Sessions"
count={sessions.length}
isOpen={openSections.has("sessions")}
onToggle={() => toggleSection("sessions")}
/>
{openSections.has("sessions") && <SessionsSection sessions={sessions} />}
</div>
{/* Skills */}
<div>
<SectionHeader
title="Skills"
count={skills.length}
isOpen={openSections.has("skills")}
onToggle={() => toggleSection("skills")}
/>
{openSections.has("skills") && <SkillsSection skills={skills} />}
</div>
{/* Memories */}
<div>
<SectionHeader
title="Memories"
count={dailyLogs.length}
isOpen={openSections.has("memories")}
onToggle={() => toggleSection("memories")}
/>
{openSections.has("memories") && (
<MemoriesSection mainMemory={mainMemory} dailyLogs={dailyLogs} />
)}
</div>
</>
)}
</div>
</aside>
);
}

39
apps/web/app/globals.css Normal file
View File

@ -0,0 +1,39 @@
@import "tailwindcss";
:root {
--color-bg: #0a0a0a;
--color-surface: #141414;
--color-surface-hover: #1a1a1a;
--color-border: #262626;
--color-text: #ededed;
--color-text-muted: #888;
--color-accent: #e85d3a;
--color-accent-hover: #f06a47;
}
body {
background: var(--color-bg);
color: var(--color-text);
font-family:
"Inter",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
sans-serif;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
}

19
apps/web/app/layout.tsx Normal file
View File

@ -0,0 +1,19 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "OpenClaw Chat",
description: "OpenClaw agent chat interface",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className="dark">
<body className="antialiased">{children}</body>
</html>
);
}

118
apps/web/app/page.tsx Normal file
View File

@ -0,0 +1,118 @@
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useEffect, useRef, useState } from "react";
import { ChatMessage } from "./components/chat-message";
import { Sidebar } from "./components/sidebar";
const transport = new DefaultChatTransport({ api: "/api/chat" });
export default function Home() {
const { messages, sendMessage, status, stop, error } = useChat({ transport });
const [input, setInput] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const isStreaming = status === "streaming" || status === "submitted";
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isStreaming) return;
sendMessage({ text: input });
setInput("");
};
return (
<div className="flex h-screen">
<Sidebar />
{/* Main chat area */}
<main className="flex-1 flex flex-col min-w-0">
{/* Chat header */}
<header className="px-6 py-3 border-b border-[var(--color-border)] flex items-center justify-between bg-[var(--color-surface)]">
<div>
<h2 className="text-sm font-semibold">Agent Chat</h2>
<p className="text-xs text-[var(--color-text-muted)]">
{status === "ready"
? "Ready"
: status === "submitted"
? "Thinking..."
: status === "streaming"
? "Streaming..."
: status === "error"
? "Error"
: status}
</p>
</div>
{isStreaming && (
<button
onClick={() => stop()}
className="px-3 py-1 text-xs rounded-md bg-[var(--color-border)] hover:bg-[var(--color-text-muted)] text-[var(--color-text)] transition-colors"
>
Stop
</button>
)}
</header>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-6">
{messages.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-6xl mb-4">🦞</p>
<h3 className="text-lg font-semibold mb-1">OpenClaw Chat</h3>
<p className="text-sm text-[var(--color-text-muted)]">
Send a message to start a conversation with your agent.
</p>
</div>
</div>
) : (
<div className="max-w-3xl mx-auto py-4">
{messages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Error display */}
{error && (
<div className="px-6 py-2 bg-red-900/20 border-t border-red-800/30">
<p className="text-sm text-red-400">Error: {error.message}</p>
</div>
)}
{/* Input */}
<div className="px-6 py-4 border-t border-[var(--color-border)] bg-[var(--color-surface)]">
<form onSubmit={handleSubmit} className="max-w-3xl mx-auto flex gap-3">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Message OpenClaw..."
disabled={isStreaming}
className="flex-1 px-4 py-3 bg-[var(--color-bg)] border border-[var(--color-border)] rounded-xl text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)] focus:border-transparent disabled:opacity-50 text-sm"
/>
<button
type="submit"
disabled={!input.trim() || isStreaming}
className="px-5 py-3 bg-[var(--color-accent)] hover:bg-[var(--color-accent-hover)] text-white rounded-xl font-medium text-sm transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{isStreaming ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
"Send"
)}
</button>
</form>
</div>
</main>
</div>
);
}

8
apps/web/next.config.ts Normal file
View File

@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Allow long-running API routes for agent streaming
serverExternalPackages: [],
};
export default nextConfig;

25
apps/web/package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "openclaw-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack --port 3100",
"build": "next build",
"start": "next start --port 3100"
},
"dependencies": {
"@ai-sdk/react": "^3.0.75",
"ai": "^6.0.73",
"next": "^15.3.3",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.8",
"@types/node": "^22.15.21",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"tailwindcss": "^4.1.8",
"typescript": "^5.8.3"
}
}

View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

23
apps/web/tsconfig.json Normal file
View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "ES2022"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@ -147,7 +147,11 @@
"tui:dev": "OPENCLAW_PROFILE=dev CLAWDBOT_PROFILE=dev node scripts/run-node.mjs --dev tui",
"ui:build": "node scripts/ui.js build",
"ui:dev": "node scripts/ui.js dev",
"ui:install": "node scripts/ui.js install"
"ui:install": "node scripts/ui.js install",
"web:dev": "pnpm --dir apps/web dev",
"web:build": "pnpm --dir apps/web build",
"web:install": "pnpm --dir apps/web install",
"tail": "tail -f ~/.openclaw/agents/*/sessions/*.jsonl"
},
"dependencies": {
"@agentclientprotocol/sdk": "0.13.1",

691
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
packages:
- .
- ui
- apps/web
- packages/*
- extensions/*

View File

@ -41,6 +41,7 @@ export function registerAgentCommands(program: Command, args: { agentChannelOpti
)
.option("--deliver", "Send the agent's reply back to the selected channel", false)
.option("--json", "Output result as JSON", false)
.option("--stream-json", "Stream NDJSON events to stdout", false)
.option(
"--timeout <seconds>",
"Override agent command timeout (seconds, default 600 or config value)",
@ -73,10 +74,17 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.openclaw.ai/cli/age
.action(async (opts) => {
const verboseLevel = typeof opts.verbose === "string" ? opts.verbose.toLowerCase() : "";
setVerbose(verboseLevel === "on");
if (opts.json && opts.streamJson) {
throw new Error("Choose either --json or --stream-json, not both.");
}
// Build default deps (keeps parity with other commands; future-proofing).
const deps = createDefaultDeps();
await runCommandWithRuntime(defaultRuntime, async () => {
await agentCliCommand(opts, defaultRuntime, deps);
await agentCliCommand(
{ ...opts, streamJson: Boolean(opts.streamJson) },
defaultRuntime,
deps,
);
});
});

View File

@ -15,7 +15,7 @@ import type { OpenClawConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import * as configModule from "../config/config.js";
import { callGateway } from "../gateway/call.js";
import { agentCliCommand } from "./agent-via-gateway.js";
import { agentCliCommand, emitNdjsonLine } from "./agent-via-gateway.js";
import { agentCommand } from "./agent.js";
const runtime: RuntimeEnv = {
@ -122,4 +122,102 @@ describe("agentCliCommand", () => {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("routes to streaming gateway path when --stream-json is set", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-agent-cli-"));
const store = path.join(dir, "sessions.json");
mockConfig(store);
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
// callGateway should receive an onEvent callback when streaming
vi.mocked(callGateway).mockImplementation(async (opts) => {
// Simulate a couple of gateway events via the onEvent callback
const onEvent = (opts as { onEvent?: (evt: unknown) => void }).onEvent;
if (onEvent) {
onEvent({
event: "chat",
payload: { runId: "r1", state: "delta", message: { text: "he" } },
seq: 1,
});
onEvent({
event: "chat",
payload: { runId: "r1", state: "final", message: { text: "hello" } },
seq: 2,
});
}
return { runId: "r1", status: "ok", result: { payloads: [{ text: "hello" }] } };
});
try {
await agentCliCommand({ message: "hi", to: "+1555", streamJson: true }, runtime);
expect(callGateway).toHaveBeenCalledTimes(1);
// Verify onEvent was passed to callGateway
const callOpts = vi.mocked(callGateway).mock.calls[0][0] as Record<string, unknown>;
expect(typeof callOpts.onEvent).toBe("function");
// Verify NDJSON lines were written to stdout (2 events + 1 result)
const writes = stdoutSpy.mock.calls.map(([data]) => String(data));
expect(writes).toHaveLength(3);
for (const line of writes) {
// Each line should be valid JSON followed by a newline
expect(line.endsWith("\n")).toBe(true);
expect(() => JSON.parse(line)).not.toThrow();
}
// The last line should be the result event
const lastLine = JSON.parse(writes[2]);
expect(lastLine.event).toBe("result");
expect(lastLine.status).toBe("ok");
// Normal log output should NOT be called (NDJSON-only)
expect(runtime.log).not.toHaveBeenCalled();
} finally {
stdoutSpy.mockRestore();
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("passes --stream-json through to embedded agent when --local is set", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-agent-cli-"));
const store = path.join(dir, "sessions.json");
mockConfig(store);
vi.mocked(agentCommand).mockResolvedValueOnce(undefined);
try {
await agentCliCommand({ message: "hi", to: "+1555", local: true, streamJson: true }, runtime);
expect(callGateway).not.toHaveBeenCalled();
expect(agentCommand).toHaveBeenCalledTimes(1);
const passedOpts = vi.mocked(agentCommand).mock.calls[0][0] as Record<string, unknown>;
expect(passedOpts.streamJson).toBe(true);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
describe("emitNdjsonLine", () => {
it("writes valid JSON followed by a newline", () => {
const spy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
try {
emitNdjsonLine({
event: "agent",
runId: "r1",
stream: "lifecycle",
data: { phase: "start" },
});
expect(spy).toHaveBeenCalledTimes(1);
const output = String(spy.mock.calls[0][0]);
expect(output.endsWith("\n")).toBe(true);
const parsed = JSON.parse(output);
expect(parsed.event).toBe("agent");
expect(parsed.runId).toBe("r1");
expect(parsed.data).toEqual({ phase: "start" });
} finally {
spy.mockRestore();
}
});
});

View File

@ -15,6 +15,11 @@ import {
import { agentCommand } from "./agent.js";
import { resolveSessionKeyForRequest } from "./agent/session.js";
/** Write a single NDJSON line to stdout. */
export function emitNdjsonLine(obj: Record<string, unknown>): void {
process.stdout.write(`${JSON.stringify(obj)}\n`);
}
type AgentGatewayResult = {
payloads?: Array<{
text?: string;
@ -39,6 +44,8 @@ export type AgentCliOpts = {
thinking?: string;
verbose?: string;
json?: boolean;
/** Stream NDJSON events to stdout during the agent run. */
streamJson?: boolean;
timeout?: string;
deliver?: boolean;
channel?: string;
@ -172,6 +179,78 @@ export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: Runtim
return response;
}
/**
* Gateway agent call with live NDJSON event streaming to stdout.
* Reuses callGateway with an onEvent callback to emit each gateway event as an NDJSON line.
*/
async function agentViaGatewayStreamJson(opts: AgentCliOpts, _runtime: RuntimeEnv) {
const body = (opts.message ?? "").trim();
if (!body) {
throw new Error("Message (--message) is required");
}
if (!opts.to && !opts.sessionId && !opts.agent) {
throw new Error("Pass --to <E.164>, --session-id, or --agent to choose a session");
}
const cfg = loadConfig();
const agentIdRaw = opts.agent?.trim();
const agentId = agentIdRaw ? normalizeAgentId(agentIdRaw) : undefined;
if (agentId) {
const knownAgents = listAgentIds(cfg);
if (!knownAgents.includes(agentId)) {
throw new Error(
`Unknown agent id "${agentIdRaw}". Use "${formatCliCommand("openclaw agents list")}" to see configured agents.`,
);
}
}
const timeoutSeconds = parseTimeoutSeconds({ cfg, timeout: opts.timeout });
const gatewayTimeoutMs = Math.max(10_000, (timeoutSeconds + 30) * 1000);
const sessionKey = resolveSessionKeyForRequest({
cfg,
agentId,
to: opts.to,
sessionId: opts.sessionId,
}).sessionKey;
const channel = normalizeMessageChannel(opts.channel) ?? DEFAULT_CHAT_CHANNEL;
const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey();
const response = await callGateway<GatewayAgentResponse>({
method: "agent",
params: {
message: body,
agentId,
to: opts.to,
replyTo: opts.replyTo,
sessionId: opts.sessionId,
sessionKey,
thinking: opts.thinking,
deliver: Boolean(opts.deliver),
channel,
replyChannel: opts.replyChannel,
replyAccountId: opts.replyAccount,
timeout: timeoutSeconds,
lane: opts.lane,
extraSystemPrompt: opts.extraSystemPrompt,
idempotencyKey,
},
expectFinal: true,
timeoutMs: gatewayTimeoutMs,
clientName: GATEWAY_CLIENT_NAMES.CLI,
mode: GATEWAY_CLIENT_MODES.CLI,
onEvent: (evt) => {
// Emit each gateway event as an NDJSON line (chat deltas, agent tool/lifecycle events).
emitNdjsonLine({ event: evt.event, ...(evt.payload as Record<string, unknown>) });
},
});
// Emit the final result as the last NDJSON line.
emitNdjsonLine({ event: "result", ...response });
return response;
}
export async function agentCliCommand(opts: AgentCliOpts, runtime: RuntimeEnv, deps?: CliDeps) {
const localOpts = {
...opts,
@ -182,6 +261,11 @@ export async function agentCliCommand(opts: AgentCliOpts, runtime: RuntimeEnv, d
return await agentCommand(localOpts, runtime, deps);
}
// Stream NDJSON via the gateway (no embedded fallback — streaming should fail loud).
if (opts.streamJson) {
return await agentViaGatewayStreamJson(opts, runtime);
}
try {
return await agentViaGatewayCommand(opts, runtime);
} catch (err) {

View File

@ -46,6 +46,7 @@ import {
import {
clearAgentRunContext,
emitAgentEvent,
onAgentEvent,
registerAgentRunContext,
} from "../infra/agent-events.js";
import { getRemoteSkillEligibility } from "../infra/skills-remote.js";
@ -55,6 +56,7 @@ import { applyVerboseOverride } from "../sessions/level-overrides.js";
import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js";
import { resolveSendPolicy } from "../sessions/send-policy.js";
import { resolveMessageChannel } from "../utils/message-channel.js";
import { emitNdjsonLine } from "./agent-via-gateway.js";
import { deliverAgentCommandResult } from "./agent/delivery.js";
import { resolveAgentRunContext } from "./agent/run-context.js";
import { updateSessionStoreAfterAgentRun } from "./agent/session-store.js";
@ -156,6 +158,24 @@ export async function agentCommand(
let sessionEntry = resolvedSessionEntry;
const runId = opts.runId?.trim() || sessionId;
// Subscribe to agent events for NDJSON streaming when --stream-json is active.
const unsubNdjson = opts.streamJson
? onAgentEvent((evt) => {
if (evt.runId !== runId) {
return;
}
emitNdjsonLine({
event: "agent",
runId: evt.runId,
seq: evt.seq,
stream: evt.stream,
ts: evt.ts,
data: evt.data,
...(evt.sessionKey ? { sessionKey: evt.sessionKey } : {}),
});
})
: undefined;
try {
if (opts.deliver === true) {
const sendPolicy = resolveSendPolicy({
@ -510,6 +530,16 @@ export async function agentCommand(
});
}
// Emit the final result as NDJSON when streaming.
if (opts.streamJson) {
emitNdjsonLine({
event: "result",
runId,
status: "ok",
payloads: result.payloads ?? [],
});
}
const payloads = result.payloads ?? [];
return await deliverAgentCommandResult({
cfg,
@ -521,6 +551,7 @@ export async function agentCommand(
payloads,
});
} finally {
unsubNdjson?.();
clearAgentRunContext(runId);
}
}

View File

@ -41,6 +41,8 @@ export type AgentCommandOpts = {
thinkingOnce?: string;
verbose?: string;
json?: boolean;
/** Stream NDJSON events to stdout during the agent run. */
streamJson?: boolean;
timeout?: string;
deliver?: boolean;
/** Override delivery target (separate from session routing). */

View File

@ -41,6 +41,8 @@ export type CallGatewayOptions = {
* Does not affect config loading; callers still control auth via opts.token/password/env/config.
*/
configPath?: string;
/** Optional callback for gateway events received while the request is in flight. */
onEvent?: (evt: { event: string; payload?: unknown; seq?: number }) => void;
};
export type GatewayConnectionDetails = {
@ -220,6 +222,9 @@ export async function callGateway<T = Record<string, unknown>>(
deviceIdentity: loadOrCreateDeviceIdentity(),
minProtocol: opts.minProtocol ?? PROTOCOL_VERSION,
maxProtocol: opts.maxProtocol ?? PROTOCOL_VERSION,
onEvent: opts.onEvent
? (evt) => opts.onEvent!({ event: evt.event, payload: evt.payload, seq: evt.seq })
: undefined,
onHelloOk: async () => {
try {
const result = await client.request<T>(opts.method, opts.params, {