feat: 引入 AI 聊天总结功能,并新增聊天会话、消息管理接口及前端组件。

This commit is contained in:
liuliu 2025-12-17 11:47:04 +08:00
parent c99d5f4c19
commit 1bf7945090
5 changed files with 273 additions and 2 deletions

View File

@ -1,5 +1,7 @@
import { Elysia } from "elysia";
import { db } from "./db";
import { generateSummary } from "./llm";
import { randomUUID } from "crypto";
// Define the API app
export const api = new Elysia({ prefix: "/api" })
@ -101,6 +103,57 @@ export const api = new Elysia({ prefix: "/api" })
console.error('Import failed:', err);
throw new Error(err.message);
}
})
.post("/summary", async ({ body }) => {
const { conversationId, startTime, endTime } = body as { conversationId: string, startTime: number, endTime: number };
// 1. Check cache (summaries table)
// Format date string YYYY-MM-DD from startTime for simple caching key
const dateObj = new Date(startTime);
const dateStr = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}-${String(dateObj.getDate()).padStart(2, '0')}`;
const cached = db.query("SELECT * FROM summaries WHERE conversation_id = $cid AND date = $date").get({
$cid: conversationId,
$date: dateStr
}) as any;
if (cached) {
console.log("Returning cached summary for", dateStr);
return { content: cached.content };
}
// 2. Fetch messages
const messages = db.query(`
SELECT m.content, u.name as senderName
FROM messages m
LEFT JOIN users u ON m.sender_id = u.id
WHERE conversation_id = $cid AND timestamp >= $start AND timestamp <= $end
ORDER BY timestamp ASC
`).all({
$cid: conversationId,
$start: startTime,
$end: endTime
}) as any[];
if (messages.length === 0) {
return { content: "该时间段内没有消息。" };
}
// 3. Generate summary
const messageTexts = messages.map(m => `${m.senderName || 'Unknown'}: ${m.content}`);
const summaryContent = await generateSummary(messageTexts);
// 4. Save to cache
const insertSummary = db.prepare("INSERT INTO summaries (id, conversation_id, date, content, created_at) VALUES ($id, $cid, $date, $content, $createdAt)");
insertSummary.run({
$id: randomUUID(),
$cid: conversationId,
$date: dateStr,
$content: summaryContent,
$createdAt: Date.now()
});
return { content: summaryContent };
});
export type App = typeof api;

View File

@ -42,6 +42,18 @@ export function initDB() {
)
`);
// Summaries
db.run(`
CREATE TABLE IF NOT EXISTS summaries (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
date TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (conversation_id) REFERENCES conversations(id)
)
`);
// Seed data if empty
const userCount = db.query("SELECT count(*) as count FROM users").get() as { count: number };
if (userCount.count === 0) {

48
src/backend/llm.ts Normal file
View File

@ -0,0 +1,48 @@
export interface LLMResponse {
content: string;
}
const API_KEY = process.env.LLM_API_KEY || 'sk-xxxx'; // Default or from env
const BASE_URL = process.env.LLM_BASE_URL || 'https://api.openai.com/v1';
const MODEL = process.env.LLM_MODEL || 'gpt-3.5-turbo';
export async function generateSummary(messages: string[]): Promise<string> {
if (messages.length === 0) return "没有足够的消息进行总结。";
const prompt = `
使
${messages.join('\n')}
`.trim();
try {
const response = await fetch(`${BASE_URL}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`
},
body: JSON.stringify({
model: MODEL,
messages: [
{ role: 'system', content: '你是一个乐于助人的AI助手善于总结聊天内容。' },
{ role: 'user', content: prompt }
],
temperature: 0.7
})
});
if (!response.ok) {
const error = await response.text();
console.error('LLM API Error:', error);
throw new Error(`LLM request failed: ${response.statusText}`);
}
const data = await response.json() as any;
return data.choices?.[0]?.message?.content || "无法生成总结。";
} catch (error) {
console.error('Generate Summary Error:', error);
return "生成总结时发生错误,请稍后再试。";
}
}

View File

@ -1,7 +1,8 @@
import { MoreHorizontal, Smile, FolderOpen, Scissors, Clipboard, Search, X } from 'lucide-react';
import { MoreHorizontal, Smile, FolderOpen, Scissors, Clipboard, Search, X, Sparkles } from 'lucide-react';
import { client } from '../client';
import type { Conversation } from '../data/mock';
import { MessageBubble } from './MessageBubble';
import { SummaryModal } from './SummaryModal';
import { clsx } from 'clsx';
import { useEffect, useRef, useState } from 'react';
@ -22,6 +23,10 @@ export function ChatWindow({ conversation }: ChatWindowProps) {
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
// Summary modal
const [showSummary, setShowSummary] = useState(false);
const fetchMessages = async (isLoadMore = false, query = '', start = '', end = '') => {
if (!conversation?.id || loading) return;
@ -106,6 +111,11 @@ export function ChatWindow({ conversation }: ChatWindowProps) {
}
}, [searchQuery, isSearching, startDate, endDate]); // Be careful with dependency loops, but this seems okay
// Reset modal when conversation changes
useEffect(() => {
setShowSummary(false);
}, [conversation?.id]);
// Initial load when conversation changes
useEffect(() => {
setMessages([]);
@ -194,6 +204,14 @@ export function ChatWindow({ conversation }: ChatWindowProps) {
</div>
</div>
{/* AI Summary Modal */}
{showSummary && conversation && (
<SummaryModal
conversationId={conversation.id}
onClose={() => setShowSummary(false)}
/>
)}
{/* Messages */}
<div
className="flex-1 overflow-y-auto p-6 scrollbar-thin"
@ -236,7 +254,13 @@ export function ChatWindow({ conversation }: ChatWindowProps) {
<div className="h-10 flex items-center px-4 gap-4 text-[#666]">
<Smile className="w-5 h-5 cursor-pointer hover:text-[#333]" />
<FolderOpen className="w-5 h-5 cursor-pointer hover:text-[#333]" />
{/* Fake scissors icon using a character or placeholder if icon missing, but lucide has Scissors usually. Using generic placeholders if not sure */}
<button
onClick={() => setShowSummary(true)}
className="flex items-center gap-1 text-purple-600 hover:text-purple-700 transition-colors ml-auto text-sm font-medium"
title="AI Chat Summary"
>
<Sparkles className="w-4 h-4" /> AI
</button>
</div>
{/* Text Area */}

View File

@ -0,0 +1,134 @@
import { useState } from 'react';
import { X, Calendar, Sparkles, Loader2, Copy } from 'lucide-react';
import { client } from '../client';
import { clsx } from 'clsx';
interface SummaryModalProps {
conversationId: string;
onClose: () => void;
}
export function SummaryModal({ conversationId, onClose }: SummaryModalProps) {
const [step, setStep] = useState<'date' | 'result'>('date');
const [selectedDate, setSelectedDate] = useState(() => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return yesterday.toISOString().split('T')[0];
});
const [loading, setLoading] = useState(false);
const [summary, setSummary] = useState('');
const [error, setError] = useState('');
const handleGenerate = async () => {
setLoading(true);
setError('');
try {
const start = new Date(selectedDate);
start.setHours(0, 0, 0, 0);
const end = new Date(selectedDate);
end.setHours(23, 59, 59, 999);
const { data, error: apiError } = await client.api.summary.post({
conversationId,
startTime: start.getTime(),
endTime: end.getTime()
});
if (apiError) throw apiError;
// @ts-ignore
if (data?.content) {
// @ts-ignore
setSummary(data.content);
setStep('result');
} else {
throw new Error("Failed to get summary");
}
} catch (err: any) {
setError(err.message || "生成总结失败");
} finally {
setLoading(false);
}
};
const handleCopy = () => {
navigator.clipboard.writeText(summary);
};
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md flex flex-col max-h-[85vh]">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b shrink-0">
<h3 className="text-lg font-medium flex items-center gap-2">
<Sparkles className="w-5 h-5 text-purple-600" />
AI
</h3>
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded-full">
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto flex-1">
{step === 'date' ? (
<div className="space-y-4">
<p className="text-sm text-gray-600"></p>
<div className="flex items-center gap-2 border p-2 rounded-md hover:border-blue-500 transition-colors">
<Calendar className="w-5 h-5 text-gray-400" />
<input
type="date"
className="flex-1 outline-none text-gray-700"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
max={new Date().toISOString().split('T')[0]}
/>
</div>
{error && (
<div className="text-sm text-red-500 bg-red-50 p-2 rounded">
{error}
</div>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between text-sm text-gray-500">
<span>{selectedDate} </span>
<button onClick={handleCopy} className="flex items-center gap-1 hover:text-blue-600">
<Copy className="w-3 h-3" />
</button>
</div>
<div className="prose prose-sm max-w-none bg-gray-50 p-4 rounded-md text-gray-700 whitespace-pre-wrap leading-relaxed">
{summary}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t bg-gray-50 rounded-b-lg flex justify-end gap-3 shrink-0">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-200 rounded-md transition-colors"
>
{step === 'result' ? '关闭' : '取消'}
</button>
{step === 'date' && (
<button
onClick={handleGenerate}
disabled={loading}
className={clsx(
"px-4 py-2 text-sm text-white rounded-md flex items-center gap-2 transition-all",
loading ? "bg-purple-400 cursor-not-allowed" : "bg-purple-600 hover:bg-purple-700 shadow-md hover:shadow-lg"
)}
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
{loading ? '生成中...' : '开始生成'}
</button>
)}
</div>
</div>
</div>
);
}