Files
CamTalk/frontend/src/hooks/useSessionList.ts
2026-06-20 19:57:36 +08:00

228 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// useSessionList — 会话历史列表管理(后端 API 驱动)
// 职责:会话 CRUD、消息加载、切换会话
// 数据源:后端 /api/conversations REST API
// ============================================================
import { useCallback, useEffect, useRef, useState } from "react";
import { v4 as uuidv4 } from "uuid";
import {
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;
}
/** 将后端 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]);
// 持久化 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(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(
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(
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]
);
/** 加载指定会话的消息历史(从后端 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 selectSession = useCallback(
async (id: string): Promise<ChatMessage[]> => {
setActiveSessionId(id);
return loadMessages(id);
},
[loadMessages]
);
return {
sessions,
activeSessionId,
setActiveSessionId,
isLoading,
createSession,
deleteSession,
renameSession,
loadMessages,
selectSession,
loadSessions,
};
}