Files
life-echo/api/routers/conversations.py
Sully c2ce4c61f1 修复版本1.0.7的若干问题 (#11)
* fix/ 0:00 audio ui

* fix/ persist memoir image state and collapse voice history

Keep generated chapter images from staying in processing after successful uploads, and restore segmented voice recordings as a single audio message when reopening conversations.

Made-with: Cursor

* fix/ persist local conversation state and stabilize voice UI

Keep CreateMemory conversations driven by Room so recent text and audio survive page exits, and prevent stale 0:00 voice bubbles while list ordering follows the latest local message time.

Made-with: Cursor

* fix/ server-side root cause for conversation list time and message timestamps

- Add Conversation.last_message_at column with migration and index
- Update last_message_at on text message, audio segment, and AI response
- Sort conversation list by COALESCE(last_message_at, started_at) DESC
- Return real per-message timestamps from Redis history instead of now()
- Pass user_message_timestamp through agent pipeline to avoid LLM delay skew
- Remove all debug logging from server, client, and CI workflow
- Restore import json in conversation_agent (was broken by debug removal)
- Client: remove DebugRuntimeLogger, stop sending transcript as text message

Made-with: Cursor

---------

Co-authored-by: Kevin <kevin@brighteng.org>
2026-03-14 23:58:46 +08:00

328 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
对话相关 API 路由
"""
from datetime import datetime, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Body
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import func, select
import uuid
from database import get_async_db, Conversation, Segment, User
from database.models import Conversation as ConversationModel, Segment as SegmentModel
from middleware.auth import get_current_user
from database.models import User as UserModel
router = APIRouter(prefix="/api/conversations", tags=["conversations"])
def _datetime_to_timestamp_ms(value: datetime | None) -> int:
if value is None:
return int(datetime.now(timezone.utc).timestamp() * 1000)
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return int(value.timestamp() * 1000)
def _message_timestamp_ms(msg: dict, fallback: datetime | None) -> int:
raw_timestamp = msg.get("timestamp")
if isinstance(raw_timestamp, (int, float)):
return int(raw_timestamp)
if isinstance(raw_timestamp, str):
try:
return int(datetime.fromisoformat(raw_timestamp.replace("Z", "+00:00")).timestamp() * 1000)
except ValueError:
pass
return _datetime_to_timestamp_ms(fallback)
def _latest_message_time_ms(conversation: ConversationModel, history: list[dict]) -> int:
if conversation.last_message_at:
return _datetime_to_timestamp_ms(conversation.last_message_at)
if history:
return _message_timestamp_ms(history[-1], conversation.started_at)
return _datetime_to_timestamp_ms(conversation.started_at)
def _build_messages_from_history(
conversation_id: str,
history: list[dict],
fallback_timestamp: datetime | None,
) -> list[dict]:
messages: list[dict] = []
seen_audio_sessions: set[str] = set()
for idx, msg in enumerate(history):
role = msg.get("role")
message_type = msg.get("messageType", "text")
voice_session_id = msg.get("voiceSessionId")
if role == "human" and message_type == "audio" and voice_session_id:
if voice_session_id in seen_audio_sessions:
continue
seen_audio_sessions.add(voice_session_id)
messages.append(
{
"id": f"{conversation_id}_msg_{idx}",
"conversationId": conversation_id,
"content": msg.get("content", ""),
"senderType": "user" if role == "human" else "assistant",
"timestamp": _message_timestamp_ms(msg, fallback_timestamp),
"messageType": message_type,
}
)
return messages
@router.get("")
async def get_conversations(
current_user: UserModel = Depends(get_current_user),
db: AsyncSession = Depends(get_async_db)
):
"""获取当前用户的所有对话列表(需要认证)"""
stmt = select(ConversationModel).where(
ConversationModel.user_id == current_user.id
).order_by(func.coalesce(ConversationModel.last_message_at, ConversationModel.started_at).desc())
result = await db.execute(stmt)
conversations = result.scalars().all()
# 转换为列表项格式
from services.redis_service import redis_service
conversation_list = []
for conv in conversations:
# 从Redis获取最新消息预览
latest_message = None
history: list[dict] = []
try:
history = await redis_service.get_conversation_history(conv.id)
if history:
latest_message = history[-1].get("content", "")[:50] # 取前50个字符
except Exception:
pass
conversation_list.append({
"id": conv.id,
"title": conv.summary[:30] if conv.summary else "岁月知己", # 使用summary作为标题如果没有则使用默认标题
"avatarUrl": None,
"latestMessagePreview": latest_message or conv.summary,
"latestMessageTime": _latest_message_time_ms(conv, history),
"unreadCount": 0,
"isDefaultAssistant": conv.summary is None # 如果没有summary则认为是默认助手
})
return conversation_list
@router.post("")
async def create_conversation(
current_user: UserModel = Depends(get_current_user),
db: AsyncSession = Depends(get_async_db)
):
"""创建新对话(需要认证)。对话轮数在每次发送消息时校验。"""
conversation = ConversationModel(
id=str(uuid.uuid4()),
user_id=current_user.id,
started_at=datetime.now(timezone.utc),
status="active"
)
db.add(conversation)
await db.commit()
await db.refresh(conversation)
return {
"id": conversation.id,
"user_id": conversation.user_id,
"started_at": conversation.started_at.isoformat(),
"status": conversation.status
}
@router.get("/{conversation_id}")
async def get_conversation(
conversation_id: str,
current_user: UserModel = Depends(get_current_user),
db: AsyncSession = Depends(get_async_db)
):
"""获取对话详情(需要认证,只能访问自己的对话)"""
conversation = await db.get(ConversationModel, conversation_id)
if not conversation:
raise HTTPException(status_code=404, detail="Conversation not found")
# 验证用户权限
if conversation.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权访问此对话")
return {
"id": conversation.id,
"user_id": conversation.user_id,
"started_at": conversation.started_at.isoformat(),
"ended_at": conversation.ended_at.isoformat() if conversation.ended_at else None,
"duration_seconds": conversation.duration_seconds,
"summary": conversation.summary,
"status": conversation.status,
"current_topic": conversation.current_topic,
"conversation_stage": conversation.conversation_stage
}
@router.post("/{conversation_id}/end")
async def end_conversation(
conversation_id: str,
current_user: UserModel = Depends(get_current_user),
db: AsyncSession = Depends(get_async_db)
):
"""结束对话(需要认证,只能结束自己的对话)"""
conversation = await db.get(ConversationModel, conversation_id)
if not conversation:
raise HTTPException(status_code=404, detail="Conversation not found")
# 验证用户权限
if conversation.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权操作此对话")
conversation.status = "ended"
conversation.ended_at = datetime.now(timezone.utc)
if conversation.started_at:
duration = (conversation.ended_at - conversation.started_at).total_seconds()
conversation.duration_seconds = int(duration)
await db.commit()
return {
"id": conversation.id,
"status": conversation.status,
"ended_at": conversation.ended_at.isoformat(),
"duration_seconds": conversation.duration_seconds
}
@router.delete("/{conversation_id}")
async def delete_conversation(
conversation_id: str,
current_user: UserModel = Depends(get_current_user),
db: AsyncSession = Depends(get_async_db)
):
"""删除对话(需要认证,只能删除自己的对话)"""
conversation = await db.get(ConversationModel, conversation_id)
if not conversation:
raise HTTPException(status_code=404, detail="Conversation not found")
# 验证用户权限
if conversation.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权删除此对话")
# 删除Redis中的对话历史
from services.redis_service import redis_service
try:
await redis_service.clear_conversation_history(conversation_id)
except:
pass
# 删除数据库中的对话级联删除segments
await db.delete(conversation)
await db.commit()
return {"message": "对话已删除"}
@router.get("/{conversation_id}/messages")
async def get_messages(
conversation_id: str,
current_user: UserModel = Depends(get_current_user),
db: AsyncSession = Depends(get_async_db)
):
"""获取对话的消息列表(需要认证,只能访问自己的对话)"""
# 验证对话存在且属于当前用户
conversation = await db.get(ConversationModel, conversation_id)
if not conversation:
raise HTTPException(status_code=404, detail="Conversation not found")
if conversation.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权访问此对话")
# 从Redis获取消息历史
from services.redis_service import redis_service
try:
history = await redis_service.get_conversation_history(conversation_id)
return _build_messages_from_history(
conversation_id=conversation_id,
history=history,
fallback_timestamp=conversation.started_at,
)
except Exception:
# 如果Redis中没有数据返回空列表
return []
@router.post("/{conversation_id}/organize")
async def organize_conversation(
conversation_id: str,
current_user: UserModel = Depends(get_current_user),
db: AsyncSession = Depends(get_async_db)
):
"""
整理对话内容成章节(需要认证,只能整理自己的对话)
手动触发对话整理,将对话中的内容整理成回忆录章节
"""
import logging
logger = logging.getLogger(__name__)
# 验证对话存在且属于当前用户
conversation = await db.get(ConversationModel, conversation_id)
if not conversation:
raise HTTPException(status_code=404, detail="Conversation not found")
if conversation.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权操作此对话")
# 获取所有未处理的段落
stmt = select(SegmentModel).where(
SegmentModel.conversation_id == conversation_id,
SegmentModel.processed == False
)
result = await db.execute(stmt)
segments = result.scalars().all()
if not segments:
# 如果没有未处理的段落,尝试处理所有段落
stmt = select(SegmentModel).where(
SegmentModel.conversation_id == conversation_id
)
result = await db.execute(stmt)
segments = result.scalars().all()
if not segments:
raise HTTPException(status_code=400, detail="该对话没有可整理的内容")
# 免费版仅允许 1 个章节整理Pro/Pro+ 无限制
from routers.quota import get_chapter_count, check_can_submit_organize
chapter_count = await get_chapter_count(current_user.id, db)
can_submit, quota_message = check_can_submit_organize(
current_user.subscription_type, chapter_count
)
if not can_submit:
raise HTTPException(status_code=403, detail=quota_message)
# 提交到Celery任务处理
try:
from routers.websocket import manager
from tasks.memoir_tasks import process_memoir_segments
segment_ids = [seg.id for seg in segments]
process_memoir_segments.delay(conversation.user_id, segment_ids)
logger.info(f"手动触发对话整理: conversation_id={conversation_id}, segments={len(segment_ids)}")
return {
"message": "对话整理任务已提交",
"conversation_id": conversation_id,
"segments_count": len(segment_ids)
}
except Exception as e:
logger.error(f"提交整理任务失败: {e}")
raise HTTPException(status_code=500, detail=f"提交整理任务失败: {str(e)}")