👌 IMPROVE: save chat sesh into nextjs web

This commit is contained in:
kumarabhirup 2026-02-08 19:11:36 -08:00
parent 5da09483f8
commit 12ebd39308
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
11 changed files with 792 additions and 108 deletions

View 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 }));
},
});
});
}

View 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 }
);
}
}

View 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 });
}

View 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 });
}

View 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 });
}

View File

@ -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 */}

View File

@ -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 ? (

View File

@ -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

View File

@ -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"
}
}
}
}

View File

@ -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