diff --git a/src/backend/api.ts b/src/backend/api.ts index b2c8695..ee206ef 100644 --- a/src/backend/api.ts +++ b/src/backend/api.ts @@ -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; diff --git a/src/backend/db.ts b/src/backend/db.ts index ea1f267..fcaa5c6 100644 --- a/src/backend/db.ts +++ b/src/backend/db.ts @@ -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) { diff --git a/src/backend/llm.ts b/src/backend/llm.ts new file mode 100644 index 0000000..f17fc2e --- /dev/null +++ b/src/backend/llm.ts @@ -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 { + 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 "生成总结时发生错误,请稍后再试。"; + } +} diff --git a/src/frontend/components/ChatWindow.tsx b/src/frontend/components/ChatWindow.tsx index 83a5370..090b7cb 100644 --- a/src/frontend/components/ChatWindow.tsx +++ b/src/frontend/components/ChatWindow.tsx @@ -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) { + {/* AI Summary Modal */} + {showSummary && conversation && ( + setShowSummary(false)} + /> + )} + {/* Messages */}
- {/* Fake scissors icon using a character or placeholder if icon missing, but lucide has Scissors usually. Using generic placeholders if not sure */} +
{/* Text Area */} diff --git a/src/frontend/components/SummaryModal.tsx b/src/frontend/components/SummaryModal.tsx new file mode 100644 index 0000000..1e0ac84 --- /dev/null +++ b/src/frontend/components/SummaryModal.tsx @@ -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 ( +
+
+ {/* Header */} +
+

+ + AI 聊天总结 +

+ +
+ + {/* Content */} +
+ {step === 'date' ? ( +
+

请选择要总结的日期(默认昨天):

+
+ + setSelectedDate(e.target.value)} + max={new Date().toISOString().split('T')[0]} + /> +
+ {error && ( +
+ {error} +
+ )} +
+ ) : ( +
+
+ {selectedDate} 的总结: + +
+
+ {summary} +
+
+ )} +
+ + {/* Footer */} +
+ + {step === 'date' && ( + + )} +
+
+
+ ); +}