feat: add stateful responses API
This commit is contained in:
@@ -24,6 +24,8 @@ class DifyBrain(BaseBrain):
|
||||
def __init__(self):
|
||||
self._user_id = f"ai-video-{uuid4().hex}"
|
||||
self._client: AsyncClient | None = None
|
||||
self._llm: DifyLLMService | None = None
|
||||
self._restored_conversation_id = ""
|
||||
|
||||
def _get_client(self, cfg: AssistantConfig) -> AsyncClient:
|
||||
if self._client is None:
|
||||
@@ -54,8 +56,25 @@ class DifyBrain(BaseBrain):
|
||||
return cfg.greeting
|
||||
|
||||
def build_llm(self, cfg: AssistantConfig, context: LLMContext) -> FrameProcessor:
|
||||
return DifyLLMService(
|
||||
self._llm = DifyLLMService(
|
||||
cfg,
|
||||
client=self._get_client(cfg),
|
||||
user_id=self._user_id,
|
||||
conversation_id=self._restored_conversation_id,
|
||||
)
|
||||
return self._llm
|
||||
|
||||
def restore_text_state(self, snapshot: dict) -> None:
|
||||
if snapshot.get("type") != "dify":
|
||||
raise ValueError("Responses runtime state does not belong to Dify")
|
||||
self._user_id = str(snapshot.get("user_id") or self._user_id)
|
||||
self._restored_conversation_id = str(snapshot.get("conversation_id") or "")
|
||||
|
||||
def text_state_snapshot(self) -> dict:
|
||||
return {
|
||||
"type": "dify",
|
||||
"user_id": self._user_id,
|
||||
"conversation_id": (
|
||||
self._llm.conversation_id if self._llm is not None else ""
|
||||
),
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ class DifyLLMService(LLMService):
|
||||
*,
|
||||
client: AsyncClient | None = None,
|
||||
user_id: str | None = None,
|
||||
conversation_id: str = "",
|
||||
):
|
||||
super().__init__(
|
||||
settings=LLMSettings(
|
||||
@@ -75,7 +76,11 @@ class DifyLLMService(LLMService):
|
||||
api_base=normalize_api_base(cfg.dify_api_url),
|
||||
)
|
||||
self._user_id = user_id or f"ai-video-{uuid4().hex}"
|
||||
self._conversation_id = ""
|
||||
self._conversation_id = conversation_id
|
||||
|
||||
@property
|
||||
def conversation_id(self) -> str:
|
||||
return self._conversation_id
|
||||
|
||||
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||
await super().process_frame(frame, direction)
|
||||
|
||||
@@ -66,3 +66,11 @@ class FastGPTBrain(BaseBrain):
|
||||
|
||||
def build_llm(self, cfg: AssistantConfig, context: LLMContext) -> FrameProcessor:
|
||||
return FastGPTLLMService(cfg, chat_id=self._chat_id)
|
||||
|
||||
def restore_text_state(self, snapshot: dict) -> None:
|
||||
if snapshot.get("type") != "fastgpt":
|
||||
raise ValueError("Responses runtime state does not belong to FastGPT")
|
||||
self._chat_id = str(snapshot.get("chat_id") or self._chat_id)
|
||||
|
||||
def text_state_snapshot(self) -> dict:
|
||||
return {"type": "fastgpt", "chat_id": self._chat_id}
|
||||
|
||||
@@ -83,6 +83,7 @@ class PromptBrain(BaseBrain):
|
||||
self._startup_failed = False
|
||||
self._greeting_pending = False
|
||||
self._entry_dispatched = False
|
||||
self._restored_text_state: dict[str, Any] | None = None
|
||||
|
||||
async def greeting(self, cfg: AssistantConfig) -> str:
|
||||
# The built-in opening Message owns the greeting so speech and the
|
||||
@@ -127,6 +128,12 @@ class PromptBrain(BaseBrain):
|
||||
self._startup_failed = False
|
||||
self._greeting_pending = False
|
||||
self._entry_dispatched = False
|
||||
if self._restored_text_state is not None:
|
||||
state = self._restored_text_state
|
||||
self._preflight_finished = bool(state.get("preflight_finished"))
|
||||
self._opening_finished = bool(state.get("opening_finished"))
|
||||
self._opening_started = self._opening_finished
|
||||
self._entry_dispatched = bool(state.get("entry_dispatched"))
|
||||
schemas: list[FunctionSchema] = []
|
||||
registered_names: set[str] = set()
|
||||
binding_by_id = {binding.tool_id: binding for binding in cfg.tool_bindings}
|
||||
@@ -171,6 +178,21 @@ class PromptBrain(BaseBrain):
|
||||
)
|
||||
runtime.set_tools(schemas)
|
||||
|
||||
def restore_text_state(self, snapshot: dict[str, Any]) -> None:
|
||||
"""Restore Prompt startup progress for a later Responses API turn."""
|
||||
if snapshot.get("type") != "prompt":
|
||||
raise ValueError("Responses runtime state does not belong to a prompt")
|
||||
self._restored_text_state = dict(snapshot)
|
||||
|
||||
def text_state_snapshot(self) -> dict[str, Any]:
|
||||
"""Prevent completed startup actions from running again next turn."""
|
||||
return {
|
||||
"type": "prompt",
|
||||
"preflight_finished": self._preflight_finished,
|
||||
"opening_finished": self._opening_finished,
|
||||
"entry_dispatched": self._entry_dispatched,
|
||||
}
|
||||
|
||||
async def setup_realtime(
|
||||
self,
|
||||
cfg: AssistantConfig,
|
||||
|
||||
@@ -187,6 +187,7 @@ class WorkflowBrain(BaseBrain):
|
||||
self._next_message_token = 1
|
||||
self._pending_message: _MessageContinuation | None = None
|
||||
self._realtime_controller: WorkflowRealtimeController | None = None
|
||||
self._restored_text_node_id: str | None = None
|
||||
|
||||
async def greeting(self, _cfg: AssistantConfig) -> str:
|
||||
"""Workflow opening speech belongs to an explicit Message or Agent."""
|
||||
@@ -274,6 +275,18 @@ class WorkflowBrain(BaseBrain):
|
||||
if self._realtime_controller is not None:
|
||||
await self._realtime_controller.start()
|
||||
return
|
||||
if self._restored_text_node_id:
|
||||
node_id = self._restored_text_node_id
|
||||
self._restored_text_node_id = None
|
||||
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
|
||||
await self._emit_node_active(node_id)
|
||||
await self._emit_variables(reason="restored", node_id=node_id)
|
||||
if self._manager is None:
|
||||
raise RuntimeError("Workflow FlowManager 尚未初始化")
|
||||
node_config = await self._resolve_path(node_id)
|
||||
await self._activate_node_config(node_config, initialize=True)
|
||||
logger.info(f"工作流文本状态已恢复: 当前节点={self._manager.current_node}")
|
||||
return
|
||||
self._state.enter(self._engine.start_id, WorkflowStatus.STARTING)
|
||||
await self._emit_node_active(self._engine.start_id)
|
||||
await self._emit_variables(
|
||||
@@ -287,6 +300,33 @@ class WorkflowBrain(BaseBrain):
|
||||
await self._activate_node_config(node_config, initialize=True)
|
||||
logger.info(f"工作流模式启用: 当前节点={self._manager.current_node}")
|
||||
|
||||
def restore_text_state(self, snapshot: dict[str, Any]) -> None:
|
||||
"""Restore one stable Responses API workflow node before connecting."""
|
||||
if not snapshot:
|
||||
return
|
||||
if snapshot.get("type") != "workflow":
|
||||
raise ValueError("Responses runtime state does not belong to a workflow")
|
||||
if snapshot.get("revision") != self._engine.revision:
|
||||
raise ValueError("Workflow definition changed after the previous response")
|
||||
node_id = str(snapshot.get("current_node_id") or "")
|
||||
if not self._engine.node_type(node_id):
|
||||
raise ValueError("Previous workflow node no longer exists")
|
||||
self._restored_text_node_id = node_id
|
||||
|
||||
def text_state_snapshot(self) -> dict[str, Any]:
|
||||
"""Return the minimum durable state needed by the next text request."""
|
||||
node_id = (
|
||||
str(self._manager.current_node)
|
||||
if self._manager is not None and self._manager.current_node
|
||||
else self._state.current_node_id
|
||||
)
|
||||
return {
|
||||
"type": "workflow",
|
||||
"revision": self._engine.revision,
|
||||
"current_node_id": node_id,
|
||||
"status": self._state.status.value,
|
||||
}
|
||||
|
||||
async def _initial_node_config(self) -> NodeConfig:
|
||||
"""Only a default-only Start advances before the first user turn."""
|
||||
outgoing = self._engine.outgoing(self._engine.start_id)
|
||||
|
||||
Reference in New Issue
Block a user