feat: add session updates and message dialogs
This commit is contained in:
@@ -74,6 +74,7 @@ from services.pipecat.processors import (
|
||||
PassthroughLLMAssistantAggregator,
|
||||
RealtimeDynamicVariableProcessor,
|
||||
RealtimeUserInputProcessor,
|
||||
SessionUpdateProcessor,
|
||||
UserInput,
|
||||
UserInputProcessor,
|
||||
UserTurnRoutingProcessor,
|
||||
@@ -365,6 +366,10 @@ async def run_pipeline(
|
||||
or tool_interruption_strategy.is_muted
|
||||
)
|
||||
)
|
||||
session_update = SessionUpdateProcessor(
|
||||
brain.on_session_update,
|
||||
should_ignore_update=lambda: call_end.ending,
|
||||
)
|
||||
client_tools = ClientToolBroker()
|
||||
vision_capture = VisionCaptureProcessor()
|
||||
knowledge_retrieval = KnowledgeRetrievalProcessor(
|
||||
@@ -578,6 +583,7 @@ async def run_pipeline(
|
||||
[
|
||||
transport.input(),
|
||||
client_tools,
|
||||
session_update,
|
||||
vision_capture,
|
||||
user_input,
|
||||
stt_processor,
|
||||
@@ -778,6 +784,16 @@ async def run_realtime_pipeline(
|
||||
input_sample_rate, output_sample_rate = realtime_audio_sample_rates(cfg)
|
||||
user_input = RealtimeUserInputProcessor()
|
||||
dynamic_variables = RealtimeDynamicVariableProcessor(brain, cfg, realtime)
|
||||
|
||||
async def refresh_realtime_instructions() -> None:
|
||||
update = getattr(realtime, "update_instructions", None)
|
||||
if callable(update):
|
||||
await update(brain.system_prompt(cfg))
|
||||
|
||||
session_update = SessionUpdateProcessor(
|
||||
brain.on_session_update,
|
||||
after_update=refresh_realtime_instructions,
|
||||
)
|
||||
greeting = await brain.greeting(cfg)
|
||||
|
||||
recorder = await ConversationRecorder.start(
|
||||
@@ -790,6 +806,7 @@ async def run_realtime_pipeline(
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
transport.input(),
|
||||
session_update,
|
||||
user_input,
|
||||
realtime,
|
||||
dynamic_variables,
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""Reusable frame processors shared by cascade and realtime pipelines."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from uuid import uuid4
|
||||
|
||||
from loguru import logger
|
||||
from models import AssistantConfig
|
||||
from services.brains import Brain
|
||||
from services.brains.base import SessionVariableUpdate
|
||||
from services.conversation_history import ConversationRecorder
|
||||
from services.knowledge import search as search_knowledge
|
||||
from db.session import SessionLocal
|
||||
@@ -38,6 +39,7 @@ from pipecat.utils.time import time_now_iso8601
|
||||
|
||||
|
||||
KNOWLEDGE_CONTEXT_MARKER = "<!-- knowledge-context -->"
|
||||
MAX_SESSION_UPDATE_VARIABLES = 20
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -69,6 +71,69 @@ class UserInputError(ValueError):
|
||||
self.input_id = input_id
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionUpdate:
|
||||
"""Validated silent update to declared session variables."""
|
||||
|
||||
update_id: str
|
||||
dynamic_variables: dict[str, str | int | float | bool]
|
||||
|
||||
|
||||
class SessionUpdateError(ValueError):
|
||||
def __init__(self, message: str, *, update_id: str = "") -> None:
|
||||
super().__init__(message)
|
||||
self.update_id = update_id
|
||||
|
||||
|
||||
def parse_session_update(message) -> SessionUpdate | None:
|
||||
"""Parse the silent session-update wire format."""
|
||||
if not isinstance(message, dict) or message.get("type") != "session-update":
|
||||
return None
|
||||
|
||||
update_id = str(message.get("update_id") or "").strip()
|
||||
if not update_id or len(update_id) > 128:
|
||||
raise SessionUpdateError(
|
||||
"session-update 缺少有效的 update_id",
|
||||
update_id=update_id,
|
||||
)
|
||||
if message.get("schema_version") != 1:
|
||||
raise SessionUpdateError(
|
||||
"不支持的 session-update schema_version",
|
||||
update_id=update_id,
|
||||
)
|
||||
|
||||
raw_variables = message.get("dynamic_variables")
|
||||
if not isinstance(raw_variables, dict) or not raw_variables:
|
||||
raise SessionUpdateError(
|
||||
"session-update dynamic_variables 不能为空",
|
||||
update_id=update_id,
|
||||
)
|
||||
if len(raw_variables) > MAX_SESSION_UPDATE_VARIABLES:
|
||||
raise SessionUpdateError(
|
||||
f"session-update 一次最多更新 {MAX_SESSION_UPDATE_VARIABLES} 个变量",
|
||||
update_id=update_id,
|
||||
)
|
||||
|
||||
dynamic_variables: dict[str, str | int | float | bool] = {}
|
||||
for raw_name, value in raw_variables.items():
|
||||
if not isinstance(raw_name, str):
|
||||
raise SessionUpdateError(
|
||||
"session-update 动态变量名必须是字符串",
|
||||
update_id=update_id,
|
||||
)
|
||||
if not isinstance(value, (str, int, float, bool)) or value is None:
|
||||
raise SessionUpdateError(
|
||||
f"动态变量 {raw_name} 仅支持字符串、数字或布尔值",
|
||||
update_id=update_id,
|
||||
)
|
||||
dynamic_variables[raw_name] = value
|
||||
|
||||
return SessionUpdate(
|
||||
update_id=update_id,
|
||||
dynamic_variables=dynamic_variables,
|
||||
)
|
||||
|
||||
|
||||
def parse_user_input(message) -> UserInput | None:
|
||||
"""Parse the sole public user-input wire format."""
|
||||
if not isinstance(message, dict):
|
||||
@@ -198,6 +263,94 @@ class UserInputProcessor(FrameProcessor):
|
||||
)
|
||||
|
||||
|
||||
class SessionUpdateProcessor(FrameProcessor):
|
||||
"""Apply silent dynamic-variable updates without creating a user turn."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
apply_update: Callable[
|
||||
[dict[str, str | int | float | bool]],
|
||||
Awaitable[SessionVariableUpdate],
|
||||
],
|
||||
*,
|
||||
after_update: Callable[[], Awaitable[None]] | None = None,
|
||||
should_ignore_update: Callable[[], bool] | None = None,
|
||||
):
|
||||
super().__init__()
|
||||
self._apply_update = apply_update
|
||||
self._after_update = after_update
|
||||
self._should_ignore_update = should_ignore_update or (lambda: False)
|
||||
|
||||
async def process_frame(self, frame, direction: FrameDirection):
|
||||
await super().process_frame(frame, direction)
|
||||
|
||||
if not isinstance(frame, InputTransportMessageFrame):
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
|
||||
try:
|
||||
update = parse_session_update(frame.message)
|
||||
except SessionUpdateError as exc:
|
||||
await self._emit_result(exc.update_id, "error", message=str(exc))
|
||||
return
|
||||
if update is None:
|
||||
await self.push_frame(frame, direction)
|
||||
return
|
||||
if self._should_ignore_update():
|
||||
await self._emit_result(
|
||||
update.update_id,
|
||||
"error",
|
||||
message="当前不能更新会话状态",
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
result = await self._apply_update(update.dynamic_variables)
|
||||
if result.changed and self._after_update is not None:
|
||||
await self._after_update()
|
||||
except Exception as exc: # noqa: BLE001 - protocol failures need a reply
|
||||
logger.warning(f"会话状态更新失败: {exc}")
|
||||
await self._emit_result(
|
||||
update.update_id,
|
||||
"error",
|
||||
message=str(exc),
|
||||
)
|
||||
return
|
||||
|
||||
await self._emit_result(
|
||||
update.update_id,
|
||||
"accepted",
|
||||
changed=result.changed,
|
||||
dynamic_variables=result.dynamic_variables,
|
||||
)
|
||||
|
||||
async def _emit_result(
|
||||
self,
|
||||
update_id: str,
|
||||
status: str,
|
||||
*,
|
||||
message: str = "",
|
||||
changed: list[str] | None = None,
|
||||
dynamic_variables: dict[str, str | int | float | bool] | None = None,
|
||||
) -> None:
|
||||
await self.push_frame(
|
||||
OutputTransportMessageUrgentFrame(
|
||||
message={
|
||||
"type": "session-update-result",
|
||||
"update_id": update_id,
|
||||
"status": status,
|
||||
**({"message": message} if message else {}),
|
||||
**({"changed": changed} if changed is not None else {}),
|
||||
**(
|
||||
{"dynamic_variables": dynamic_variables}
|
||||
if dynamic_variables is not None
|
||||
else {}
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class CallEndingUserMuteStrategy(BaseUserMuteStrategy):
|
||||
"""Keep user media muted after an end-call tool starts terminating a call."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user