add orchestrator chat to project overview

- OrchestratorChat component with Lovable-style UI (suggestion chips, reasoning panel, tool call badges)
- /api/projects/[projectId]/agent-chat proxy route to agent runner
- Injects project context (repo, vision, deployment URL) into session
- AGENT_RUNNER_URL wired to agents.vibnai.com

Made-with: Cursor
This commit is contained in:
2026-02-27 18:06:02 -08:00
parent c9ef2379ec
commit 8d95a74cc6
3 changed files with 457 additions and 0 deletions

View File

@@ -12,6 +12,7 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { OrchestratorChat } from "@/components/OrchestratorChat";
import { import {
GitBranch, GitBranch,
GitCommit, GitCommit,
@@ -198,6 +199,12 @@ export default function ProjectOverviewPage() {
return ( return (
<div className="container mx-auto py-8 px-6 max-w-5xl space-y-6"> <div className="container mx-auto py-8 px-6 max-w-5xl space-y-6">
{/* ── Orchestrator Chat ── */}
<OrchestratorChat
projectId={projectId}
projectName={project.productName}
/>
{/* ── Header ── */} {/* ── Header ── */}
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>

View File

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

View File

@@ -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 (
<div className={`flex gap-3 ${isUser ? "flex-row-reverse" : ""}`}>
{/* Avatar */}
<div
className={`shrink-0 w-7 h-7 rounded-full flex items-center justify-center mt-0.5
${isUser
? "bg-primary text-primary-foreground"
: "bg-muted border border-border"
}`}
>
{isUser ? <User className="h-3.5 w-3.5" /> : <Bot className="h-3.5 w-3.5" />}
</div>
{/* Bubble */}
<div className={`flex-1 max-w-[85%] space-y-1.5 ${isUser ? "items-end flex flex-col" : ""}`}>
<div
className={`rounded-2xl px-4 py-3 text-sm leading-relaxed whitespace-pre-wrap
${isUser
? "bg-primary text-primary-foreground rounded-tr-sm"
: msg.error
? "bg-destructive/10 text-destructive border border-destructive/20 rounded-tl-sm"
: "bg-muted/60 border border-border rounded-tl-sm"
}`}
>
{msg.content}
</div>
{/* Tool calls & meta */}
{!isUser && (
<div className="flex flex-wrap items-center gap-1.5 px-1">
{msg.toolCalls && msg.toolCalls.length > 0 && (
<div className="flex flex-wrap gap-1">
{msg.toolCalls.map((t, i) => (
<span
key={i}
className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded bg-muted border border-border text-muted-foreground font-mono"
>
<Wrench className="h-2.5 w-2.5" />
{t}
</span>
))}
</div>
)}
{msg.reasoning && (
<button
onClick={() => setShowReasoning(v => !v)}
className="inline-flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
>
<Sparkles className="h-2.5 w-2.5" />
reasoning
{showReasoning ? <ChevronUp className="h-2.5 w-2.5" /> : <ChevronDown className="h-2.5 w-2.5" />}
</button>
)}
{msg.model && (
<span className="text-[10px] text-muted-foreground/60 ml-auto">{msg.model}</span>
)}
</div>
)}
{/* Reasoning panel */}
{!isUser && showReasoning && msg.reasoning && (
<div className="mx-1 rounded-lg border border-border/50 bg-muted/30 px-3 py-2 text-[11px] text-muted-foreground leading-relaxed font-mono max-h-40 overflow-y-auto">
{msg.reasoning}
</div>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Typing indicator
// ---------------------------------------------------------------------------
function TypingIndicator() {
return (
<div className="flex gap-3">
<div className="shrink-0 w-7 h-7 rounded-full bg-muted border border-border flex items-center justify-center">
<Bot className="h-3.5 w-3.5" />
</div>
<div className="bg-muted/60 border border-border rounded-2xl rounded-tl-sm px-4 py-3">
<div className="flex gap-1 items-center h-4">
{[0, 1, 2].map(i => (
<div
key={i}
className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 animate-bounce"
style={{ animationDelay: `${i * 0.15}s` }}
/>
))}
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function OrchestratorChat({
projectId,
projectName,
placeholder = "Ask your AI team anything…",
}: OrchestratorChatProps) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
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 (
<div className="flex flex-col rounded-xl border border-border bg-background overflow-hidden shadow-sm">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-muted/20">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<span className="text-sm font-medium">
{projectName ? `${projectName} AI` : "Project AI"}
</span>
<Badge variant="outline" className="text-[10px] h-4 px-1.5">GLM-5</Badge>
</div>
{hasMessages && (
<Button
variant="ghost"
size="sm"
onClick={clearChat}
className="h-7 text-xs text-muted-foreground"
>
<RotateCcw className="h-3 w-3 mr-1" />
Clear
</Button>
)}
</div>
{/* Messages */}
<div className="flex-1 min-h-0">
{!hasMessages ? (
/* Empty state — Lovable-style centered prompt */
<div className="flex flex-col items-center justify-center px-6 py-10 gap-6 text-center">
<div>
<p className="text-lg font-semibold">What should we build?</p>
<p className="text-sm text-muted-foreground mt-1">
Your AI team is ready. Ask them anything about this project.
</p>
</div>
<div className="flex flex-wrap justify-center gap-2 max-w-lg">
{SUGGESTIONS.map(s => (
<button
key={s}
onClick={() => sendMessage(s)}
className="text-xs px-3 py-1.5 rounded-full border border-border bg-muted/40 hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
{s}
</button>
))}
</div>
</div>
) : (
<ScrollArea className="h-[360px]">
<div className="px-4 py-4 space-y-5">
{messages.map((msg, i) => (
<MessageBubble key={i} msg={msg} />
))}
{loading && <TypingIndicator />}
<div ref={bottomRef} />
</div>
</ScrollArea>
)}
</div>
{/* Input */}
<div className="border-t border-border px-3 py-3 bg-muted/10">
<div className="flex gap-2 items-end">
<Textarea
ref={textareaRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
rows={1}
className="resize-none min-h-[40px] max-h-[120px] flex-1 text-sm bg-background border-border focus-visible:ring-1 rounded-xl"
style={{ fieldSizing: "content" } as React.CSSProperties}
disabled={loading}
/>
<Button
size="icon"
onClick={() => sendMessage(input)}
disabled={!input.trim() || loading}
className="h-10 w-10 shrink-0 rounded-xl"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
<p className="text-[10px] text-muted-foreground/50 mt-1.5 pl-1">
Enter to send · Shift+Enter for newline
</p>
</div>
</div>
);
}