feat: migrate chat API to AI SDK with tool support and add active bar interaction to analytics charts

This commit is contained in:
poyrazavsever
2026-06-06 20:38:16 +03:00
parent 1cd00d4efe
commit b3da493ef1
6 changed files with 491 additions and 426 deletions
@@ -154,7 +154,13 @@ export function AnalyticsClient({ data }: AnalyticsClientProps) {
borderRadius: '0.375rem',
}}
/>
<Bar dataKey="value" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} barSize={60} />
<Bar
dataKey="value"
fill="hsl(var(--primary))"
radius={[4, 4, 0, 0]}
barSize={60}
activeBar={{ fill: "hsl(var(--primary))", opacity: 0.8 }}
/>
</BarChart>
</ResponsiveContainer>
</div>
+210 -259
View File
@@ -1,9 +1,10 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { Brain, Send, Paperclip, MoreHorizontal, Search, MessageSquare, Plus, FileText, CheckCircle2, Trash2 } from "lucide-react";
import { Brain, Send, MessageSquare, Plus, Trash2, Loader2, Wrench } from "lucide-react";
import { createClient } from "@/lib/supabase/client";
import { v4 as uuidv4 } from "uuid";
import { Button } from "poyraz-ui/atoms";
import { useChat } from "ai/react";
interface ChatSession {
id: string;
@@ -11,23 +12,22 @@ interface ChatSession {
created_at: string;
}
interface ChatMessage {
id: string;
session_id: string;
role: "user" | "assistant" | "system";
content: string;
created_at: string;
}
export default function AIChatPage() {
const [supabase] = useState(() => createClient());
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const { messages, input, setInput, handleInputChange, handleSubmit, append, setMessages, isLoading, stop } = useChat({
api: "/api/chat",
body: {
sessionId: activeSessionId
},
onError: (err) => {
console.error(err);
}
});
// Auto-scroll to bottom
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -61,7 +61,10 @@ export default function AIChatPage() {
// 2. Fetch Messages when session changes
useEffect(() => {
if (!activeSessionId) return;
if (!activeSessionId) {
setMessages([]);
return;
}
const fetchMessages = async () => {
const { data } = await supabase
@@ -71,308 +74,256 @@ export default function AIChatPage() {
.order("created_at", { ascending: true });
if (data) {
setMessages(data as ChatMessage[]);
}
};
fetchMessages();
// Subscribe to real-time changes
const channel = supabase
.channel(`chat:${activeSessionId}`)
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'chat_messages',
filter: `session_id=eq.${activeSessionId}`
}, (payload) => {
setMessages(prev => {
if (prev.find(m => m.id === payload.new.id)) return prev;
return [...prev, payload.new as ChatMessage];
});
})
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [activeSessionId, supabase]);
const handleNewChat = async () => {
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
const newSession = {
user_id: user.id,
title: "Yeni Sohbet",
updated_at: new Date().toISOString()
};
const { data, error } = await supabase
.from("chat_sessions")
.insert(newSession)
.select()
.single();
if (data) {
setSessions([data, ...sessions]);
setActiveSessionId(data.id);
// Map Supabase messages to AI SDK format
const formattedMessages = data.map((msg: any) => ({
id: msg.id,
role: msg.role as 'user' | 'assistant' | 'system',
content: msg.content,
}));
setMessages(formattedMessages);
} else {
setMessages([]);
}
};
const handleSend = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
fetchMessages();
}, [activeSessionId, supabase, setMessages]);
const handleNewChat = async () => {
setActiveSessionId(null);
setMessages([]);
};
const handleDeleteSession = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
await supabase.from("chat_sessions").delete().eq("id", id);
const updatedSessions = sessions.filter(s => s.id !== id);
setSessions(updatedSessions);
if (activeSessionId === id) {
setActiveSessionId(updatedSessions.length > 0 ? updatedSessions[0].id : null);
if (updatedSessions.length === 0) setMessages([]);
}
};
const customHandleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
const userMessageContent = input.trim();
let sessionId = activeSessionId;
const currentInput = input;
setInput("");
// Ensure we have a session
let sessionId = activeSessionId;
if (!sessionId) {
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
if (!sessionId) {
const { data: newSess } = await supabase
.from("chat_sessions")
.insert({ user_id: user.id, title: userMessageContent.slice(0, 30) + "..." })
.insert({ user_id: user.id, title: currentInput.slice(0, 30) + "..." })
.select()
.single();
if (newSess) {
sessionId = newSess.id;
setActiveSessionId(sessionId);
setSessions([newSess, ...sessions]);
} else return;
} else {
return;
}
}
const resolvedSessionId = sessionId;
if (!resolvedSessionId) return;
setIsLoading(true);
// 1. Optimistic Update: Add User Message locally
const optimisticUserMsg: ChatMessage = {
id: uuidv4(),
session_id: resolvedSessionId,
role: "user",
content: userMessageContent,
created_at: new Date().toISOString()
};
setMessages(prev => [...prev, optimisticUserMsg]);
// 2. Save User Message to Supabase
await supabase.from("chat_messages").insert({
id: optimisticUserMsg.id,
session_id: resolvedSessionId,
role: "user",
content: userMessageContent
});
try {
// 3. Fetch AI preferences for API call
const { data: settings } = await supabase
.from("user_settings")
.select("*")
.eq("user_id", user.id)
.single();
const provider = settings?.ai_model || localStorage.getItem("mindspace_ai_provider") || "gemini";
const apiKey = settings?.api_key || localStorage.getItem("mindspace_api_key") || "";
// 4. Call AI API
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, apiKey, userMessageContent }),
});
if (!res.ok) throw new Error("AI API Error");
const data = await res.json();
// 5. Optimistic Update: Add Assistant Message locally
const assistantMsg: ChatMessage = {
id: uuidv4(),
session_id: resolvedSessionId,
role: "assistant",
content: data.reply || "Cevap üretilemedi.",
created_at: new Date().toISOString()
};
setMessages(prev => [...prev, assistantMsg]);
// 6. Save Assistant Message to Supabase
await supabase.from("chat_messages").insert({
id: assistantMsg.id,
session_id: resolvedSessionId,
role: "assistant",
content: assistantMsg.content
});
// 7. Update session title if it was default
const currentSession = sessions.find(s => s.id === resolvedSessionId);
if (currentSession?.title === "Yeni Sohbet") {
const newTitle = userMessageContent.slice(0, 40);
await supabase.from("chat_sessions").update({ title: newTitle }).eq("id", resolvedSessionId);
setSessions(prev => prev.map(s => s.id === resolvedSessionId ? { ...s, title: newTitle } : s));
}
} catch (error) {
console.error(error);
const errorMsg: ChatMessage = {
id: uuidv4(),
session_id: resolvedSessionId,
role: "assistant",
content: "Hata oluştu. Lütfen Ayarlar sayfasından API anahtarınızı kontrol edin.",
created_at: new Date().toISOString()
};
setMessages(prev => [...prev, errorMsg]);
await supabase.from("chat_messages").insert({
id: errorMsg.id,
session_id: resolvedSessionId,
role: "assistant",
content: errorMsg.content
});
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const deleteSession = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
await supabase.from("chat_sessions").delete().eq("id", id);
setSessions(prev => prev.filter(s => s.id !== id));
if (activeSessionId === id) {
setActiveSessionId(null);
setMessages([]);
}
// Append using AI SDK with explicit body to ensure sessionId is passed immediately
append({ role: 'user', content: currentInput }, { body: { sessionId } });
};
return (
<div className="mx-auto max-w-7xl animate-in fade-in slide-in-from-bottom-4 duration-500 h-[calc(100vh-80px)] flex flex-col text-foreground font-sans">
<div className="flex h-[calc(100vh-6rem)] w-full overflow-hidden border border-border rounded-lg bg-background">
{/* Top Header */}
<div className="flex items-center justify-between pb-4 border-b border-white/5 mt-4 shrink-0">
<h1 className="text-lg font-medium text-muted-foreground flex items-center gap-2">
<Brain className="h-5 w-5 text-primary" />
<span className="text-foreground">MindSpace</span> / AI Assistant
</h1>
<div className="flex items-center gap-3">
<button onClick={handleNewChat} className="bg-primary hover:bg-primary/90 text-primary-foreground border border-primary/20 px-4 py-1.5 rounded-sm text-xs font-semibold flex items-center gap-2 transition-colors shadow-lg shadow-primary/20">
{/* Sidebar - Sessions List */}
<div className="w-80 border-r border-border bg-muted/20 flex flex-col hidden md:flex">
<div className="p-4 border-b border-border flex items-center justify-between">
<h2 className="font-semibold text-foreground flex items-center gap-2">
<MessageSquare className="h-4 w-4" />
Geçmiş Sohbetler
</h2>
<Button variant="outline" size="icon" className="h-8 w-8" onClick={handleNewChat}>
<Plus className="h-4 w-4" />
NEW CHAT
</button>
</div>
</Button>
</div>
{/* Main Chat Interface */}
<div className="flex-1 flex gap-6 mt-6 min-h-0">
{/* Left Sidebar: Chat Sessions */}
<div className="hidden lg:flex w-72 flex-col gap-4 shrink-0 border border-white/5 bg-[#0A0710] rounded-sm overflow-hidden">
<div className="p-4 border-b border-white/5 bg-[#0F0B15]/50">
<div className="bg-[#150F1D] border border-white/5 rounded-sm px-3 py-2 flex items-center gap-2">
<Search className="h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search history..."
className="bg-transparent border-none outline-none text-xs w-full placeholder:text-muted-foreground/50 text-foreground"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto tiny-scrollbar p-3 space-y-1">
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground px-2 py-1 mb-1">Recent Conversations</div>
{sessions.map((session) => (
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{sessions.length === 0 ? (
<div className="text-center text-sm text-muted-foreground mt-10">Henüz sohbet yok.</div>
) : (
sessions.map(session => (
<div
key={session.id}
onClick={() => setActiveSessionId(session.id)}
className={`group flex items-center gap-3 p-3 rounded-sm cursor-pointer transition-colors ${activeSessionId === session.id ? 'bg-[#1F172B] border border-primary/30 text-primary' : 'hover:bg-white/5 text-muted-foreground hover:text-foreground border border-transparent'}`}
className={`group flex items-center justify-between p-3 rounded-md cursor-pointer transition-colors ${activeSessionId === session.id ? 'bg-primary/10 text-primary' : 'hover:bg-muted/50 text-foreground'}`}
>
<MessageSquare className="h-4 w-4 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{session.title}</div>
<div className="text-[10px] mt-0.5 opacity-60">{new Date(session.created_at).toLocaleDateString()}</div>
<div className="truncate text-sm font-medium w-full pr-2">
{session.title || "İsimsiz Sohbet"}
</div>
<button onClick={(e) => deleteSession(session.id, e)} className="opacity-0 group-hover:opacity-100 p-1 hover:bg-white/10 rounded-sm transition-opacity">
<Trash2 className="h-3.5 w-3.5 text-red-400" />
<button
onClick={(e) => handleDeleteSession(session.id, e)}
className={`opacity-0 group-hover:opacity-100 p-1 hover:bg-destructive/10 hover:text-destructive rounded transition-all`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
))
)}
</div>
</div>
{/* Right Side: Active Chat Area */}
<div className="flex-1 flex flex-col rounded-sm border border-white/5 bg-[#0A0710] overflow-hidden relative">
{/* Main Chat Area */}
<div className="flex-1 flex flex-col bg-background relative">
{/* Chat Header */}
<div className="h-14 border-b border-border flex items-center justify-between px-6 bg-background/50 backdrop-blur-sm z-10">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<Brain className="h-4 w-4" />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">Ajan Asistan</h2>
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
Sisteme görev ve veri ekleyebilir
</p>
</div>
</div>
<Button variant="ghost" size="icon" className="md:hidden" onClick={handleNewChat}>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* Chat Messages Area */}
<div className="flex-1 overflow-y-auto tiny-scrollbar p-6 space-y-8">
{messages.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground opacity-60">
<Brain className="w-16 h-16 mb-4 text-primary opacity-50" />
<p className="text-sm font-medium">Hello! I am your MindSpace AI Assistant.</p>
<p className="text-xs mt-1">Start a conversation or select a session from history.</p>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-6 space-y-6 scroll-smooth">
{messages.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center opacity-70 max-w-md mx-auto">
<div className="h-16 w-16 bg-primary/10 rounded-2xl flex items-center justify-center mb-6 text-primary">
<Brain className="h-8 w-8" />
</div>
<h3 className="text-xl font-bold text-foreground mb-2">Ajan Asistan'a Hoşgeldiniz</h3>
<p className="text-sm text-muted-foreground mb-4">Sadece sorularınızı cevaplamakla kalmaz, komutlarınızla projeler, görevler ve finans kayıtları da oluşturabilir.</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full mt-4">
<Button variant="outline" className="h-auto py-3 justify-start text-left text-xs whitespace-normal" onClick={() => setInput("Bana 'Sunum hazırlığı' adında yeni bir görev ekler misin?")}>
Görev Ekle
</Button>
<Button variant="outline" className="h-auto py-3 justify-start text-left text-xs whitespace-normal" onClick={() => setInput("Bugün 350 TL Yemek harcaması yaptım, kaydeder misin?")}>
Harcama Ekle
</Button>
<Button variant="outline" className="h-auto py-3 justify-start text-left text-xs whitespace-normal" onClick={() => setInput("Aktif projelerimin durumunu özetler misin?")}>
Projelerimi Sorgula
</Button>
<Button variant="outline" className="h-auto py-3 justify-start text-left text-xs whitespace-normal" onClick={() => setInput("Son 30 günlük finansal özetimi ver.")}>
Gelir/Gider Özeti
</Button>
</div>
</div>
) : (
messages.map((msg) => (
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`flex gap-3 max-w-[85%] ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'}`}>
<div className={`shrink-0 h-8 w-8 rounded-full flex items-center justify-center ${msg.role === 'user' ? 'bg-primary text-primary-foreground' : 'bg-muted border border-border text-foreground'}`}>
{msg.role === 'user' ? <span className="text-xs font-bold">ME</span> : <Brain className="h-4 w-4" />}
</div>
<div className="flex flex-col gap-2">
{msg.content && (
<div className={`px-4 py-3 rounded-2xl text-sm ${msg.role === 'user' ? 'bg-primary text-primary-foreground rounded-tr-none' : 'bg-muted/50 border border-border text-foreground rounded-tl-none whitespace-pre-wrap'}`}>
{msg.content}
</div>
)}
{messages.map((m) => (
<div key={m.id} className={`flex items-start gap-4 max-w-4xl ${m.role === "user" ? "ml-auto" : "mr-auto"}`}>
{m.role === "assistant" && (
<div className="h-8 w-8 rounded-sm bg-primary/20 border border-primary/30 flex items-center justify-center shrink-0">
<Brain className="h-5 w-5 text-primary" />
</div>
)}
<div className={`flex-1 p-5 ${m.role === "user" ? "bg-[#150F1D] border border-white/10 rounded-sm rounded-tr-none" : "bg-[linear-gradient(135deg,rgba(108,91,176,0.05)_0%,rgba(10,7,16,0)_100%)] border border-primary/10 rounded-sm rounded-tl-none"}`}>
<p className="text-sm leading-relaxed whitespace-pre-wrap">{m.content}</p>
</div>
{m.role === "user" && (
<div className="h-8 w-8 rounded-sm bg-[#1F172B] border border-white/10 flex items-center justify-center font-bold text-foreground shrink-0 text-[10px]">ME</div>
{/* Render Tool Invocations for AI Messages */}
{msg.toolInvocations && msg.toolInvocations.map((tool) => (
<div key={tool.toolCallId} className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/30 border border-border px-3 py-1.5 rounded-md self-start">
{tool.state === 'result' ? (
<>
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
<span><span className="font-medium text-foreground">{tool.toolName}</span> aracı başarıyla çalıştırıldı.</span>
</>
) : (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
<span><span className="font-medium text-foreground">{tool.toolName}</span> aracı çalıştırılıyor...</span>
</>
)}
</div>
))}
{isLoading && (
<div className="flex items-center gap-2 text-primary text-xs ml-12">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
</span>
MindSpace AI is thinking...
</div>
</div>
</div>
))
)}
{isLoading && messages[messages.length - 1]?.role === 'user' && (
<div className="flex justify-start">
<div className="flex gap-3 max-w-[85%] flex-row">
<div className="shrink-0 h-8 w-8 rounded-full bg-muted border border-border text-foreground flex items-center justify-center">
<Brain className="h-4 w-4" />
</div>
<div className="px-4 py-3 rounded-2xl text-sm bg-muted/50 border border-border text-foreground rounded-tl-none flex items-center gap-2">
<span className="h-2 w-2 bg-primary/50 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></span>
<span className="h-2 w-2 bg-primary/50 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></span>
<span className="h-2 w-2 bg-primary/50 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></span>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Chat Input Box */}
<div className="p-4 border-t border-white/5 bg-[#0F0B15]/80 backdrop-blur-sm">
<div className="max-w-4xl mx-auto flex flex-col bg-[#150F1D] border border-white/10 rounded-sm focus-within:border-primary/50 transition-colors p-2 shadow-2xl">
{/* Input Area */}
<div className="p-4 border-t border-border bg-background">
<form onSubmit={customHandleSubmit} className="relative flex items-end gap-2 max-w-4xl mx-auto">
<div className="relative flex-1 bg-muted/30 border border-border rounded-xl flex items-center focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary transition-all">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onChange={handleInputChange}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
customHandleSubmit(e);
}
}}
placeholder="Verilerinize dayalı bir soru sorun veya görev/finans kaydı eklemesini isteyin..."
className="w-full bg-transparent border-none focus:outline-none focus:ring-0 resize-none py-4 pl-4 pr-12 text-sm text-foreground placeholder:text-muted-foreground min-h-[56px] max-h-[200px]"
rows={1}
disabled={isLoading}
placeholder="Message your MindSpace AI... (Press Enter to send)"
className="w-full bg-transparent border-none outline-none text-sm text-foreground resize-none p-2 min-h-[60px] tiny-scrollbar disabled:opacity-50"
/>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-white/5">
<div className="flex gap-2">
<button className="p-1.5 hover:bg-white/10 rounded-sm text-muted-foreground transition-colors" title="Attach Context"><Paperclip className="h-4 w-4" /></button>
<button className="p-1.5 hover:bg-white/10 rounded-sm text-muted-foreground transition-colors" title="Settings"><MoreHorizontal className="h-4 w-4" /></button>
</div>
<button
onClick={handleSend}
disabled={isLoading || !input.trim()}
className="bg-primary hover:bg-primary/90 text-primary-foreground p-2 rounded-sm transition-colors shadow-lg shadow-primary/20 disabled:opacity-50"
<div className="absolute right-2 bottom-2 flex gap-1">
{isLoading ? (
<Button
type="button"
variant="outline"
size="icon"
className="h-10 w-10 rounded-lg bg-background text-foreground"
onClick={() => stop()}
>
<Send className="h-4 w-4" />
</button>
<div className="w-3 h-3 bg-current" />
</Button>
) : (
<Button
type="submit"
size="icon"
className={`h-10 w-10 rounded-lg ${input.trim() ? 'bg-primary text-primary-foreground hover:bg-primary/90' : 'bg-muted text-muted-foreground'}`}
disabled={!input.trim()}
>
<Send className="h-4 w-4 ml-0.5" />
</Button>
)}
</div>
</div>
<p className="text-center text-[10px] text-muted-foreground mt-3">AI can make mistakes. Verify important decisions.</p>
</form>
<div className="text-center mt-3 flex items-center justify-center gap-4 text-[10px] text-muted-foreground">
<span>Yapay Zeka Hatalar Yapabilir.</span>
<div className="flex items-center gap-1">
<Wrench className="h-3 w-3" />
<span>Veri okuma/yazma yetkisi aktiftir.</span>
</div>
</div>
</div>
</div>
+12 -2
View File
@@ -167,8 +167,18 @@ export function DashboardClient({ data }: DashboardClientProps) {
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)'
}}
/>
<Bar dataKey="income" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
<Bar dataKey="expense" fill="hsl(var(--destructive))" radius={[4, 4, 0, 0]} />
<Bar
dataKey="income"
fill="hsl(var(--primary))"
radius={[4, 4, 0, 0]}
activeBar={{ fill: "hsl(var(--primary))", opacity: 0.8 }}
/>
<Bar
dataKey="expense"
fill="hsl(var(--destructive))"
radius={[4, 4, 0, 0]}
activeBar={{ fill: "hsl(var(--destructive))", opacity: 0.8 }}
/>
</BarChart>
</ResponsiveContainer>
) : (
+139 -148
View File
@@ -1,163 +1,154 @@
import { NextResponse } from "next/server";
import { streamText, tool } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { createGoogleGenerativeAI } from '@ai-sdk/google';
import { z } from 'zod';
import { createClient } from '@/lib/supabase/server';
type ChatProvider = "groq" | "openai" | "ollama" | "gemini";
export const maxDuration = 30; // Allow longer execution for tool calls
type ChatRequestBody = {
provider?: ChatProvider;
apiKey?: string;
userMessageContent?: string;
};
type ProviderErrorResponse = {
error?: {
message?: string;
};
};
type ChatCompletionResponse = {
choices?: Array<{
message?: {
content?: string;
};
}>;
};
type OllamaResponse = {
response?: string;
};
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : "Bilinmeyen sunucu hatası";
}
export async function POST(request: Request) {
export async function POST(req: Request) {
try {
const body = (await request.json()) as ChatRequestBody;
const provider = body.provider ?? "ollama";
const apiKey = body.apiKey ?? "";
const userMessageContent = body.userMessageContent?.trim();
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!userMessageContent) {
return NextResponse.json(
{ error: "Mesaj içeriği boş olamaz." },
{ status: 400 },
);
if (!user) {
return new Response('Yetkisiz erişim', { status: 401 });
}
let assistantReply = "";
const { messages, sessionId, provider: clientProvider, apiKey: clientApiKey } = await req.json();
if (provider === "gemini") {
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-pro:generateContent?key=${apiKey}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
system_instruction: {
parts: [{ text: "Sen MindSpace adlı kullanıcının kişisel yapay zeka terapistisin ve sırdaşısın. Şefkatli, yargılamayan ve destekleyici cevaplar ver. Kullanıcının iş süreçlerini asiste edebilirsin." }]
},
contents: [
{
parts: [{ text: userMessageContent }]
}
]
}),
}
);
// Read App Settings for Provider/API Key
const { data: appSettings } = await supabase
.from("app_settings")
.select("*")
.eq("user_id", user.id)
.single();
if (!response.ok) {
throw new Error("Gemini API hatası");
}
const provider = clientProvider || appSettings?.ai_provider || "openai";
const apiKey = clientApiKey || appSettings?.api_key || "";
const data = await response.json();
assistantReply = data.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
} else if (provider === "groq") {
const response = await fetch(
"https://api.groq.com/openai/v1/chat/completions",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: "llama-3.1-8b-instant",
messages: [
{
role: "system",
content:
"Sen MindSpace adlı kullanıcının kişisel yapay zeka terapistisin ve sırdaşısın. Şefkatli, yargılamayan ve destekleyici cevaplar ver. Yüzeysel öğütlerden kaçın.",
},
{ role: "user", content: userMessageContent },
],
}),
},
);
if (!response.ok) {
const errorData = (await response.json()) as ProviderErrorResponse;
throw new Error(errorData.error?.message || "Groq API hatası");
}
const data = (await response.json()) as ChatCompletionResponse;
assistantReply = data.choices?.[0]?.message?.content ?? "";
} else if (provider === "openai") {
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: "gpt-4o",
messages: [
{
role: "system",
content:
"Sen MindSpace adlı kullanıcının kişisel yapay zeka terapistisin ve sırdaşısın. Şefkatli ve destekleyici cevap ver.",
},
{ role: "user", content: userMessageContent },
],
}),
});
if (!response.ok) {
throw new Error("OpenAI API hatası");
}
const data = (await response.json()) as ChatCompletionResponse;
assistantReply = data.choices?.[0]?.message?.content ?? "";
let model;
if (provider === 'gemini') {
const google = createGoogleGenerativeAI({ apiKey });
model = google('gemini-1.5-pro-latest');
} else if (provider === 'groq') {
const groq = createOpenAI({ apiKey, baseURL: 'https://api.groq.com/openai/v1' });
model = groq('llama-3.1-8b-instant');
} else {
const response = await fetch("http://127.0.0.1:11434/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "llama3",
prompt: `Sen MindSpace adlı kullanıcının kişisel yapay zeka terapistisin ve sırdaşısın. Şefkatli ve destekleyici cevap ver.\n\nKullanıcı: ${userMessageContent}\nTerapist:`,
stream: false,
const openai = createOpenAI({ apiKey });
model = openai('gpt-4o');
}
// Identify the latest user message to save to Supabase
const latestMessage = messages[messages.length - 1];
if (sessionId && latestMessage && latestMessage.role === 'user') {
// Sadece metin varsa kaydediyoruz
if (latestMessage.content) {
await supabase.from("chat_messages").insert({
session_id: sessionId,
role: "user",
content: latestMessage.content,
});
}
}
const systemPrompt = `Sen kullanıcının kişisel Freelancer İş Asistanı ve Danışmanısın. Cognis Freelancer OS içinde yaşıyorsun.
Kullanıcının iş süreçlerini, projelerini ve finansal durumunu organize etmesine yardımcı oluyorsun.
Gerektiğinde araçları (tools) kullanarak sistemden güncel verileri çek ve doğrudan veri ekle.
Aşağıdaki yeteneklere sahipsin:
- Finansal verileri listeleyebilir ve yeni finans kaydı (gelir/gider) girebilirsin.
- Görevleri okuyabilir ve yeni görevler ekleyebilirsin.
- Projeleri sorgulayabilir ve projelerin detaylarını/tasarım sistemini çekebilirsin.
Kullanıcıya her zaman proaktif, kısa ve profesyonel yanıtlar ver. Türkçe dilinde cevapla.`;
const result = streamText({
model,
system: systemPrompt,
messages,
tools: {
getFinancialSummary: tool({
description: 'Son X gündeki gelir ve gider işlemlerinin listesini getirir.',
parameters: z.object({ days: z.number().default(30) }),
execute: async ({ days }) => {
const pastDate = new Date();
pastDate.setDate(pastDate.getDate() - days);
const { data } = await supabase.from('finance_transactions')
.select('type, amount, category, transaction_date')
.gte('transaction_date', pastDate.toISOString());
return data || [];
}
}),
listTasks: tool({
description: 'Kullanıcının mevcut görevlerini belirli bir duruma göre listeler.',
parameters: z.object({ status: z.enum(['todo', 'in_progress', 'completed', 'all']).default('all') }),
execute: async ({ status }) => {
let query = supabase.from('tasks').select('id, title, status, due_at');
if (status !== 'all') query = query.eq('status', status);
const { data } = await query.order('created_at', { ascending: false }).limit(20);
return data || [];
}
}),
createTask: tool({
description: 'Sisteme yeni bir görev ekler.',
parameters: z.object({
title: z.string().describe('Görev başlığı'),
description: z.string().optional().describe('Görevin detayı')
}),
execute: async ({ title, description }) => {
const { data, error } = await supabase.from('tasks')
.insert({ user_id: user.id, title, description, status: 'todo' })
.select().single();
if (error) return { success: false, error: error.message };
return { success: true, task: data };
}
}),
searchProjects: tool({
description: 'Projeleri isimlerine veya durumlarına göre listeler',
parameters: z.object({ status: z.enum(['active', 'planning', 'completed', 'paused', 'all']).default('active') }),
execute: async ({ status }) => {
let query = supabase.from('projects').select('id, name, status, progress, budget');
if (status !== 'all') query = query.eq('status', status);
const { data } = await query.limit(10);
return data || [];
}
}),
addFinanceTransaction: tool({
description: 'Sisteme yeni bir finansal kayıt (gelir veya gider) ekler.',
parameters: z.object({
type: z.enum(['income', 'expense']).describe('income (gelir) veya expense (gider)'),
amount: z.number().describe('Tutar'),
category: z.string().describe('Kategori örn. Yazılım, Yemek, Vergi vb.')
}),
execute: async ({ type, amount, category }) => {
const { data, error } = await supabase.from('finance_transactions')
.insert({
user_id: user.id,
type,
amount,
category,
transaction_date: new Date().toISOString()
})
.select().single();
if (error) return { success: false, error: error.message };
return { success: true, transaction: data };
}
})
},
onFinish: async ({ text }) => {
// Save assistant response to DB
if (sessionId && text) {
await supabase.from("chat_messages").insert({
session_id: sessionId,
role: "assistant",
content: text,
});
}
}
});
if (!response.ok) {
throw new Error("Ollama API yanıt vermedi.");
}
const data = (await response.json()) as OllamaResponse;
assistantReply = data.response ?? "";
}
if (!assistantReply) {
throw new Error("Model geçerli bir yanıt üretmedi.");
}
return NextResponse.json({ reply: assistantReply });
} catch (error: unknown) {
const message = getErrorMessage(error);
console.error("API route hatası:", error);
return NextResponse.json({ error: message }, { status: 500 });
return result.toDataStreamResponse();
} catch (error: any) {
console.error("Chat API error:", error);
return new Response(error.message || "Internal Server Error", { status: 500 });
}
}
+3
View File
@@ -9,6 +9,8 @@
"lint": "eslint ."
},
"dependencies": {
"@ai-sdk/google": "^3.0.80",
"@ai-sdk/openai": "^3.0.68",
"@base-ui/react": "^1.5.0",
"@hookform/resolvers": "^5.4.0",
"@iconify/react": "^6.0.2",
@@ -23,6 +25,7 @@
"@radix-ui/react-toast": "^1.2.15",
"@supabase/ssr": "^0.10.3",
"@tailwindcss/postcss": "^4.3.0",
"ai": "^6.0.197",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dexie": "^4.4.3",
+106 -2
View File
@@ -8,6 +8,12 @@ importers:
.:
dependencies:
'@ai-sdk/google':
specifier: ^3.0.80
version: 3.0.80(zod@4.4.3)
'@ai-sdk/openai':
specifier: ^3.0.68
version: 3.0.68(zod@4.4.3)
'@base-ui/react':
specifier: ^1.5.0
version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -50,6 +56,9 @@ importers:
'@tailwindcss/postcss':
specifier: ^4.3.0
version: 4.3.0
ai:
specifier: ^6.0.197
version: 6.0.197(zod@4.4.3)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -70,7 +79,7 @@ importers:
version: 1.17.0(react@19.2.7)
next:
specifier: ^16.2.7
version: 16.2.7(@babel/core@7.29.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 16.2.7(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -144,6 +153,34 @@ importers:
packages:
'@ai-sdk/gateway@3.0.125':
resolution: {integrity: sha512-tocl7cUDoTpmhZqeW8XVKMMznZQwwQAEunF0VyNKmf64qt8NbMIAEiet/vRMzh7Jr9WcFeb6EZjmhLTP4Qx2Og==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/google@3.0.80':
resolution: {integrity: sha512-5ORbm/yFUPO0MEvZsxBMN0cdKw2+lwU/wVn5KN3KF8Dmk1LughuDuUohMh/7iU/XFTiyB0OvmTW/tdV/J7O9zg==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/openai@3.0.68':
resolution: {integrity: sha512-FCs/DPr4M95UyZ/ABHJmTmCEYRCka/4J0Bna0nsd78QCdGIS0X/zhn+fVzB7mZJo7464uOWYUjROx9PGNGOb0w==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider-utils@4.0.27':
resolution: {integrity: sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider@3.0.10':
resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==}
engines: {node: '>=18'}
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
@@ -741,6 +778,10 @@ packages:
'@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
'@opentelemetry/api@1.9.1':
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
engines: {node: '>=8.0.0'}
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -1524,6 +1565,9 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
@@ -1901,6 +1945,10 @@ packages:
cpu: [x64]
os: [win32]
'@vercel/oidc@3.2.0':
resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
engines: {node: '>= 20'}
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@@ -1919,6 +1967,12 @@ packages:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
ai@6.0.197:
resolution: {integrity: sha512-U3KsjkqwQXGHC0u0VeUDqUaNaBS/uQc7v4Vj92Cjv5lPx5DIyRBQYk4Hipy5vwD9AQKIG8uRvdaN9R+pAvrtcQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
ajv-formats@3.0.1:
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
peerDependencies:
@@ -3106,6 +3160,9 @@ packages:
json-schema-typed@8.0.2:
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
json-schema@0.4.0:
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
@@ -4262,6 +4319,36 @@ packages:
snapshots:
'@ai-sdk/gateway@3.0.125(zod@4.4.3)':
dependencies:
'@ai-sdk/provider': 3.0.10
'@ai-sdk/provider-utils': 4.0.27(zod@4.4.3)
'@vercel/oidc': 3.2.0
zod: 4.4.3
'@ai-sdk/google@3.0.80(zod@4.4.3)':
dependencies:
'@ai-sdk/provider': 3.0.10
'@ai-sdk/provider-utils': 4.0.27(zod@4.4.3)
zod: 4.4.3
'@ai-sdk/openai@3.0.68(zod@4.4.3)':
dependencies:
'@ai-sdk/provider': 3.0.10
'@ai-sdk/provider-utils': 4.0.27(zod@4.4.3)
zod: 4.4.3
'@ai-sdk/provider-utils@4.0.27(zod@4.4.3)':
dependencies:
'@ai-sdk/provider': 3.0.10
'@standard-schema/spec': 1.1.0
eventsource-parser: 3.1.0
zod: 4.4.3
'@ai-sdk/provider@3.0.10':
dependencies:
json-schema: 0.4.0
'@alloc/quick-lru@5.2.0': {}
'@babel/code-frame@7.29.7':
@@ -4848,6 +4935,8 @@ snapshots:
'@open-draft/until@2.1.0': {}
'@opentelemetry/api@1.9.1': {}
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
@@ -5663,6 +5752,8 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {}
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
'@supabase/auth-js@2.105.3':
@@ -6011,6 +6102,8 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
optional: true
'@vercel/oidc@3.2.0': {}
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -6024,6 +6117,14 @@ snapshots:
agent-base@7.1.4: {}
ai@6.0.197(zod@4.4.3):
dependencies:
'@ai-sdk/gateway': 3.0.125(zod@4.4.3)
'@ai-sdk/provider': 3.0.10
'@ai-sdk/provider-utils': 4.0.27(zod@4.4.3)
'@opentelemetry/api': 1.9.1
zod: 4.4.3
ajv-formats@3.0.1(ajv@8.20.0):
optionalDependencies:
ajv: 8.20.0
@@ -7326,6 +7427,8 @@ snapshots:
json-schema-typed@8.0.2: {}
json-schema@0.4.0: {}
json-stable-stringify-without-jsonify@1.0.1: {}
json5@1.0.2:
@@ -7533,7 +7636,7 @@ snapshots:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
next@16.2.7(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
'@next/env': 16.2.7
'@swc/helpers': 0.5.15
@@ -7552,6 +7655,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.2.7
'@next/swc-win32-arm64-msvc': 16.2.7
'@next/swc-win32-x64-msvc': 16.2.7
'@opentelemetry/api': 1.9.1
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'