👌 IMPROVE: save chat sesh into nextjs web
This commit is contained in:
parent
5da09483f8
commit
12ebd39308
25
apps/web/app/api/new-session/route.ts
Normal file
25
apps/web/app/api/new-session/route.ts
Normal file
@ -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<Response>((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 }));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
131
apps/web/app/api/sessions/[sessionId]/route.ts
Normal file
131
apps/web/app/api/sessions/[sessionId]/route.ts
Normal file
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
70
apps/web/app/api/web-sessions/[id]/messages/route.ts
Normal file
70
apps/web/app/api/web-sessions/[id]/messages/route.ts
Normal file
@ -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 });
|
||||
}
|
||||
43
apps/web/app/api/web-sessions/[id]/route.ts
Normal file
43
apps/web/app/api/web-sessions/[id]/route.ts
Normal file
@ -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 });
|
||||
}
|
||||
67
apps/web/app/api/web-sessions/route.ts
Normal file
67
apps/web/app/api/web-sessions/route.ts
Normal file
@ -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 });
|
||||
}
|
||||
@ -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 <p className="text-sm text-[var(--color-text-muted)] px-3">No sessions found.</p>;
|
||||
}
|
||||
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 (
|
||||
<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 className="space-y-2">
|
||||
{sessions.length > 3 && (
|
||||
<div className="px-3">
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
{filteredSessions.length === 0 ? (
|
||||
<p className="text-sm text-[var(--color-text-muted)] px-3">
|
||||
{searchTerm ? "No matching chats." : "No chats yet. Send a message to start."}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{filteredSessions.map((s) => {
|
||||
const isActive = s.id === activeSessionId;
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
onClick={() => 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)]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm truncate flex-1">{s.title}</span>
|
||||
<span className="text-xs text-[var(--color-text-muted)] flex-shrink-0">
|
||||
{timeAgo(s.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
{s.messageCount > 0 && (
|
||||
<p className="text-xs text-[var(--color-text-muted)] mt-0.5">
|
||||
{s.messageCount} message{s.messageCount !== 1 ? "s" : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -221,11 +240,14 @@ function SectionHeader({
|
||||
|
||||
// --- Main Sidebar ---
|
||||
|
||||
export function Sidebar() {
|
||||
const [openSections, setOpenSections] = useState<Set<SidebarSection>>(
|
||||
new Set(["sessions"]),
|
||||
);
|
||||
const [sessions, setSessions] = useState<SessionRow[]>([]);
|
||||
export function Sidebar({
|
||||
onSessionSelect,
|
||||
onNewSession,
|
||||
activeSessionId,
|
||||
refreshKey,
|
||||
}: SidebarProps) {
|
||||
const [openSections, setOpenSections] = useState<Set<SidebarSection>>(new Set(["chats"]));
|
||||
const [webSessions, setWebSessions] = useState<WebSession[]>([]);
|
||||
const [skills, setSkills] = useState<SkillEntry[]>([]);
|
||||
const [mainMemory, setMainMemory] = useState<string | null>(null);
|
||||
const [dailyLogs, setDailyLogs] = useState<MemoryFile[]>([]);
|
||||
@ -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 (
|
||||
<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)]">
|
||||
{/* Header with New Chat button */}
|
||||
<div className="px-4 py-4 border-b border-[var(--color-border)] flex items-center justify-between">
|
||||
<h1 className="text-base font-bold flex items-center gap-2">
|
||||
<span className="text-xl">🦞</span>
|
||||
<span>OpenClaw</span>
|
||||
</h1>
|
||||
<button
|
||||
onClick={onNewSession}
|
||||
title="New Chat"
|
||||
className="p-1.5 rounded-md hover:bg-[var(--color-surface-hover)] text-[var(--color-text-muted)] hover:text-[var(--color-text)] transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@ -280,15 +322,21 @@ export function Sidebar() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Sessions */}
|
||||
{/* Chats (web sessions) */}
|
||||
<div>
|
||||
<SectionHeader
|
||||
title="Sessions"
|
||||
count={sessions.length}
|
||||
isOpen={openSections.has("sessions")}
|
||||
onToggle={() => toggleSection("sessions")}
|
||||
title="Chats"
|
||||
count={webSessions.length}
|
||||
isOpen={openSections.has("chats")}
|
||||
onToggle={() => toggleSection("chats")}
|
||||
/>
|
||||
{openSections.has("sessions") && <SessionsSection sessions={sessions} />}
|
||||
{openSections.has("chats") && (
|
||||
<ChatsSection
|
||||
sessions={webSessions}
|
||||
onSessionSelect={onSessionSelect}
|
||||
activeSessionId={activeSessionId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Skills */}
|
||||
|
||||
@ -2,17 +2,24 @@
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, 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 { messages, sendMessage, status, stop, error, setMessages } = useChat({ transport });
|
||||
const [input, setInput] = useState("");
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||
const [loadingSession, setLoadingSession] = useState(false);
|
||||
const [startingNewSession, setStartingNewSession] = useState(false);
|
||||
const [sidebarRefreshKey, setSidebarRefreshKey] = useState(0);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Track which messages have already been persisted to avoid double-saves
|
||||
const savedMessageIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
@ -20,48 +27,229 @@ export default function Home() {
|
||||
|
||||
const isStreaming = status === "streaming" || status === "submitted";
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const refreshSidebar = useCallback(() => {
|
||||
setSidebarRefreshKey((k) => k + 1);
|
||||
}, []);
|
||||
|
||||
/** Persist messages to the web session's .jsonl file */
|
||||
const saveMessages = useCallback(
|
||||
async (
|
||||
sessionId: string,
|
||||
msgs: Array<{ id: string; role: string; content: string }>,
|
||||
title?: string,
|
||||
) => {
|
||||
const toSave = msgs.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
try {
|
||||
await fetch(`/api/web-sessions/${sessionId}/messages`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ messages: toSave, title }),
|
||||
});
|
||||
for (const m of msgs) savedMessageIdsRef.current.add(m.id);
|
||||
refreshSidebar();
|
||||
} catch (err) {
|
||||
console.error("Failed to save messages:", err);
|
||||
}
|
||||
},
|
||||
[refreshSidebar],
|
||||
);
|
||||
|
||||
/** Create a new web chat session and return its ID */
|
||||
const createSession = useCallback(async (title: string): Promise<string> => {
|
||||
const res = await fetch("/api/web-sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.session.id;
|
||||
}, []);
|
||||
|
||||
/** Extract plain text from a UIMessage */
|
||||
const getMessageText = useCallback(
|
||||
(msg: (typeof messages)[number]): string => {
|
||||
return (
|
||||
msg.parts
|
||||
?.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("\n") ?? ""
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// When streaming finishes, save the assistant's response
|
||||
const prevStatusRef = useRef(status);
|
||||
useEffect(() => {
|
||||
const wasStreaming =
|
||||
prevStatusRef.current === "streaming" || prevStatusRef.current === "submitted";
|
||||
const isNowReady = status === "ready";
|
||||
|
||||
if (wasStreaming && isNowReady && currentSessionId) {
|
||||
// Save any unsaved messages (typically the assistant response)
|
||||
const unsaved = messages.filter((m) => !savedMessageIdsRef.current.has(m.id));
|
||||
if (unsaved.length > 0) {
|
||||
const toSave = unsaved.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: getMessageText(m),
|
||||
}));
|
||||
saveMessages(currentSessionId, toSave);
|
||||
}
|
||||
}
|
||||
prevStatusRef.current = status;
|
||||
}, [status, messages, currentSessionId, saveMessages, getMessageText]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || isStreaming) return;
|
||||
sendMessage({ text: input });
|
||||
|
||||
const userText = input.trim();
|
||||
setInput("");
|
||||
|
||||
// "/new" triggers a new session (same as clicking the + button)
|
||||
if (userText.toLowerCase() === "/new") {
|
||||
handleNewSession();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a session if we don't have one yet
|
||||
let sessionId = currentSessionId;
|
||||
if (!sessionId) {
|
||||
const title = userText.length > 60 ? userText.slice(0, 60) + "..." : userText;
|
||||
sessionId = await createSession(title);
|
||||
setCurrentSessionId(sessionId);
|
||||
refreshSidebar();
|
||||
}
|
||||
|
||||
// Save the user message immediately
|
||||
const userMsgId = `user-${Date.now()}`;
|
||||
await saveMessages(sessionId, [
|
||||
{ id: userMsgId, role: "user", content: userText },
|
||||
]);
|
||||
|
||||
// Send to agent
|
||||
sendMessage({ text: userText });
|
||||
};
|
||||
|
||||
/** Load a previous web chat session */
|
||||
const handleSessionSelect = useCallback(
|
||||
async (sessionId: string) => {
|
||||
if (sessionId === currentSessionId) return;
|
||||
|
||||
setLoadingSession(true);
|
||||
setCurrentSessionId(sessionId);
|
||||
savedMessageIdsRef.current.clear();
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/web-sessions/${sessionId}`);
|
||||
if (!response.ok) throw new Error("Failed to load session");
|
||||
|
||||
const data = await response.json();
|
||||
const sessionMessages: Array<{
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}> = data.messages || [];
|
||||
|
||||
// Convert to UIMessage format and mark all as saved
|
||||
const uiMessages = sessionMessages.map((msg) => {
|
||||
savedMessageIdsRef.current.add(msg.id);
|
||||
return {
|
||||
id: msg.id,
|
||||
role: msg.role,
|
||||
parts: [{ type: "text" as const, text: msg.content }],
|
||||
};
|
||||
});
|
||||
|
||||
setMessages(uiMessages);
|
||||
} catch (err) {
|
||||
console.error("Error loading session:", err);
|
||||
} finally {
|
||||
setLoadingSession(false);
|
||||
}
|
||||
},
|
||||
[currentSessionId, setMessages],
|
||||
);
|
||||
|
||||
/** Start a brand new session: clear UI, send /new to agent */
|
||||
const handleNewSession = useCallback(async () => {
|
||||
// Clear the UI immediately
|
||||
setCurrentSessionId(null);
|
||||
setMessages([]);
|
||||
savedMessageIdsRef.current.clear();
|
||||
|
||||
// Send /new to the agent backend to start a fresh session
|
||||
setStartingNewSession(true);
|
||||
try {
|
||||
await fetch("/api/new-session", { method: "POST" });
|
||||
} catch (err) {
|
||||
console.error("Failed to send /new:", err);
|
||||
} finally {
|
||||
setStartingNewSession(false);
|
||||
}
|
||||
}, [setMessages]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<Sidebar
|
||||
onSessionSelect={handleSessionSelect}
|
||||
onNewSession={handleNewSession}
|
||||
activeSessionId={currentSessionId ?? undefined}
|
||||
refreshKey={sidebarRefreshKey}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
<h2 className="text-sm font-semibold">
|
||||
{currentSessionId ? "Chat Session" : "New Chat"}
|
||||
</h2>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
{status === "ready"
|
||||
? "Ready"
|
||||
: status === "submitted"
|
||||
? "Thinking..."
|
||||
: status === "streaming"
|
||||
? "Streaming..."
|
||||
: status === "error"
|
||||
? "Error"
|
||||
: status}
|
||||
{startingNewSession
|
||||
? "Starting new session..."
|
||||
: loadingSession
|
||||
? "Loading session..."
|
||||
: 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>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto px-6">
|
||||
{messages.length === 0 ? (
|
||||
{loadingSession ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="w-8 h-8 border-2 border-[var(--color-border)] border-t-[var(--color-accent)] rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-sm text-[var(--color-text-muted)]">Loading session...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-6xl mb-4">🦞</p>
|
||||
@ -96,12 +284,12 @@ export default function Home() {
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Message OpenClaw..."
|
||||
disabled={isStreaming}
|
||||
disabled={isStreaming || loadingSession || startingNewSession}
|
||||
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}
|
||||
disabled={!input.trim() || isStreaming || loadingSession || startingNewSession}
|
||||
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 ? (
|
||||
|
||||
@ -4,15 +4,7 @@ const nextConfig: NextConfig = {
|
||||
// Allow long-running API routes for agent streaming
|
||||
serverExternalPackages: [],
|
||||
|
||||
// Turbopack experimental configuration
|
||||
experimental: {
|
||||
turbo: {
|
||||
resolveAlias: {},
|
||||
resolveExtensions: [".ts", ".tsx", ".js", ".jsx", ".json"],
|
||||
},
|
||||
},
|
||||
|
||||
// Ensure Node.js built-ins work correctly (for webpack fallback)
|
||||
// Ensure Node.js built-ins work correctly
|
||||
webpack: (config, { isServer }) => {
|
||||
if (isServer) {
|
||||
// Don't attempt to bundle Node.js built-ins
|
||||
|
||||
120
apps/web/package-lock.json
generated
120
apps/web/package-lock.json
generated
@ -529,6 +529,126 @@
|
||||
"node_modules/typescript": {
|
||||
"resolved": "../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "15.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.3.tgz",
|
||||
"integrity": "sha512-WRJERLuH+O3oYB4yZNVahSVFmtxRNjNF1I1c34tYMoJb0Pve+7/RaLAJJizyYiFhjYNGHRAE1Ri2Fd23zgDqhg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "15.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.3.tgz",
|
||||
"integrity": "sha512-XHdzH/yBc55lu78k/XwtuFR/ZXUTcflpRXcsu0nKmF45U96jt1tsOZhVrn5YH+paw66zOANpOnFQ9i6/j+UYvw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "15.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.3.tgz",
|
||||
"integrity": "sha512-VZ3sYL2LXB8znNGcjhocikEkag/8xiLgnvQts41tq6i+wql63SMS1Q6N8RVXHw5pEUjiof+II3HkDd7GFcgkzw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "15.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.3.tgz",
|
||||
"integrity": "sha512-h6Y1fLU4RWAp1HPNJWDYBQ+e3G7sLckyBXhmH9ajn8l/RSMnhbuPBV/fXmy3muMcVwoJdHL+UtzRzs0nXOf9SA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "15.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.3.tgz",
|
||||
"integrity": "sha512-jJ8HRiF3N8Zw6hGlytCj5BiHyG/K+fnTKVDEKvUCyiQ/0r5tgwO7OgaRiOjjRoIx2vwLR+Rz8hQoPrnmFbJdfw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "15.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.3.tgz",
|
||||
"integrity": "sha512-HrUcTr4N+RgiiGn3jjeT6Oo208UT/7BuTr7K0mdKRBtTbT4v9zJqCDKO97DUqqoBK1qyzP1RwvrWTvU6EPh/Cw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "15.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.3.tgz",
|
||||
"integrity": "sha512-SxorONgi6K7ZUysMtRF3mIeHC5aA3IQLmKFQzU0OuhuUYwpOBc1ypaLJLP5Bf3M9k53KUUUj4vTPwzGvl/NwlQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "15.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.3.tgz",
|
||||
"integrity": "sha512-4QZG6F8enl9/S2+yIiOiju0iCTFd93d8VC1q9LZS4p/Xuk81W2QDjCFeoogmrWWkAD59z8ZxepBQap2dKS5ruw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack --port 3100",
|
||||
"dev": "next dev --port 3100",
|
||||
"build": "next build",
|
||||
"start": "next start --port 3100"
|
||||
},
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user