From 0318f2ac77faa0929838398d99ce08855310c129 Mon Sep 17 00:00:00 2001 From: mstfyldz Date: Mon, 10 Aug 2026 00:07:44 +0300 Subject: [PATCH] feat: persist general chat as chat_threads, decoupled from cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sohbet/Asistan sayfasındaki genel sohbetler artık kalıcı: yeni chat_threads/thread_messages tabloları (cases ile ilişkisiz) ile sendThreadMessage endpoint'i eklendi. Eski caseId==='general' (kalıcı olmayan) dalı kaldırıldı, AI çağrısı ortak callAiChat yardımcısına taşındı. Co-Authored-By: Claude Sonnet 5 --- src/controllers/chat.controller.ts | 158 ++++++++++++++++++----------- src/routes/chat.routes.ts | 3 +- 2 files changed, 102 insertions(+), 59 deletions(-) diff --git a/src/controllers/chat.controller.ts b/src/controllers/chat.controller.ts index 8562c67..7d47209 100644 --- a/src/controllers/chat.controller.ts +++ b/src/controllers/chat.controller.ts @@ -3,10 +3,11 @@ import { supabase } from '../lib/supabase'; import { AuthenticatedRequest } from '../middleware/auth'; import { getAiConfig } from '../lib/aiConfig'; -// Bir dosyanın ilk mesajından kısa bir başlık üretir (Claude/ChatGPT'nin yaptığı gibi). -// Başarısız olursa null döner — çağıran taraf bu durumda "Yeni Sohbet" başlığını korur, -// asıl sohbet cevabını etkilemez. -async function generateCaseTitle(firstMessage: string): Promise { +// İlk mesajdan kısa bir başlık üretir (Claude/ChatGPT'nin yaptığı gibi). Başarısız +// olursa null döner — çağıran taraf bu durumda varsayılan başlığı korur, asıl +// sohbet cevabını etkilemez. Hem dava-dosyası-siz "Yeni Sohbet" (cases.kind='chat') +// hem de chat_threads kayıtları için ortak kullanılır. +async function generateShortTitle(firstMessage: string): Promise { try { const { runpodUrl, aiModel } = getAiConfig(); const response = await fetch(`${runpodUrl}/api/chat`, { @@ -31,11 +32,29 @@ async function generateCaseTitle(firstMessage: string): Promise { if (!raw) return null; return raw.slice(0, 80); } catch (err) { - console.error('Generate Case Title Error:', err); + console.error('Generate Short Title Error:', err); return null; } } +// AI modeline chat completion isteği atan ortak yardımcı. RunPod worker uykudaysa +// soğuk başlangıç birkaç dakika sürebilir; burada sunucu tarafında bir zaman aşımı +// dayatmıyoruz (istemci kendi AbortController'ıyla bekleme süresini yönetiyor) — +// böylece yavaş ama başarılı olacak bir yanıtı erkenden kesmiyoruz. +async function callAiChat(messages: { role: string; content: string }[]): Promise { + const { runpodUrl, aiModel } = getAiConfig(); + const response = await fetch(`${runpodUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: aiModel, messages, think: false, stream: false }), + }); + if (!response.ok) { + throw new Error(`AI Model request failed: ${response.statusText}`); + } + const data = await response.json(); + return data.message?.content || 'Yanıt alınamadı.'; +} + export const deleteChatHistory = async (req: AuthenticatedRequest, res: Response) => { try { const { caseId } = req.params; @@ -64,39 +83,6 @@ export const chatMessage = async (req: AuthenticatedRequest, res: Response) => { return res.status(400).json({ error: 'Missing caseId or content' }); } - // "general" = dosyasız/genel sohbet. Şemada chat_messages.case_id NOT NULL bir - // cases FK'si olduğundan bu moddaki mesajları veritabanına yazmıyoruz — sadece - // AI'ye iletip yanıtı doğrudan döndürüyoruz. - if (caseId === 'general') { - const generalSystemPrompt = 'Sen bir hukuk asistanısın. Belirli bir dava dosyasına bağlı olmayan genel hukuki soruları cevaplıyorsun. Emin olmadığın noktalarda bunu açıkça belirt, uydurma bilgi verme.'; - const { runpodUrl, aiModel } = getAiConfig(); - - const response = await fetch(`${runpodUrl}/api/chat`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: aiModel, - messages: [ - { role: 'system', content: generalSystemPrompt }, - { role: 'user', content }, - ], - think: false, - stream: false, - }), - }); - - if (!response.ok) { - throw new Error(`AI Model request failed: ${response.statusText}`); - } - - const data = await response.json(); - const aiContent = data.message?.content || 'Yanıt alınamadı.'; - - return res.json({ - message: { id: `general-${Date.now()}`, case_id: 'general', role: 'assistant', content: aiContent }, - }); - } - // kind='case' olan gerçek dava dosyalarının başlığı UYAP'tan/kullanıcıdan geliyor — // bu asla otomatik yeniden adlandırılmamalı. Otomatik başlık üretimi SADECE // kind='chat' (Sohbet'teki "Yeni Sohbet") kayıtları için geçerli. @@ -150,25 +136,7 @@ export const chatMessage = async (req: AuthenticatedRequest, res: Response) => { messages.push({ role: 'user', content }); } - const { runpodUrl, aiModel } = getAiConfig(); - - const response = await fetch(`${runpodUrl}/api/chat`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: aiModel, - messages: messages, - think: false, - stream: false, - }), - }); - - if (!response.ok) { - throw new Error(`AI Model request failed: ${response.statusText}`); - } - - const data = await response.json(); - const aiContent = data.message?.content || 'Yanıt alınamadı.'; + const aiContent = await callAiChat(messages); const { data: aiMessage, error: aiMsgError } = await supabase .from('chat_messages') @@ -182,7 +150,7 @@ export const chatMessage = async (req: AuthenticatedRequest, res: Response) => { let newTitle: string | null = null; const isFirstMessage = !pastMessages || pastMessages.length <= 1; if (isFirstMessage && caseKind === 'chat') { - newTitle = await generateCaseTitle(content); + newTitle = await generateShortTitle(content); if (newTitle) { const { error: titleError } = await supabase .from('cases') @@ -201,3 +169,77 @@ export const chatMessage = async (req: AuthenticatedRequest, res: Response) => { res.status(500).json({ error: error.message || 'Internal Server Error' }); } }; + +// Genel (dosyasız) Sohbet/Asistan sayfası için — chat_threads/thread_messages +// tabloları cases ile hiçbir ilişkiye sahip değil (bkz. sql/9_chat_threads.sql). +// Thread oluşturma/silme/geçmiş okuma doğrudan frontend'den Supabase ile yapılıyor +// (events/clients/drafts ile aynı desen); burada sadece AI çağrısı + mesaj kaydı var. +export const sendThreadMessage = async (req: AuthenticatedRequest, res: Response) => { + try { + const { threadId } = req.params; + const { content } = req.body; + const userId = req.user?.id; + + if (!content || !threadId) { + return res.status(400).json({ error: 'Missing threadId or content' }); + } + + const { data: thread } = await supabase + .from('chat_threads') + .select('id, user_id') + .eq('id', threadId) + .maybeSingle(); + if (!thread || thread.user_id !== userId) { + return res.status(403).json({ error: 'Bu sohbete erişim yetkiniz yok.' }); + } + + const { error: userMsgError } = await supabase + .from('thread_messages') + .insert([{ thread_id: threadId, user_id: userId, role: 'user', content }]); + if (userMsgError) throw userMsgError; + + const { data: pastMessages } = await supabase + .from('thread_messages') + .select('role, content') + .eq('thread_id', threadId) + .order('created_at', { ascending: true }) + .limit(20); + + const generalSystemPrompt = 'Sen bir hukuk asistanısın. Belirli bir dava dosyasına bağlı olmayan genel hukuki soruları cevaplıyorsun. Emin olmadığın noktalarda bunu açıkça belirt, uydurma bilgi verme.'; + const messages: any[] = [{ role: 'system', content: generalSystemPrompt }]; + if (pastMessages && pastMessages.length > 0) { + pastMessages.forEach(m => messages.push({ role: m.role, content: m.content })); + } else { + messages.push({ role: 'user', content }); + } + + const aiContent = await callAiChat(messages); + + const { data: aiMessage, error: aiMsgError } = await supabase + .from('thread_messages') + .insert([{ thread_id: threadId, user_id: userId, role: 'assistant', content: aiContent }]) + .select().single(); + if (aiMsgError) throw aiMsgError; + + let newTitle: string | null = null; + const isFirstMessage = !pastMessages || pastMessages.length <= 1; + if (isFirstMessage) { + newTitle = await generateShortTitle(content); + if (newTitle) { + const { error: titleError } = await supabase + .from('chat_threads') + .update({ title: newTitle }) + .eq('id', threadId); + if (titleError) { + console.error('Thread Title Update Error:', titleError); + newTitle = null; + } + } + } + + res.json({ message: aiMessage, newTitle }); + } catch (error: any) { + console.error('Send Thread Message Error:', error); + res.status(500).json({ error: error.message || 'Internal Server Error' }); + } +}; diff --git a/src/routes/chat.routes.ts b/src/routes/chat.routes.ts index 15b02b6..ced8185 100644 --- a/src/routes/chat.routes.ts +++ b/src/routes/chat.routes.ts @@ -1,9 +1,10 @@ import { Router } from 'express'; import { requireAuth } from '../middleware/auth'; -import { chatMessage, deleteChatHistory } from '../controllers/chat.controller'; +import { chatMessage, deleteChatHistory, sendThreadMessage } from '../controllers/chat.controller'; const router = Router(); +router.post('/threads/:threadId/message', requireAuth, sendThreadMessage); router.post('/:caseId/message', requireAuth, chatMessage); router.delete('/:caseId', requireAuth, deleteChatHistory);