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

@@ -46,7 +46,6 @@ func (e *EinoOrchestrator) ProcessQuery(
ctx context.Context, ctx context.Context,
sessionID string, sessionID string,
req models.WsQuery, req models.WsQuery,
history []models.Message,
sender orchestrator.Sender, sender orchestrator.Sender,
) error { ) error {
log := logger.Log log := logger.Log
@@ -112,15 +111,7 @@ func (e *EinoOrchestrator) ProcessQuery(
ctx = WithStartTime(ctx, startTime) ctx = WithStartTime(ctx, startTime)
ctx = WithPipelineState(ctx, genLocalState(ctx)) ctx = WithPipelineState(ctx, genLocalState(ctx))
// 6. 追加用户消息到历史 // 6. 调用 GraphStream 模式 + 运行时 Callback
if req.Text != "" {
_ = e.sessionMgr.AppendMessage(ctx, sessionID, models.Message{
Role: "user",
Content: req.Text,
})
}
// 7. 调用 GraphStream 模式 + 运行时 Callback
streamReader, err := e.graph.Runnable.Stream(ctx, input, e.callbacks) streamReader, err := e.graph.Runnable.Stream(ctx, input, e.callbacks)
if err != nil { if err != nil {
log.Errorw("Graph Stream 启动失败", "error", err) log.Errorw("Graph Stream 启动失败", "error", err)
@@ -133,7 +124,7 @@ func (e *EinoOrchestrator) ProcessQuery(
return err return err
} }
// 8. 消费 StreamReader触发整条链路执行side effects 推送消息到客户端) // 7. 消费 StreamReader触发整条链路执行side effects 推送消息到客户端)
var output PipelineOutput var output PipelineOutput
for { for {
o, err := streamReader.Recv() o, err := streamReader.Recv()
@@ -147,12 +138,28 @@ func (e *EinoOrchestrator) ProcessQuery(
output = o output = o
} }
// 8. 追加用户消息到历史(使用 STT 结果,兼容文本输入和语音输入)
userText := output.TranscribedText
if userText == "" {
userText = req.Text // fallback 到原始文本输入
}
if userText != "" {
if err := e.sessionMgr.AppendMessage(ctx, sessionID, models.Message{
Role: "user",
Content: userText,
}); err != nil {
log.Errorw("追加用户消息到历史失败", "session", sessionID, "error", err)
}
}
// 9. 追加助手消息到历史 // 9. 追加助手消息到历史
if output.FullResponse != "" { if output.FullResponse != "" {
_ = e.sessionMgr.AppendMessage(ctx, sessionID, models.Message{ if err := e.sessionMgr.AppendMessage(ctx, sessionID, models.Message{
Role: "assistant", Role: "assistant",
Content: output.FullResponse, Content: output.FullResponse,
}) }); err != nil {
log.Errorw("追加助手消息到历史失败", "session", sessionID, "error", err)
}
} }
latency := time.Since(startTime).Milliseconds() latency := time.Since(startTime).Milliseconds()

View File

@@ -14,13 +14,11 @@ type Orchestrator interface {
// ctx 用于整体超时和中断控制。 // ctx 用于整体超时和中断控制。
// sessionID 用于会话管理和历史获取。 // sessionID 用于会话管理和历史获取。
// req 包含图像和音频数据。 // req 包含图像和音频数据。
// history 是最近的对话历史。
// sender 用于向客户端推送消息。 // sender 用于向客户端推送消息。
ProcessQuery( ProcessQuery(
ctx context.Context, ctx context.Context,
sessionID string, sessionID string,
req models.WsQuery, req models.WsQuery,
history []models.Message,
sender Sender, sender Sender,
) error ) error
} }

View File

@@ -18,6 +18,7 @@ type ConversationSummary struct {
Title string `json:"title"` Title string `json:"title"`
LastMessage string `json:"last_message"` LastMessage string `json:"last_message"`
MessageCount int `json:"message_count"` MessageCount int `json:"message_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }

View File

@@ -142,11 +142,11 @@ func (m *MemoryManager) Create(ctx context.Context, userID string, config models
} }
m.mu.Unlock() m.mu.Unlock()
// Write-Through异步写 PG // Write-Through异步写 PG(使用 Background context避免 HTTP 请求结束后 context 被取消)
if m.sessRepo != nil { if m.sessRepo != nil {
go func() { go func() {
cfgJSON, _ := json.Marshal(config) cfgJSON, _ := json.Marshal(config)
if err := m.sessRepo.Save(ctx, store.SessionRecord{ if err := m.sessRepo.Save(context.Background(), store.SessionRecord{
ID: id, UserID: userID, Title: models.DefaultSessionTitle, ID: id, UserID: userID, Title: models.DefaultSessionTitle,
Config: cfgJSON, CreatedAt: now, UpdatedAt: now, Config: cfgJSON, CreatedAt: now, UpdatedAt: now,
}); err != nil { }); err != nil {
@@ -204,11 +204,11 @@ func (m *MemoryManager) UpdateConfig(ctx context.Context, sessionID string, patc
cfg := entry.session.Config cfg := entry.session.Config
m.mu.Unlock() m.mu.Unlock()
// Write-Through异步更新 PG // Write-Through异步更新 PG(使用 Background context
if m.sessRepo != nil { if m.sessRepo != nil {
go func() { go func() {
cfgJSON, _ := json.Marshal(cfg) cfgJSON, _ := json.Marshal(cfg)
if err := m.sessRepo.UpdateConfig(ctx, sessionID, cfgJSON); err != nil { if err := m.sessRepo.UpdateConfig(context.Background(), sessionID, cfgJSON); err != nil {
logger.Log.Warnw("update session config in DB failed", "session", sessionID, "error", err) logger.Log.Warnw("update session config in DB failed", "session", sessionID, "error", err)
} }
}() }()
@@ -233,10 +233,10 @@ func (m *MemoryManager) UpdateTitle(ctx context.Context, sessionID string, title
entry.lastActive = time.Now() entry.lastActive = time.Now()
m.mu.Unlock() m.mu.Unlock()
// Write-Through异步更新 PG // Write-Through异步更新 PG(使用 Background context
if m.sessRepo != nil { if m.sessRepo != nil {
go func() { go func() {
if err := m.sessRepo.UpdateTitle(ctx, sessionID, title); err != nil { if err := m.sessRepo.UpdateTitle(context.Background(), sessionID, title); err != nil {
logger.Log.Warnw("update session title in DB failed", "session", sessionID, "error", err) logger.Log.Warnw("update session title in DB failed", "session", sessionID, "error", err)
} }
}() }()
@@ -271,6 +271,7 @@ func (m *MemoryManager) ListByUser(ctx context.Context, userID string, page, siz
list = append(list, ConversationSummary{ list = append(list, ConversationSummary{
ID: rec.ID, ID: rec.ID,
Title: rec.Title, Title: rec.Title,
CreatedAt: rec.CreatedAt,
UpdatedAt: rec.UpdatedAt, UpdatedAt: rec.UpdatedAt,
}) })
sessionIDs = append(sessionIDs, rec.ID) sessionIDs = append(sessionIDs, rec.ID)
@@ -320,6 +321,7 @@ func (m *MemoryManager) listByUserFromMemory(ctx context.Context, userID string,
summary := ConversationSummary{ summary := ConversationSummary{
ID: entry.session.ID, ID: entry.session.ID,
Title: entry.session.Title, Title: entry.session.Title,
CreatedAt: entry.session.CreatedAt,
UpdatedAt: entry.lastActive, UpdatedAt: entry.lastActive,
} }
summary.MessageCount = len(entry.history) summary.MessageCount = len(entry.history)
@@ -394,8 +396,10 @@ func (m *MemoryManager) AppendMessage(_ context.Context, sessionID string, msg m
entry.history = append(entry.history, msg) entry.history = append(entry.history, msg)
// 自动更新标题:首条 user 消息时,如果标题为默认值,自动更新为消息前 20 字符 // 自动更新标题:首条 user 消息时,如果标题为默认值,自动更新为消息前 20 字符
titleUpdated := false
if msg.Role == "user" && entry.session.Title == models.DefaultSessionTitle { if msg.Role == "user" && entry.session.Title == models.DefaultSessionTitle {
entry.session.Title = generateTitle(msg.Content) entry.session.Title = generateTitle(msg.Content)
titleUpdated = true
} }
// 超过上限时裁剪,保留最新的 maxHistory 条 // 超过上限时裁剪,保留最新的 maxHistory 条
@@ -406,13 +410,31 @@ func (m *MemoryManager) AppendMessage(_ context.Context, sessionID string, msg m
now := time.Now() now := time.Now()
entry.lastActive = now entry.lastActive = now
entry.session.UpdatedAt = now entry.session.UpdatedAt = now
// 复制标题(释放锁后安全使用)
persistTitle := entry.session.Title
m.mu.Unlock() m.mu.Unlock()
// Write-Through异步写冷存储,不阻塞调用方 // Write-Through消息同步写入 PostgreSQL保证调用顺序 = 插入顺序,
// 避免用户消息和 AI 消息的异步 goroutine 执行顺序不确定导致排序错乱)
if m.msgRepo != nil { if m.msgRepo != nil {
if err := m.msgRepo.SaveMessage(context.Background(), sessionID, msg, 0); err != nil {
logger.Log.Warnw("persist message failed", "session", sessionID, "error", err)
}
}
// Write-Through异步更新会话元数据标题 + updated_at到 PostgreSQL
if m.sessRepo != nil {
go func() { go func() {
if err := m.msgRepo.SaveMessage(context.Background(), sessionID, msg, 0); err != nil { if titleUpdated {
logger.Log.Warnw("persist message failed", "session", sessionID, "error", err) if err := m.sessRepo.UpdateTitle(context.Background(), sessionID, persistTitle); err != nil {
logger.Log.Warnw("persist session title failed", "session", sessionID, "error", err)
}
} else {
// 即使标题没变,也要刷新 updated_at保证列表排序正确
if err := m.sessRepo.Touch(context.Background(), sessionID); err != nil {
logger.Log.Warnw("touch session in DB failed", "session", sessionID, "error", err)
}
} }
}() }()
} }
@@ -540,10 +562,10 @@ func (m *MemoryManager) Destroy(ctx context.Context, sessionID string) error {
delete(m.sessions, sessionID) delete(m.sessions, sessionID)
m.mu.Unlock() m.mu.Unlock()
// Write-Through异步删除 PG // Write-Through异步删除 PG(使用 Background context
if m.sessRepo != nil { if m.sessRepo != nil {
go func() { go func() {
if err := m.sessRepo.Delete(ctx, sessionID); err != nil { if err := m.sessRepo.Delete(context.Background(), sessionID); err != nil {
logger.Log.Warnw("delete session from DB failed", "session", sessionID, "error", err) logger.Log.Warnw("delete session from DB failed", "session", sessionID, "error", err)
} }
}() }()

View File

@@ -98,15 +98,13 @@ func ServeWS(sessionMgr session.Manager, orch orchestrator.Orchestrator, cfg *co
heartbeatTimeout := time.Duration(cfg.Server.HeartbeatTimeout) * time.Second heartbeatTimeout := time.Duration(cfg.Server.HeartbeatTimeout) * time.Second
version := cfg.App.Version version := cfg.App.Version
maxHistory := cfg.Session.MaxHistory
return func(c *gin.Context) { return func(c *gin.Context) {
serveWS(c, sessionMgr, orch, upgrader, heartbeatInterval, heartbeatTimeout, version, maxHistory, tokenMgr) serveWS(c, sessionMgr, orch, upgrader, heartbeatInterval, heartbeatTimeout, version, tokenMgr)
} }
} }
func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orchestrator, func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orchestrator,
upgrader websocket.Upgrader, heartbeatInterval, heartbeatTimeout time.Duration, version string, maxHistory int, tokenMgr *auth.TokenManager) { upgrader websocket.Upgrader, heartbeatInterval, heartbeatTimeout time.Duration, version string, tokenMgr *auth.TokenManager) {
// --- JWT 认证upgrade 前完成,失败直接返回 HTTP 错误) --- // --- JWT 认证upgrade 前完成,失败直接返回 HTTP 错误) ---
token := c.Query("token") token := c.Query("token")
@@ -236,9 +234,6 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
logger.Log.Warnw("set active request failed", "session", sessionID, "error", err) logger.Log.Warnw("set active request failed", "session", sessionID, "error", err)
} }
// 获取对话历史
history, _ := client.sessionMgr.GetHistory(context.Background(), sessionID, maxHistory)
// 创建可取消的 context // 创建可取消的 context
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
client.mu.Lock() client.mu.Lock()
@@ -260,7 +255,7 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
_ = client.sessionMgr.ClearActiveRequest(context.Background(), sessionID) _ = client.sessionMgr.ClearActiveRequest(context.Background(), sessionID)
}() }()
if err := client.orchestrator.ProcessQuery(ctx, sessionID, msg, history, sender); err != nil { if err := client.orchestrator.ProcessQuery(ctx, sessionID, msg, sender); err != nil {
logger.Log.Errorw("process query failed", "session", sessionID, "request", msg.RequestID, "error", err) logger.Log.Errorw("process query failed", "session", sessionID, "request", msg.RequestID, "error", err)
} }
}() }()

View File

@@ -48,7 +48,6 @@ func (m *MockOrchestrator) ProcessQuery(
ctx context.Context, ctx context.Context,
sessionID string, sessionID string,
req models.WsQuery, req models.WsQuery,
history []models.Message,
sender orchestrator.Sender, sender orchestrator.Sender,
) error { ) error {
if m.Err != nil { if m.Err != nil {

View File

@@ -0,0 +1,246 @@
## CamTalk 对话历史功能 — Bug 分析与修复方案
### 一、整体架构现状
当前对话历史系统存在一个**根本性的架构缺陷**:前端和后端各自维护了一套完全独立的会话管理系统,两者之间从未同步。
**前端**`useSessionList` Hook + `localStorage` 管理会话列表和消息存储。会话 ID 由前端 `uuid` 生成,消息通过 `localStorage` 持久化。
**后端**`MemoryManager` (内存) + `PgSessionRepository` / `PgMessageRepository` (PostgreSQL) 管理会话和消息。会话 ID 由后端 `uuid.New()` 生成。
前端 `api.ts` 中没有任何对话相关的 REST API 调用,后端提供的 `/api/conversations` 全套接口List / Create / Get / Patch / Delete / GetMessages完全未被前端使用。
---
### 二、Bug 清单
#### P0 — 严重级别
**Bug 1前后端会话系统完全脱节**
前端创建会话(`useSessionList.createSession`)只在 localStorage 中写入一条 `SessionSummary`,后端完全不知道这个会话的存在。后端在 WebSocket 连接时创建的会话(`ws/handler.go` L148有独立的 ID前端也无法感知。两套 ID 体系互不关联,导致:
- 前端切换/删除会话无法影响后端
- 后端消息持久化到 PG 但前端无法读取
- 对话历史不能跨设备、跨浏览器同步
- 清除浏览器数据后所有历史丢失
**Bug 2活跃对话中创建/切换会话导致后端消息写入错误会话**
复现步骤:
1. 用户在会话 A 中正在对话WebSocket 已连接,后端 sessionID = A
2. 用户点击"新建对话"
3. 前端 `handleNewSession` 调用 `createSession()` 创建前端会话 B调用 `setMessages([])` 清空 UI
4. 由于 `connectionStatus === "connected"`,调用 `stopSession()` 断开 WebSocket
5. 用户在新 UI 中发送消息,前端显示在"新对话"下
6. 但 WebSocket 重连后,后端创建了一个**全新的**会话 C
结果:前端认为是会话 B后端实际是会话 C。如果 `stopSession` 未执行(连接状态判断时序问题),消息甚至会写入旧会话 A。
**Bug 3刷新页面后 activeSessionId 丢失,消息无法自动保存**
`useSessionList``activeSessionId` 初始值为 `null`,且不会从 localStorage 恢复:
```typescript
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
```
初始化逻辑(`App.tsx` L122-129仅在 `sessions.length === 0` 时调用 `createSession()`。对于回访用户sessions 不为空),`activeSessionId` 保持 `null`
自动保存的 `useEffect`L136-140需要 `activeSessionId` 非 null
```typescript
if (activeSessionId && messages.length > 0) {
persistSession(activeSessionId, messages);
}
```
结果:回访用户如果不点击侧边栏选择会话,所有新消息不会被持久化,刷新页面即丢失。
#### P1 — 重要级别
**Bug 4切换会话时强制断开 WebSocket用户体验差**
`handleSelectSession``handleNewSession` 都调用 `stopSession()`,而 `stopSession` 会断开 WebSocket 连接。每次切换会话都需要重新建立连接TCP 握手 + JWT 认证 + VAD 初始化),增加约 1-3 秒延迟。
正确做法应该是在切换会话时保持 WebSocket 连接,仅在后端切换 sessionID通过发送 `conversation_id` 参数重连,或者在协议中增加切换会话的消息类型)。
**Bug 5前端 historyRef 是无效的死代码**
`useVisionSession` 中的 `historyRef`L48被维护但从未被实际使用
```typescript
const historyRef = useRef<Array<{ role: string; content: string }>>([]);
```
它被 push`llm_done` 时 L258、`sendTextMessage` 时 L466、`interrupt` 时 L417但从未被读取或发送到后端。前端的 LLM 上下文完全由后端 `session.Manager.GetHistory` 独立管理。这段代码增加了维护负担却没有任何功能价值。
**Bug 6VAD 语音输入时用户消息未加入 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 8ChatPanel 使用数组 index 作为 React key**
```tsx
{messages.map((msg, index) => (
<div key={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 2WebSocket 会话切换(解决 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 是核心,完成后对话历史功能即可正常工作。

View File

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

View File

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

View File

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

View File

@@ -1,109 +1,227 @@
// ============================================================ // ============================================================
// useSessionList — 会话历史列表管理 // useSessionList — 会话历史列表管理(后端 API 驱动)
// 职责:会话 CRUD、消息持久化、切换会话 // 职责:会话 CRUD、消息加载、切换会话
// 数据源:后端 /api/conversations REST API
// ============================================================ // ============================================================
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { import {
loadSessionSummaries, listConversations,
saveSessionSummaries, createConversation,
loadSessionMessages, deleteConversation,
saveSessionMessages, renameConversation,
deleteSessionMessages, getConversationMessages,
} from "../lib/storage"; type ConversationListItem,
} from "../lib/api";
import type { ChatMessage, SessionSummary } from "../types"; import type { ChatMessage, SessionSummary } from "../types";
const LAST_ACTIVE_KEY = "camtalk:last_active_session";
/** 截取预览文本 */ /** 截取预览文本 */
function getPreview(text: string, maxLen = 50): string { function getPreview(text: string, maxLen = 50): string {
const clean = text.replace(/[\n\r]/g, " ").trim(); const clean = text.replace(/[\n\r]/g, " ").trim();
return clean.length > maxLen ? clean.slice(0, maxLen) + "…" : clean; return clean.length > maxLen ? clean.slice(0, maxLen) + "…" : clean;
} }
export function useSessionList() { /** 将后端 ConversationListItem 转为前端 SessionSummary */
const [sessions, setSessions] = useState<SessionSummary[]>(() => loadSessionSummaries()); function toSessionSummary(item: ConversationListItem): SessionSummary {
const [activeSessionId, setActiveSessionId] = useState<string | null>(null); 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); 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 createSession = useCallback(async (): Promise<string | null> => {
const id = uuidv4(); if (!accessToken) {
const now = Date.now(); // 未登录时回退到本地 ID
const summary: SessionSummary = { const id = uuidv4();
id, const now = Date.now();
title: "新对话", const summary: SessionSummary = {
createdAt: now, id,
lastActiveAt: now, title: "新对话",
messageCount: 0, createdAt: now,
preview: "", lastActiveAt: now,
}; messageCount: 0,
setSessions((prev) => [summary, ...prev]); preview: "",
setActiveSessionId(id); };
// 持久化 setSessions((prev) => [summary, ...prev]);
const all = [summary, ...sessionsRef.current]; setActiveSessionId(id);
saveSessionSummaries(all); return 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) => { const deleteSession = useCallback(
setSessions((prev) => prev.filter((s) => s.id !== id)); async (id: string) => {
deleteSessionMessages(id); setSessions((prev) => prev.filter((s) => s.id !== id));
const remaining = sessionsRef.current.filter((s) => s.id !== id); if (accessToken) {
saveSessionSummaries(remaining); try {
// 如果删除的是当前会话,清空 active await deleteConversation(accessToken, id);
setActiveSessionId((prev) => (prev === id ? null : prev)); } 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) => { const renameSession = useCallback(
setSessions((prev) => prev.map((s) => (s.id === id ? { ...s, title } : s))); async (id: string, title: string) => {
const updated = sessionsRef.current.map((s) => (s.id === id ? { ...s, title } : s)); setSessions((prev) =>
saveSessionSummaries(updated); 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 persistSession = useCallback((sessionId: string, messages: ChatMessage[]) => { const loadMessages = useCallback(
if (!sessionId) return; async (sessionId: string): Promise<ChatMessage[]> => {
saveSessionMessages(sessionId, messages); if (!accessToken) return [];
// 更新摘要 try {
const firstUserMsg = messages.find((m) => m.role === "user"); const res = await getConversationMessages(accessToken, sessionId);
const title = firstUserMsg ? getPreview(firstUserMsg.content, 20) : "新对话"; if (res.data) {
const lastMsg = messages[messages.length - 1]; return res.data.messages.map(toChatMessage);
const summary: Partial<SessionSummary> = { }
title, } catch (err) {
messageCount: messages.length, console.error("[SessionList] 加载消息失败:", err);
lastActiveAt: lastMsg?.timestamp || Date.now(), }
preview: lastMsg ? getPreview(lastMsg.content) : "", return [];
}; },
setSessions((prev) => { [accessToken]
const updated = prev.map((s) => (s.id === sessionId ? { ...s, ...summary } : s)); );
saveSessionSummaries(updated);
return updated;
});
}, []);
/** 加载指定会话的消息历史 */ /** 选择会话 */
const loadMessages = useCallback((sessionId: string): ChatMessage[] => { const selectSession = useCallback(
return loadSessionMessages(sessionId); async (id: string): Promise<ChatMessage[]> => {
}, []); setActiveSessionId(id);
return loadMessages(id);
/** 选择会话(返回需要加载的消息) */ },
const selectSession = useCallback((id: string): ChatMessage[] => { [loadMessages]
setActiveSessionId(id); );
return loadSessionMessages(id);
}, []);
return { return {
sessions, sessions,
activeSessionId, activeSessionId,
setActiveSessionId, setActiveSessionId,
isLoading,
createSession, createSession,
deleteSession, deleteSession,
renameSession, renameSession,
persistSession,
loadMessages, loadMessages,
selectSession, selectSession,
loadSessions,
}; };
} }

View File

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

View File

@@ -104,3 +104,96 @@ export async function logout(
body: JSON.stringify({ refresh_token: refreshToken }), 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 reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private shouldReconnect = true; private shouldReconnect = true;
private token: string | undefined; private token: string | undefined;
private conversationId: string | undefined;
private messageHandlers = new Set<MessageHandler>(); private messageHandlers = new Set<MessageHandler>();
private statusHandlers = new Set<StatusHandler>(); private statusHandlers = new Set<StatusHandler>();
@@ -46,15 +47,20 @@ export class CamTalkWebSocket {
return () => this.statusHandlers.delete(handler); return () => this.statusHandlers.delete(handler);
} }
/** 建立连接,可选传入 JWT token 用于认证 */ /** 建立连接,可选传入 JWT token 和 conversation_id 用于认证和会话恢复 */
connect(token?: string): void { connect(token?: string, conversationId?: string): void {
if (this.ws?.readyState === WebSocket.OPEN) return; if (this.ws?.readyState === WebSocket.OPEN) return;
this.token = token; this.token = token;
this.conversationId = conversationId;
this.shouldReconnect = true; this.shouldReconnect = true;
this.setStatus("connecting"); 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); const ws = new WebSocket(url);
ws.onopen = () => { ws.onopen = () => {
@@ -133,7 +139,7 @@ export class CamTalkWebSocket {
this.reconnectTimer = setTimeout(() => { this.reconnectTimer = setTimeout(() => {
this.reconnectAttempt++; this.reconnectAttempt++;
this.connect(this.token); this.connect(this.token, this.conversationId);
}, totalDelay); }, totalDelay);
} }

View File

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