feat: 优化对话历史功能

This commit is contained in:
2026-06-20 19:57:36 +08:00
parent 2a7d4c74d4
commit ab07e01adf
15 changed files with 653 additions and 174 deletions

View File

@@ -4,6 +4,7 @@
// ============================================================
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { v4 as uuidv4 } from "uuid";
import { useVisionSession } from "./hooks/useVisionSession";
import { useSessionList } from "./hooks/useSessionList";
import { VideoPreview } from "./components/VideoPreview";
@@ -53,12 +54,13 @@ function AppContent() {
const {
sessions,
activeSessionId,
isLoading: sessionsLoading,
createSession,
deleteSession,
renameSession,
persistSession,
selectSession,
} = useSessionList();
loadSessions,
} = useSessionList(accessToken);
// ---- 视觉会话 ----
const {
@@ -87,7 +89,7 @@ function AppContent() {
toggleCamera,
toggleMic,
sendTextMessage,
} = useVisionSession(accessToken);
} = useVisionSession(accessToken, activeSessionId);
const isConnected = connectionStatus === "connected";
@@ -118,58 +120,57 @@ function AppContent() {
const { t: tr } = useMemo(() => ({ t: (key: string) => t(key, parseLocale(config.language)) }), [config.language]);
// ---- 初始化:如果没有会话,创建一个 ----
// ---- 初始化:加载完成后如果没有会话,创建一个 ----
const initializedRef = useRef(false);
useEffect(() => {
if (!initializedRef.current) {
if (!sessionsLoading && !initializedRef.current) {
initializedRef.current = true;
if (sessions.length === 0) {
createSession();
}
}
}, [sessions.length, createSession]);
// ---- 自动保存messages 变化时持久化到当前会话 ----
const messagesRef = useRef(messages);
useEffect(() => { messagesRef.current = messages; }, [messages]);
}, [sessionsLoading, sessions.length, createSession]);
// ---- 侧边栏打开时刷新会话列表 ----
useEffect(() => {
if (activeSessionId && messages.length > 0) {
persistSession(activeSessionId, messages);
if (sidebarOpen) {
loadSessions();
}
}, [messages, activeSessionId, persistSession]);
}, [sidebarOpen, loadSessions]);
// ---- 侧边栏操作 ----
const handleNewSession = useCallback(() => {
createSession();
setMessages([]);
// 如果已连接,断开
const handleNewSession = useCallback(async () => {
// 如果已连接,先断开
if (connectionStatus === "connected") {
stopSession();
await stopSession();
}
// 通过后端 API 创建会话
await createSession();
setMessages([]);
setSidebarOpen(false);
}, [createSession, setMessages, connectionStatus, stopSession]);
const handleSelectSession = useCallback((id: string) => {
// 保存当前会话
if (activeSessionId && messagesRef.current.length > 0) {
persistSession(activeSessionId, messagesRef.current);
}
// 如果已连接,断开
const handleSelectSession = useCallback(async (id: string) => {
// 如果已连接,先断开
if (connectionStatus === "connected") {
stopSession();
await stopSession();
}
// 加载目标会话
const loaded = selectSession(id);
// 从后端 API 加载目标会话的消息
const loaded = await selectSession(id);
setMessages(loaded);
}, [activeSessionId, persistSession, connectionStatus, stopSession, selectSession, setMessages]);
setSidebarOpen(false);
}, [connectionStatus, stopSession, selectSession, setMessages]);
const handleDeleteSession = useCallback((id: string) => {
deleteSession(id);
const handleDeleteSession = useCallback(async (id: string) => {
await deleteSession(id);
if (id === activeSessionId) {
// 断开连接并清空消息
if (connectionStatus === "connected") {
await stopSession();
}
setMessages([]);
}
}, [deleteSession, activeSessionId, setMessages]);
}, [deleteSession, activeSessionId, setMessages, connectionStatus, stopSession]);
// ---- 识别画面 ----
const handleRecognize = useCallback(() => {
@@ -194,6 +195,7 @@ function AppContent() {
// 插入系统提示消息
const scenarioName = sc ? `${sc.icon} ${tr(sc.nameKey)}` : scenarioId;
setMessages(prev => [...prev, {
id: uuidv4(),
role: "system",
content: tr("chat.scenarioSwitched").replace("{name}", scenarioName),
timestamp: Date.now(),

View File

@@ -154,13 +154,13 @@ export function ChatPanel({
</div>
)}
{messages.map((msg, index) => (
{messages.map((msg) => (
msg.role === "system" ? (
<div key={index} className="chat-message chat-message--system">
<div key={msg.id} className="chat-message chat-message--system">
<span className="chat-message--system__text">{msg.content}</span>
</div>
) : (
<div key={index} className={`chat-message chat-message--${msg.role}`}>
<div key={msg.id} className={`chat-message chat-message--${msg.role}`}>
<div className="chat-message__role">
{msg.role === "user" ? t("chat.userLabel") : "AI"}
</div>

View File

@@ -25,7 +25,7 @@ export function useWebSocketManager() {
return {
status,
lastMessage,
connect: (token?: string) => wsClient.connect(token),
connect: (token?: string, conversationId?: string) => wsClient.connect(token, conversationId),
disconnect: () => wsClient.disconnect(),
send: wsClient.send.bind(wsClient),
};

View File

@@ -1,109 +1,227 @@
// ============================================================
// useSessionList — 会话历史列表管理
// 职责:会话 CRUD、消息持久化、切换会话
// useSessionList — 会话历史列表管理(后端 API 驱动)
// 职责:会话 CRUD、消息加载、切换会话
// 数据源:后端 /api/conversations REST API
// ============================================================
import { useCallback, useEffect, useRef, useState } from "react";
import { v4 as uuidv4 } from "uuid";
import {
loadSessionSummaries,
saveSessionSummaries,
loadSessionMessages,
saveSessionMessages,
deleteSessionMessages,
} from "../lib/storage";
listConversations,
createConversation,
deleteConversation,
renameConversation,
getConversationMessages,
type ConversationListItem,
} from "../lib/api";
import type { ChatMessage, SessionSummary } from "../types";
const LAST_ACTIVE_KEY = "camtalk:last_active_session";
/** 截取预览文本 */
function getPreview(text: string, maxLen = 50): string {
const clean = text.replace(/[\n\r]/g, " ").trim();
return clean.length > maxLen ? clean.slice(0, maxLen) + "…" : clean;
}
export function useSessionList() {
const [sessions, setSessions] = useState<SessionSummary[]>(() => loadSessionSummaries());
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
/** 将后端 ConversationListItem 转为前端 SessionSummary */
function toSessionSummary(item: ConversationListItem): SessionSummary {
return {
id: item.id,
title: item.title || "新对话",
createdAt: new Date(item.created_at).getTime(),
lastActiveAt: new Date(item.updated_at).getTime(),
messageCount: item.message_count,
preview: getPreview(item.last_message || ""),
};
}
/** 将后端 StoredMessage 转为前端 ChatMessage */
function toChatMessage(msg: {
id: number;
role: string;
content: string;
tokens_used: number;
created_at: string;
}): ChatMessage {
return {
id: String(msg.id),
role: msg.role as ChatMessage["role"],
content: msg.content,
timestamp: new Date(msg.created_at).getTime(),
tokensUsed: msg.tokens_used || undefined,
};
}
export function useSessionList(accessToken?: string | null) {
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(
() => localStorage.getItem(LAST_ACTIVE_KEY)
);
const [isLoading, setIsLoading] = useState(false);
const sessionsRef = useRef(sessions);
useEffect(() => { sessionsRef.current = sessions; }, [sessions]);
useEffect(() => {
sessionsRef.current = sessions;
}, [sessions]);
// 持久化 activeSessionId 到 localStorage仅用于恢复选中状态
useEffect(() => {
if (activeSessionId) {
localStorage.setItem(LAST_ACTIVE_KEY, activeSessionId);
} else {
localStorage.removeItem(LAST_ACTIVE_KEY);
}
}, [activeSessionId]);
/** 从后端加载会话列表 */
const loadSessions = useCallback(async () => {
if (!accessToken) return;
setIsLoading(true);
try {
const res = await listConversations(accessToken);
if (res.data) {
const list = res.data.conversations.map(toSessionSummary);
setSessions(list);
// 恢复上次选中的会话(如果仍然存在)
const lastId = localStorage.getItem(LAST_ACTIVE_KEY);
if (lastId && list.find((s) => s.id === lastId)) {
setActiveSessionId(lastId);
} else if (list.length > 0) {
setActiveSessionId(list[0].id);
}
}
} catch (err) {
console.error("[SessionList] 加载会话列表失败:", err);
} finally {
setIsLoading(false);
}
}, [accessToken]);
// 初始化加载 + token 变化时重新加载
useEffect(() => {
if (accessToken) {
loadSessions();
}
}, [accessToken]); // eslint-disable-line react-hooks/exhaustive-deps
/** 创建新会话 */
const createSession = useCallback((): string => {
const id = uuidv4();
const now = Date.now();
const summary: SessionSummary = {
id,
title: "新对话",
createdAt: now,
lastActiveAt: now,
messageCount: 0,
preview: "",
};
setSessions((prev) => [summary, ...prev]);
setActiveSessionId(id);
// 持久化
const all = [summary, ...sessionsRef.current];
saveSessionSummaries(all);
return id;
}, []);
const createSession = useCallback(async (): Promise<string | null> => {
if (!accessToken) {
// 未登录时回退到本地 ID
const id = uuidv4();
const now = Date.now();
const summary: SessionSummary = {
id,
title: "新对话",
createdAt: now,
lastActiveAt: now,
messageCount: 0,
preview: "",
};
setSessions((prev) => [summary, ...prev]);
setActiveSessionId(id);
return id;
}
try {
const res = await createConversation(accessToken);
if (res.data) {
const id = res.data.id;
const now = Date.now();
const summary: SessionSummary = {
id,
title: res.data.title || "新对话",
createdAt: new Date(res.data.created_at).getTime() || now,
lastActiveAt: new Date(res.data.updated_at).getTime() || now,
messageCount: 0,
preview: "",
};
setSessions((prev) => [summary, ...prev]);
setActiveSessionId(id);
return id;
}
} catch (err) {
console.error("[SessionList] 创建会话失败:", err);
}
return null;
}, [accessToken]);
/** 删除会话 */
const deleteSession = useCallback((id: string) => {
setSessions((prev) => prev.filter((s) => s.id !== id));
deleteSessionMessages(id);
const remaining = sessionsRef.current.filter((s) => s.id !== id);
saveSessionSummaries(remaining);
// 如果删除的是当前会话,清空 active
setActiveSessionId((prev) => (prev === id ? null : prev));
}, []);
const deleteSession = useCallback(
async (id: string) => {
setSessions((prev) => prev.filter((s) => s.id !== id));
if (accessToken) {
try {
await deleteConversation(accessToken, id);
} catch (err) {
console.error("[SessionList] 删除会话失败:", err);
}
}
// 如果删除的是当前会话,切换到第一个或清空
setActiveSessionId((prev) => {
if (prev === id) {
const remaining = sessionsRef.current.filter((s) => s.id !== id);
return remaining.length > 0 ? remaining[0].id : null;
}
return prev;
});
},
[accessToken]
);
/** 重命名会话 */
const renameSession = useCallback((id: string, title: string) => {
setSessions((prev) => prev.map((s) => (s.id === id ? { ...s, title } : s)));
const updated = sessionsRef.current.map((s) => (s.id === id ? { ...s, title } : s));
saveSessionSummaries(updated);
}, []);
const renameSession = useCallback(
async (id: string, title: string) => {
setSessions((prev) =>
prev.map((s) => (s.id === id ? { ...s, title } : s))
);
if (accessToken) {
try {
await renameConversation(accessToken, id, title);
} catch (err) {
console.error("[SessionList] 重命名会话失败:", err);
}
}
},
[accessToken]
);
/** 保存指定会话的消息并更新摘要 */
const persistSession = useCallback((sessionId: string, messages: ChatMessage[]) => {
if (!sessionId) return;
saveSessionMessages(sessionId, messages);
// 更新摘要
const firstUserMsg = messages.find((m) => m.role === "user");
const title = firstUserMsg ? getPreview(firstUserMsg.content, 20) : "新对话";
const lastMsg = messages[messages.length - 1];
const summary: Partial<SessionSummary> = {
title,
messageCount: messages.length,
lastActiveAt: lastMsg?.timestamp || Date.now(),
preview: lastMsg ? getPreview(lastMsg.content) : "",
};
setSessions((prev) => {
const updated = prev.map((s) => (s.id === sessionId ? { ...s, ...summary } : s));
saveSessionSummaries(updated);
return updated;
});
}, []);
/** 加载指定会话的消息历史(从后端 API */
const loadMessages = useCallback(
async (sessionId: string): Promise<ChatMessage[]> => {
if (!accessToken) return [];
try {
const res = await getConversationMessages(accessToken, sessionId);
if (res.data) {
return res.data.messages.map(toChatMessage);
}
} catch (err) {
console.error("[SessionList] 加载消息失败:", err);
}
return [];
},
[accessToken]
);
/** 加载指定会话的消息历史 */
const loadMessages = useCallback((sessionId: string): ChatMessage[] => {
return loadSessionMessages(sessionId);
}, []);
/** 选择会话(返回需要加载的消息) */
const selectSession = useCallback((id: string): ChatMessage[] => {
setActiveSessionId(id);
return loadSessionMessages(id);
}, []);
/** 选择会话 */
const selectSession = useCallback(
async (id: string): Promise<ChatMessage[]> => {
setActiveSessionId(id);
return loadMessages(id);
},
[loadMessages]
);
return {
sessions,
activeSessionId,
setActiveSessionId,
isLoading,
createSession,
deleteSession,
renameSession,
persistSession,
loadMessages,
selectSession,
loadSessions,
};
}

View File

@@ -22,14 +22,12 @@ import type { ChatMessage, SessionConfig, ServerMessage, LLMDoneMessage } from "
export type SessionMode = "dialogue" | "observation";
const MAX_HISTORY_ROUNDS = 10;
export interface SessionStats {
queryCount: number;
totalTokens: number;
}
export function useVisionSession(accessToken?: string | null) {
export function useVisionSession(accessToken?: string | null, conversationId?: string | null) {
const { t } = useI18n();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [currentReply, setCurrentReply] = useState<string>("");
@@ -44,9 +42,6 @@ export function useVisionSession(accessToken?: string | null) {
// 上一帧采样数据(用于关键帧检测)
const prevFrameRef = useRef<Uint8ClampedArray | null>(null);
// 对话历史role + content用于多轮上下文
const historyRef = useRef<Array<{ role: string; content: string }>>([]);
// 待发消息队列(未连接时暂存,连接后自动发送)
const pendingMessagesRef = useRef<Array<{ text: string; requestId: string }>>([]);
@@ -77,6 +72,12 @@ export function useVisionSession(accessToken?: string | null) {
statusRef.current = status;
}, [status]);
// 用 ref 跟踪 conversationId避免回调闭包问题
const conversationIdRef = useRef(conversationId);
useEffect(() => {
conversationIdRef.current = conversationId;
}, [conversationId]);
// 观察模式:画面变化时自动发送 query
const { isObserving, startObserving, stopObserving } = useObservationMode({
onChange: useCallback(
@@ -94,6 +95,7 @@ export function useVisionSession(accessToken?: string | null) {
setMessages((prev) => [
...prev,
{
id: uuidv4(),
role: "user",
content: t("session.changeDetected"),
timestamp: Date.now(),
@@ -146,9 +148,8 @@ export function useVisionSession(accessToken?: string | null) {
setStats((prev) => ({ ...prev, queryCount: prev.queryCount + 1 }));
setMessages((prev) => [
...prev,
{ role: "user", content: msg.text, timestamp: Date.now() },
{ id: uuidv4(), role: "user", content: msg.text, timestamp: Date.now() },
]);
historyRef.current.push({ role: "user", content: msg.text });
setIsProcessing(true);
}
}
@@ -220,7 +221,7 @@ export function useVisionSession(accessToken?: string | null) {
// 添加用户消息STT 流式结果会逐步更新文本)
setMessages((prev) => [
...prev,
{ role: "user", content: t("session.recognizing"), timestamp: Date.now() },
{ id: uuidv4(), role: "user", content: t("session.recognizing"), timestamp: Date.now() },
]);
setIsProcessing(true);
},
@@ -254,12 +255,6 @@ export function useVisionSession(accessToken?: string | null) {
case "llm_done": {
const done = msg as LLMDoneMessage;
// 记录到对话历史
historyRef.current.push({ role: "assistant", content: done.full_text });
// 裁剪历史到最近 N 轮
if (historyRef.current.length > MAX_HISTORY_ROUNDS * 2) {
historyRef.current = historyRef.current.slice(-MAX_HISTORY_ROUNDS * 2);
}
// 累计 token 统计
if (done.tokens_used?.total) {
@@ -272,6 +267,7 @@ export function useVisionSession(accessToken?: string | null) {
setMessages((prev) => [
...prev,
{
id: uuidv4(),
role: "assistant",
content: done.full_text,
timestamp: Date.now(),
@@ -317,9 +313,9 @@ export function useVisionSession(accessToken?: string | null) {
/** 启动视频通话(摄像头 + 麦克风 + VAD */
const startSession = useCallback(async () => {
// 1. 确保 WebSocket 已连接
// 1. 确保 WebSocket 已连接(传入 conversationId 以恢复会话)
if (statusRef.current !== "connected") {
connect(accessToken || undefined);
connect(accessToken || undefined, conversationIdRef.current || undefined);
// 等待连接完成(通过 status 变化触发后续流程,这里直接继续)
}
@@ -357,7 +353,6 @@ export function useVisionSession(accessToken?: string | null) {
setCurrentReply("");
setIsProcessing(false);
setStats({ queryCount: 0, totalTokens: 0 });
historyRef.current = [];
prevFrameRef.current = null;
setIsCameraOn(false);
setIsMicOn(false);
@@ -376,7 +371,7 @@ export function useVisionSession(accessToken?: string | null) {
setIsProcessing(false);
setIsCameraOn(false);
setIsMicOn(false);
// 不断开 WebSocket不清空消息、历史、统计
// 不断开 WebSocket不清空消息、统计
}, [stopObserving, stopVAD, stopMic, stopCamera]);
/** 摄像头开关 */
@@ -414,10 +409,9 @@ export function useVisionSession(accessToken?: string | null) {
// 将未完成的流式内容保存为最终消息
if (currentReply) {
const interrupted = currentReply + t("session.interrupted");
historyRef.current.push({ role: "assistant", content: interrupted });
setMessages((prev) => [
...prev,
{ role: "assistant", content: interrupted, timestamp: Date.now() },
{ id: uuidv4(), role: "assistant", content: interrupted, timestamp: Date.now() },
]);
}
setCurrentReply("");
@@ -439,7 +433,7 @@ export function useVisionSession(accessToken?: string | null) {
if (statusRef.current !== "connected") {
pendingMessagesRef.current.push({ text: text.trim(), requestId });
// 自动连接 WebSocket消息在连接成功后由 flush 统一添加到 UI避免重复
connect(accessToken || undefined);
connect(accessToken || undefined, conversationIdRef.current || undefined);
return;
}
@@ -459,12 +453,9 @@ export function useVisionSession(accessToken?: string | null) {
// 添加用户消息
setMessages((prev) => [
...prev,
{ role: "user", content: text.trim(), timestamp: Date.now() },
{ id: uuidv4(), role: "user", content: text.trim(), timestamp: Date.now() },
]);
// 记录到对话历史
historyRef.current.push({ role: "user", content: text.trim() });
setIsProcessing(true);
},
[captureFrame, send, connect, accessToken],

View File

@@ -104,3 +104,96 @@ export async function logout(
body: JSON.stringify({ refresh_token: refreshToken }),
});
}
// ---- Conversation API ----
export interface ConversationListItem {
id: string;
title: string;
last_message: string;
message_count: number;
created_at: string;
updated_at: string;
}
export interface ConversationListResponse {
conversations: ConversationListItem[];
total: number;
page: number;
size: number;
}
export interface CreateConversationResponse {
id: string;
title: string;
created_at: string;
updated_at: string;
}
export interface StoredMessage {
id: number;
role: string;
content: string;
tokens_used: number;
created_at: string;
}
export interface MessagesResponse {
messages: StoredMessage[];
total: number;
}
export async function listConversations(
token: string,
page = 1,
size = 50
): Promise<ApiResponse<ConversationListResponse>> {
return request<ConversationListResponse>(
`/conversations?page=${page}&size=${size}`,
{ headers: authHeaders(token) }
);
}
export async function createConversation(
token: string,
config?: Record<string, unknown>
): Promise<ApiResponse<CreateConversationResponse>> {
return request<CreateConversationResponse>("/conversations", {
method: "POST",
headers: authHeaders(token),
body: JSON.stringify(config ? { config } : {}),
});
}
export async function deleteConversation(
token: string,
id: string
): Promise<ApiResponse<void>> {
return request<void>(`/conversations/${id}`, {
method: "DELETE",
headers: authHeaders(token),
});
}
export async function renameConversation(
token: string,
id: string,
title: string
): Promise<ApiResponse<{ message: string }>> {
return request<{ message: string }>(`/conversations/${id}`, {
method: "PATCH",
headers: authHeaders(token),
body: JSON.stringify({ title }),
});
}
export async function getConversationMessages(
token: string,
id: string,
limit = 200
): Promise<ApiResponse<MessagesResponse>> {
return request<MessagesResponse>(
`/conversations/${id}/messages?limit=${limit}`,
{ headers: authHeaders(token) }
);
}

View File

@@ -25,6 +25,7 @@ export class CamTalkWebSocket {
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private shouldReconnect = true;
private token: string | undefined;
private conversationId: string | undefined;
private messageHandlers = new Set<MessageHandler>();
private statusHandlers = new Set<StatusHandler>();
@@ -46,15 +47,20 @@ export class CamTalkWebSocket {
return () => this.statusHandlers.delete(handler);
}
/** 建立连接,可选传入 JWT token 用于认证 */
connect(token?: string): void {
/** 建立连接,可选传入 JWT token 和 conversation_id 用于认证和会话恢复 */
connect(token?: string, conversationId?: string): void {
if (this.ws?.readyState === WebSocket.OPEN) return;
this.token = token;
this.conversationId = conversationId;
this.shouldReconnect = true;
this.setStatus("connecting");
const url = token ? `${WS_URL}?token=${encodeURIComponent(token)}` : WS_URL;
const params = new URLSearchParams();
if (token) params.set("token", token);
if (conversationId) params.set("conversation_id", conversationId);
const queryString = params.toString();
const url = queryString ? `${WS_URL}?${queryString}` : WS_URL;
const ws = new WebSocket(url);
ws.onopen = () => {
@@ -133,7 +139,7 @@ export class CamTalkWebSocket {
this.reconnectTimer = setTimeout(() => {
this.reconnectAttempt++;
this.connect(this.token);
this.connect(this.token, this.conversationId);
}, totalDelay);
}

View File

@@ -33,6 +33,7 @@ export interface SessionSummary {
// ---- 聊天消息 ----
export interface ChatMessage {
id: string;
role: "user" | "assistant" | "system";
content: string;
imageUrl?: string;