feat: v2 版本 #206
1
.gitignore
vendored
1
.gitignore
vendored
@@ -24,3 +24,4 @@ Thumbs.db
|
||||
|
||||
# ---- Obsidian ----
|
||||
.obsidian/
|
||||
.claudian/sessions/conv-1781943335504-q62bzosye.meta.json
|
||||
|
||||
@@ -46,7 +46,6 @@ func (e *EinoOrchestrator) ProcessQuery(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
req models.WsQuery,
|
||||
history []models.Message,
|
||||
sender orchestrator.Sender,
|
||||
) error {
|
||||
log := logger.Log
|
||||
@@ -112,15 +111,7 @@ func (e *EinoOrchestrator) ProcessQuery(
|
||||
ctx = WithStartTime(ctx, startTime)
|
||||
ctx = WithPipelineState(ctx, genLocalState(ctx))
|
||||
|
||||
// 6. 追加用户消息到历史
|
||||
if req.Text != "" {
|
||||
_ = e.sessionMgr.AppendMessage(ctx, sessionID, models.Message{
|
||||
Role: "user",
|
||||
Content: req.Text,
|
||||
})
|
||||
}
|
||||
|
||||
// 7. 调用 Graph(Stream 模式 + 运行时 Callback)
|
||||
// 6. 调用 Graph(Stream 模式 + 运行时 Callback)
|
||||
streamReader, err := e.graph.Runnable.Stream(ctx, input, e.callbacks)
|
||||
if err != nil {
|
||||
log.Errorw("Graph Stream 启动失败", "error", err)
|
||||
@@ -133,7 +124,7 @@ func (e *EinoOrchestrator) ProcessQuery(
|
||||
return err
|
||||
}
|
||||
|
||||
// 8. 消费 StreamReader(触发整条链路执行,side effects 推送消息到客户端)
|
||||
// 7. 消费 StreamReader(触发整条链路执行,side effects 推送消息到客户端)
|
||||
var output PipelineOutput
|
||||
for {
|
||||
o, err := streamReader.Recv()
|
||||
@@ -147,12 +138,28 @@ func (e *EinoOrchestrator) ProcessQuery(
|
||||
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. 追加助手消息到历史
|
||||
if output.FullResponse != "" {
|
||||
_ = e.sessionMgr.AppendMessage(ctx, sessionID, models.Message{
|
||||
if err := e.sessionMgr.AppendMessage(ctx, sessionID, models.Message{
|
||||
Role: "assistant",
|
||||
Content: output.FullResponse,
|
||||
})
|
||||
}); err != nil {
|
||||
log.Errorw("追加助手消息到历史失败", "session", sessionID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
latency := time.Since(startTime).Milliseconds()
|
||||
|
||||
@@ -14,13 +14,11 @@ type Orchestrator interface {
|
||||
// ctx 用于整体超时和中断控制。
|
||||
// sessionID 用于会话管理和历史获取。
|
||||
// req 包含图像和音频数据。
|
||||
// history 是最近的对话历史。
|
||||
// sender 用于向客户端推送消息。
|
||||
ProcessQuery(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
req models.WsQuery,
|
||||
history []models.Message,
|
||||
sender Sender,
|
||||
) error
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ type ConversationSummary struct {
|
||||
Title string `json:"title"`
|
||||
LastMessage string `json:"last_message"`
|
||||
MessageCount int `json:"message_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
|
||||
@@ -142,11 +142,11 @@ func (m *MemoryManager) Create(ctx context.Context, userID string, config models
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
// Write-Through:异步写 PG
|
||||
// Write-Through:异步写 PG(使用 Background context,避免 HTTP 请求结束后 context 被取消)
|
||||
if m.sessRepo != nil {
|
||||
go func() {
|
||||
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,
|
||||
Config: cfgJSON, CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
@@ -204,11 +204,11 @@ func (m *MemoryManager) UpdateConfig(ctx context.Context, sessionID string, patc
|
||||
cfg := entry.session.Config
|
||||
m.mu.Unlock()
|
||||
|
||||
// Write-Through:异步更新 PG
|
||||
// Write-Through:异步更新 PG(使用 Background context)
|
||||
if m.sessRepo != nil {
|
||||
go func() {
|
||||
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)
|
||||
}
|
||||
}()
|
||||
@@ -233,10 +233,10 @@ func (m *MemoryManager) UpdateTitle(ctx context.Context, sessionID string, title
|
||||
entry.lastActive = time.Now()
|
||||
m.mu.Unlock()
|
||||
|
||||
// Write-Through:异步更新 PG
|
||||
// Write-Through:异步更新 PG(使用 Background context)
|
||||
if m.sessRepo != nil {
|
||||
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)
|
||||
}
|
||||
}()
|
||||
@@ -271,6 +271,7 @@ func (m *MemoryManager) ListByUser(ctx context.Context, userID string, page, siz
|
||||
list = append(list, ConversationSummary{
|
||||
ID: rec.ID,
|
||||
Title: rec.Title,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
UpdatedAt: rec.UpdatedAt,
|
||||
})
|
||||
sessionIDs = append(sessionIDs, rec.ID)
|
||||
@@ -320,6 +321,7 @@ func (m *MemoryManager) listByUserFromMemory(ctx context.Context, userID string,
|
||||
summary := ConversationSummary{
|
||||
ID: entry.session.ID,
|
||||
Title: entry.session.Title,
|
||||
CreatedAt: entry.session.CreatedAt,
|
||||
UpdatedAt: entry.lastActive,
|
||||
}
|
||||
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)
|
||||
|
||||
// 自动更新标题:首条 user 消息时,如果标题为默认值,自动更新为消息前 20 字符
|
||||
titleUpdated := false
|
||||
if msg.Role == "user" && entry.session.Title == models.DefaultSessionTitle {
|
||||
entry.session.Title = generateTitle(msg.Content)
|
||||
titleUpdated = true
|
||||
}
|
||||
|
||||
// 超过上限时裁剪,保留最新的 maxHistory 条
|
||||
@@ -406,13 +410,31 @@ func (m *MemoryManager) AppendMessage(_ context.Context, sessionID string, msg m
|
||||
now := time.Now()
|
||||
entry.lastActive = now
|
||||
entry.session.UpdatedAt = now
|
||||
|
||||
// 复制标题(释放锁后安全使用)
|
||||
persistTitle := entry.session.Title
|
||||
m.mu.Unlock()
|
||||
|
||||
// Write-Through:异步写冷存储,不阻塞调用方
|
||||
// Write-Through:消息同步写入 PostgreSQL(保证调用顺序 = 插入顺序,
|
||||
// 避免用户消息和 AI 消息的异步 goroutine 执行顺序不确定导致排序错乱)
|
||||
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() {
|
||||
if err := m.msgRepo.SaveMessage(context.Background(), sessionID, msg, 0); err != nil {
|
||||
logger.Log.Warnw("persist message failed", "session", sessionID, "error", err)
|
||||
if titleUpdated {
|
||||
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)
|
||||
m.mu.Unlock()
|
||||
|
||||
// Write-Through:异步删除 PG
|
||||
// Write-Through:异步删除 PG(使用 Background context)
|
||||
if m.sessRepo != nil {
|
||||
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)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -98,15 +98,13 @@ func ServeWS(sessionMgr session.Manager, orch orchestrator.Orchestrator, cfg *co
|
||||
heartbeatTimeout := time.Duration(cfg.Server.HeartbeatTimeout) * time.Second
|
||||
version := cfg.App.Version
|
||||
|
||||
maxHistory := cfg.Session.MaxHistory
|
||||
|
||||
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,
|
||||
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 错误) ---
|
||||
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)
|
||||
}
|
||||
|
||||
// 获取对话历史
|
||||
history, _ := client.sessionMgr.GetHistory(context.Background(), sessionID, maxHistory)
|
||||
|
||||
// 创建可取消的 context
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
client.mu.Lock()
|
||||
@@ -260,7 +255,7 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
||||
_ = 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)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -48,7 +48,6 @@ func (m *MockOrchestrator) ProcessQuery(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
req models.WsQuery,
|
||||
history []models.Message,
|
||||
sender orchestrator.Sender,
|
||||
) error {
|
||||
if m.Err != nil {
|
||||
|
||||
246
docs/conversation-history-bug-analysis.md
Normal file
246
docs/conversation-history-bug-analysis.md
Normal 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 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) => (
|
||||
<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 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 是核心,完成后对话历史功能即可正常工作。
|
||||
@@ -4,8 +4,11 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="CamTalk — 多模态实时 AI 视觉对话助手,通过摄像头和麦克风与 AI 自然交互" />
|
||||
<title>CamTalk — AI 视觉对话助手</title>
|
||||
<meta name="description" content="CamTalk - 多模态实时 AI 视觉对话助手,通过摄像头和麦克风与 AI 自然交互" />
|
||||
<title>CamTalk - AI 视觉对话助手</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700;800&family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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";
|
||||
@@ -11,7 +12,7 @@ import { ChatPanel } from "./components/ChatPanel";
|
||||
import { ConfigPanel } from "./components/ConfigPanel";
|
||||
import { SessionSidebar } from "./components/SessionSidebar";
|
||||
import { ToastContainer } from "./components/Toast";
|
||||
import { AuthPage } from "./components/AuthPage";
|
||||
import { LandingPage } from "./components/LandingPage";
|
||||
import { AuthProvider, useAuth } from "./lib/auth";
|
||||
import { loadConfig, loadTheme, saveTheme } from "./lib/storage";
|
||||
import { I18nContext, parseLocale, t } from "./lib/i18n";
|
||||
@@ -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(),
|
||||
@@ -232,7 +234,7 @@ function AppContent() {
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <AuthPage />;
|
||||
return <LandingPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -154,13 +154,13 @@ export function ChatPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, index) => (
|
||||
{messages.map((msg) => (
|
||||
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>
|
||||
</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">
|
||||
{msg.role === "user" ? t("chat.userLabel") : "AI"}
|
||||
</div>
|
||||
|
||||
1183
frontend/src/components/LandingPage/LandingPage.css
Normal file
1183
frontend/src/components/LandingPage/LandingPage.css
Normal file
File diff suppressed because it is too large
Load Diff
163
frontend/src/components/LandingPage/LoginModal.tsx
Normal file
163
frontend/src/components/LandingPage/LoginModal.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
// ============================================================
|
||||
// LoginModal — 登录 / 注册模态框
|
||||
// 职责:在 LandingPage 上弹出的认证表单,复用现有 auth 系统
|
||||
// ============================================================
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useAuth } from "../../lib/auth";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
|
||||
type AuthMode = "login" | "register";
|
||||
|
||||
interface LoginModalProps {
|
||||
initialMode?: AuthMode;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function LoginModal({ initialMode = "login", onClose }: LoginModalProps) {
|
||||
const { login, register } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const [mode, setMode] = useState<AuthMode>(initialMode);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 点击遮罩关闭
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === overlayRef.current) onClose();
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
// ESC 关闭
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
// 阻止 body 滚动
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (username.length < 3 || username.length > 64) {
|
||||
setError(t("auth.error.usernameLength"));
|
||||
return;
|
||||
}
|
||||
if (password.length < 8 || password.length > 72) {
|
||||
setError(t("auth.error.passwordLength"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
const fn = mode === "login" ? login : register;
|
||||
const result = await fn(username, password);
|
||||
setIsSubmitting(false);
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
}
|
||||
// 登录成功时 auth 状态更新,App 自动切到主界面,modal 自然消失
|
||||
},
|
||||
[username, password, mode, login, register, t]
|
||||
);
|
||||
|
||||
const switchMode = useCallback(() => {
|
||||
setMode((m) => (m === "login" ? "register" : "login"));
|
||||
setError("");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="lp-modal-overlay" ref={overlayRef} onClick={handleOverlayClick}>
|
||||
<div className="lp-modal" role="dialog" aria-modal="true">
|
||||
<div className="lp-modal__inner">
|
||||
<button type="button" className="lp-modal__close" onClick={onClose} aria-label="Close">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="lp-modal__header">
|
||||
<div className="lp-modal__logo">CamTalk</div>
|
||||
<div className="lp-modal__subtitle">{t("auth.subtitle")}</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="lp-modal__tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`lp-modal__tab ${mode === "login" ? "lp-modal__tab--active" : ""}`}
|
||||
onClick={() => { setMode("login"); setError(""); }}
|
||||
>
|
||||
{t("auth.login")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`lp-modal__tab ${mode === "register" ? "lp-modal__tab--active" : ""}`}
|
||||
onClick={() => { setMode("register"); setError(""); }}
|
||||
>
|
||||
{t("auth.register")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="lp-modal__field">
|
||||
<span className="lp-modal__field-label">{t("auth.username")}</span>
|
||||
<input
|
||||
type="text"
|
||||
className="lp-modal__field-input"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={t("auth.username.placeholder")}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="lp-modal__field">
|
||||
<span className="lp-modal__field-label">{t("auth.password")}</span>
|
||||
<input
|
||||
type="password"
|
||||
className="lp-modal__field-input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("auth.password.placeholder")}
|
||||
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <div className="lp-modal__error">{error}</div>}
|
||||
|
||||
<button type="submit" className="lp-modal__submit" disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? t("auth.submitting")
|
||||
: mode === "login"
|
||||
? t("auth.login")
|
||||
: t("auth.register")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="lp-modal__footer">
|
||||
{mode === "login" ? t("auth.noAccount") : t("auth.hasAccount")}
|
||||
<button type="button" className="lp-modal__link" onClick={switchMode}>
|
||||
{mode === "login" ? t("auth.register") : t("auth.login")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
390
frontend/src/components/LandingPage/index.tsx
Normal file
390
frontend/src/components/LandingPage/index.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
// ============================================================
|
||||
// LandingPage — 官网首页(含登录弹窗)
|
||||
// 职责:未登录用户的落地页,展示产品介绍并提供登录/注册入口
|
||||
// ============================================================
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { LoginModal } from "./LoginModal";
|
||||
import "./LandingPage.css";
|
||||
|
||||
type ModalMode = "login" | "register";
|
||||
|
||||
export function LandingPage() {
|
||||
const [modal, setModal] = useState<{ open: boolean; mode: ModalMode }>({
|
||||
open: false,
|
||||
mode: "login",
|
||||
});
|
||||
|
||||
const openModal = useCallback((mode: ModalMode) => {
|
||||
setModal({ open: true, mode });
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setModal((prev) => ({ ...prev, open: false }));
|
||||
}, []);
|
||||
|
||||
// ---- Scroll Reveal ----
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
observerRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add("lp-visible");
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.1, rootMargin: "0px 0px -40px 0px" }
|
||||
);
|
||||
|
||||
document.querySelectorAll(".lp-fade-in").forEach((el) => {
|
||||
observerRef.current?.observe(el);
|
||||
});
|
||||
|
||||
return () => observerRef.current?.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="landing-page">
|
||||
<div className="lp-bg-grid" />
|
||||
|
||||
{/* ========== Top Navigation ========== */}
|
||||
<nav className="lp-nav">
|
||||
<div className="lp-nav__logo">CamTalk</div>
|
||||
<div className="lp-nav__links">
|
||||
<a href="#problem">痛点</a>
|
||||
<a href="#features">特性</a>
|
||||
<a href="#scenes">场景</a>
|
||||
<a href="#tech">技术</a>
|
||||
</div>
|
||||
<div className="lp-nav__actions">
|
||||
<button type="button" className="lp-nav__btn lp-nav__btn--ghost" onClick={() => openModal("login")}>
|
||||
登录
|
||||
</button>
|
||||
<button type="button" className="lp-nav__btn lp-nav__btn--primary" onClick={() => openModal("register")}>
|
||||
免费注册
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ========== HERO ========== */}
|
||||
<section className="lp-hero">
|
||||
<div className="lp-container lp-hero-content">
|
||||
<div className="lp-hero-badge">
|
||||
<span className="lp-hero-badge__dot" />
|
||||
<span>XEngineers</span>
|
||||
</div>
|
||||
<h1>
|
||||
<span className="lp-gradient">CamTalk</span>
|
||||
<br />
|
||||
给 AI 装上眼睛和耳朵
|
||||
</h1>
|
||||
<p className="lp-hero__sub">
|
||||
多模态实时 AI 视觉对话助手。打开浏览器,对着摄像头说话,AI 实时理解画面和语音,以文字和语音同步回答你。
|
||||
</p>
|
||||
|
||||
<div className="lp-hero-actions">
|
||||
<button type="button" className="lp-cta-btn" onClick={() => openModal("register")}>
|
||||
立即体验 CamTalk
|
||||
</button>
|
||||
<div className="lp-hero-actions__links">
|
||||
<a href="https://www.bilibili.com/video/BV1dDJK6cE5S/" target="_blank" rel="noreferrer">
|
||||
路演视频
|
||||
</a>
|
||||
<a href="https://github.com/XEngineers/CamTalk" target="_blank" rel="noreferrer">
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Device Mockup - Double Bezel */}
|
||||
<div className="lp-hero-visual">
|
||||
<div className="lp-hero-visual__inner">
|
||||
<div className="lp-hero-visual__screen">
|
||||
<div className="lp-screen-left">
|
||||
<div className="lp-scan-line" />
|
||||
<div className="lp-camera-ring">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M23 7l-7 5 7 5V7z" />
|
||||
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lp-screen-right">
|
||||
<div className="lp-chat-bubble lp-chat-bubble--user">这道数学题怎么做?</div>
|
||||
<div className="lp-chat-bubble lp-chat-bubble--ai">
|
||||
这是一道二次方程求解题。观察方程 x² - 5x + 6 = 0,可以使用因式分解法……
|
||||
<div className="lp-typing">
|
||||
<span /><span /><span />
|
||||
</div>
|
||||
</div>
|
||||
<div className="lp-chat-bubble lp-chat-bubble--user">能用求根公式再算一遍吗?</div>
|
||||
<div className="lp-audio-wave">
|
||||
<span /><span /><span /><span /><span /><span /><span />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== PROBLEM ========== */}
|
||||
<section id="problem">
|
||||
<div className="lp-container">
|
||||
<div className="lp-section-header lp-fade-in">
|
||||
<div className="lp-section-header__tag">痛点分析</div>
|
||||
<h2>AI 能说会道,却看不到你眼前的世界</h2>
|
||||
<p>传统 AI 助手存在三大断层,割裂了自然交流的直觉</p>
|
||||
</div>
|
||||
<div className="lp-problem-grid">
|
||||
<div className="lp-problem-card lp-fade-in">
|
||||
<div className="lp-problem-card__icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>视觉断层</h3>
|
||||
<p>用户必须先拍照、保存、上传、再打字描述上下文,AI 才能「看到」画面。四步操作,割裂了自然交流的直觉。</p>
|
||||
</div>
|
||||
<div className="lp-problem-card lp-fade-in">
|
||||
<div className="lp-problem-card__icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>交互断层</h3>
|
||||
<p>面对外语菜单、数学公式、电路图等复杂内容,打字描述极其低效。用户脑中的问题转不成文字,AI 也就无法作答。</p>
|
||||
</div>
|
||||
<div className="lp-problem-card lp-fade-in">
|
||||
<div className="lp-problem-card__icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>成本断层</h3>
|
||||
<p>实时视频流 + 大模型推理的组合让 API 成本居高不下。传统方案月成本高达 $5,000,无法面向普通用户商业化。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== SOLUTION ========== */}
|
||||
<section id="solution">
|
||||
<div className="lp-container">
|
||||
<div className="lp-section-header lp-fade-in">
|
||||
<h2>看 + 听 + 说 = 真正理解你的 AI</h2>
|
||||
<p>CamTalk 用一个对话界面闭合所有断层</p>
|
||||
</div>
|
||||
<div className="lp-flow-wrapper lp-fade-in">
|
||||
<div className="lp-flow-steps">
|
||||
{[
|
||||
{ icon: "📷", label: "摄像头采集" },
|
||||
{ icon: "🧠", label: "边缘预处理" },
|
||||
{ icon: "🔌", label: "WebSocket" },
|
||||
{ icon: "⚡", label: "Eino 编排" },
|
||||
{ icon: "👁️", label: "视觉理解" },
|
||||
{ icon: "💬", label: "流式回复" },
|
||||
{ icon: "🔊", label: "语音输出" },
|
||||
].map((step, i, arr) => (
|
||||
<div key={i} style={{ display: "contents" }}>
|
||||
<div className="lp-flow-step">
|
||||
<div className="lp-flow-step__node">{step.icon}</div>
|
||||
<div className="lp-flow-step__label">{step.label}</div>
|
||||
</div>
|
||||
{i < arr.length - 1 && <div className="lp-flow-arrow">→</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== FEATURES ========== */}
|
||||
<section id="features">
|
||||
<div className="lp-container">
|
||||
<div className="lp-section-header lp-fade-in">
|
||||
<div className="lp-section-header__tag">核心亮点</div>
|
||||
<h2>五大技术创新</h2>
|
||||
<p>从端到端架构到成本控制,每一层都经过精心设计</p>
|
||||
</div>
|
||||
<div className="lp-features-grid">
|
||||
{[
|
||||
{
|
||||
num: "01", icon: "⚡",
|
||||
title: "流式并行推送",
|
||||
desc: "LLM 文本流与 TTS 音频流并行输出。用户先看到文字,紧接着听到语音,感知延迟低于 0.5 秒,接近真人对话节奏。",
|
||||
},
|
||||
{
|
||||
num: "02", icon: "🧠",
|
||||
title: "声明式 AI 编排",
|
||||
desc: "基于 CloudWeGo Eino Graph 的 7 节点 DAG 流水线(STT → History → ChatModel → Splitter → TTS),类型安全、可扩展、易测试。",
|
||||
},
|
||||
{
|
||||
num: "03", icon: "💰",
|
||||
title: "端云协同降本",
|
||||
desc: "浏览器端 VAD 语音检测 + 关键帧像素比较 + 混合采样策略,节省 70% 带宽,月成本从 $5,000 降至 $300,降幅 90%。",
|
||||
},
|
||||
{
|
||||
num: "04", icon: "🎯",
|
||||
title: "多场景智能模式",
|
||||
desc: "5 种 AI 角色(自由对话 / 模拟面试 / 英语老师 / 辩论对手 / 同声翻译)× 3 种视觉模式 × 观察模式,灵活覆盖学习与工作。",
|
||||
},
|
||||
{
|
||||
num: "05", icon: "🏗️",
|
||||
title: "生产级工程架构",
|
||||
desc: "三级存储自动降级(Memory → Redis → PostgreSQL)、JWT 双 token 认证、Docker Compose 一键部署、完善的错误处理与降级策略。",
|
||||
},
|
||||
].map((f) => (
|
||||
<div className="lp-feature-card lp-fade-in" key={f.num}>
|
||||
<div className="lp-feature-card__number">{f.num}</div>
|
||||
<div className="lp-feature-card__icon">
|
||||
{f.icon}
|
||||
</div>
|
||||
<h3>{f.title}</h3>
|
||||
<p>{f.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== USERS ========== */}
|
||||
<section id="users">
|
||||
<div className="lp-container">
|
||||
<div className="lp-section-header lp-fade-in">
|
||||
<h2>为每一类学习者而设计</h2>
|
||||
<p>无论你是在学英语、准备面试,还是想让 AI 帮你看看眼前的世界</p>
|
||||
</div>
|
||||
<div className="lp-users-grid">
|
||||
{[
|
||||
{ avatar: "🧑🎓", title: "语言学习者", desc: "对着课本或实物,与 AI 英语外教用英语自由对话,实时纠正语法和发音" },
|
||||
{ avatar: "💼", title: "面试准备者", desc: "开启模拟面试模式,AI 面试官通过摄像头观察你的表情与状态,给出针对性反馈" },
|
||||
{ avatar: "🌍", title: "跨境交流者", desc: "出国旅行时对着外文菜单、路牌实时翻译,AI 语音播报翻译结果" },
|
||||
{ avatar: "👁️", title: "视障人士", desc: "AI 实时描述摄像头画面中的环境、障碍物和文字,提供无障碍信息辅助" },
|
||||
{ avatar: "🔬", title: "学生 / 教师", desc: "对着题目问「怎么做?」,AI 看到画面后逐步讲解,就像身边有一位私教" },
|
||||
].map((u) => (
|
||||
<div className="lp-user-card lp-fade-in" key={u.title}>
|
||||
<div className="lp-user-card__avatar">{u.avatar}</div>
|
||||
<h4>{u.title}</h4>
|
||||
<p>{u.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== SCENES ========== */}
|
||||
<section id="scenes">
|
||||
<div className="lp-container">
|
||||
<div className="lp-section-header lp-fade-in">
|
||||
<h2>五种模式,覆盖真实需求</h2>
|
||||
</div>
|
||||
<div className="lp-scenes-list">
|
||||
{[
|
||||
{ icon: "💬", title: "自由对话", desc: "对着摄像头随意聊天,AI 实时理解画面并语音回答", tag: "通用" },
|
||||
{ icon: "🗣️", title: "英语老师", desc: "AI 外教结合摄像头场景进行英语口语教学,实时纠正语法", tag: "学习" },
|
||||
{ icon: "🎤", title: "模拟面试", desc: "AI 面试官根据你的回答追问,通过摄像头观察你的表现", tag: "求职" },
|
||||
{ icon: "⚔️", title: "辩论对手", desc: "AI 反驳你的观点,锻炼你的逻辑思维和表达能力", tag: "思维" },
|
||||
{ icon: "🌐", title: "同声翻译", desc: "实时识别画面中的外语文字并语音翻译,口语化输出", tag: "工具" },
|
||||
].map((s) => (
|
||||
<div className="lp-scene-row lp-fade-in" key={s.title}>
|
||||
<div className="lp-scene-row__icon">{s.icon}</div>
|
||||
<div>
|
||||
<h4>{s.title}</h4>
|
||||
<div className="lp-scene-row__desc">{s.desc}</div>
|
||||
</div>
|
||||
<div className="lp-scene-row__tag">
|
||||
{s.tag}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== METRICS ========== */}
|
||||
<section id="metrics">
|
||||
<div className="lp-container">
|
||||
<div className="lp-section-header lp-fade-in">
|
||||
<h2>用数据说话</h2>
|
||||
</div>
|
||||
<div className="lp-metrics-grid">
|
||||
{[
|
||||
{ value: "< 2s", label: "端到端响应延迟" },
|
||||
{ value: "90%", label: "API 成本降幅" },
|
||||
{ value: "70%", label: "带宽节省率" },
|
||||
{ value: "5+3", label: "场景 × 视觉模式" },
|
||||
].map((m) => (
|
||||
<div className="lp-metric-card lp-fade-in" key={m.label}>
|
||||
<div className="lp-metric-card__value">{m.value}</div>
|
||||
<div className="lp-metric-card__label">{m.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== TECH STACK ========== */}
|
||||
<section id="tech">
|
||||
<div className="lp-container">
|
||||
<div className="lp-section-header lp-fade-in">
|
||||
<h2>三层系统,生产级质量</h2>
|
||||
<p>前端轻量预处理 → Go 网关智能编排 → 云端 AI 按需调用</p>
|
||||
</div>
|
||||
<div className="lp-tech-layers">
|
||||
{[
|
||||
{ badge: "前端层", tags: ["React 18", "TypeScript", "Vite", "WebRTC VAD", "Canvas 关键帧检测", "WebSocket", "i18n (中/英/日)"] },
|
||||
{ badge: "网关层", tags: ["Go + Gin", "gorilla/websocket", "Eino Graph", "JWT 双 Token", "Zap 日志", "Viper 配置"] },
|
||||
{ badge: "存储层", tags: ["L1 Memory", "L2 Redis", "L3 PostgreSQL", "TieredManager 自动降级"] },
|
||||
{ badge: "AI 服务", tags: ["qwen3-vl-plus (LLM)", "MiMo ASR (STT)", "MiMo TTS", "Docker Compose"] },
|
||||
].map((layer) => (
|
||||
<div className="lp-tech-layer lp-fade-in" key={layer.badge}>
|
||||
<div>
|
||||
<div className="lp-tech-layer__badge">
|
||||
{layer.badge}
|
||||
</div>
|
||||
</div>
|
||||
<div className="lp-tech-layer__tags">
|
||||
{layer.tags.map((tag) => <span key={tag}>{tag}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== CTA ========== */}
|
||||
<section className="lp-cta-section">
|
||||
<div className="lp-container lp-fade-in">
|
||||
<h2>让 AI 看见你看见的世界</h2>
|
||||
<p>CamTalk,不只是聊天,而是真正的多模态视觉对话。</p>
|
||||
<button type="button" className="lp-cta-btn" onClick={() => openModal("register")}>
|
||||
立即体验 CamTalk
|
||||
</button>
|
||||
<div className="lp-cta-links">
|
||||
<a href="https://www.bilibili.com/video/BV1dDJK6cE5S/" target="_blank" rel="noreferrer">
|
||||
路演视频
|
||||
</a>
|
||||
<a href="https://github.com/XEngineers/CamTalk" target="_blank" rel="noreferrer">
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== Footer ========== */}
|
||||
<footer className="lp-footer">
|
||||
<div className="lp-container">
|
||||
CamTalk © 2026 XEngineers
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* ========== Login Modal ========== */}
|
||||
{modal.open && (
|
||||
<LoginModal initialMode={modal.mode} onClose={closeModal} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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<SessionSummary[]>(() => loadSessionSummaries());
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(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<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]);
|
||||
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<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((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<SessionSummary> = {
|
||||
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<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 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<ChatMessage[]> => {
|
||||
setActiveSessionId(id);
|
||||
return loadMessages(id);
|
||||
},
|
||||
[loadMessages]
|
||||
);
|
||||
|
||||
return {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
setActiveSessionId,
|
||||
isLoading,
|
||||
createSession,
|
||||
deleteSession,
|
||||
renameSession,
|
||||
persistSession,
|
||||
loadMessages,
|
||||
selectSession,
|
||||
loadSessions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<ChatMessage[]>([]);
|
||||
const [currentReply, setCurrentReply] = useState<string>("");
|
||||
@@ -44,9 +42,6 @@ export function useVisionSession(accessToken?: string | 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 }>>([]);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -208,3 +208,96 @@ export async function logout(
|
||||
body: JSON.stringify({ refresh_token: refreshTokenStr }),
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 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) }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export class CamTalkWebSocket {
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private shouldReconnect = true;
|
||||
private token: string | undefined;
|
||||
private conversationId: string | undefined;
|
||||
|
||||
private messageHandlers = new Set<MessageHandler>();
|
||||
private statusHandlers = new Set<StatusHandler>();
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface SessionSummary {
|
||||
// ---- 聊天消息 ----
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
imageUrl?: string;
|
||||
|
||||
Reference in New Issue
Block a user