feat: 添加会话标题自动生成功能

- 后端:首次 AI 回复后自动从用户消息提取标题
- 后端:通过 WebSocket 推送 session_updated 事件
- 前端:useChat hook 处理标题更新事件
- 前端:Sidebar 组件实时更新会话标题显示
This commit is contained in:
2025-12-12 17:45:17 +08:00
parent f561687307
commit 65a23f1e71
9 changed files with 141 additions and 3 deletions
+9 -1
View File
@@ -2,7 +2,7 @@
* App Component * App Component
*/ */
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { import {
Sidebar, Sidebar,
FileBrowser, FileBrowser,
@@ -19,6 +19,7 @@ export function App() {
const [isInitializing, setIsInitializing] = useState(true); const [isInitializing, setIsInitializing] = useState(true);
const [showFileBrowser, setShowFileBrowser] = useState(false); const [showFileBrowser, setShowFileBrowser] = useState(false);
const [showConfig, setShowConfig] = useState(false); const [showConfig, setShowConfig] = useState(false);
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
// 初始化:加载或创建会话 // 初始化:加载或创建会话
useEffect(() => { useEffect(() => {
@@ -52,6 +53,11 @@ export function App() {
setCurrentSessionId(session.id); setCurrentSessionId(session.id);
}; };
// 会话标题更新回调
const handleSessionUpdated = useCallback((sessionId: string, name: string) => {
setSessionTitleUpdate({ sessionId, name });
}, []);
if (isInitializing) { if (isInitializing) {
return ( return (
<div className="h-screen flex items-center justify-center bg-gray-900"> <div className="h-screen flex items-center justify-center bg-gray-900">
@@ -69,6 +75,7 @@ export function App() {
currentSessionId={currentSessionId} currentSessionId={currentSessionId}
onSelectSession={handleSelectSession} onSelectSession={handleSelectSession}
onCreateSession={handleCreateSession} onCreateSession={handleCreateSession}
sessionTitleUpdate={sessionTitleUpdate}
/> />
<div className="flex-1 flex"> <div className="flex-1 flex">
@@ -78,6 +85,7 @@ export function App() {
<ChatPage <ChatPage
key={currentSessionId} key={currentSessionId}
sessionId={currentSessionId} sessionId={currentSessionId}
onSessionUpdated={handleSessionUpdated}
showFileBrowser={showFileBrowser} showFileBrowser={showFileBrowser}
onToggleFileBrowser={() => setShowFileBrowser(!showFileBrowser)} onToggleFileBrowser={() => setShowFileBrowser(!showFileBrowser)}
onOpenConfig={() => setShowConfig(true)} onOpenConfig={() => setShowConfig(true)}
+3
View File
@@ -15,6 +15,7 @@ import {
interface ChatPageProps { interface ChatPageProps {
sessionId: string; sessionId: string;
onSessionUpdated?: (sessionId: string, name: string) => void;
// 工具栏按钮 // 工具栏按钮
showFileBrowser?: boolean; showFileBrowser?: boolean;
onToggleFileBrowser?: () => void; onToggleFileBrowser?: () => void;
@@ -23,6 +24,7 @@ interface ChatPageProps {
export function ChatPage({ export function ChatPage({
sessionId, sessionId,
onSessionUpdated,
showFileBrowser, showFileBrowser,
onToggleFileBrowser, onToggleFileBrowser,
onOpenConfig, onOpenConfig,
@@ -39,6 +41,7 @@ export function ChatPage({
onError: (error) => { onError: (error) => {
console.error('Chat error:', error); console.error('Chat error:', error);
}, },
onSessionUpdated,
}); });
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
+73
View File
@@ -196,6 +196,20 @@ export async function processMessage(sessionId: string, content: string): Promis
payload: assistantMessage, payload: assistantMessage,
}); });
// 检查是否需要生成会话标题(首次对话完成后)
const session = sessionManager.get(sessionId);
const messages = sessionManager.getMessages(sessionId);
if (session && !session.name && messages.length === 2) {
// 首条用户消息 + 首条 AI 回复 = 2 条消息
const userMessage = messages.find(m => m.role === 'user');
if (userMessage) {
// 异步生成标题,不阻塞响应
generateSessionTitle(sessionId, userMessage.content, response).catch(err => {
console.error('[Agent] Failed to generate session title:', err);
});
}
}
emitStatusEvent(sessionId, 'idle'); emitStatusEvent(sessionId, 'idle');
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
@@ -247,3 +261,62 @@ export function getAgentStats(sessionId: string): {
contextUsage: agent.getContextUsageFormatted(), contextUsage: agent.getContextUsageFormatted(),
}; };
} }
// ============================================================================
// 会话标题生成
// ============================================================================
/**
* 从用户消息中提取简短标题
* 使用简单的启发式方法,不依赖 LLM
*/
function extractTitleFromMessage(userMessage: string): string {
// 移除多余空白
const cleaned = userMessage.trim().replace(/\s+/g, ' ');
// 如果消息很短,直接使用
if (cleaned.length <= 30) {
return cleaned;
}
// 尝试提取第一句话
const firstSentence = cleaned.match(/^[^。!?.!?]+[。!?.!?]?/)?.[0] || cleaned;
// 如果第一句话也很长,截断
if (firstSentence.length > 40) {
return firstSentence.slice(0, 37) + '...';
}
return firstSentence;
}
/**
* 生成会话标题并更新
*/
async function generateSessionTitle(
sessionId: string,
userMessage: string,
_assistantResponse: string
): Promise<void> {
const sessionManager = getSessionManager();
// 使用简单提取方式生成标题
const title = extractTitleFromMessage(userMessage);
// 更新会话标题
const updatedSession = await sessionManager.updateSessionName(sessionId, title);
if (updatedSession) {
// 广播标题更新事件
broadcastToSession(sessionId, {
type: 'session_updated',
sessionId,
payload: {
id: updatedSession.id,
name: updatedSession.name,
},
});
console.log(`[Agent] Session title generated: "${title}"`);
}
}
+16
View File
@@ -253,6 +253,22 @@ export class SessionManager {
return fullMessage; return fullMessage;
} }
/**
* 更新会话名称/标题
*/
async updateSessionName(sessionId: string, name: string): Promise<Session | undefined> {
const session = this.sessions.get(sessionId);
if (!session) return undefined;
session.name = name;
session.updatedAt = new Date().toISOString();
// 持久化
await this.persist(sessionId);
return session;
}
/** /**
* 获取会话数量 * 获取会话数量
*/ */
+2 -1
View File
@@ -108,7 +108,8 @@ export interface ServerMessage {
| 'tool_result' | 'tool_result'
| 'done' | 'done'
| 'cancelled' | 'cancelled'
| 'error'; | 'error'
| 'session_updated';
sessionId: string; sessionId: string;
payload?: unknown; payload?: unknown;
} }
+16
View File
@@ -19,6 +19,8 @@ interface SidebarProps {
onCreateSession: (session: Session) => void; onCreateSession: (session: Session) => void;
/** 是否启用响应式布局(移动端抽屉式菜单) */ /** 是否启用响应式布局(移动端抽屉式菜单) */
responsive?: boolean; responsive?: boolean;
/** 会话标题更新事件(从 WebSocket 接收) */
sessionTitleUpdate?: { sessionId: string; name: string } | null;
} }
export function Sidebar({ export function Sidebar({
@@ -26,6 +28,7 @@ export function Sidebar({
onSelectSession, onSelectSession,
onCreateSession, onCreateSession,
responsive = false, responsive = false,
sessionTitleUpdate,
}: SidebarProps) { }: SidebarProps) {
const [sessions, setSessions] = useState<Session[]>([]); const [sessions, setSessions] = useState<Session[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -84,6 +87,19 @@ export function Sidebar({
loadSessions(); loadSessions();
}, []); }, []);
// 处理会话标题更新
useEffect(() => {
if (sessionTitleUpdate) {
setSessions((prev) =>
prev.map((s) =>
s.id === sessionTitleUpdate.sessionId
? { ...s, name: sessionTitleUpdate.name }
: s
)
);
}
}, [sessionTitleUpdate]);
const handleOverlayClick = () => { const handleOverlayClick = () => {
setIsOpen(false); setIsOpen(false);
}; };
+11 -1
View File
@@ -11,6 +11,7 @@ interface UseChatOptions {
sessionId: string; sessionId: string;
onError?: (error: Error) => void; onError?: (error: Error) => void;
onSessionNotFound?: () => void; onSessionNotFound?: () => void;
onSessionUpdated?: (sessionId: string, name: string) => void;
} }
interface ChatState { interface ChatState {
@@ -20,7 +21,7 @@ interface ChatState {
streamingContent: string; streamingContent: string;
} }
export function useChat({ sessionId, onError, onSessionNotFound }: UseChatOptions) { export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdated }: UseChatOptions) {
const [state, setState] = useState<ChatState>({ const [state, setState] = useState<ChatState>({
messages: [], messages: [],
isConnected: false, isConnected: false,
@@ -38,8 +39,10 @@ export function useChat({ sessionId, onError, onSessionNotFound }: UseChatOption
// 用 ref 存储回调,避免依赖变化导致无限循环 // 用 ref 存储回调,避免依赖变化导致无限循环
const onErrorRef = useRef(onError); const onErrorRef = useRef(onError);
const onSessionNotFoundRef = useRef(onSessionNotFound); const onSessionNotFoundRef = useRef(onSessionNotFound);
const onSessionUpdatedRef = useRef(onSessionUpdated);
onErrorRef.current = onError; onErrorRef.current = onError;
onSessionNotFoundRef.current = onSessionNotFound; onSessionNotFoundRef.current = onSessionNotFound;
onSessionUpdatedRef.current = onSessionUpdated;
// 加载历史消息 // 加载历史消息
const loadMessages = useCallback(async () => { const loadMessages = useCallback(async () => {
@@ -134,6 +137,13 @@ export function useChat({ sessionId, onError, onSessionNotFound }: UseChatOption
onErrorRef.current?.(new Error(message.payload?.message || 'Unknown error')); onErrorRef.current?.(new Error(message.payload?.message || 'Unknown error'));
setState((prev) => ({ ...prev, isLoading: false, streamingContent: '' })); setState((prev) => ({ ...prev, isLoading: false, streamingContent: '' }));
break; break;
case 'session_updated':
// 会话信息更新(如标题)
if (message.payload?.id && message.payload?.name) {
onSessionUpdatedRef.current?.(message.payload.id, message.payload.name);
}
break;
} }
} catch { } catch {
// 忽略解析错误 // 忽略解析错误
+8
View File
@@ -21,6 +21,7 @@ export function App() {
const [isInitializing, setIsInitializing] = useState(true); const [isInitializing, setIsInitializing] = useState(true);
const [showFileBrowser, setShowFileBrowser] = useState(false); const [showFileBrowser, setShowFileBrowser] = useState(false);
const [showConfig, setShowConfig] = useState(false); const [showConfig, setShowConfig] = useState(false);
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
// 初始化:加载或创建会话 // 初始化:加载或创建会话
useEffect(() => { useEffect(() => {
@@ -64,6 +65,11 @@ export function App() {
} }
}, []); }, []);
// 会话标题更新回调
const handleSessionUpdated = useCallback((sessionId: string, name: string) => {
setSessionTitleUpdate({ sessionId, name });
}, []);
if (isInitializing) { if (isInitializing) {
return ( return (
<div className="h-screen flex items-center justify-center bg-gray-900"> <div className="h-screen flex items-center justify-center bg-gray-900">
@@ -82,6 +88,7 @@ export function App() {
onSelectSession={handleSelectSession} onSelectSession={handleSelectSession}
onCreateSession={handleCreateSession} onCreateSession={handleCreateSession}
responsive responsive
sessionTitleUpdate={sessionTitleUpdate}
/> />
{/* 主内容区域 */} {/* 主内容区域 */}
@@ -93,6 +100,7 @@ export function App() {
key={currentSessionId} key={currentSessionId}
sessionId={currentSessionId} sessionId={currentSessionId}
onSessionNotFound={handleSessionNotFound} onSessionNotFound={handleSessionNotFound}
onSessionUpdated={handleSessionUpdated}
responsive responsive
showFileBrowser={showFileBrowser} showFileBrowser={showFileBrowser}
onToggleFileBrowser={() => setShowFileBrowser(!showFileBrowser)} onToggleFileBrowser={() => setShowFileBrowser(!showFileBrowser)}
+3
View File
@@ -16,6 +16,7 @@ import {
interface ChatPageProps { interface ChatPageProps {
sessionId: string; sessionId: string;
onSessionNotFound?: () => void; onSessionNotFound?: () => void;
onSessionUpdated?: (sessionId: string, name: string) => void;
responsive?: boolean; responsive?: boolean;
// 工具栏按钮 // 工具栏按钮
showFileBrowser?: boolean; showFileBrowser?: boolean;
@@ -26,6 +27,7 @@ interface ChatPageProps {
export function ChatPage({ export function ChatPage({
sessionId, sessionId,
onSessionNotFound, onSessionNotFound,
onSessionUpdated,
responsive = false, responsive = false,
showFileBrowser, showFileBrowser,
onToggleFileBrowser, onToggleFileBrowser,
@@ -44,6 +46,7 @@ export function ChatPage({
console.error('Chat error:', error); console.error('Chat error:', error);
}, },
onSessionNotFound, onSessionNotFound,
onSessionUpdated,
}); });
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);