From 65a23f1e713b73b49b195dcc1114fb922f3b8152 Mon Sep 17 00:00:00 2001 From: kurihada Date: Fri, 12 Dec 2025 17:45:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E8=87=AA=E5=8A=A8=E7=94=9F=E6=88=90=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:首次 AI 回复后自动从用户消息提取标题 - 后端:通过 WebSocket 推送 session_updated 事件 - 前端:useChat hook 处理标题更新事件 - 前端:Sidebar 组件实时更新会话标题显示 --- packages/desktop/src/App.tsx | 10 +++- packages/desktop/src/pages/Chat.tsx | 3 ++ packages/server/src/agent/adapter.ts | 73 ++++++++++++++++++++++++++ packages/server/src/session/manager.ts | 16 ++++++ packages/server/src/types.ts | 3 +- packages/ui/src/components/Sidebar.tsx | 16 ++++++ packages/ui/src/hooks/useChat.ts | 12 ++++- packages/web/src/App.tsx | 8 +++ packages/web/src/pages/Chat.tsx | 3 ++ 9 files changed, 141 insertions(+), 3 deletions(-) diff --git a/packages/desktop/src/App.tsx b/packages/desktop/src/App.tsx index 66fb255..332a5b1 100644 --- a/packages/desktop/src/App.tsx +++ b/packages/desktop/src/App.tsx @@ -2,7 +2,7 @@ * App Component */ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { Sidebar, FileBrowser, @@ -19,6 +19,7 @@ export function App() { const [isInitializing, setIsInitializing] = useState(true); const [showFileBrowser, setShowFileBrowser] = useState(false); const [showConfig, setShowConfig] = useState(false); + const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null); // 初始化:加载或创建会话 useEffect(() => { @@ -52,6 +53,11 @@ export function App() { setCurrentSessionId(session.id); }; + // 会话标题更新回调 + const handleSessionUpdated = useCallback((sessionId: string, name: string) => { + setSessionTitleUpdate({ sessionId, name }); + }, []); + if (isInitializing) { return (
@@ -69,6 +75,7 @@ export function App() { currentSessionId={currentSessionId} onSelectSession={handleSelectSession} onCreateSession={handleCreateSession} + sessionTitleUpdate={sessionTitleUpdate} />
@@ -78,6 +85,7 @@ export function App() { setShowFileBrowser(!showFileBrowser)} onOpenConfig={() => setShowConfig(true)} diff --git a/packages/desktop/src/pages/Chat.tsx b/packages/desktop/src/pages/Chat.tsx index 62a5993..16f7518 100644 --- a/packages/desktop/src/pages/Chat.tsx +++ b/packages/desktop/src/pages/Chat.tsx @@ -15,6 +15,7 @@ import { interface ChatPageProps { sessionId: string; + onSessionUpdated?: (sessionId: string, name: string) => void; // 工具栏按钮 showFileBrowser?: boolean; onToggleFileBrowser?: () => void; @@ -23,6 +24,7 @@ interface ChatPageProps { export function ChatPage({ sessionId, + onSessionUpdated, showFileBrowser, onToggleFileBrowser, onOpenConfig, @@ -39,6 +41,7 @@ export function ChatPage({ onError: (error) => { console.error('Chat error:', error); }, + onSessionUpdated, }); const messagesEndRef = useRef(null); diff --git a/packages/server/src/agent/adapter.ts b/packages/server/src/agent/adapter.ts index f5635bf..e681539 100644 --- a/packages/server/src/agent/adapter.ts +++ b/packages/server/src/agent/adapter.ts @@ -196,6 +196,20 @@ export async function processMessage(sessionId: string, content: string): Promis 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'); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -247,3 +261,62 @@ export function getAgentStats(sessionId: string): { 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 { + 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}"`); + } +} diff --git a/packages/server/src/session/manager.ts b/packages/server/src/session/manager.ts index e357bc6..cf9c5a2 100644 --- a/packages/server/src/session/manager.ts +++ b/packages/server/src/session/manager.ts @@ -253,6 +253,22 @@ export class SessionManager { return fullMessage; } + /** + * 更新会话名称/标题 + */ + async updateSessionName(sessionId: string, name: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return undefined; + + session.name = name; + session.updatedAt = new Date().toISOString(); + + // 持久化 + await this.persist(sessionId); + + return session; + } + /** * 获取会话数量 */ diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index bc7c32e..00aa5bc 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -108,7 +108,8 @@ export interface ServerMessage { | 'tool_result' | 'done' | 'cancelled' - | 'error'; + | 'error' + | 'session_updated'; sessionId: string; payload?: unknown; } diff --git a/packages/ui/src/components/Sidebar.tsx b/packages/ui/src/components/Sidebar.tsx index 21580bf..b4708e5 100644 --- a/packages/ui/src/components/Sidebar.tsx +++ b/packages/ui/src/components/Sidebar.tsx @@ -19,6 +19,8 @@ interface SidebarProps { onCreateSession: (session: Session) => void; /** 是否启用响应式布局(移动端抽屉式菜单) */ responsive?: boolean; + /** 会话标题更新事件(从 WebSocket 接收) */ + sessionTitleUpdate?: { sessionId: string; name: string } | null; } export function Sidebar({ @@ -26,6 +28,7 @@ export function Sidebar({ onSelectSession, onCreateSession, responsive = false, + sessionTitleUpdate, }: SidebarProps) { const [sessions, setSessions] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -84,6 +87,19 @@ export function Sidebar({ loadSessions(); }, []); + // 处理会话标题更新 + useEffect(() => { + if (sessionTitleUpdate) { + setSessions((prev) => + prev.map((s) => + s.id === sessionTitleUpdate.sessionId + ? { ...s, name: sessionTitleUpdate.name } + : s + ) + ); + } + }, [sessionTitleUpdate]); + const handleOverlayClick = () => { setIsOpen(false); }; diff --git a/packages/ui/src/hooks/useChat.ts b/packages/ui/src/hooks/useChat.ts index 865d45c..19a3944 100644 --- a/packages/ui/src/hooks/useChat.ts +++ b/packages/ui/src/hooks/useChat.ts @@ -11,6 +11,7 @@ interface UseChatOptions { sessionId: string; onError?: (error: Error) => void; onSessionNotFound?: () => void; + onSessionUpdated?: (sessionId: string, name: string) => void; } interface ChatState { @@ -20,7 +21,7 @@ interface ChatState { streamingContent: string; } -export function useChat({ sessionId, onError, onSessionNotFound }: UseChatOptions) { +export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdated }: UseChatOptions) { const [state, setState] = useState({ messages: [], isConnected: false, @@ -38,8 +39,10 @@ export function useChat({ sessionId, onError, onSessionNotFound }: UseChatOption // 用 ref 存储回调,避免依赖变化导致无限循环 const onErrorRef = useRef(onError); const onSessionNotFoundRef = useRef(onSessionNotFound); + const onSessionUpdatedRef = useRef(onSessionUpdated); onErrorRef.current = onError; onSessionNotFoundRef.current = onSessionNotFound; + onSessionUpdatedRef.current = onSessionUpdated; // 加载历史消息 const loadMessages = useCallback(async () => { @@ -134,6 +137,13 @@ export function useChat({ sessionId, onError, onSessionNotFound }: UseChatOption onErrorRef.current?.(new Error(message.payload?.message || 'Unknown error')); setState((prev) => ({ ...prev, isLoading: false, streamingContent: '' })); break; + + case 'session_updated': + // 会话信息更新(如标题) + if (message.payload?.id && message.payload?.name) { + onSessionUpdatedRef.current?.(message.payload.id, message.payload.name); + } + break; } } catch { // 忽略解析错误 diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 1b26e0e..fd42c91 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -21,6 +21,7 @@ export function App() { const [isInitializing, setIsInitializing] = useState(true); const [showFileBrowser, setShowFileBrowser] = useState(false); const [showConfig, setShowConfig] = useState(false); + const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null); // 初始化:加载或创建会话 useEffect(() => { @@ -64,6 +65,11 @@ export function App() { } }, []); + // 会话标题更新回调 + const handleSessionUpdated = useCallback((sessionId: string, name: string) => { + setSessionTitleUpdate({ sessionId, name }); + }, []); + if (isInitializing) { return (
@@ -82,6 +88,7 @@ export function App() { onSelectSession={handleSelectSession} onCreateSession={handleCreateSession} responsive + sessionTitleUpdate={sessionTitleUpdate} /> {/* 主内容区域 */} @@ -93,6 +100,7 @@ export function App() { key={currentSessionId} sessionId={currentSessionId} onSessionNotFound={handleSessionNotFound} + onSessionUpdated={handleSessionUpdated} responsive showFileBrowser={showFileBrowser} onToggleFileBrowser={() => setShowFileBrowser(!showFileBrowser)} diff --git a/packages/web/src/pages/Chat.tsx b/packages/web/src/pages/Chat.tsx index b79fd8a..aaad472 100644 --- a/packages/web/src/pages/Chat.tsx +++ b/packages/web/src/pages/Chat.tsx @@ -16,6 +16,7 @@ import { interface ChatPageProps { sessionId: string; onSessionNotFound?: () => void; + onSessionUpdated?: (sessionId: string, name: string) => void; responsive?: boolean; // 工具栏按钮 showFileBrowser?: boolean; @@ -26,6 +27,7 @@ interface ChatPageProps { export function ChatPage({ sessionId, onSessionNotFound, + onSessionUpdated, responsive = false, showFileBrowser, onToggleFileBrowser, @@ -44,6 +46,7 @@ export function ChatPage({ console.error('Chat error:', error); }, onSessionNotFound, + onSessionUpdated, }); const messagesEndRef = useRef(null);