feat: persist general chat as chat_threads, decoupled from cases
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
982f908bf9
commit
0318f2ac77
@@ -3,10 +3,11 @@ import { supabase } from '../lib/supabase';
|
|||||||
import { AuthenticatedRequest } from '../middleware/auth';
|
import { AuthenticatedRequest } from '../middleware/auth';
|
||||||
import { getAiConfig } from '../lib/aiConfig';
|
import { getAiConfig } from '../lib/aiConfig';
|
||||||
|
|
||||||
// Bir dosyanın ilk mesajından kısa bir başlık üretir (Claude/ChatGPT'nin yaptığı gibi).
|
// İlk mesajdan kısa bir başlık üretir (Claude/ChatGPT'nin yaptığı gibi). Başarısız
|
||||||
// Başarısız olursa null döner — çağıran taraf bu durumda "Yeni Sohbet" başlığını korur,
|
// olursa null döner — çağıran taraf bu durumda varsayılan başlığı korur, asıl
|
||||||
// asıl sohbet cevabını etkilemez.
|
// sohbet cevabını etkilemez. Hem dava-dosyası-siz "Yeni Sohbet" (cases.kind='chat')
|
||||||
async function generateCaseTitle(firstMessage: string): Promise<string | null> {
|
// hem de chat_threads kayıtları için ortak kullanılır.
|
||||||
|
async function generateShortTitle(firstMessage: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const { runpodUrl, aiModel } = getAiConfig();
|
const { runpodUrl, aiModel } = getAiConfig();
|
||||||
const response = await fetch(`${runpodUrl}/api/chat`, {
|
const response = await fetch(`${runpodUrl}/api/chat`, {
|
||||||
@@ -31,11 +32,29 @@ async function generateCaseTitle(firstMessage: string): Promise<string | null> {
|
|||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
return raw.slice(0, 80);
|
return raw.slice(0, 80);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Generate Case Title Error:', err);
|
console.error('Generate Short Title Error:', err);
|
||||||
return null;
|
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<string> {
|
||||||
|
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) => {
|
export const deleteChatHistory = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { caseId } = req.params;
|
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' });
|
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 —
|
// 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
|
// 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.
|
// 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 });
|
messages.push({ role: 'user', content });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { runpodUrl, aiModel } = getAiConfig();
|
const aiContent = await callAiChat(messages);
|
||||||
|
|
||||||
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 { data: aiMessage, error: aiMsgError } = await supabase
|
const { data: aiMessage, error: aiMsgError } = await supabase
|
||||||
.from('chat_messages')
|
.from('chat_messages')
|
||||||
@@ -182,7 +150,7 @@ export const chatMessage = async (req: AuthenticatedRequest, res: Response) => {
|
|||||||
let newTitle: string | null = null;
|
let newTitle: string | null = null;
|
||||||
const isFirstMessage = !pastMessages || pastMessages.length <= 1;
|
const isFirstMessage = !pastMessages || pastMessages.length <= 1;
|
||||||
if (isFirstMessage && caseKind === 'chat') {
|
if (isFirstMessage && caseKind === 'chat') {
|
||||||
newTitle = await generateCaseTitle(content);
|
newTitle = await generateShortTitle(content);
|
||||||
if (newTitle) {
|
if (newTitle) {
|
||||||
const { error: titleError } = await supabase
|
const { error: titleError } = await supabase
|
||||||
.from('cases')
|
.from('cases')
|
||||||
@@ -201,3 +169,77 @@ export const chatMessage = async (req: AuthenticatedRequest, res: Response) => {
|
|||||||
res.status(500).json({ error: error.message || 'Internal Server Error' });
|
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' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { requireAuth } from '../middleware/auth';
|
import { requireAuth } from '../middleware/auth';
|
||||||
import { chatMessage, deleteChatHistory } from '../controllers/chat.controller';
|
import { chatMessage, deleteChatHistory, sendThreadMessage } from '../controllers/chat.controller';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
router.post('/threads/:threadId/message', requireAuth, sendThreadMessage);
|
||||||
router.post('/:caseId/message', requireAuth, chatMessage);
|
router.post('/:caseId/message', requireAuth, chatMessage);
|
||||||
router.delete('/:caseId', requireAuth, deleteChatHistory);
|
router.delete('/:caseId', requireAuth, deleteChatHistory);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user