diff --git a/backend/schemas.py b/backend/schemas.py index 2ecb038..6c49291 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -28,6 +28,7 @@ ToolParameterLocation = Literal["path", "query", "body", "header"] ToolExecutionMode = Literal["immediate", "async"] ClientToolResponseWaitMode = Literal["timeout", "session"] DynamicVariableType = Literal["string", "number", "boolean"] +PromptEntryMode = Literal["wait_user", "generate"] # 外部应用类型:其 config.apiKey 是该助手私有密钥,读时打码 / 写时哨兵 EXTERNAL_TYPES = {"dify", "fastgpt", "opencode"} @@ -106,10 +107,12 @@ class OpeningMessageConfig(CamelModel): title: str = Field(default="重要提示", min_length=1, max_length=120) message: str = Field(min_length=1, max_length=2000) confirm_label: str = Field(default="确认", min_length=1, max_length=40) + skip_speech_on_confirm: bool = True class StartupConfig(CamelModel): execution_mode: Literal["sequential"] = "sequential" + entry_mode: PromptEntryMode | None = None actions: list[StartupAction] = Field(default_factory=list, max_length=5) opening_message: OpeningMessageConfig | None = None @@ -118,6 +121,12 @@ class StartupConfig(CamelModel): ids = [action.id for action in self.actions] if len(ids) != len(set(ids)): raise ValueError("启动 Action 的 id 不能重复") + if self.entry_mode is None: + # Preserve the behavior introduced before entryMode became an + # explicit setting. Newly saved clients always send the field. + self.entry_mode = ( + "generate" if self.opening_message is not None else "wait_user" + ) return self @@ -180,7 +189,9 @@ class AssistantUpsert(CamelModel): if self.type != "prompt": self.startup = StartupConfig() if self.runtime_mode == "realtime" and ( - self.startup.actions or self.startup.opening_message is not None + self.startup.actions + or self.startup.opening_message is not None + or self.startup.entry_mode != "wait_user" ): raise ValueError("Prompt Realtime 模式暂不支持启动阶段") # 外部托管大脑只能 cascade,拦住不兼容的 realtime diff --git a/backend/services/brains/prompt_brain.py b/backend/services/brains/prompt_brain.py index e24dcb2..0e5784e 100644 --- a/backend/services/brains/prompt_brain.py +++ b/backend/services/brains/prompt_brain.py @@ -75,6 +75,8 @@ class PromptBrain(BaseBrain): self._opening_finished = False self._opening_input_blocked = False self._startup_failed = False + self._greeting_pending = False + self._entry_dispatched = False async def greeting(self, cfg: AssistantConfig) -> str: # The built-in opening Message owns the greeting so speech and the @@ -115,6 +117,8 @@ class PromptBrain(BaseBrain): self._opening_finished = not self._has_opening_stage() self._opening_input_blocked = False self._startup_failed = False + self._greeting_pending = False + self._entry_dispatched = False llm_tool_ids = ( set(cfg.llm_tool_ids) if cfg.llm_tool_ids is not None else None ) @@ -150,18 +154,27 @@ class PromptBrain(BaseBrain): self._preflight_finished = True async def on_connected(self, *, greeting_pending: bool = False) -> None: + self._greeting_pending = greeting_pending if ( - self._has_opening_stage() + (self._has_opening_stage() or self._entry_mode() == "generate") and self._runtime is not None and self._runtime.set_input_enabled is not None ): self._runtime.set_input_enabled(False) self._opening_input_blocked = True + await self._enter_prompt_if_ready() + + async def on_greeting_finished(self) -> None: + self._greeting_pending = False + await self._enter_prompt_if_ready() async def on_client_ready(self) -> None: if self._output is not None: await self._output.mark_client_ready() - if self._opening_started or self._opening_finished or self._startup_failed: + if self._startup_failed: + return + if self._opening_started or self._opening_finished: + await self._enter_prompt_if_ready() return self._opening_started = True runtime = self._runtime @@ -169,7 +182,6 @@ class PromptBrain(BaseBrain): raise RuntimeError("PromptBrain 尚未初始化") opening_message = self._opening_message() opening_actions = self._startup_actions("opening") - generate_after_confirmation = opening_message is not None speech = ( self._render_greeting(self._cfg).strip() if opening_message is not None @@ -184,7 +196,8 @@ class PromptBrain(BaseBrain): speak=self._speak_opening, set_input_enabled=runtime.set_input_enabled, input_already_blocked=self._opening_input_blocked, - # Keep the gate until the automatic first reply is queued. + # Prompt owns the gate until its explicit entry behavior + # has been dispatched. release_input_on_success=False, release_input_on_failure=False, ) @@ -197,13 +210,7 @@ class PromptBrain(BaseBrain): if opening_actions: result = await self._action_stages.run( self._opening_actions_stage_spec(), - # The Prompt opening lifecycle owns the input gate when a - # confirmation message will trigger an automatic reply. - set_input_enabled=( - None - if generate_after_confirmation - else runtime.set_input_enabled - ), + set_input_enabled=None, input_already_blocked=self._opening_input_blocked, release_input_on_failure=False, on_outcome=self._publish_opening_outcome, @@ -215,12 +222,7 @@ class PromptBrain(BaseBrain): self._startup_failed = True raise self._opening_finished = True - self._opening_input_blocked = False - if generate_after_confirmation and not runtime.call_end.ending: - logger.debug("Prompt 开场确认完成,触发自动首句") - await runtime.queue_frame(LLMRunFrame()) - if runtime.set_input_enabled is not None: - runtime.set_input_enabled(True) + await self._enter_prompt_if_ready() async def _speak_opening(self, content: str) -> Awaitable[None] | None: if self._output is None: @@ -236,6 +238,36 @@ class PromptBrain(BaseBrain): value = startup.get("opening_message", startup.get("openingMessage")) return value if isinstance(value, dict) else None + def _entry_mode(self) -> str: + startup = self._cfg.startup if isinstance(self._cfg.startup, dict) else {} + value = str(startup.get("entry_mode", startup.get("entryMode")) or "") + if value in {"wait_user", "generate"}: + return value + # Raw runtime configs saved before entryMode existed generated a first + # reply after their opening confirmation. Keep that legacy behavior. + return "generate" if self._opening_message() is not None else "wait_user" + + async def _enter_prompt_if_ready(self) -> None: + if ( + self._entry_dispatched + or self._startup_failed + or not self._opening_finished + or self._greeting_pending + ): + return + runtime = self._runtime + if runtime is None or runtime.call_end.ending: + return + + self._entry_dispatched = True + entry_mode = self._entry_mode() + if entry_mode == "generate": + logger.debug("Prompt 开场阶段完成,按进入行为触发自动首句") + await runtime.queue_frame(LLMRunFrame()) + if self._opening_input_blocked and runtime.set_input_enabled is not None: + runtime.set_input_enabled(True) + self._opening_input_blocked = False + def _has_opening_stage(self) -> bool: return self._opening_message() is not None or bool( self._startup_actions("opening") @@ -290,6 +322,12 @@ class PromptBrain(BaseBrain): ).strip(), ), completion_policy=MESSAGE_CONFIRMATION, + skip_speech_on_confirmation=bool( + config.get( + "skip_speech_on_confirm", + config.get("skipSpeechOnConfirm", True), + ) + ), ) async def _publish_opening_outcome( diff --git a/backend/services/message_stage.py b/backend/services/message_stage.py index 923c302..0791379 100644 --- a/backend/services/message_stage.py +++ b/backend/services/message_stage.py @@ -38,6 +38,7 @@ class MessageStageSpec: speech: str = "" display: MessageDisplaySpec | None = None completion_policy: MessageCompletionPolicy = MESSAGE_PLAYBACK + skip_speech_on_confirmation: bool = True @dataclass(frozen=True) @@ -54,7 +55,7 @@ class MessageStageRunner: """Run one atomic user-visible message stage. Speech is queued before the client message is dispatched. A confirmation - stage completes when the user confirms, even if audio is still playing. A + may interrupt the remaining audio or keep playback as the final gate. A speech-only stage completes at the real transport playback boundary. """ @@ -117,11 +118,14 @@ class MessageStageRunner: return result action = result.action - # Confirmation is the gate. It deliberately does not wait for the - # audio completion future, so the user can continue immediately. + # A confirmation may either interrupt the remaining speech or + # acknowledge the dialog while keeping playback as the final gate. if ( playback_completion is not None - and not require_confirmation + and ( + not require_confirmation + or not spec.skip_speech_on_confirmation + ) ): await playback_completion @@ -187,7 +191,10 @@ class MessageStageRunner: response_wait_mode=( "session" if require_confirmation else "timeout" ), - interrupt_on_result=require_confirmation, + interrupt_on_result=( + require_confirmation + and spec.skip_speech_on_confirmation + ), ) except ClientToolError as exc: return MessageStageResult( diff --git a/backend/tests/test_brains.py b/backend/tests/test_brains.py index 0f1d00b..a959bd2 100644 --- a/backend/tests/test_brains.py +++ b/backend/tests/test_brains.py @@ -366,6 +366,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase): tools=[tool], startup={ "execution_mode": "sequential", + "entry_mode": "generate", "opening_message": { "title": "重要提示", "message": "请确认已阅读。", @@ -434,6 +435,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(called_tool_ids, []) self.assertEqual(client_calls[0][0], "show_message") self.assertFalse(client_calls[0][1]["dismissible"]) + self.assertTrue(client_calls[0][2]["interrupt_on_result"]) self.assertTrue( any( isinstance(frame, TTSSpeakFrame) @@ -478,6 +480,157 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase): 1, ) + async def test_opening_confirmation_can_wait_for_user_afterwards(self): + cfg = AssistantConfig( + type="prompt", + greeting="请先确认", + startup={ + "entry_mode": "wait_user", + "opening_message": { + "title": "重要提示", + "message": "请确认已阅读。", + }, + }, + ) + brain = build_brain(cfg) + queued = [] + input_states = [] + + async def queue_frame(frame): + queued.append(frame) + + class FakeClientTools: + async def call(self, *_args, **_kwargs): + return {"status": "ok", "data": {"action": "confirmed"}} + + await brain.setup( + cfg, + BrainRuntime( + context=LLMContext(messages=[]), + llm=FakeLLM(), + queue_frame=queue_frame, + set_system_prompt=lambda _prompt: None, + set_tools=lambda _tools: None, + call_end=FakeCallEnd(), + client_tools=FakeClientTools(), + set_input_enabled=input_states.append, + ), + ) + + await brain.on_connected(greeting_pending=False) + await brain.on_client_ready() + + self.assertEqual(input_states, [False, True]) + self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in queued)) + + async def test_prompt_generate_entry_waits_for_shared_greeting_playback(self): + cfg = AssistantConfig( + type="prompt", + greeting="固定开场白", + startup={"entry_mode": "generate"}, + ) + brain = build_brain(cfg) + queued = [] + input_states = [] + + async def queue_frame(frame): + queued.append(frame) + + await brain.setup( + cfg, + BrainRuntime( + context=LLMContext(messages=[]), + llm=FakeLLM(), + queue_frame=queue_frame, + set_system_prompt=lambda _prompt: None, + set_tools=lambda _tools: None, + call_end=FakeCallEnd(), + set_input_enabled=input_states.append, + ), + ) + + await brain.on_connected(greeting_pending=True) + await brain.on_client_ready() + self.assertEqual(input_states, [False]) + self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in queued)) + + await brain.on_greeting_finished() + self.assertEqual(input_states, [False, True]) + self.assertEqual( + sum(isinstance(frame, LLMRunFrame) for frame in queued), + 1, + ) + + async def test_opening_confirmation_can_keep_speech_playing_before_entry(self): + cfg = AssistantConfig( + type="prompt", + greeting="必须完整播放的开场白", + startup={ + "entry_mode": "generate", + "opening_message": { + "title": "重要提示", + "message": "请确认已阅读。", + "skip_speech_on_confirm": False, + }, + }, + ) + brain = build_brain(cfg) + queued = [] + input_states = [] + message_displayed = asyncio.Event() + + class TrackedCallEnd(FakeCallEnd): + def __init__(self): + super().__init__() + self.playback_completion = None + + def track_speech(self): + self.tracked_speeches += 1 + self.playback_completion = asyncio.get_running_loop().create_future() + return self.playback_completion + + class FakeClientTools: + async def call(self, *_args, **options): + self.options = options + message_displayed.set() + return {"status": "ok", "data": {"action": "confirmed"}} + + async def queue_frame(frame): + queued.append(frame) + + call_end = TrackedCallEnd() + client_tools = FakeClientTools() + await brain.setup( + cfg, + BrainRuntime( + context=LLMContext(messages=[]), + llm=FakeLLM(), + queue_frame=queue_frame, + set_system_prompt=lambda _prompt: None, + set_tools=lambda _tools: None, + call_end=call_end, + client_tools=client_tools, + set_input_enabled=input_states.append, + ), + ) + + await brain.on_connected(greeting_pending=False) + opening_task = asyncio.create_task(brain.on_client_ready()) + await message_displayed.wait() + await asyncio.sleep(0) + + self.assertFalse(client_tools.options["interrupt_on_result"]) + self.assertFalse(opening_task.done()) + self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in queued)) + + call_end.playback_completion.set_result(None) + await opening_task + self.assertEqual(input_states, [False, True]) + self.assertEqual( + sum(isinstance(frame, LLMRunFrame) for frame in queued), + 1, + ) + async def test_required_opening_failure_keeps_input_blocked_and_ends_call(self): cfg = AssistantConfig( type="prompt", diff --git a/backend/tests/test_startup_actions.py b/backend/tests/test_startup_actions.py index 755f039..edf41cb 100644 --- a/backend/tests/test_startup_actions.py +++ b/backend/tests/test_startup_actions.py @@ -109,6 +109,8 @@ class StartupActionValidationTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(body.startup.actions, []) self.assertEqual(body.startup.opening_message.confirm_label, "确认") + self.assertTrue(body.startup.opening_message.skip_speech_on_confirm) + self.assertEqual(body.startup.entry_mode, "generate") async def test_startup_tool_does_not_need_conversation_binding(self): tool = SimpleNamespace( diff --git a/frontend/src/components/assistant-editor/prompt-editor.tsx b/frontend/src/components/assistant-editor/prompt-editor.tsx index 8806978..bfeccdc 100644 --- a/frontend/src/components/assistant-editor/prompt-editor.tsx +++ b/frontend/src/components/assistant-editor/prompt-editor.tsx @@ -200,6 +200,7 @@ export function PromptEditor({ title: "重要提示", message: "请确认已阅读以上信息。", confirmLabel: "确认", + skipSpeechOnConfirm: true, } : null, }); @@ -323,7 +324,7 @@ export function PromptEditor({
弹窗不可跳过;失败时将结束本次通话。这是平台内置开场阶段, 不会作为工具暴露给模型。