feat: add stateful responses API

This commit is contained in:
Xin Wang
2026-09-07 00:15:32 +08:00
parent ab409ba9c0
commit 43f2cc445e
22 changed files with 2461 additions and 5 deletions
+20 -1
View File
@@ -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 ""
),
}
+6 -1
View File
@@ -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)
+8
View File
@@ -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}
+22
View File
@@ -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,
+40
View File
@@ -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)