feat: 引入 AI 聊天总结功能,并新增聊天会话、消息管理接口及前端组件。
This commit is contained in:
parent
c99d5f4c19
commit
1bf7945090
@ -1,5 +1,7 @@
|
|||||||
import { Elysia } from "elysia";
|
import { Elysia } from "elysia";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
|
import { generateSummary } from "./llm";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
// Define the API app
|
// Define the API app
|
||||||
export const api = new Elysia({ prefix: "/api" })
|
export const api = new Elysia({ prefix: "/api" })
|
||||||
@ -101,6 +103,57 @@ export const api = new Elysia({ prefix: "/api" })
|
|||||||
console.error('Import failed:', err);
|
console.error('Import failed:', err);
|
||||||
throw new Error(err.message);
|
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;
|
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
|
// Seed data if empty
|
||||||
const userCount = db.query("SELECT count(*) as count FROM users").get() as { count: number };
|
const userCount = db.query("SELECT count(*) as count FROM users").get() as { count: number };
|
||||||
if (userCount.count === 0) {
|
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 { client } from '../client';
|
||||||
import type { Conversation } from '../data/mock';
|
import type { Conversation } from '../data/mock';
|
||||||
import { MessageBubble } from './MessageBubble';
|
import { MessageBubble } from './MessageBubble';
|
||||||
|
import { SummaryModal } from './SummaryModal';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
@ -22,6 +23,10 @@ export function ChatWindow({ conversation }: ChatWindowProps) {
|
|||||||
const [startDate, setStartDate] = useState('');
|
const [startDate, setStartDate] = useState('');
|
||||||
const [endDate, setEndDate] = useState('');
|
const [endDate, setEndDate] = useState('');
|
||||||
|
|
||||||
|
// Summary modal
|
||||||
|
const [showSummary, setShowSummary] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
const fetchMessages = async (isLoadMore = false, query = '', start = '', end = '') => {
|
const fetchMessages = async (isLoadMore = false, query = '', start = '', end = '') => {
|
||||||
if (!conversation?.id || loading) return;
|
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
|
}, [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
|
// Initial load when conversation changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
@ -194,6 +204,14 @@ export function ChatWindow({ conversation }: ChatWindowProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* AI Summary Modal */}
|
||||||
|
{showSummary && conversation && (
|
||||||
|
<SummaryModal
|
||||||
|
conversationId={conversation.id}
|
||||||
|
onClose={() => setShowSummary(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
<div
|
<div
|
||||||
className="flex-1 overflow-y-auto p-6 scrollbar-thin"
|
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]">
|
<div className="h-10 flex items-center px-4 gap-4 text-[#666]">
|
||||||
<Smile className="w-5 h-5 cursor-pointer hover:text-[#333]" />
|
<Smile className="w-5 h-5 cursor-pointer hover:text-[#333]" />
|
||||||
<FolderOpen 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>
|
</div>
|
||||||
|
|
||||||
{/* Text Area */}
|
{/* 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