2026-06-14 15:44:25 +08:00
|
|
|
|
// ============================================================
|
2026-06-20 19:57:36 +08:00
|
|
|
|
// useSessionList — 会话历史列表管理(后端 API 驱动)
|
|
|
|
|
|
// 职责:会话 CRUD、消息加载、切换会话
|
|
|
|
|
|
// 数据源:后端 /api/conversations REST API
|
2026-06-14 15:44:25 +08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
|
|
import { v4 as uuidv4 } from "uuid";
|
|
|
|
|
|
import {
|
2026-06-20 19:57:36 +08:00
|
|
|
|
listConversations,
|
|
|
|
|
|
createConversation,
|
|
|
|
|
|
deleteConversation,
|
|
|
|
|
|
renameConversation,
|
|
|
|
|
|
getConversationMessages,
|
|
|
|
|
|
type ConversationListItem,
|
|
|
|
|
|
} from "../lib/api";
|
2026-06-14 15:44:25 +08:00
|
|
|
|
import type { ChatMessage, SessionSummary } from "../types";
|
|
|
|
|
|
|
2026-06-20 19:57:36 +08:00
|
|
|
|
const LAST_ACTIVE_KEY = "camtalk:last_active_session";
|
|
|
|
|
|
|
2026-06-14 15:44:25 +08:00
|
|
|
|
/** 截取预览文本 */
|
|
|
|
|
|
function getPreview(text: string, maxLen = 50): string {
|
|
|
|
|
|
const clean = text.replace(/[\n\r]/g, " ").trim();
|
|
|
|
|
|
return clean.length > maxLen ? clean.slice(0, maxLen) + "…" : clean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 19:57:36 +08:00
|
|
|
|
/** 将后端 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);
|
2026-06-14 15:44:25 +08:00
|
|
|
|
const sessionsRef = useRef(sessions);
|
2026-06-20 19:57:36 +08:00
|
|
|
|
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) {
|
2026-06-22 12:39:37 +08:00
|
|
|
|
const list = (res.data.conversations || []).map(toSessionSummary);
|
2026-06-20 19:57:36 +08:00
|
|
|
|
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
|
2026-06-14 15:44:25 +08:00
|
|
|
|
|
|
|
|
|
|
/** 创建新会话 */
|
2026-06-20 19:57:36 +08:00
|
|
|
|
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]);
|
2026-06-14 15:44:25 +08:00
|
|
|
|
|
|
|
|
|
|
/** 删除会话 */
|
2026-06-20 19:57:36 +08:00
|
|
|
|
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]
|
|
|
|
|
|
);
|
2026-06-14 15:44:25 +08:00
|
|
|
|
|
|
|
|
|
|
/** 重命名会话 */
|
2026-06-20 19:57:36 +08:00
|
|
|
|
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) {
|
2026-06-22 12:39:37 +08:00
|
|
|
|
return (res.data.messages || []).map(toChatMessage);
|
2026-06-20 19:57:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error("[SessionList] 加载消息失败:", err);
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
},
|
|
|
|
|
|
[accessToken]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
/** 选择会话 */
|
|
|
|
|
|
const selectSession = useCallback(
|
|
|
|
|
|
async (id: string): Promise<ChatMessage[]> => {
|
|
|
|
|
|
setActiveSessionId(id);
|
|
|
|
|
|
return loadMessages(id);
|
|
|
|
|
|
},
|
|
|
|
|
|
[loadMessages]
|
|
|
|
|
|
);
|
2026-06-14 15:44:25 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
sessions,
|
|
|
|
|
|
activeSessionId,
|
|
|
|
|
|
setActiveSessionId,
|
2026-06-20 19:57:36 +08:00
|
|
|
|
isLoading,
|
2026-06-14 15:44:25 +08:00
|
|
|
|
createSession,
|
|
|
|
|
|
deleteSession,
|
|
|
|
|
|
renameSession,
|
|
|
|
|
|
loadMessages,
|
|
|
|
|
|
selectSession,
|
2026-06-20 19:57:36 +08:00
|
|
|
|
loadSessions,
|
2026-06-14 15:44:25 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|