From 12ebd393086fd2e14d999f16c1f38d0b1e00a53c Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Sun, 8 Feb 2026 19:11:36 -0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=91=8C=20IMPROVE:=20save=20chat=20sesh=20?= =?UTF-8?q?into=20nextjs=20web?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/api/new-session/route.ts | 25 ++ .../web/app/api/sessions/[sessionId]/route.ts | 131 ++++++++++ .../api/web-sessions/[id]/messages/route.ts | 70 +++++ apps/web/app/api/web-sessions/[id]/route.ts | 43 ++++ apps/web/app/api/web-sessions/route.ts | 67 +++++ apps/web/app/components/sidebar.tsx | 190 ++++++++------ apps/web/app/page.tsx | 240 ++++++++++++++++-- apps/web/next.config.ts | 10 +- apps/web/package-lock.json | 120 +++++++++ apps/web/package.json | 2 +- apps/web/tsconfig.tsbuildinfo | 2 +- 11 files changed, 792 insertions(+), 108 deletions(-) create mode 100644 apps/web/app/api/new-session/route.ts create mode 100644 apps/web/app/api/sessions/[sessionId]/route.ts create mode 100644 apps/web/app/api/web-sessions/[id]/messages/route.ts create mode 100644 apps/web/app/api/web-sessions/[id]/route.ts create mode 100644 apps/web/app/api/web-sessions/route.ts diff --git a/apps/web/app/api/new-session/route.ts b/apps/web/app/api/new-session/route.ts new file mode 100644 index 00000000000..84ee3ba2626 --- /dev/null +++ b/apps/web/app/api/new-session/route.ts @@ -0,0 +1,25 @@ +import { runAgent } from "@/lib/agent-runner"; + +// Force Node.js runtime (required for child_process) +export const runtime = "nodejs"; + +export const maxDuration = 30; + +/** POST /api/new-session — send /new to the agent to start a fresh backend session */ +export async function POST() { + return new Promise((resolve) => { + runAgent("/new", { + onTextDelta: () => {}, + onLifecycleEnd: () => {}, + onError: (err) => { + console.error("[new-session] Error:", err); + resolve( + Response.json({ ok: false, error: err.message }, { status: 500 }), + ); + }, + onClose: () => { + resolve(Response.json({ ok: true })); + }, + }); + }); +} diff --git a/apps/web/app/api/sessions/[sessionId]/route.ts b/apps/web/app/api/sessions/[sessionId]/route.ts new file mode 100644 index 00000000000..0d69a5309d8 --- /dev/null +++ b/apps/web/app/api/sessions/[sessionId]/route.ts @@ -0,0 +1,131 @@ +import { readFileSync, readdirSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +export const dynamic = "force-dynamic"; + +type JSONLMessage = { + type: string; + id: string; + parentId: string | null; + timestamp: string; + message?: { + role: "user" | "assistant"; + content: Array< + | { type: "text"; text: string } + | { type: "image"; data: string } + | { type: "thinking"; thinking: string; thinkingSignature?: string } + >; + timestamp?: number; + }; + customType?: string; + data?: unknown; +}; + +function resolveOpenClawDir(): string { + return join(homedir(), ".openclaw"); +} + +function findSessionFile(sessionId: string): string | null { + const openclawDir = resolveOpenClawDir(); + const agentsDir = join(openclawDir, "agents"); + + if (!existsSync(agentsDir)) { + return null; + } + + try { + const agentDirs = readdirSync(agentsDir, { withFileTypes: true }); + for (const agentDir of agentDirs) { + if (!agentDir.isDirectory()) continue; + + const sessionFile = join( + agentsDir, + agentDir.name, + "sessions", + `${sessionId}.jsonl` + ); + + if (existsSync(sessionFile)) { + return sessionFile; + } + } + } catch { + // ignore errors + } + + return null; +} + +export async function GET( + _request: Request, + { params }: { params: Promise<{ sessionId: string }> } +) { + const { sessionId } = await params; + + if (!sessionId) { + return Response.json({ error: "Session ID required" }, { status: 400 }); + } + + const sessionFile = findSessionFile(sessionId); + + if (!sessionFile) { + return Response.json({ error: "Session not found" }, { status: 404 }); + } + + try { + const content = readFileSync(sessionFile, "utf-8"); + const lines = content + .trim() + .split("\n") + .filter((line) => line.trim()); + + const messages: Array<{ + id: string; + role: "user" | "assistant"; + content: string; + timestamp: string; + }> = []; + + for (const line of lines) { + try { + const entry = JSON.parse(line) as JSONLMessage; + + if (entry.type === "message" && entry.message) { + // Extract text content from the message + const textContent = entry.message.content + .filter((part) => part.type === "text" || part.type === "thinking") + .map((part) => { + if (part.type === "text") { + return part.text; + } + if (part.type === "thinking") { + return `[Thinking: ${part.thinking.slice(0, 100)}...]`; + } + return ""; + }) + .join("\n"); + + if (textContent) { + messages.push({ + id: entry.id, + role: entry.message.role, + content: textContent, + timestamp: entry.timestamp, + }); + } + } + } catch { + // skip malformed lines + } + } + + return Response.json({ sessionId, messages }); + } catch (error) { + console.error("Error reading session:", error); + return Response.json( + { error: "Failed to read session" }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/web-sessions/[id]/messages/route.ts b/apps/web/app/api/web-sessions/[id]/messages/route.ts new file mode 100644 index 00000000000..b2c171a8ff5 --- /dev/null +++ b/apps/web/app/api/web-sessions/[id]/messages/route.ts @@ -0,0 +1,70 @@ +import { + appendFileSync, + readFileSync, + writeFileSync, + existsSync, + mkdirSync, +} from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +export const dynamic = "force-dynamic"; + +const WEB_CHAT_DIR = join(homedir(), ".openclaw", "web-chat"); +const INDEX_FILE = join(WEB_CHAT_DIR, "index.json"); + +type IndexEntry = { + id: string; + title: string; + createdAt: number; + updatedAt: number; + messageCount: number; +}; + +/** POST /api/web-sessions/[id]/messages — append messages to a session */ +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const filePath = join(WEB_CHAT_DIR, `${id}.jsonl`); + + // Auto-create the session file if it doesn't exist yet + if (!existsSync(WEB_CHAT_DIR)) { + mkdirSync(WEB_CHAT_DIR, { recursive: true }); + } + if (!existsSync(filePath)) { + writeFileSync(filePath, ""); + } + + const { messages, title } = await request.json(); + + if (!Array.isArray(messages) || messages.length === 0) { + return Response.json({ error: "messages array required" }, { status: 400 }); + } + + // Append each message as a JSONL line + for (const msg of messages) { + appendFileSync(filePath, JSON.stringify(msg) + "\n"); + } + + // Update index metadata + try { + if (existsSync(INDEX_FILE)) { + const index: IndexEntry[] = JSON.parse( + readFileSync(INDEX_FILE, "utf-8"), + ); + const session = index.find((s) => s.id === id); + if (session) { + session.updatedAt = Date.now(); + session.messageCount += messages.length; + if (title) session.title = title; + writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2)); + } + } + } catch { + // index update is best-effort + } + + return Response.json({ ok: true }); +} diff --git a/apps/web/app/api/web-sessions/[id]/route.ts b/apps/web/app/api/web-sessions/[id]/route.ts new file mode 100644 index 00000000000..fb299f1edf0 --- /dev/null +++ b/apps/web/app/api/web-sessions/[id]/route.ts @@ -0,0 +1,43 @@ +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +export const dynamic = "force-dynamic"; + +const WEB_CHAT_DIR = join(homedir(), ".openclaw", "web-chat"); + +export type ChatLine = { + id: string; + role: "user" | "assistant"; + content: string; + timestamp: string; +}; + +/** GET /api/web-sessions/[id] — read all messages for a web chat session */ +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const filePath = join(WEB_CHAT_DIR, `${id}.jsonl`); + + if (!existsSync(filePath)) { + return Response.json({ error: "Session not found" }, { status: 404 }); + } + + const content = readFileSync(filePath, "utf-8"); + const messages: ChatLine[] = content + .trim() + .split("\n") + .filter((line) => line.trim()) + .map((line) => { + try { + return JSON.parse(line) as ChatLine; + } catch { + return null; + } + }) + .filter((m): m is ChatLine => m !== null); + + return Response.json({ id, messages }); +} diff --git a/apps/web/app/api/web-sessions/route.ts b/apps/web/app/api/web-sessions/route.ts new file mode 100644 index 00000000000..a07455e6e46 --- /dev/null +++ b/apps/web/app/api/web-sessions/route.ts @@ -0,0 +1,67 @@ +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { randomUUID } from "node:crypto"; + +export const dynamic = "force-dynamic"; + +const WEB_CHAT_DIR = join(homedir(), ".openclaw", "web-chat"); +const INDEX_FILE = join(WEB_CHAT_DIR, "index.json"); + +export type WebSessionMeta = { + id: string; + title: string; + createdAt: number; + updatedAt: number; + messageCount: number; +}; + +function ensureDir() { + if (!existsSync(WEB_CHAT_DIR)) { + mkdirSync(WEB_CHAT_DIR, { recursive: true }); + } +} + +function readIndex(): WebSessionMeta[] { + ensureDir(); + if (!existsSync(INDEX_FILE)) return []; + try { + return JSON.parse(readFileSync(INDEX_FILE, "utf-8")); + } catch { + return []; + } +} + +function writeIndex(sessions: WebSessionMeta[]) { + ensureDir(); + writeFileSync(INDEX_FILE, JSON.stringify(sessions, null, 2)); +} + +/** GET /api/web-sessions — list all web chat sessions */ +export async function GET() { + const sessions = readIndex(); + return Response.json({ sessions }); +} + +/** POST /api/web-sessions — create a new web chat session */ +export async function POST(req: Request) { + const body = await req.json().catch(() => ({})); + const id = randomUUID(); + const session: WebSessionMeta = { + id, + title: body.title || "New Chat", + createdAt: Date.now(), + updatedAt: Date.now(), + messageCount: 0, + }; + + const sessions = readIndex(); + sessions.unshift(session); + writeIndex(sessions); + + // Create empty .jsonl file + ensureDir(); + writeFileSync(join(WEB_CHAT_DIR, `${id}.jsonl`), ""); + + return Response.json({ session }); +} diff --git a/apps/web/app/components/sidebar.tsx b/apps/web/app/components/sidebar.tsx index 0fd89b6c32c..ca48a85ace9 100644 --- a/apps/web/app/components/sidebar.tsx +++ b/apps/web/app/components/sidebar.tsx @@ -4,17 +4,12 @@ import { useEffect, useState } from "react"; // --- Types --- -type SessionRow = { - key: string; - sessionId: string; +type WebSession = { + id: string; + title: string; + createdAt: number; updatedAt: number; - label?: string; - displayName?: string; - channel?: string; - model?: string; - modelProvider?: string; - thinkingLevel?: string; - totalTokens?: number; + messageCount: number; }; type SkillEntry = { @@ -29,7 +24,14 @@ type MemoryFile = { sizeBytes: number; }; -type SidebarSection = "sessions" | "skills" | "memories"; +type SidebarSection = "chats" | "skills" | "memories"; + +type SidebarProps = { + onSessionSelect?: (sessionId: string) => void; + onNewSession?: () => void; + activeSessionId?: string; + refreshKey?: number; +}; // --- Helpers --- @@ -45,54 +47,71 @@ function timeAgo(ts: number): string { 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

No sessions found.

; - } +function ChatsSection({ + sessions, + onSessionSelect, + activeSessionId, +}: { + sessions: WebSession[]; + onSessionSelect?: (sessionId: string) => void; + activeSessionId?: string; +}) { + const [searchTerm, setSearchTerm] = useState(""); + + const filteredSessions = sessions.filter((s) => + s.title.toLowerCase().includes(searchTerm.toLowerCase()), + ); return ( -
- {sessions.map((s) => ( -
-
- - {s.label ?? s.displayName ?? s.key} - - {s.updatedAt && ( - - {timeAgo(s.updatedAt)} - - )} -
-
- {s.channel && ( - {s.channel} - )} - {s.model && ( - - {s.model} - - )} - {s.totalTokens != null && s.totalTokens > 0 && ( - - {formatTokens(s.totalTokens)} tok - - )} -
+
+ {sessions.length > 3 && ( +
+ setSearchTerm(e.target.value)} + placeholder="Search chats..." + className="w-full px-3 py-1.5 text-xs bg-[var(--color-bg)] border border-[var(--color-border)] rounded-md text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none focus:ring-1 focus:ring-[var(--color-accent)] focus:border-transparent" + />
- ))} + )} + + {filteredSessions.length === 0 ? ( +

+ {searchTerm ? "No matching chats." : "No chats yet. Send a message to start."} +

+ ) : ( +
+ {filteredSessions.map((s) => { + const isActive = s.id === activeSessionId; + return ( +
onSessionSelect?.(s.id)} + className={`mx-2 px-3 py-2 rounded-lg hover:bg-[var(--color-surface-hover)] cursor-pointer transition-colors ${ + isActive + ? "bg-[var(--color-surface-hover)] border-l-2 border-[var(--color-accent)]" + : "" + }`} + > +
+ {s.title} + + {timeAgo(s.updatedAt)} + +
+ {s.messageCount > 0 && ( +

+ {s.messageCount} message{s.messageCount !== 1 ? "s" : ""} +

+ )} +
+ ); + })} +
+ )}
); } @@ -221,11 +240,14 @@ function SectionHeader({ // --- Main Sidebar --- -export function Sidebar() { - const [openSections, setOpenSections] = useState>( - new Set(["sessions"]), - ); - const [sessions, setSessions] = useState([]); +export function Sidebar({ + onSessionSelect, + onNewSession, + activeSessionId, + refreshKey, +}: SidebarProps) { + const [openSections, setOpenSections] = useState>(new Set(["chats"])); + const [webSessions, setWebSessions] = useState([]); const [skills, setSkills] = useState([]); const [mainMemory, setMainMemory] = useState(null); const [dailyLogs, setDailyLogs] = useState([]); @@ -240,16 +262,17 @@ export function Sidebar() { }); }; + // Fetch sidebar data (re-runs when refreshKey changes) useEffect(() => { async function load() { setLoading(true); try { - const [sessionsRes, skillsRes, memoriesRes] = await Promise.all([ - fetch("/api/sessions").then((r) => r.json()), + const [webSessionsRes, skillsRes, memoriesRes] = await Promise.all([ + fetch("/api/web-sessions").then((r) => r.json()), fetch("/api/skills").then((r) => r.json()), fetch("/api/memories").then((r) => r.json()), ]); - setSessions(sessionsRes.sessions ?? []); + setWebSessions(webSessionsRes.sessions ?? []); setSkills(skillsRes.skills ?? []); setMainMemory(memoriesRes.mainMemory ?? null); setDailyLogs(memoriesRes.dailyLogs ?? []); @@ -260,16 +283,35 @@ export function Sidebar() { } } load(); - }, []); + }, [refreshKey]); return (