- Update device_interaction views - Update admin README and CLAUDE.md - Add affinity system design doc - Add device chat record subtitle storage scheme doc Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
15 KiB
设备端聊天记录上报功能 — 修改计划(方案 B:服务器端字幕落库)
Context
设备端 Unity 项目(LTY_Project,运行在 RK3588 上)需要把"用户在设备前与 Lila 的语音对话"以文字形式落到服务器,让手机端 App 能在原有 /api/ai/rtc-chat-history/ 接口里看到。不需要在设备屏幕显示文字,也不需要打字输入。
调研后发现:火山引擎对话式 AI 的"服务端字幕回调"链路已经全部搭好且在跑:
- 设备端 getJson.cs:70-72 已经在 RTC AgentConfig 里配置了
EnableConversationStateCallback=true和ServerMessageURLForRTS=httpBaseUrl+"api/device/rtc-token/conversation_status/"—— 火山引擎会把房间内 ASR 字幕(用户和 AI 的)和会话状态推到这个 URL。 - 服务器 device_interaction/views.py:1199-1421 的
conversation_statusaction 已经在接收并解析两种二进制格式:subv(字幕格式,views.py:1258-1302):包含text/userId/definite/paragraph/sequence/language/mode/timestamp—— 其中text就是用户和 AI 的对话文本。当前只 log 出来,没有写入数据库。conv(对话状态格式,views.py:1309-1376):包含TaskId/UserID/RoundID/Stage.Code/Description—— 已经在通过 WebSocket 转推给设备/手机驱动状态机。
所以方案 B 的本质是:只在服务器端 conversation_status 接口的 subv 分支里加几十行写库逻辑,把字幕文本作为 ChatMessage 写到与手机端相同的表里。设备端、手机端、火山引擎一律不动。
关键设计决策(已与用户确认)
- 设备端零改动:不复制 ASR 客户端、不挂 ChatLogManager、不改 LoginRTC、不改 WebSocketConnection。
- 手机端零改动:手机端 App 现有的
GET /api/ai/rtc-chat-history/?page_size=50在服务器开始落库后会自然返回设备端产生的消息(与手机端 App 自己跟 Lila 聊天的记录混在同一个 user 名下,与之前用户确认的"不加 device_mac 字段"一致)。 - bot 关联:设备端字幕也归到
RTC_Voice_Agent这个 Bot 名下(与手机端 App 用同一个 Bot),保证 RTCChatHistoryAPIView 的filter(user=request.user, bot=bot)查询直接覆盖。 - 落库时机:每条字幕到达就处理,仅在判定为"一句话最终结果"时写入
ChatMessage。
RTC userId ↔ ParadiseUser 映射(关键事实)
从 device_interaction/views.py:1142 可知,get_by_mac 返回的 RTC user_id 就是 str(user_device.user.id)(ParadiseUser 主键的字符串形式)。这传给设备端 getJson.cs:67 的 targetUserIds.Add(UserId),最终火山字幕回调里 subtitle.userId 字段:
userId == "bot01"→ AI 说的话(assistant),来自 getJson.cs:69agentConfig["UserId"] = "bot01"的硬编码userId == "<纯数字字符串>"→ 用户说的话,该数字就是 ParadiseUser.id
这意味着用户消息可以直接从字幕字段反查 ParadiseUser,无需额外映射。
AI 消息(bot01)的 user 归属需要别的方法解决 —— 见下面"AI 字幕 user 归属"小节。
服务器端 — 核心修改
唯一改动文件:C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py
1. 修改 conversation_status action 的 subv 分支(views.py:1258-1302)
当前代码:解析出 subtitle_items 列表后只构建 response_data 返回。
需要在 subtitle_items 构建后,紧接着遍历每条 subtitle 并按下面的规则写入 ChatMessage:
# 伪代码(详细见执行阶段)
for item in subtitle_items:
text = item['text'].strip()
user_id_in_subtitle = item['userId']
is_definite = item['definite']
is_paragraph_end = item['paragraph']
sequence = item['sequence']
if not text:
continue
# 只在"一句话结束"时写库(见下面"流式字幕去重"小节)
if not (is_definite and is_paragraph_end):
# 中间结果先在 Redis 里累积,不落库
_accumulate_partial_subtitle(task_id_or_round_key, user_id_in_subtitle, text, sequence)
continue
# 反查 ParadiseUser
if user_id_in_subtitle == 'bot01':
sender = ChatMessage.SENDER_BOT # 'assistant'
paradise_user = _resolve_user_for_bot_subtitle(...) # 见 AI 字幕 user 归属
else:
sender = ChatMessage.SENDER_USER # 'user'
try:
paradise_user = ParadiseUser.objects.get(id=int(user_id_in_subtitle))
except (ParadiseUser.DoesNotExist, ValueError):
logger.warning('subtitle userId %s 无法解析为 ParadiseUser,跳过', user_id_in_subtitle)
continue
if paradise_user is None:
continue
# 拿 RTC Bot
try:
bot = Bot.objects.get(name='RTC_Voice_Agent')
except Bot.DoesNotExist:
logger.error('RTC_Voice_Agent Bot 未配置,跳过字幕落库')
continue
# 拼接累积的中间结果(如果之前累积过)
final_text = _flush_accumulated_subtitle(task_id_or_round_key, user_id_in_subtitle, text)
ChatMessage.objects.create(
user=paradise_user,
bot=bot,
message=final_text,
sender=sender,
message_type=ChatMessage.MESSAGE_TYPE_TEXT,
)
实际选择"直接落库 vs 累积后落库"的策略由执行阶段在第一次跑通后看真实字幕事件流来定 —— 见下面"流式字幕去重"小节的两种策略。
2. AI 字幕 user 归属(关键难点)
字幕外层 JSON {message, binary, signature} 里没有 task_id(views.py:1232-1234)。subv 二进制内部解析出来也只有 userId/text/sequence/...,也没有 task_id(views.py:1281-1291)。所以"userId='bot01' 时归属哪个用户"需要外部上下文。
首选方案:用最近一次 conv 状态回调的 user_id 作为上下文
conv 状态回调在 views.py:1333-1334 已经解析出 TaskId 和 UserID(这里的 UserID 来自火山,等于 ParadiseUser.id 的字符串)。在 conv 分支处理时把 (TaskId, UserID, RoundID) 写到 Redis short-TTL(如 30 秒),key 形如 rtc_active_round:<TaskId> value={user_id, round_id, last_seen_ts}。
但字幕回调没有 task_id,没法按 task_id 反查 —— 退而求其次:维护一个全局"最近活跃用户"的列表(按时间倒序),AI 字幕到达时取距其最近的那个用户。这在并发设备数较少时有效;如果同时有多个设备 RTC 房间在跑,需要更精细的关联。
更稳妥的备选方案:在 _resolve_user_for_bot_subtitle 里查 Redis
由于 get_by_mac 已经把 rtc_room:{user_id}:{task_id} 写到 Redis(views.py:1169),可以在这里额外加一份反向索引 rtc_active_user:bot01_last:{round_or_task} → user_id,由 conv 分支负责更新。
最稳妥的兜底方案:等火山回调 URL 实测
火山引擎 RTS webhook 可能在 URL query string 里带 ?task_id=xxx(很多服务端 webhook 都这么干),或者在 HTTP header 里带(如 X-Volc-Task-Id)。设备端配置的 URL 是固定的 api/device/rtc-token/conversation_status/(无 query),但火山服务器实际调用时是否会附加,需要在生产日志(logger.info('JSON data: %s', json_data) views.py:1229 已经在打)里观察。
执行阶段第一步是先打开生产日志看一次真实的 subv 字幕回调 raw payload,确认能不能直接拿到 task_id 或类似关联字段。如果能,整个映射问题秒杀;如果不能,用 Redis 维护"最近活跃 task→user 映射"。
3. 流式字幕去重 / "一句话最终"判定
火山引擎对话式 AI 字幕是流式的,同一句话会推多条 definite=false 中间结果,最后推 definite=true 最终结果;多句话之间用 paragraph=true 标记段落结束。
参考火山官方文档(views.py:1206 注释里给的 https://www.volcengine.com/docs/6348/1415216)的字段定义:
definite=true:当前 utterance 已确定paragraph=true:当前段落(可能含多个 utterance)已结束sequence:序号,单调递增
两种落库策略,执行阶段二选一:
策略 A(简单):只在 definite=true && paragraph=true 时把 text 直接落库。优点:实现简单、不需要状态。缺点:火山的 paragraph 颗粒度可能太大(一段话含好几个完整意群),落库的 message 可能很长;或者反过来颗粒度太小(一个 utterance 就是一段),导致同一轮对话被拆成多条。
策略 B(拼接):用 Redis 的 List/String 累积同一对话回合(按 task_id+userId+round_key)的所有 definite=true text,遇到 paragraph=true 时把累积结果拼接成一条 ChatMessage 落库。需要超时清理(如累积 60 秒还没收到 paragraph_end 就强制落库)。
执行阶段第一步先用策略 A 跑通,观察 Unity Editor + 服务器日志里的真实字幕颗粒度,再决定要不要切策略 B。
4. (可选轻量优化)get_by_mac 写一份反向索引
修改 views.py:1167-1184 在已有 cache.set(redis_key, token_data, expire_time) 后追加:
# 反向索引:task_id → user_id,用于字幕回调中 bot01 字幕的 user 归属
cache.set(f"rtc_task_user:{task_id}", user_id, expire_time)
这样 conversation_status 处理 conv 回调时能 cache.get(f"rtc_task_user:{task_id}") 直接拿到 user_id,不依赖时序假设。
只在执行阶段确认"火山字幕回调里有 task_id 关联"时这步才有意义;如果没有 task_id,这层映射救不了字幕分支(字幕分支根本不知道当前 task_id 是哪个)。
验证
按顺序:
1. 摸火山真实字幕回调的 schema(最关键的第一步)
在不改任何代码的情况下,先收集一次真实字幕事件:
- 服务器开 DEBUG 日志,确认 views.py:1229
logger.info('JSON data: %s', json_data)和 views.py:1275logger.info('Parsed subtitle data: %s', subtitle_data)都能输出 - 设备端真机或 Editor Play → 进入 Game 场景 → RTC 加房 → 对着麦说一句话
- 看服务器日志里:
- 外层 JSON 除了
message/binary/signature还有没有别的字段(可能藏着 task_id) - HTTP 请求 URL(可能带
?task_id=)和 headers(可能带X-Volc-*)—— 需要在 view 函数最顶端临时加logger.info('headers=%s, query=%s', dict(request.headers), request.query_params.dict()) - subtitle_data 内的所有字段,特别是
userId='bot01'时有没有别的字段能关联到 user
- 外层 JSON 除了
这一步直接决定第 2 节"AI 字幕 user 归属"用哪种方案。
2. 改代码 + 跑通用户消息
先实现"用户消息"分支(subtitle.userId 是数字 → ParadiseUser)—— 这部分不依赖 task_id 映射,最容易跑通。
- 改完后用
python manage.py runserver起本地服务 - 设备端切到本地(RootManager.cs:30
LocalTest=true) - Editor Play 加 RTC 房 → 说一句话 → 在 Django shell 里
ChatMessage.objects.filter(sender='user').order_by('-id')[:5]看是否有新记录
3. 跑通 AI 消息
按第 1 步实测结果选第 2 节的方案,实现 bot01 → user 归属。
然后让 Lila 回一句话,验证 ChatMessage.objects.filter(sender='assistant').order_by('-id')[:5]。
4. 三端联调
用手机端 App 登录绑定该设备的同一个用户账号,进入聊天记录界面,确认能看到刚才设备前对话的字幕(user 和 assistant 各一条)。
5. 真机验证 + 长稳
烧 APK 到 RK3588,对话 5-10 分钟,看:
- ChatMessage 表数量是否符合预期(无重复、无丢失)
- 服务器 error log 有没有写库失败(数据库唯一约束、字段长度等)
- ChatMessage.message 字段最大 2048(aiapp/models.py:39),超长字幕需要 truncate
6. 回归
确认设备端 RTC 视频/音频/对话状态机(aiState)一切正常 —— 因为只改了服务器端,设备端逻辑应该零影响。
需修改文件清单
服务器端(唯一改动):
C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py- 在
VolcEngineTokenViewSet.conversation_statusaction 的subv分支(第 1278-1302 行)追加字幕落库逻辑(约 30-50 行) - 顶部加
from aiapp.models import ChatMessage, Bot和from userapp.models import ParadiseUser - 临时调试期间加 headers / query 的 logger.info(第 1217 行 附近)—— 跑通后可保留或删除
- 可选:在
get_by_mac的 第 1184 行 后追加cache.set(f"rtc_task_user:{task_id}", user_id, expire_time) - 可选:在
conv分支 第 1356 行 附近追加"最近活跃 task→user"映射写入
- 在
服务器端不需要 migration(ChatMessage 表结构不变,复用现有字段)。
服务器端不需要确认 RTC_Voice_Agent Bot 存在 —— 已经被手机端用着,必然存在;执行时跑一次 Bot.objects.get(name='RTC_Voice_Agent') 验证一次。
设备端:无修改。 手机端:无修改。 火山引擎:无配置变化(设备端 getJson.cs 已经配置好了)。
风险与回滚
- 主要风险:字幕落库逻辑写错 → 数据库写脏数据。缓解:先在本地服务器跑,写完后用 SQL 批量 DELETE 测试期产生的脏数据再上正式环境。
- 回滚:只需
git revertviews.py 的那个 commit,因为没有 schema 改动。