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:
@@ -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>
|
||||||
|
|||||||
111
app/api/projects/[projectId]/agent-chat/route.ts
Normal file
111
app/api/projects/[projectId]/agent-chat/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
339
components/OrchestratorChat.tsx
Normal file
339
components/OrchestratorChat.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user