📦 NEW: nextjs chat
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
9fa5c96dd4
commit
3568fa09ff
4
.gitignore
vendored
4
.gitignore
vendored
@ -69,5 +69,9 @@ IDENTITY.md
|
||||
USER.md
|
||||
.tgz
|
||||
|
||||
# Next.js
|
||||
**/.next/
|
||||
next-env.d.ts
|
||||
|
||||
# local tooling
|
||||
.serena/
|
||||
|
||||
149
apps/web/app/api/chat/route.ts
Normal file
149
apps/web/app/api/chat/route.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
63
apps/web/app/api/memories/route.ts
Normal file
63
apps/web/app/api/memories/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
96
apps/web/app/api/sessions/route.ts
Normal file
96
apps/web/app/api/sessions/route.ts
Normal 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 });
|
||||
}
|
||||
85
apps/web/app/api/skills/route.ts
Normal file
85
apps/web/app/api/skills/route.ts
Normal 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 });
|
||||
}
|
||||
56
apps/web/app/components/chat-message.tsx
Normal file
56
apps/web/app/components/chat-message.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
322
apps/web/app/components/sidebar.tsx
Normal file
322
apps/web/app/components/sidebar.tsx
Normal 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
39
apps/web/app/globals.css
Normal 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
19
apps/web/app/layout.tsx
Normal 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
118
apps/web/app/page.tsx
Normal 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
8
apps/web/next.config.ts
Normal 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
25
apps/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
apps/web/postcss.config.mjs
Normal file
7
apps/web/postcss.config.mjs
Normal file
@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
23
apps/web/tsconfig.json
Normal file
23
apps/web/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@ -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
691
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,7 @@
|
||||
packages:
|
||||
- .
|
||||
- ui
|
||||
- apps/web
|
||||
- packages/*
|
||||
- extensions/*
|
||||
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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). */
|
||||
|
||||
@ -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, {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user