Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e3dd4d5
chore(realtime): 加日志排查连续对话回复重复的问题
LUPENGHAN Aug 27, 2026
9b3fe23
fix(realtime): 连续对话模式下丢弃 vendor 未经请求就自己起的 response
LUPENGHAN Aug 27, 2026
55bf846
fix(realtime): 调用工具前不开口的规则扩到所有工具,不只是改/删定位
LUPENGHAN Aug 27, 2026
c505125
fix(assistant): 连续对话模式加固两处跟 PTT 同款的乱序风险
LUPENGHAN Aug 27, 2026
6bb7ac0
fix(assistant): PTT 上滑取消不发 stream.end,导致后续按住说话永久失效
LUPENGHAN Aug 27, 2026
3d005a7
revert(realtime): prompt 精简实际用下来更难用,恢复到 #383 之前的完整版本
LUPENGHAN Aug 27, 2026
3f9a087
fix(realtime): 补两道 composed 已有、realtime 缺失的代码级闸门
LUPENGHAN Aug 27, 2026
fbbb58e
fix(realtime): 说了哪天但没说几点时问用户,不再自己套一个整点
LUPENGHAN Aug 27, 2026
e900e36
fix(realtime): 一次删除确认覆盖整批删除,不再每删一条都要重新确认
LUPENGHAN Aug 27, 2026
be0b8b1
fix(realtime): vendor 说「没有活跃 response」不再当致命错误挂断整通电话
LUPENGHAN Aug 27, 2026
88e0a79
fix(realtime): 复用 vendor 会话时清掉上一通电话残留的流状态
LUPENGHAN Aug 27, 2026
dc23491
fix(ws): agent 提前退出时也回收音频流,否则整条 WebSocket 会被卡死
LUPENGHAN Aug 27, 2026
8d30e52
fix(assistant): 打断之后不再把上一句的残片写进已经停掉的播放流
LUPENGHAN Aug 27, 2026
f856e5e
fix(assistant): 连续通话收到服务端报错时真正挂断,不再只显示一个错误
LUPENGHAN Aug 27, 2026
59c86d1
feat(realtime): 日志记下用户说了什么、模型给工具传了什么
LUPENGHAN Aug 27, 2026
58d6640
fix(assistant): 回复先于转写到达时不再多出一条永不消失的气泡
LUPENGHAN Aug 27, 2026
092ca53
fix(voice): 转写和回复带上 vendor 的输入 item id,轮次归属不再靠猜到达顺序
LUPENGHAN Aug 27, 2026
470ff74
perf(assistant): 播放期间少跟音频抢 JS 线程,减少 TTS 卡顿
LUPENGHAN Aug 28, 2026
bbdfd63
fix(assistant): 原生调用卡住不再让结束对话按不动、TTS 从此不出声
LUPENGHAN Aug 28, 2026
dcce73c
perf(realtime): 消歧提问不再让模型抄一遍日程原文,省下 7~11 秒
LUPENGHAN Aug 28, 2026
7017c0e
perf(assistant): 开播前给原生播放器攒 200ms 起跑余量,堵住每条回复开头的断音
LUPENGHAN Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions backend/src/timeflow/gateway/websocket/agent_ports.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ def duration_ms(self) -> int:
"""How long the audio ran."""
...

@property
def turn_id(self) -> str | None:
"""Which stretch of user speech this belongs to, when the producer knows."""
...


class ReplyTextProgress(Protocol):
"""How much of a reply's wording is known so far."""
Expand All @@ -95,6 +100,11 @@ def done(self) -> bool:
"""Whether this is the last update for this reply."""
...

@property
def turn_id(self) -> str | None:
"""Which stretch of user speech this belongs to, when the producer knows."""
...


class DialogueQuestionInfo(Protocol):
"""A question the agent needs answered before it can act."""
Expand Down Expand Up @@ -124,6 +134,11 @@ def candidates(self) -> tuple[dict[str, Any], ...]:
"""Choices the user is being asked to pick between, when there are any."""
...

@property
def turn_id(self) -> str | None:
"""Which stretch of user speech this belongs to, when the producer knows."""
...


class CommandOutcome(Protocol):
"""A command that was carried out, ready to be sent to the client."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ async def deliver_transcript(
transcript=transcript.text,
language=transcript.language,
duration_ms=transcript.duration_ms,
turn_id=transcript.turn_id,
),
)
await self._send(stream.session_id, message.type, message.model_dump())
Expand All @@ -90,6 +91,7 @@ async def deliver_reply_text(self, reply: ReplyTextProgress, stream: StreamIdent
reply_id=reply.reply_id,
speech_text=reply.speech_text,
done=reply.done,
turn_id=reply.turn_id,
),
)
await self._send(stream.session_id, message.type, message.model_dump())
Expand Down Expand Up @@ -123,6 +125,7 @@ async def deliver_question(
speech_text=question.speech_text,
required_response=question.required_response,
candidates=list(question.candidates),
turn_id=question.turn_id,
),
)
await self._send(stream.session_id, message.type, message.model_dump())
Expand Down
40 changes: 35 additions & 5 deletions backend/src/timeflow/gateway/websocket/handlers/voice_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,18 @@ async def handle_start(
# between this check and the assignment below.
active = self._active_streams.get(session.session_id)
if active is not None:
# Logged, not silent: a client that never sends voice.stream.end for an
# active stream (found on push-to-talk's swipe-to-cancel gesture, which
# closed the shared connection's local listener without ending the stream)
# gets every future voice.stream.start on this session rejected here, with
# nothing else in this file's logs to explain why.
logger.warning(
"rejected voice.stream.start: session_id=%s already has an active "
"stream stream_id=%s -- the client must send voice.stream.end (or "
"disconnect) before opening a new one",
session.session_id,
active.context.stream_id,
)
return self._error(request_id, "A stream is already active for this session", active)

payload = message.payload
Expand Down Expand Up @@ -239,17 +251,35 @@ async def _drain_to_sink(self, stream: _ActiveStream) -> None:
"stream_id": stream.context.stream_id,
},
)
self._retire_failed_stream(stream)
finally:
# Retired however consume() ended, not just when it raised: a sink may
# also give up by returning -- the realtime agent does exactly that when
# it cannot open a vendor session, and its pump returning early on a
# vendor error looks the same from here. Either way nothing drains the
# queue afterwards, so leaving the stream registered wedges the whole
# connection (see _retire_stream).
self._retire_stream(stream)

def _retire_failed_stream(self, stream: _ActiveStream) -> None:
"""Drop a stream whose consumer has died, so the session keeps working.
def _retire_stream(self, stream: _ActiveStream) -> None:
"""Drop a stream nobody is reading any more, so the session keeps working.

A no-op on the ordinary path, where voice.stream.end already took the stream
out before the sink finished with it.

Removing it alone is not enough: the receive loop may be parked on a full
queue with nobody left to drain it, and would stay there forever. Emptying
the queue releases it, and the next frame is refused instead of enqueued.
queue with nobody left to drain it, and would stay there forever -- including
for the voice.stream.end that is the client's only way out. Emptying the queue
releases it, and the next frame is refused instead of enqueued.
"""
session_id = stream.context.session.session_id
if self._active_streams.get(session_id) is stream:
logger.warning(
"retiring an audio stream its consumer walked away from: "
"session_id=%s stream_id=%s -- further frames will be refused until "
"the client starts a new stream",
session_id,
stream.context.stream_id,
)
self._active_streams.pop(session_id, None)
while not stream.queue.empty():
stream.queue.get_nowait()
Expand Down
4 changes: 4 additions & 0 deletions backend/src/timeflow/gateway/websocket/messages/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ class VoiceAsrCompletedPayload(BaseModel):
transcript: str
language: str
duration_ms: int
# Which stretch of user speech this transcribes, so the client can pair it with the
# reply answering it instead of guessing from arrival order. Optional: only the
# realtime backend has a vendor id to report, and older clients ignore it.
turn_id: str | None = None


class VoiceAsrCompleted(BaseModel):
Expand Down
4 changes: 4 additions & 0 deletions backend/src/timeflow/gateway/websocket/messages/dialogue.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ class VoiceDialogueReplyPayload(BaseModel):
reply_id: str
speech_text: str
done: bool = False
# Which stretch of user speech this answers; see VoiceAsrCompletedPayload.turn_id.
turn_id: str | None = None


class VoiceDialogueReply(BaseModel):
Expand All @@ -34,6 +36,8 @@ class VoiceDialogueQuestionPayload(BaseModel):
speech_text: str
required_response: str | None = None
candidates: list[dict[str, Any]] = []
# Which stretch of user speech this asks about; see VoiceAsrCompletedPayload.turn_id.
turn_id: str | None = None


class VoiceDialogueQuestion(BaseModel):
Expand Down
Loading
Loading