Skip to content

Commit 1eddd8b

Browse files
committed
merge: sync main and preserve platform contract
2 parents 8b7c47c + 9c33221 commit 1eddd8b

30 files changed

Lines changed: 1098 additions & 142 deletions

main_logic/core.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4958,6 +4958,22 @@ async def _stream_data_now(self, message: dict):
49584958
# 在锁外检查是否需要创建新session(不要在锁内创建session,避免死锁)
49594959
if not self.session_ready and self._starting_session_count == 0:
49604960
if not self.session or not self.is_active:
4961+
# 音频 / 屏幕 / 摄像头都是持续流,只负责给已经存在(或由前端
4962+
# 显式 start_session 正在创建)的会话供数,不能把单个媒体帧
4963+
# 当成“用户要求启动会话”。
4964+
#
4965+
# API session 异常断开时,前端会停止录音并延迟发送一次
4966+
# start_session。屏幕采集可能在这段窗口内继续产生帧;旧逻辑把
4967+
# screen/camera/audio 都映射为 audio 并立即自动建连,抢在前端
4968+
# 恢复流程之前启动,且 cleanup 已清空 self.websocket,最终得到
4969+
# 一个无法向 UI 回传的孤儿会话。媒体流在这里直接丢弃,恢复入口
4970+
# 统一收口到显式 start_session;文本保留旧的兼容性自动启动。
4971+
if input_type != 'text':
4972+
logger.debug(
4973+
"Session未就绪,丢弃 %s 流数据并等待显式 start_session",
4974+
input_type,
4975+
)
4976+
return
49614977
# Memory Server 专属冷却检查
49624978
if self._emit_cooldown_turn_end_if_needed():
49634979
return
@@ -4966,9 +4982,7 @@ async def _stream_data_now(self, message: dict):
49664982
if self._session_start_circuit_open:
49674983
return
49684984
logger.info(f"Session未就绪且不存在,根据输入类型 {input_type} 自动创建 session")
4969-
# 根据输入类型确定模式
4970-
mode = 'text' if input_type == 'text' else 'audio'
4971-
await self.start_session(self.websocket, new=False, input_mode=mode)
4985+
await self.start_session(self.websocket, new=False, input_mode='text')
49724986

49734987
# 检查启动是否成功
49744988
if not self.session or not self.is_active:
@@ -4996,6 +5010,14 @@ async def _process_stream_data_internal(self, message: dict):
49965010

49975011
# 如果 session 不存在或不活跃,检查是否可以自动重建
49985012
if not self.session or not self.is_active:
5013+
# 与 _stream_data_now 的入口规则保持对偶:持续媒体帧不得自行
5014+
# 创建会话。这个二次守卫覆盖直接调用本方法、flush 及竞态窗口。
5015+
if input_type != 'text':
5016+
logger.debug(
5017+
"Session不存在或未激活,丢弃 %s 流数据并等待显式 start_session",
5018+
input_type,
5019+
)
5020+
return
49995021
# Memory Server 专属冷却检查
50005022
if self._emit_cooldown_turn_end_if_needed():
50015023
return
@@ -5024,9 +5046,7 @@ async def _process_stream_data_internal(self, message: dict):
50245046
self.sync_message_queue.put({'type': 'system', 'data': 'websocket disconnected'})
50255047
return
50265048

5027-
# 根据输入类型确定模式
5028-
mode = 'text' if input_type == 'text' else 'audio'
5029-
await self.start_session(self.websocket, new=False, input_mode=mode)
5049+
await self.start_session(self.websocket, new=False, input_mode='text')
50305050

50315051
# 检查启动是否成功
50325052
if not self.session or not self.is_active:

main_logic/omni_realtime_client.py

Lines changed: 61 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@
5050
# ── Proactive audio prompt cache ──────────────────────────────────────
5151
_PROACTIVE_AUDIO_DIR = Path(__file__).resolve().parent.parent / "static" / "proactive_audio"
5252
_PROACTIVE_AUDIO_CACHE: Dict[str, bytes] = {}
53+
_IMAGE_ANALYSIS_PENDING_CONTEXT = (
54+
"[实时屏幕截图或相机画面正在分析中。先不要瞎编内容,可以稍等片刻。"
55+
"在此期间不要用搜索功能应付。等收到画面分析结果后再描述画面。]"
56+
)
5357

5458

5559
def _load_proactive_audio(filename: str) -> bytes:
@@ -261,7 +265,7 @@ def __init__(
261265
self._image_recognized_this_turn = False
262266
self._image_sent_this_turn = False
263267
self._image_being_analyzed = False
264-
self._image_description = "[实时屏幕截图或相机画面正在分析中。先不要瞎编内容,可以稍等片刻。在此期间不要用搜索功能应付。等收到画面分析结果后再描述画面。]"
268+
self._image_description = _IMAGE_ANALYSIS_PENDING_CONTEXT
265269
self._latest_image_b64 = None # Cached latest screenshot for proactive injection
266270
self._proactive_image_consumed = True # Whether the cached image has been used by a proactive nudge
267271
self._proactive_injecting = False # True while prompt_ephemeral is injecting audio — suppresses mic input
@@ -1268,15 +1272,63 @@ async def _analyze_image_with_vision_model(self, image_b64: str) -> str:
12681272

12691273
except Exception as e:
12701274
logger.error(f"Error analyzing image with vision model: {e}")
1271-
self.image_recognized_this_turn = True
1272-
self._image_being_analyzed = False
1275+
self._image_recognized_this_turn = True
12731276
self._image_description = f"[实时屏幕截图或相机画面]: 分析出错: {str(e)}"
12741277
# 检测内容审查错误并发送中文提示到前端(不关闭session)
12751278
error_str = str(e)
12761279
if 'censorship' in error_str:
12771280
if self.on_status_message:
12781281
await self.on_status_message(json.dumps({"code": "IMAGE_BLOCKED"}))
12791282
return "图片识别发生严重错误!"
1283+
1284+
async def _stream_image_with_vision_model(self, image_b64: str) -> None:
1285+
"""Analyze one frame and inject both pending and ready context.
1286+
1287+
Realtime backends without native image input need a text description
1288+
from VISION_MODEL. The previous first-frame fast path only updated
1289+
``_image_description`` locally, so the realtime model could answer
1290+
before the description ever reached its conversation. Serialize the
1291+
expensive analysis and inject the completed description immediately;
1292+
this does not depend on another frame arriving while the user speaks.
1293+
"""
1294+
async with self._image_lock:
1295+
if self._image_recognized_this_turn:
1296+
return
1297+
1298+
self._image_being_analyzed = True
1299+
try:
1300+
pending_event = {
1301+
"type": "conversation.item.create",
1302+
"item": {
1303+
"type": "message",
1304+
"role": "user",
1305+
"content": [{
1306+
"type": "input_text",
1307+
"text": _IMAGE_ANALYSIS_PENDING_CONTEXT,
1308+
}],
1309+
},
1310+
}
1311+
logger.info("Sending image analysis pending context.")
1312+
await self.send_event(pending_event)
1313+
1314+
await self._analyze_image_with_vision_model(image_b64)
1315+
1316+
ready_event = {
1317+
"type": "conversation.item.create",
1318+
"item": {
1319+
"type": "message",
1320+
"role": "user",
1321+
"content": [{
1322+
"type": "input_text",
1323+
"text": self._image_description,
1324+
}],
1325+
},
1326+
}
1327+
logger.info("Sending image description after recognition.")
1328+
await self.send_event(ready_event)
1329+
self._image_sent_this_turn = True
1330+
finally:
1331+
self._image_being_analyzed = False
12801332

12811333
async def stream_image(self, image_b64: str) -> None:
12821334
"""Stream raw image data to the API."""
@@ -1285,9 +1337,12 @@ async def stream_image(self, image_b64: str) -> None:
12851337
self._proactive_image_consumed = False
12861338

12871339
try:
1288-
# Models without native vision (step, free on lanlan.tech) — first frame triggers VISION_MODEL analysis
1289-
if '实时屏幕截图或相机画面正在分析中' in self._image_description and not self._supports_native_image:
1290-
await self._analyze_image_with_vision_model(image_b64)
1340+
# Models without native vision (step, free on lanlan.tech) use
1341+
# VISION_MODEL text context. This path is independent of
1342+
# _audio_in_buffer so a completed analysis is available before
1343+
# the user's next utterance even while the microphone is idle.
1344+
if not self._supports_native_image:
1345+
await self._stream_image_with_vision_model(image_b64)
12911346
return
12921347

12931348
# Rate limiting for native image input (with VAD-based throttling)
@@ -1349,47 +1404,6 @@ async def stream_image(self, image_b64: str) -> None:
13491404
]
13501405
}
13511406
}
1352-
else:
1353-
# Model does not support video streaming, use VISION_MODEL to analyze
1354-
# Only recognize one image per conversation turn
1355-
async with self._image_lock:
1356-
if not self._image_recognized_this_turn:
1357-
if not self._image_being_analyzed:
1358-
self._image_being_analyzed = True
1359-
text_event = {
1360-
"type": "conversation.item.create",
1361-
"item": {
1362-
"type": "message",
1363-
"role": "user",
1364-
"content": [
1365-
{
1366-
"type": "input_text",
1367-
"text": self._image_description
1368-
}
1369-
]
1370-
}
1371-
}
1372-
logger.info("Sending image description before recognition.")
1373-
await self.send_event(text_event)
1374-
await self._analyze_image_with_vision_model(image_b64)
1375-
elif not self._image_sent_this_turn:
1376-
self._image_sent_this_turn = True
1377-
text_event = {
1378-
"type": "conversation.item.create",
1379-
"item": {
1380-
"type": "message",
1381-
"role": "user",
1382-
"content": [
1383-
{
1384-
"type": "input_text",
1385-
"text": self._image_description
1386-
}
1387-
]
1388-
}
1389-
}
1390-
logger.info("Sending image description after recognition.")
1391-
await self.send_event(text_event)
1392-
return
13931407

13941408
await self.send_event(append_event)
13951409
except Exception as e:

0 commit comments

Comments
 (0)