feat: 引入 AI 聊天总结功能,并新增聊天会话、消息管理接口及前端组件。
This commit is contained in:
parent
c99d5f4c19
commit
1bf7945090
@ -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;
|
||||
|
||||
@ -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
48
src/backend/llm.ts
Normal 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 "生成总结时发生错误,请稍后再试。";
|
||||
}
|
||||
}
|
||||
@ -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 */}
|
||||
|
||||
134
src/frontend/components/SummaryModal.tsx
Normal file
134
src/frontend/components/SummaryModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user