Files
CamTalk/frontend/src/hooks/useSessionList.ts
hhs 4cc0713459 fix: 修复空会话返回 null 导致前端崩溃的问题
## 问题描述
当用户创建新会话但未发送任何消息就切换到其他会话时,前端控制台报错:
"TypeError: Cannot read properties of null (reading 'map')"

根本原因:Go 后端未初始化的切片序列化为 JSON 时会变成 `null` 而非 `[]`,
前端尝试对 `null` 调用 `.map()` 导致崩溃。

## 修复方案
采用多层防御策略,同时修复后端和前端:

### 后端修复(确保 API 契约正确)
1. message_pg.go:93 - 将 `var messages []StoredMessage` 改为
   `messages := make([]StoredMessage, 0)`,确保空结果序列化为 `[]`
2. conversation.go - 在两个响应路径(PG 查询 + 内存回退)添加防御性 nil 检查

### 前端防御(多层保护)
1. useSessionList.ts - 在 loadMessages 和 loadSessions 中添加 null 合并操作
   `(res.data.messages || [])` 确保即使后端退化也不会崩溃

## 影响范围
- 所有空会话(新建后未发送消息的对话)现在可以正常切换
- API 响应符合 JSON 最佳实践(数组字段永远是 `[]` 而非 `null`)
2026-06-22 12:39:37 +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,
};
}