diff --git a/app/[workspace]/project/[projectId]/overview/page.tsx b/app/[workspace]/project/[projectId]/overview/page.tsx index 599b641..550968d 100644 --- a/app/[workspace]/project/[projectId]/overview/page.tsx +++ b/app/[workspace]/project/[projectId]/overview/page.tsx @@ -12,6 +12,7 @@ import { } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { OrchestratorChat } from "@/components/OrchestratorChat"; import { GitBranch, GitCommit, @@ -198,6 +199,12 @@ export default function ProjectOverviewPage() { return (
+ {/* ── Orchestrator Chat ── */} + + {/* ── Header ── */}
diff --git a/app/api/projects/[projectId]/agent-chat/route.ts b/app/api/projects/[projectId]/agent-chat/route.ts new file mode 100644 index 0000000..39e5e95 --- /dev/null +++ b/app/api/projects/[projectId]/agent-chat/route.ts @@ -0,0 +1,111 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/lib/auth"; +import { query } from "@/lib/db-postgres"; + +const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333"; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { projectId } = await params; + const { message } = await req.json(); + + if (!message?.trim()) { + return NextResponse.json({ error: '"message" is required' }, { status: 400 }); + } + + // Load project context to inject into the orchestrator session + let projectContext = ""; + try { + const rows = await query<{ data: any }>( + `SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`, + [projectId] + ); + if (rows.length > 0) { + const p = rows[0].data; + const lines = [ + `Project: ${p.productName ?? p.name ?? "Unnamed"}`, + p.productVision ? `Vision: ${p.productVision}` : null, + p.giteaRepo ? `Gitea repo: ${p.giteaRepo}` : null, + p.coolifyAppUuid ? `Coolify app UUID: ${p.coolifyAppUuid}` : null, + p.deploymentUrl ? `Live URL: ${p.deploymentUrl}` : null, + p.theiaWorkspaceUrl ? `IDE: ${p.theiaWorkspaceUrl}` : null, + ].filter(Boolean); + projectContext = lines.join("\n"); + } + } catch { + // Non-fatal — orchestrator still works without extra context + } + + // Use projectId as the session ID so each project has its own conversation + const sessionId = `project_${projectId}`; + + // First message in a new session? Prepend project context + const enrichedMessage = projectContext + ? `[Project context]\n${projectContext}\n\n[User message]\n${message}` + : message; + + try { + const res = await fetch(`${AGENT_RUNNER_URL}/orchestrator/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: enrichedMessage, session_id: sessionId }), + signal: AbortSignal.timeout(120_000), // 2 min — agents can take time + }); + + if (!res.ok) { + const errText = await res.text(); + return NextResponse.json( + { error: `Agent runner error: ${res.status} — ${errText.slice(0, 200)}` }, + { status: 502 } + ); + } + + const data = await res.json(); + return NextResponse.json({ + reply: data.reply, + reasoning: data.reasoning ?? null, + toolCalls: data.toolCalls ?? [], + turns: data.turns ?? 0, + model: data.model ?? "unknown", + sessionId, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return NextResponse.json( + { error: msg.includes("fetch") ? "Agent runner is offline" : msg }, + { status: 503 } + ); + } +} + +// Clear session for this project +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { projectId } = await params; + const sessionId = `project_${projectId}`; + + try { + await fetch(`${AGENT_RUNNER_URL}/orchestrator/sessions/${sessionId}`, { + method: "DELETE", + }); + } catch { + // Best-effort + } + + return NextResponse.json({ cleared: true }); +} diff --git a/components/OrchestratorChat.tsx b/components/OrchestratorChat.tsx new file mode 100644 index 0000000..46cb9d3 --- /dev/null +++ b/components/OrchestratorChat.tsx @@ -0,0 +1,339 @@ +"use client"; + +import { useState, useRef, useEffect, useCallback } from "react"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Send, + Loader2, + Wrench, + Bot, + User, + RotateCcw, + ChevronDown, + ChevronUp, + Sparkles, +} from "lucide-react"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface Message { + role: "user" | "assistant"; + content: string; + toolCalls?: string[]; + turns?: number; + model?: string; + reasoning?: string | null; + error?: boolean; +} + +interface OrchestratorChatProps { + projectId: string; + projectName?: string; + placeholder?: string; +} + +// --------------------------------------------------------------------------- +// Suggestion chips shown before the first message +// --------------------------------------------------------------------------- + +const SUGGESTIONS = [ + "What's the current status of this project?", + "Check if there are any open issues or PRs", + "What was the last deployment?", + "Write a quick summary of what's been built so far", +]; + +// --------------------------------------------------------------------------- +// Single message bubble +// --------------------------------------------------------------------------- + +function MessageBubble({ msg }: { msg: Message }) { + const [showReasoning, setShowReasoning] = useState(false); + const isUser = msg.role === "user"; + + return ( +
+ {/* Avatar */} +
+ {isUser ? : } +
+ + {/* Bubble */} +
+
+ {msg.content} +
+ + {/* Tool calls & meta */} + {!isUser && ( +
+ {msg.toolCalls && msg.toolCalls.length > 0 && ( +
+ {msg.toolCalls.map((t, i) => ( + + + {t} + + ))} +
+ )} + {msg.reasoning && ( + + )} + {msg.model && ( + {msg.model} + )} +
+ )} + + {/* Reasoning panel */} + {!isUser && showReasoning && msg.reasoning && ( +
+ {msg.reasoning} +
+ )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Typing indicator +// --------------------------------------------------------------------------- + +function TypingIndicator() { + return ( +
+
+ +
+
+
+ {[0, 1, 2].map(i => ( +
+ ))} +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export function OrchestratorChat({ + projectId, + projectName, + placeholder = "Ask your AI team anything…", +}: OrchestratorChatProps) { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const bottomRef = useRef(null); + const textareaRef = useRef(null); + const hasMessages = messages.length > 0; + + const scrollToBottom = useCallback(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, []); + + useEffect(() => { + scrollToBottom(); + }, [messages, loading]); + + const sendMessage = useCallback( + async (text: string) => { + const trimmed = text.trim(); + if (!trimmed || loading) return; + + setInput(""); + setMessages(prev => [...prev, { role: "user", content: trimmed }]); + setLoading(true); + + try { + const res = await fetch(`/api/projects/${projectId}/agent-chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: trimmed }), + }); + + const data = await res.json(); + + if (!res.ok) { + setMessages(prev => [ + ...prev, + { role: "assistant", content: data.error ?? "Something went wrong.", error: true }, + ]); + } else { + setMessages(prev => [ + ...prev, + { + role: "assistant", + content: data.reply || "(no reply)", + toolCalls: data.toolCalls, + turns: data.turns, + model: data.model, + reasoning: data.reasoning, + }, + ]); + } + } catch (err) { + setMessages(prev => [ + ...prev, + { + role: "assistant", + content: err instanceof Error ? err.message : "Network error — is the agent runner online?", + error: true, + }, + ]); + } finally { + setLoading(false); + setTimeout(() => textareaRef.current?.focus(), 50); + } + }, + [projectId, loading] + ); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendMessage(input); + } + }; + + const clearChat = async () => { + setMessages([]); + try { + await fetch(`/api/projects/${projectId}/agent-chat`, { method: "DELETE" }); + } catch { /* best-effort */ } + }; + + return ( +
+ + {/* Header */} +
+
+
+ + {projectName ? `${projectName} AI` : "Project AI"} + + GLM-5 +
+ {hasMessages && ( + + )} +
+ + {/* Messages */} +
+ {!hasMessages ? ( + /* Empty state — Lovable-style centered prompt */ +
+
+

What should we build?

+

+ Your AI team is ready. Ask them anything about this project. +

+
+
+ {SUGGESTIONS.map(s => ( + + ))} +
+
+ ) : ( + +
+ {messages.map((msg, i) => ( + + ))} + {loading && } +
+
+ + )} +
+ + {/* Input */} +
+
+