agent-chat/route.ts: - Loads conversation history from chat_conversations before each turn - Passes history + knowledge context to agent runner - Saves returned history back to chat_conversations after each turn - Saves AI-generated memory updates to fs_knowledge_items knowledge/route.ts (new): - GET /api/projects/[id]/knowledge — list all knowledge items - POST /api/projects/[id]/knowledge — add/update item by key - DELETE /api/projects/[id]/knowledge?id=xxx — remove item OrchestratorChat.tsx: - Added "Saved to memory" label for save_memory tool calls Made-with: Cursor
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
"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;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Friendly labels for tool call names
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const TOOL_LABELS: Record<string, string> = {
|
|
spawn_agent: "Dispatched agent",
|
|
get_job_status: "Checked job",
|
|
list_repos: "Listed repos",
|
|
list_all_issues: "Checked issues",
|
|
list_all_apps: "Checked deployments",
|
|
get_app_status: "Checked app status",
|
|
read_repo_file: "Read file",
|
|
deploy_app: "Triggered deploy",
|
|
gitea_create_issue: "Created issue",
|
|
gitea_list_issues: "Listed issues",
|
|
gitea_close_issue: "Closed issue",
|
|
gitea_comment_issue: "Added comment",
|
|
save_memory: "Saved to memory",
|
|
};
|
|
|
|
function friendlyToolName(raw: string): string {
|
|
return TOOL_LABELS[raw] ?? raw.replace(/_/g, " ");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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">
|
|
{/* Deduplicate tool calls before rendering */}
|
|
{[...new Set(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"
|
|
>
|
|
<Wrench className="h-2.5 w-2.5 shrink-0" />
|
|
{friendlyToolName(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 && msg.model !== "unknown" && (
|
|
<span className="text-[10px] text-muted-foreground/60 ml-auto">
|
|
{msg.model.includes("glm") ? "GLM-5" : msg.model.includes("gemini") ? "Gemini" : 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>
|
|
);
|
|
}
|