Phase 3:Flows 接管网页文字路径(默认仍 legacy)
按 P1-Pipecat Flows Agent化与记忆改造交接.md 的 Phase 3 完成。 **语音路径保持 legacy**,文字路径可由 AI_AGENT_RUNTIME=flows 切换。 服务器实测通过工具、姓名、记忆三项验收,随后按文档要求改回 legacy。 新增 app/agent/flows_runtime.py - FlowsBridge:LLM 文本帧 → RTVI 消息 + 分句 TTS,产出与 legacy 的 _run_turn 一致(bot-llm-started → 逐 token bot-llm-text → stopped → bot-transcription / bot-output) - UserTextRecorder:截下用户原话供策略层判定语义等级 - build_flows_parts / make_flow_manager:拆成两段,解决 "FlowManager 要 worker、worker 要等管线" 的先后依赖 - 节点只用 task_messages,**不用 role_message**(避免两条 system 消息) - respond_immediately=False(不自动开口) - handler 用闭包捕获 ctx_factory,不用模块级全局(并发会话会串身份) 新增 app/agent/llm_service.py - 固化 Phase 1 spike 的选型:复用 DeepSeekLLMService,显式覆盖模型名 (Pipecat 默认的 deepseek-v4-flash 不存在) 配置 app/config.py - 新增 AgentConfig + AI_AGENT_RUNTIME,**默认 legacy** 改造 services/bot_server.py - EngineProcessor 加 owns_text:flows 模式下语音仍归它,文字帧往下传 - runtime_deps() 暴露 memory/identity/session/tts,两条路径共用同一份资源 (避免各建一份 SQLite 连接或各加载一次 TTS) - **_run_task 改用 PipelineRunner**:直接 task.run() 时 runner 没挂上, Flows 的工具执行会失败("runner is not set; call attach() first"), 异常被转成给 LLM 的通用错误串,默认日志看不到。这正是 Phase 1 spike 记录的坑 2,集成时又踩了一次。 新增 app/tests/test_flows_runtime.py(22 项,不依赖网络) 新增 deploy.sh - **不用 --delete**、一次只同步一个目录、显式排除清单。 起因见下。 客户端 client/test_client.py 加 --text 模式(RTVI send-text) - 信封字段实测校正两次:**是 id 不是 msg_id**,**data.content 不是 text**; 写错会被服务端以 "Invalid RTVI transport message" 拒掉且客户端无感知 - 等待条件改为"等到非静音音频",不能数音频块——WebRTC 空闲时也在推静音 服务器实测(2026-09-16) - legacy 文字:11.2s 音频 / 25 个有声段 - flows 文字:9.3s 音频 / 18 个有声段,首音约 2.8s - flows 记忆闭环:只说"记住我不吃香菜" → 工具写入 confirmed 记忆 → 数据库确认 #4 [confirmed] preference 饮食偏好=不吃香菜 - 称呼"林"从档案恢复 已知问题(留给 Phase 4/7) - Pipecat 报 "Both system_instruction and an initial system message in context are set" 警告。来源未查明(我只用了 task_messages)。 切回本地 vLLM 时可能 400,因为该后端拒绝多条 system 消息。 - Flows 路径暂不暴露 web_search(交接文档要求分阶段,搜索仍走 legacy)
This commit is contained in:
+56
-5
@@ -191,6 +191,26 @@ class ReplyRecorder:
|
||||
return (self.first_non_silent_at - started_at) * 1000
|
||||
|
||||
|
||||
def rtvi_send_text(channel, text: str, *, audio_response: bool = True) -> None:
|
||||
"""通过 data channel 发一条 RTVI send-text。
|
||||
|
||||
信封格式来自 pipecat 的 RTVI 模型(实测校正过两次):
|
||||
label='rtvi-ai',**字段是 id 不是 msg_id**,type='send-text',
|
||||
data.content(**是 content 不是 text**)。
|
||||
写错任何一个,服务端都会以 "Invalid RTVI transport message: Field required" 拒掉,
|
||||
而客户端看不到任何反馈——表现为"发了消息但助手毫无反应"。
|
||||
"""
|
||||
import uuid
|
||||
msg = {
|
||||
"label": "rtvi-ai",
|
||||
"id": uuid.uuid4().hex,
|
||||
"type": "send-text",
|
||||
"data": {"content": text,
|
||||
"options": {"audio_response": audio_response}},
|
||||
}
|
||||
channel.send(json.dumps(msg))
|
||||
|
||||
|
||||
def post_offer(server: str, sdp: str, type_: str) -> dict:
|
||||
"""把 offer 发给服务端,取回 answer。"""
|
||||
payload = json.dumps({"sdp": sdp, "type": type_, "pc_id": None,
|
||||
@@ -203,7 +223,7 @@ def post_offer(server: str, sdp: str, type_: str) -> dict:
|
||||
|
||||
|
||||
async def run(server: str, wav: Path | None, out: Path, *, connect_only: bool,
|
||||
timeout_s: float) -> int:
|
||||
timeout_s: float, texts: list[str] | None = None) -> int:
|
||||
pc = RTCPeerConnection(RTCConfiguration(iceServers=[]))
|
||||
recorder = ReplyRecorder(out)
|
||||
track = None
|
||||
@@ -235,6 +255,10 @@ async def run(server: str, wav: Path | None, out: Path, *, connect_only: bool,
|
||||
track = FileAudioTrack(wav)
|
||||
pc.addTrack(track)
|
||||
log.info("音源: %s(%d 采样点)", wav.name, len(track.samples))
|
||||
elif texts:
|
||||
# 文字模式:仍要一条音轨,否则 aiortc 不收集 ICE 候选
|
||||
pc.addTrack(SilentTrack())
|
||||
log.info("文字模式:将发送 %d 条文本", len(texts))
|
||||
else:
|
||||
# --connect-only 也要加一条(静音)音轨。
|
||||
# **不能省**:aiortc 的 offer 里没有媒体时完全不收集 ICE 候选,
|
||||
@@ -312,7 +336,31 @@ async def run(server: str, wav: Path | None, out: Path, *, connect_only: bool,
|
||||
log.info("✅ ICE 已连通(%s)", pc.connectionState)
|
||||
print(f" ✅ 连接建立,状态 {pc.connectionState}")
|
||||
|
||||
if track:
|
||||
if texts:
|
||||
# 等 data channel 就绪(服务端靠它判断客户端存活)
|
||||
for _ in range(60):
|
||||
if dc_state["ok"]:
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
log.info("data channel: %s", "已就绪" if dc_state["ok"] else "未就绪")
|
||||
for t in texts:
|
||||
await asyncio.sleep(0.5)
|
||||
rtvi_send_text(channel, t)
|
||||
log.info("已发送文本: %r", t)
|
||||
# 等**非静音**音频出现。
|
||||
# 不能用"收到多少块"来判断——WebRTC 输出轨空闲时也在推静音,
|
||||
# 块数会立刻涨上去,导致客户端在服务端还在生成时就断开
|
||||
# (服务端日志表现为"本轮被取消",客户端表现为"没反应")。
|
||||
recorder.first_non_silent_at = None
|
||||
for _ in range(int(timeout_s * 10)):
|
||||
if recorder.has_speech:
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
log.info("本轮 %s", "收到语音回复" if recorder.has_speech else "未收到语音")
|
||||
# 让它把话说完
|
||||
await asyncio.sleep(4)
|
||||
await asyncio.sleep(2)
|
||||
elif track:
|
||||
# 等音频放完 + 留出响应时间
|
||||
t0 = time.perf_counter()
|
||||
while not track.finished and time.perf_counter() - t0 < timeout_s:
|
||||
@@ -350,13 +398,16 @@ def main() -> int:
|
||||
p.add_argument("--wav", type=Path)
|
||||
p.add_argument("--out", type=Path, default=Path("/tmp/client_reply.wav"))
|
||||
p.add_argument("--connect-only", action="store_true")
|
||||
p.add_argument("--text", action="append", default=None,
|
||||
help="通过 RTVI 发送文本(可重复),用于测 flows 文字路径")
|
||||
p.add_argument("--timeout", type=float, default=30.0)
|
||||
a = p.parse_args()
|
||||
if not a.connect_only and not a.wav:
|
||||
print("需要 --wav 或 --connect-only", file=sys.stderr)
|
||||
if not a.connect_only and not a.wav and not a.text:
|
||||
print("需要 --wav / --text / --connect-only", file=sys.stderr)
|
||||
return 2
|
||||
return asyncio.run(run(a.server, a.wav, a.out,
|
||||
connect_only=a.connect_only, timeout_s=a.timeout))
|
||||
connect_only=a.connect_only, timeout_s=a.timeout,
|
||||
texts=a.text))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user