>([]);
+```
+
+它被 push(`llm_done` 时 L258、`sendTextMessage` 时 L466、`interrupt` 时 L417),但从未被读取或发送到后端。前端的 LLM 上下文完全由后端 `session.Manager.GetHistory` 独立管理。这段代码增加了维护负担却没有任何功能价值。
+
+**Bug 6:VAD 语音输入时用户消息未加入 historyRef**
+
+`onSpeechEnd` 回调(L186-228)添加了用户消息到 `messages` state,但从未 push 到 `historyRef`。同样,`stt_result` 处理器(L236-249)更新消息文本后也未同步到 `historyRef`。
+
+虽然 `historyRef` 本身是死代码(Bug 5),但如果未来要利用它,这个遗漏会造成语音消息在前端历史中缺失。
+
+**Bug 7:观察模式消息未加入 historyRef**
+
+`useObservationMode` 的 `onChange` 回调(L82-107)添加了用户消息但未 push 到 `historyRef`。同 Bug 6。
+
+#### P2 — 一般级别
+
+**Bug 8:ChatPanel 使用数组 index 作为 React key**
+
+```tsx
+{messages.map((msg, index) => (
+
+```
+
+当消息列表动态变化时(如 STT 结果更新替换了占位消息),使用 index 作为 key 可能导致 React 无法正确 diff,出现闪烁或渲染异常。应使用稳定唯一的 ID(如 `timestamp` 或生成 UUID)。
+
+**Bug 9:后端 AppendMessage 中 tokensUsed 始终为 0**
+
+`MemoryManager.AppendMessage` 异步写 PG 时硬编码 `tokensUsed` 为 0:
+
+```go
+if err := m.msgRepo.SaveMessage(context.Background(), sessionID, msg, 0); err != nil {
+```
+
+`WsLLMDone` 中的 `tokens_used` 信息未被传递到持久化层,导致 PG 中所有消息的 token 统计均为 0。
+
+**Bug 10:后端 WS Handler 与 Eino 编排器重复获取历史**
+
+`handler.go` L240 获取了 `history` 并传给 `ProcessQuery`,但 `ProcessQuery`(`adapter.go`)内部并未使用这个参数。Eino Graph 的 History 节点(`nodes_history.go`)会自己重新调用 `sessionMgr.GetHistory`。传入的 `history` 参数被浪费了一次查询。
+
+**Bug 11:后端 GetMessages 内存 fallback 的 beforeID 语义不一致**
+
+PostgreSQL 实现中 `beforeID` 是消息 ID 游标(`WHERE id < $2`),而内存 fallback 将其当作数组索引偏移量:
+
+```go
+if beforeID > 0 && int(beforeID) <= total {
+ allMessages = allMessages[:beforeID]
+}
+```
+
+两种实现的语义完全不同,切换存储后端时分页行为会不一致。
+
+---
+
+### 三、修复方案
+
+#### 方案核心思路
+
+将前端会话管理从 localStorage 迁移到后端 API,实现单一数据源。前端变为"薄客户端",会话 CRUD 和消息持久化全部走后端 `/api/conversations` 接口。
+
+#### Phase 1:前端对接后端 API(解决 P0 Bug 1/2/3)
+
+**1.1 在 api.ts 中增加对话 API 封装**
+
+```typescript
+// 新增对话 API
+export async function listConversations(token: string, page = 1, size = 20) { ... }
+export async function createConversation(token: string, config?: SessionConfig) { ... }
+export async function getConversationMessages(token: string, id: string) { ... }
+export async function deleteConversation(token: string, id: string) { ... }
+export async function renameConversation(token: string, id: string, title: string) { ... }
+```
+
+**1.2 重写 useSessionList Hook**
+
+将所有 CRUD 操作从 localStorage 切换到后端 API:
+
+- `createSession` → `POST /api/conversations`
+- `deleteSession` → `DELETE /api/conversations/:id`
+- `renameSession` → `PATCH /api/conversations/:id`
+- `selectSession` → `GET /api/conversations/:id/messages`
+- 初始化时 → `GET /api/conversations` 加载列表
+- 移除 `saveSessionMessages` / `loadSessionMessages` 等 localStorage 操作
+- 将 `activeSessionId` 持久化到 localStorage(仅用于恢复选中状态)
+
+**1.3 初始化逻辑修复**
+
+```typescript
+useEffect(() => {
+ if (!initializedRef.current) {
+ initializedRef.current = true;
+ if (sessions.length === 0) {
+ createSession();
+ } else {
+ // 恢复上次选中的会话
+ const lastId = localStorage.getItem('camtalk:last_active_session');
+ if (lastId && sessions.find(s => s.id === lastId)) {
+ setActiveSessionId(lastId);
+ }
+ }
+ }
+}, [sessions.length, createSession]);
+```
+
+#### Phase 2:WebSocket 会话切换(解决 P0 Bug 2, P1 Bug 4)
+
+**2.1 WebSocket 连接增加 conversation_id 参数**
+
+后端已支持 `conversation_id` 查询参数(`handler.go` L126-133),前端需要在 `connect` 时传入当前会话 ID:
+
+```typescript
+connect(token?: string, conversationId?: string): void {
+ const params = new URLSearchParams();
+ if (token) params.set('token', token);
+ if (conversationId) params.set('conversation_id', conversationId);
+ const url = `${WS_URL}?${params.toString()}`;
+ // ...
+}
+```
+
+**2.2 切换会话时保持连接**
+
+在 `handleSelectSession` 中,不再调用 `stopSession()`,而是:
+1. 保存当前会话消息到后端(如果需要)
+2. 断开当前 WebSocket
+3. 用新会话 ID 重新连接
+
+或者更优方案:在 WebSocket 协议中增加 `switch_session` 消息类型,允许在保持连接的情况下切换后端会话。
+
+#### Phase 3:清理前端冗余代码(解决 P1 Bug 5/6/7, P2 Bug 8)
+
+**3.1 移除 historyRef**
+
+删除 `useVisionSession` 中的 `historyRef` 及其所有 push 操作。前端不再维护独立的 LLM 上下文历史,完全依赖后端。
+
+**3.2 消息列表使用稳定 key**
+
+将 `ChatMessage` 类型增加 `id` 字段(UUID),在创建消息时生成,用作文本 diff 和 React key。
+
+```typescript
+export interface ChatMessage {
+ id: string; // 新增
+ role: "user" | "assistant" | "system";
+ content: string;
+ // ...
+}
+```
+
+#### Phase 4:后端修复(解决 P2 Bug 9/10/11)
+
+**4.1 传递 tokensUsed 到持久化层**
+
+修改 `AppendMessage` 接口,增加 `tokensUsed` 参数;或在 `EinoOrchestrator.ProcessQuery` 中,在 `llm_done` 后单独调用一次 `UpdateMessageMeta` 更新 token 信息。
+
+**4.2 移除 WS Handler 中多余的 GetHistory 调用**
+
+删除 `handler.go` L240 的 `history` 获取,同时从 `ProcessQuery` 签名中移除 `history` 参数。
+
+**4.3 统一 GetMessages beforeID 语义**
+
+内存 fallback 中改为基于消息序号的偏移量,或直接移除内存 fallback(生产环境始终使用 PG)。
+
+---
+
+### 四、实施优先级
+
+| 优先级 | 修复项 | 预估工作量 |
+|--------|--------|-----------|
+| P0 | 前端对接后端 API + 初始化修复 | 2-3 天 |
+| P0 | WebSocket 会话切换 | 1-2 天 |
+| P1 | 清理 historyRef 死代码 | 0.5 天 |
+| P2 | React key + tokensUsed + GetMessages | 1 天 |
+
+总计约 5-7 天可完成全部修复。Phase 1 是核心,完成后对话历史功能即可正常工作。
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 73a7906..afb4729 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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(),
diff --git a/frontend/src/components/ChatPanel/index.tsx b/frontend/src/components/ChatPanel/index.tsx
index 4336803..27e14cb 100644
--- a/frontend/src/components/ChatPanel/index.tsx
+++ b/frontend/src/components/ChatPanel/index.tsx
@@ -154,13 +154,13 @@ export function ChatPanel({
)}
- {messages.map((msg, index) => (
+ {messages.map((msg) => (
msg.role === "system" ? (
-
+
{msg.content}
) : (
-
+
{msg.role === "user" ? t("chat.userLabel") : "AI"}
diff --git a/frontend/src/components/WebSocketManager/index.tsx b/frontend/src/components/WebSocketManager/index.tsx
index 48c00d7..acce95f 100644
--- a/frontend/src/components/WebSocketManager/index.tsx
+++ b/frontend/src/components/WebSocketManager/index.tsx
@@ -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),
};
diff --git a/frontend/src/hooks/useSessionList.ts b/frontend/src/hooks/useSessionList.ts
index 0a49c89..841a3e4 100644
--- a/frontend/src/hooks/useSessionList.ts
+++ b/frontend/src/hooks/useSessionList.ts
@@ -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
(() => loadSessionSummaries());
- const [activeSessionId, setActiveSessionId] = useState(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([]);
+ const [activeSessionId, setActiveSessionId] = useState(
+ () => 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 => {
+ 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 = {
- 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 => {
+ 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 => {
+ setActiveSessionId(id);
+ return loadMessages(id);
+ },
+ [loadMessages]
+ );
return {
sessions,
activeSessionId,
setActiveSessionId,
+ isLoading,
createSession,
deleteSession,
renameSession,
- persistSession,
loadMessages,
selectSession,
+ loadSessions,
};
}
diff --git a/frontend/src/hooks/useVisionSession.ts b/frontend/src/hooks/useVisionSession.ts
index 0e56558..4177345 100644
--- a/frontend/src/hooks/useVisionSession.ts
+++ b/frontend/src/hooks/useVisionSession.ts
@@ -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([]);
const [currentReply, setCurrentReply] = useState("");
@@ -44,9 +42,6 @@ export function useVisionSession(accessToken?: string | null) {
// 上一帧采样数据(用于关键帧检测)
const prevFrameRef = useRef(null);
- // 对话历史(role + content),用于多轮上下文
- const historyRef = useRef>([]);
-
// 待发消息队列(未连接时暂存,连接后自动发送)
const pendingMessagesRef = useRef>([]);
@@ -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],
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index ea11424..a95c3ce 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -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> {
+ return request(
+ `/conversations?page=${page}&size=${size}`,
+ { headers: authHeaders(token) }
+ );
+}
+
+export async function createConversation(
+ token: string,
+ config?: Record
+): Promise> {
+ return request("/conversations", {
+ method: "POST",
+ headers: authHeaders(token),
+ body: JSON.stringify(config ? { config } : {}),
+ });
+}
+
+export async function deleteConversation(
+ token: string,
+ id: string
+): Promise> {
+ return request(`/conversations/${id}`, {
+ method: "DELETE",
+ headers: authHeaders(token),
+ });
+}
+
+export async function renameConversation(
+ token: string,
+ id: string,
+ title: string
+): Promise> {
+ 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> {
+ return request(
+ `/conversations/${id}/messages?limit=${limit}`,
+ { headers: authHeaders(token) }
+ );
+}
diff --git a/frontend/src/lib/websocket.ts b/frontend/src/lib/websocket.ts
index 356413a..c9b4946 100644
--- a/frontend/src/lib/websocket.ts
+++ b/frontend/src/lib/websocket.ts
@@ -25,6 +25,7 @@ export class CamTalkWebSocket {
private reconnectTimer: ReturnType | null = null;
private shouldReconnect = true;
private token: string | undefined;
+ private conversationId: string | undefined;
private messageHandlers = new Set();
private statusHandlers = new Set();
@@ -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);
}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index acd0cd3..5ae323e 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -33,6 +33,7 @@ export interface SessionSummary {
// ---- 聊天消息 ----
export interface ChatMessage {
+ id: string;
role: "user" | "assistant" | "system";
content: string;
imageUrl?: string;