feat: separate prompt opening and entry behavior

This commit is contained in:
Xin Wang
2026-08-04 12:21:07 +08:00
parent 1af1cd7fed
commit 78bba090a2
8 changed files with 264 additions and 24 deletions

View File

@@ -28,6 +28,7 @@ ToolParameterLocation = Literal["path", "query", "body", "header"]
ToolExecutionMode = Literal["immediate", "async"] ToolExecutionMode = Literal["immediate", "async"]
ClientToolResponseWaitMode = Literal["timeout", "session"] ClientToolResponseWaitMode = Literal["timeout", "session"]
DynamicVariableType = Literal["string", "number", "boolean"] DynamicVariableType = Literal["string", "number", "boolean"]
PromptEntryMode = Literal["wait_user", "generate"]
# 外部应用类型:其 config.apiKey 是该助手私有密钥,读时打码 / 写时哨兵 # 外部应用类型:其 config.apiKey 是该助手私有密钥,读时打码 / 写时哨兵
EXTERNAL_TYPES = {"dify", "fastgpt", "opencode"} EXTERNAL_TYPES = {"dify", "fastgpt", "opencode"}
@@ -106,10 +107,12 @@ class OpeningMessageConfig(CamelModel):
title: str = Field(default="重要提示", min_length=1, max_length=120) title: str = Field(default="重要提示", min_length=1, max_length=120)
message: str = Field(min_length=1, max_length=2000) message: str = Field(min_length=1, max_length=2000)
confirm_label: str = Field(default="确认", min_length=1, max_length=40) confirm_label: str = Field(default="确认", min_length=1, max_length=40)
skip_speech_on_confirm: bool = True
class StartupConfig(CamelModel): class StartupConfig(CamelModel):
execution_mode: Literal["sequential"] = "sequential" execution_mode: Literal["sequential"] = "sequential"
entry_mode: PromptEntryMode | None = None
actions: list[StartupAction] = Field(default_factory=list, max_length=5) actions: list[StartupAction] = Field(default_factory=list, max_length=5)
opening_message: OpeningMessageConfig | None = None opening_message: OpeningMessageConfig | None = None
@@ -118,6 +121,12 @@ class StartupConfig(CamelModel):
ids = [action.id for action in self.actions] ids = [action.id for action in self.actions]
if len(ids) != len(set(ids)): if len(ids) != len(set(ids)):
raise ValueError("启动 Action 的 id 不能重复") 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 return self
@@ -180,7 +189,9 @@ class AssistantUpsert(CamelModel):
if self.type != "prompt": if self.type != "prompt":
self.startup = StartupConfig() self.startup = StartupConfig()
if self.runtime_mode == "realtime" and ( 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 模式暂不支持启动阶段") raise ValueError("Prompt Realtime 模式暂不支持启动阶段")
# 外部托管大脑只能 cascade,拦住不兼容的 realtime # 外部托管大脑只能 cascade,拦住不兼容的 realtime

View File

@@ -75,6 +75,8 @@ class PromptBrain(BaseBrain):
self._opening_finished = False self._opening_finished = False
self._opening_input_blocked = False self._opening_input_blocked = False
self._startup_failed = False self._startup_failed = False
self._greeting_pending = False
self._entry_dispatched = False
async def greeting(self, cfg: AssistantConfig) -> str: async def greeting(self, cfg: AssistantConfig) -> str:
# The built-in opening Message owns the greeting so speech and the # 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_finished = not self._has_opening_stage()
self._opening_input_blocked = False self._opening_input_blocked = False
self._startup_failed = False self._startup_failed = False
self._greeting_pending = False
self._entry_dispatched = False
llm_tool_ids = ( llm_tool_ids = (
set(cfg.llm_tool_ids) if cfg.llm_tool_ids is not None else None 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 self._preflight_finished = True
async def on_connected(self, *, greeting_pending: bool = False) -> None: async def on_connected(self, *, greeting_pending: bool = False) -> None:
self._greeting_pending = greeting_pending
if ( if (
self._has_opening_stage() (self._has_opening_stage() or self._entry_mode() == "generate")
and self._runtime is not None and self._runtime is not None
and self._runtime.set_input_enabled is not None and self._runtime.set_input_enabled is not None
): ):
self._runtime.set_input_enabled(False) self._runtime.set_input_enabled(False)
self._opening_input_blocked = True 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: async def on_client_ready(self) -> None:
if self._output is not None: if self._output is not None:
await self._output.mark_client_ready() 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 return
self._opening_started = True self._opening_started = True
runtime = self._runtime runtime = self._runtime
@@ -169,7 +182,6 @@ class PromptBrain(BaseBrain):
raise RuntimeError("PromptBrain 尚未初始化") raise RuntimeError("PromptBrain 尚未初始化")
opening_message = self._opening_message() opening_message = self._opening_message()
opening_actions = self._startup_actions("opening") opening_actions = self._startup_actions("opening")
generate_after_confirmation = opening_message is not None
speech = ( speech = (
self._render_greeting(self._cfg).strip() self._render_greeting(self._cfg).strip()
if opening_message is not None if opening_message is not None
@@ -184,7 +196,8 @@ class PromptBrain(BaseBrain):
speak=self._speak_opening, speak=self._speak_opening,
set_input_enabled=runtime.set_input_enabled, set_input_enabled=runtime.set_input_enabled,
input_already_blocked=self._opening_input_blocked, 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_success=False,
release_input_on_failure=False, release_input_on_failure=False,
) )
@@ -197,13 +210,7 @@ class PromptBrain(BaseBrain):
if opening_actions: if opening_actions:
result = await self._action_stages.run( result = await self._action_stages.run(
self._opening_actions_stage_spec(), self._opening_actions_stage_spec(),
# The Prompt opening lifecycle owns the input gate when a set_input_enabled=None,
# confirmation message will trigger an automatic reply.
set_input_enabled=(
None
if generate_after_confirmation
else runtime.set_input_enabled
),
input_already_blocked=self._opening_input_blocked, input_already_blocked=self._opening_input_blocked,
release_input_on_failure=False, release_input_on_failure=False,
on_outcome=self._publish_opening_outcome, on_outcome=self._publish_opening_outcome,
@@ -215,12 +222,7 @@ class PromptBrain(BaseBrain):
self._startup_failed = True self._startup_failed = True
raise raise
self._opening_finished = True self._opening_finished = True
self._opening_input_blocked = False await self._enter_prompt_if_ready()
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)
async def _speak_opening(self, content: str) -> Awaitable[None] | None: async def _speak_opening(self, content: str) -> Awaitable[None] | None:
if self._output is None: if self._output is None:
@@ -236,6 +238,36 @@ class PromptBrain(BaseBrain):
value = startup.get("opening_message", startup.get("openingMessage")) value = startup.get("opening_message", startup.get("openingMessage"))
return value if isinstance(value, dict) else None 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: def _has_opening_stage(self) -> bool:
return self._opening_message() is not None or bool( return self._opening_message() is not None or bool(
self._startup_actions("opening") self._startup_actions("opening")
@@ -290,6 +322,12 @@ class PromptBrain(BaseBrain):
).strip(), ).strip(),
), ),
completion_policy=MESSAGE_CONFIRMATION, completion_policy=MESSAGE_CONFIRMATION,
skip_speech_on_confirmation=bool(
config.get(
"skip_speech_on_confirm",
config.get("skipSpeechOnConfirm", True),
)
),
) )
async def _publish_opening_outcome( async def _publish_opening_outcome(

View File

@@ -38,6 +38,7 @@ class MessageStageSpec:
speech: str = "" speech: str = ""
display: MessageDisplaySpec | None = None display: MessageDisplaySpec | None = None
completion_policy: MessageCompletionPolicy = MESSAGE_PLAYBACK completion_policy: MessageCompletionPolicy = MESSAGE_PLAYBACK
skip_speech_on_confirmation: bool = True
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -54,7 +55,7 @@ class MessageStageRunner:
"""Run one atomic user-visible message stage. """Run one atomic user-visible message stage.
Speech is queued before the client message is dispatched. A confirmation 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. speech-only stage completes at the real transport playback boundary.
""" """
@@ -117,11 +118,14 @@ class MessageStageRunner:
return result return result
action = result.action action = result.action
# Confirmation is the gate. It deliberately does not wait for the # A confirmation may either interrupt the remaining speech or
# audio completion future, so the user can continue immediately. # acknowledge the dialog while keeping playback as the final gate.
if ( if (
playback_completion is not None playback_completion is not None
and not require_confirmation and (
not require_confirmation
or not spec.skip_speech_on_confirmation
)
): ):
await playback_completion await playback_completion
@@ -187,7 +191,10 @@ class MessageStageRunner:
response_wait_mode=( response_wait_mode=(
"session" if require_confirmation else "timeout" "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: except ClientToolError as exc:
return MessageStageResult( return MessageStageResult(

View File

@@ -366,6 +366,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
tools=[tool], tools=[tool],
startup={ startup={
"execution_mode": "sequential", "execution_mode": "sequential",
"entry_mode": "generate",
"opening_message": { "opening_message": {
"title": "重要提示", "title": "重要提示",
"message": "请确认已阅读。", "message": "请确认已阅读。",
@@ -434,6 +435,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(called_tool_ids, []) self.assertEqual(called_tool_ids, [])
self.assertEqual(client_calls[0][0], "show_message") self.assertEqual(client_calls[0][0], "show_message")
self.assertFalse(client_calls[0][1]["dismissible"]) self.assertFalse(client_calls[0][1]["dismissible"])
self.assertTrue(client_calls[0][2]["interrupt_on_result"])
self.assertTrue( self.assertTrue(
any( any(
isinstance(frame, TTSSpeakFrame) isinstance(frame, TTSSpeakFrame)
@@ -478,6 +480,157 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
1, 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): async def test_required_opening_failure_keeps_input_blocked_and_ends_call(self):
cfg = AssistantConfig( cfg = AssistantConfig(
type="prompt", type="prompt",

View File

@@ -109,6 +109,8 @@ class StartupActionValidationTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(body.startup.actions, []) self.assertEqual(body.startup.actions, [])
self.assertEqual(body.startup.opening_message.confirm_label, "确认") 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): async def test_startup_tool_does_not_need_conversation_binding(self):
tool = SimpleNamespace( tool = SimpleNamespace(

View File

@@ -200,6 +200,7 @@ export function PromptEditor({
title: "重要提示", title: "重要提示",
message: "请确认已阅读以上信息。", message: "请确认已阅读以上信息。",
confirmLabel: "确认", confirmLabel: "确认",
skipSpeechOnConfirm: true,
} }
: null, : null,
}); });
@@ -323,7 +324,7 @@ export function PromptEditor({
<div className="mt-4 space-y-3 border-t border-hairline pt-4"> <div className="mt-4 space-y-3 border-t border-hairline pt-4">
<ToggleRow <ToggleRow
title="开场确认弹窗" title="开场确认弹窗"
description="与开场白同时显示,用户确认后即可开始对话,不等待播报完成。" description="与开场白同时显示,用户确认后按下方策略进入对话。"
checked={Boolean(openingMessage)} checked={Boolean(openingMessage)}
onChange={setOpeningMessage} onChange={setOpeningMessage}
/> />
@@ -367,12 +368,34 @@ export function PromptEditor({
className="border-hairline-strong bg-background" className="border-hairline-strong bg-background"
/> />
</label> </label>
<ToggleRow
title="确认后跳过剩余开场白"
description="开启后立即停止尚未播完的开场白;关闭后等待播报完成再进入对话。"
checked={openingMessage.skipSpeechOnConfirm}
onChange={(skipSpeechOnConfirm) =>
updateOpeningMessage({ skipSpeechOnConfirm })
}
/>
<p className="text-xs leading-5 text-muted-foreground"> <p className="text-xs leading-5 text-muted-foreground">
</p> </p>
</div> </div>
)} )}
<div className="border-t border-hairline pt-3">
<ToggleRow
title="进入后立即回复"
description="开启后由助手主动生成首句;关闭后等待用户下一轮输入。"
checked={form.startup.entryMode === "generate"}
onChange={(generate) =>
updateForm("startup", {
...form.startup,
entryMode: generate ? "generate" : "wait_user",
})
}
/>
</div>
</div> </div>
)} )}
</SectionCard> </SectionCard>
@@ -404,6 +427,7 @@ export function PromptEditor({
) { ) {
updateForm("startup", { updateForm("startup", {
executionMode: "sequential", executionMode: "sequential",
entryMode: "wait_user",
actions: [], actions: [],
openingMessage: null, openingMessage: null,
}); });

View File

@@ -179,6 +179,7 @@ function blankPromptForm(name: string): AssistantForm {
turnConfig: defaultTurnConfig(), turnConfig: defaultTurnConfig(),
startup: { startup: {
executionMode: "sequential", executionMode: "sequential",
entryMode: "wait_user",
actions: [], actions: [],
openingMessage: null, openingMessage: null,
}, },
@@ -472,6 +473,7 @@ export function AssistantPage(props: AssistantPageProps) {
turnConfig: normalizeTurnConfig(a.turnConfig), turnConfig: normalizeTurnConfig(a.turnConfig),
startup: { startup: {
executionMode: "sequential", executionMode: "sequential",
entryMode: a.startup?.entryMode ?? "wait_user",
actions: a.startup?.actions ?? [], actions: a.startup?.actions ?? [],
openingMessage: a.startup?.openingMessage ?? null, openingMessage: a.startup?.openingMessage ?? null,
}, },
@@ -546,6 +548,7 @@ export function AssistantPage(props: AssistantPageProps) {
turnConfig: defaultTurnConfig(), turnConfig: defaultTurnConfig(),
startup: { startup: {
executionMode: "sequential", executionMode: "sequential",
entryMode: "wait_user",
actions: [], actions: [],
openingMessage: null, openingMessage: null,
}, },

View File

@@ -206,11 +206,13 @@ export type StartupAction = {
export type StartupConfig = { export type StartupConfig = {
executionMode: "sequential"; executionMode: "sequential";
entryMode: "wait_user" | "generate";
actions: StartupAction[]; actions: StartupAction[];
openingMessage: { openingMessage: {
title: string; title: string;
message: string; message: string;
confirmLabel: string; confirmLabel: string;
skipSpeechOnConfirm: boolean;
} | null; } | null;
}; };