feat: 添加会话标题自动生成功能
- 后端:首次 AI 回复后自动从用户消息提取标题 - 后端:通过 WebSocket 推送 session_updated 事件 - 前端:useChat hook 处理标题更新事件 - 前端:Sidebar 组件实时更新会话标题显示
This commit is contained in:
@@ -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)}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取会话数量
|
* 获取会话数量
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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 {
|
||||||
// 忽略解析错误
|
// 忽略解析错误
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user