feat: add prompt opening behavior modes

This commit is contained in:
Xin Wang
2026-08-04 13:29:21 +08:00
parent 78bba090a2
commit 5bf5987fe4
11 changed files with 235 additions and 97 deletions

View File

@@ -29,6 +29,7 @@ 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"] PromptEntryMode = Literal["wait_user", "generate"]
PromptOpeningMode = Literal["interruptible", "playback", "confirmation"]
# 外部应用类型:其 config.apiKey 是该助手私有密钥,读时打码 / 写时哨兵 # 外部应用类型:其 config.apiKey 是该助手私有密钥,读时打码 / 写时哨兵
EXTERNAL_TYPES = {"dify", "fastgpt", "opencode"} EXTERNAL_TYPES = {"dify", "fastgpt", "opencode"}
@@ -107,11 +108,11 @@ 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"
opening_mode: PromptOpeningMode | None = None
entry_mode: PromptEntryMode | None = None 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
@@ -121,11 +122,22 @@ 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 不能重复")
legacy_confirmation = (
self.opening_mode is None and self.opening_message is not None
)
if self.opening_mode is None:
self.opening_mode = (
"confirmation" if legacy_confirmation else "interruptible"
)
if self.opening_mode == "confirmation" and self.opening_message is None:
raise ValueError("确认后继续必须配置开场确认弹窗")
if self.opening_mode != "confirmation":
self.opening_message = None
if self.entry_mode is None: if self.entry_mode is None:
# Preserve the behavior introduced before entryMode became an # Preserve the behavior introduced before entryMode became an
# explicit setting. Newly saved clients always send the field. # explicit setting. Newly saved clients always send the field.
self.entry_mode = ( self.entry_mode = (
"generate" if self.opening_message is not None else "wait_user" "generate" if legacy_confirmation else "wait_user"
) )
return self return self
@@ -191,6 +203,7 @@ class AssistantUpsert(CamelModel):
if self.runtime_mode == "realtime" and ( if self.runtime_mode == "realtime" and (
self.startup.actions self.startup.actions
or self.startup.opening_message is not None or self.startup.opening_message is not None
or self.startup.opening_mode != "interruptible"
or self.startup.entry_mode != "wait_user" or self.startup.entry_mode != "wait_user"
): ):
raise ValueError("Prompt Realtime 模式暂不支持启动阶段") raise ValueError("Prompt Realtime 模式暂不支持启动阶段")

View File

@@ -132,6 +132,9 @@ class BaseBrain:
async def on_greeting_finished(self) -> None: async def on_greeting_finished(self) -> None:
"""Continue startup after the shared greeting has actually played.""" """Continue startup after the shared greeting has actually played."""
async def on_interruption_processed(self) -> None:
"""Observe that an output interruption reached assistant aggregation."""
def prepare_greeting_context( def prepare_greeting_context(
self, self,
greeting: str, greeting: str,
@@ -214,6 +217,8 @@ class Brain(Protocol):
async def on_greeting_finished(self) -> None: ... async def on_greeting_finished(self) -> None: ...
async def on_interruption_processed(self) -> None: ...
def prepare_greeting_context( def prepare_greeting_context(
self, self,
greeting: str, greeting: str,

View File

@@ -81,7 +81,7 @@ class PromptBrain(BaseBrain):
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
# client dialog can start as one atomic stage. # client dialog can start as one atomic stage.
if self._opening_message() is not None: if self._opening_mode() == "confirmation":
return "" return ""
return self._render_greeting(cfg) return self._render_greeting(cfg)
@@ -155,8 +155,19 @@ class PromptBrain(BaseBrain):
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 self._greeting_pending = greeting_pending
should_block_input = (
self._has_opening_stage()
or (
greeting_pending
and self._opening_mode() == "playback"
)
or (
not greeting_pending
and self._entry_mode() == "generate"
)
)
if ( if (
(self._has_opening_stage() or self._entry_mode() == "generate") should_block_input
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
): ):
@@ -168,6 +179,16 @@ class PromptBrain(BaseBrain):
self._greeting_pending = False self._greeting_pending = False
await self._enter_prompt_if_ready() await self._enter_prompt_if_ready()
async def on_interruption_processed(self) -> None:
if (
self._greeting_pending
and self._opening_mode() == "interruptible"
):
# The user's input now owns the first Agent turn. Do not also queue
# an empty-context entry reply when the interrupted greeting stops.
self._entry_dispatched = True
logger.debug("Prompt 可打断开场白已被用户输入接管")
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()
@@ -180,7 +201,11 @@ class PromptBrain(BaseBrain):
runtime = self._runtime runtime = self._runtime
if runtime is None: if runtime is None:
raise RuntimeError("PromptBrain 尚未初始化") raise RuntimeError("PromptBrain 尚未初始化")
opening_message = self._opening_message() opening_message = (
self._opening_message()
if self._opening_mode() == "confirmation"
else None
)
opening_actions = self._startup_actions("opening") opening_actions = self._startup_actions("opening")
speech = ( speech = (
self._render_greeting(self._cfg).strip() self._render_greeting(self._cfg).strip()
@@ -238,6 +263,19 @@ 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 _opening_mode(self) -> str:
startup = self._cfg.startup if isinstance(self._cfg.startup, dict) else {}
value = str(startup.get("opening_mode", startup.get("openingMode")) or "")
if value in {"interruptible", "playback", "confirmation"}:
return value
# Before openingMode existed, an opening message meant confirmation;
# ordinary Prompt greetings were interruptible.
return (
"confirmation"
if self._opening_message() is not None
else "interruptible"
)
def _entry_mode(self) -> str: def _entry_mode(self) -> str:
startup = self._cfg.startup if isinstance(self._cfg.startup, dict) else {} startup = self._cfg.startup if isinstance(self._cfg.startup, dict) else {}
value = str(startup.get("entry_mode", startup.get("entryMode")) or "") value = str(startup.get("entry_mode", startup.get("entryMode")) or "")
@@ -245,7 +283,7 @@ class PromptBrain(BaseBrain):
return value return value
# Raw runtime configs saved before entryMode existed generated a first # Raw runtime configs saved before entryMode existed generated a first
# reply after their opening confirmation. Keep that legacy behavior. # reply after their opening confirmation. Keep that legacy behavior.
return "generate" if self._opening_message() is not None else "wait_user" return "generate" if self._opening_mode() == "confirmation" else "wait_user"
async def _enter_prompt_if_ready(self) -> None: async def _enter_prompt_if_ready(self) -> None:
if ( if (
@@ -269,7 +307,7 @@ class PromptBrain(BaseBrain):
self._opening_input_blocked = False 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_mode() == "confirmation" or bool(
self._startup_actions("opening") self._startup_actions("opening")
) )
@@ -322,12 +360,6 @@ 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,7 +38,6 @@ 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)
@@ -55,7 +54,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
may interrupt the remaining audio or keep playback as the final gate. A stage completes when the user confirms, even if audio is still playing. A
speech-only stage completes at the real transport playback boundary. speech-only stage completes at the real transport playback boundary.
""" """
@@ -118,14 +117,11 @@ class MessageStageRunner:
return result return result
action = result.action action = result.action
# A confirmation may either interrupt the remaining speech or # Confirmation is the gate. It deliberately does not wait for the
# acknowledge the dialog while keeping playback as the final gate. # audio completion future, so the user can continue immediately.
if ( if (
playback_completion is not None playback_completion is not None
and ( and not require_confirmation
not require_confirmation
or not spec.skip_speech_on_confirmation
)
): ):
await playback_completion await playback_completion
@@ -191,10 +187,7 @@ class MessageStageRunner:
response_wait_mode=( response_wait_mode=(
"session" if require_confirmation else "timeout" "session" if require_confirmation else "timeout"
), ),
interrupt_on_result=( interrupt_on_result=require_confirmation,
require_confirmation
and spec.skip_speech_on_confirmation
),
) )
except ClientToolError as exc: except ClientToolError as exc:
return MessageStageResult( return MessageStageResult(

View File

@@ -161,6 +161,9 @@ def bind_cascade_pipeline_events(
@assistant_aggregator.event_handler("on_interruption_processed") @assistant_aggregator.event_handler("on_interruption_processed")
async def on_interruption_processed(_aggregator): async def on_interruption_processed(_aggregator):
brain_handler = getattr(brain, "on_interruption_processed", None)
if callable(brain_handler):
await brain_handler()
if client_tools is not None: if client_tools is not None:
client_tools.on_interruption_processed() client_tools.on_interruption_processed()
if not pending_user_inputs: if not pending_user_inputs:

View File

@@ -366,6 +366,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
tools=[tool], tools=[tool],
startup={ startup={
"execution_mode": "sequential", "execution_mode": "sequential",
"opening_mode": "confirmation",
"entry_mode": "generate", "entry_mode": "generate",
"opening_message": { "opening_message": {
"title": "重要提示", "title": "重要提示",
@@ -485,6 +486,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
type="prompt", type="prompt",
greeting="请先确认", greeting="请先确认",
startup={ startup={
"opening_mode": "confirmation",
"entry_mode": "wait_user", "entry_mode": "wait_user",
"opening_message": { "opening_message": {
"title": "重要提示", "title": "重要提示",
@@ -527,7 +529,10 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
cfg = AssistantConfig( cfg = AssistantConfig(
type="prompt", type="prompt",
greeting="固定开场白", greeting="固定开场白",
startup={"entry_mode": "generate"}, startup={
"opening_mode": "playback",
"entry_mode": "generate",
},
) )
brain = build_brain(cfg) brain = build_brain(cfg)
queued = [] queued = []
@@ -561,45 +566,22 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
1, 1,
) )
async def test_opening_confirmation_can_keep_speech_playing_before_entry(self): async def test_interruptible_opening_user_input_suppresses_entry_reply(self):
cfg = AssistantConfig( cfg = AssistantConfig(
type="prompt", type="prompt",
greeting="必须完整播放的开场白", greeting="可以打断的开场白",
startup={ startup={
"opening_mode": "interruptible",
"entry_mode": "generate", "entry_mode": "generate",
"opening_message": {
"title": "重要提示",
"message": "请确认已阅读。",
"skip_speech_on_confirm": False,
},
}, },
) )
brain = build_brain(cfg) brain = build_brain(cfg)
queued = [] queued = []
input_states = [] 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): async def queue_frame(frame):
queued.append(frame) queued.append(frame)
call_end = TrackedCallEnd()
client_tools = FakeClientTools()
await brain.setup( await brain.setup(
cfg, cfg,
BrainRuntime( BrainRuntime(
@@ -608,24 +590,52 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
queue_frame=queue_frame, queue_frame=queue_frame,
set_system_prompt=lambda _prompt: None, set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None, set_tools=lambda _tools: None,
call_end=call_end, call_end=FakeCallEnd(),
client_tools=client_tools,
set_input_enabled=input_states.append, set_input_enabled=input_states.append,
), ),
) )
await brain.on_connected(greeting_pending=False) await brain.on_connected(greeting_pending=True)
opening_task = asyncio.create_task(brain.on_client_ready()) self.assertEqual(input_states, [])
await message_displayed.wait()
await asyncio.sleep(0) await brain.on_interruption_processed()
await brain.on_greeting_finished()
self.assertFalse(client_tools.options["interrupt_on_result"])
self.assertFalse(opening_task.done())
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in queued)) self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in queued))
call_end.playback_completion.set_result(None) async def test_interruptible_opening_natural_finish_runs_entry_behavior(self):
await opening_task cfg = AssistantConfig(
self.assertEqual(input_states, [False, True]) type="prompt",
greeting="可以打断的开场白",
startup={
"opening_mode": "interruptible",
"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_greeting_finished()
self.assertEqual(input_states, [])
self.assertEqual( self.assertEqual(
sum(isinstance(frame, LLMRunFrame) for frame in queued), sum(isinstance(frame, LLMRunFrame) for frame in queued),
1, 1,

View File

@@ -52,6 +52,7 @@ class _Brain:
self.prepared_greeting = "" self.prepared_greeting = ""
self.greeting_pending = False self.greeting_pending = False
self.greeting_finished = 0 self.greeting_finished = 0
self.interruptions_processed = 0
def prepare_greeting_context(self, greeting, _context): def prepare_greeting_context(self, greeting, _context):
self.prepared_greeting = greeting self.prepared_greeting = greeting
@@ -62,6 +63,9 @@ class _Brain:
async def on_greeting_finished(self): async def on_greeting_finished(self):
self.greeting_finished += 1 self.greeting_finished += 1
async def on_interruption_processed(self):
self.interruptions_processed += 1
async def on_client_ready(self): async def on_client_ready(self):
for content, timestamp in ( for content, timestamp in (
("Message 节点播报", "2026-07-14T10:00:00.200+00:00"), ("Message 节点播报", "2026-07-14T10:00:00.200+00:00"),
@@ -111,6 +115,7 @@ class PipelineEventTest(unittest.IsolatedAsyncioTestCase):
) )
self.assertEqual(acknowledgements, [True]) self.assertEqual(acknowledgements, [True])
self.assertEqual(brain.interruptions_processed, 1)
async def test_greeting_keeps_playback_timestamp_until_client_ready(self): async def test_greeting_keeps_playback_timestamp_until_client_ready(self):
transport = _EventSource() transport = _EventSource()

View File

@@ -109,7 +109,7 @@ 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.opening_mode, "confirmation")
self.assertEqual(body.startup.entry_mode, "generate") 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):

View File

@@ -26,7 +26,6 @@ import {
ResourceSelectField, ResourceSelectField,
RuntimeModeSelector, RuntimeModeSelector,
TextAreaField, TextAreaField,
ToggleRow,
ToolPicker, ToolPicker,
} from "@/components/assistant-editor/editor-controls"; } from "@/components/assistant-editor/editor-controls";
import type { AssistantForm } from "@/components/assistant-editor/types"; import type { AssistantForm } from "@/components/assistant-editor/types";
@@ -35,9 +34,52 @@ import { VisionConfigSection } from "@/components/editor/vision-config-section";
import { TurnConfigEditor } from "@/components/turn-config-editor"; import { TurnConfigEditor } from "@/components/turn-config-editor";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import type { DynamicVariableDefinition, Tool } from "@/lib/api"; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type {
DynamicVariableDefinition,
StartupConfig,
Tool,
} from "@/lib/api";
type ResourceOption = { value: string; label: string }; type ResourceOption = { value: string; label: string };
type PromptOpeningMode = StartupConfig["openingMode"];
type PromptEntryMode = StartupConfig["entryMode"];
const OPENING_MODE_OPTIONS: Array<{
value: PromptOpeningMode;
label: string;
description: string;
}> = [
{
value: "interruptible",
label: "可打断",
description: "用户说话、发文字或图片时立即结束开场白,并保留这次输入。",
},
{
value: "playback",
label: "播完继续",
description: "开场白播放期间关闭输入,实际播放完成后进入 Agent。",
},
{
value: "confirmation",
label: "需要弹窗确认继续",
description: "关闭对话输入并显示弹窗,用户确认后立即进入 Agent。",
},
];
const ENTRY_MODE_OPTIONS: Array<{
value: PromptEntryMode;
label: string;
}> = [
{ value: "wait_user", label: "等待下一轮用户输入" },
{ value: "generate", label: "进入后立即回复" },
];
const promptSections = [ const promptSections = [
{ id: "conversation", label: "对话内容" }, { id: "conversation", label: "对话内容" },
@@ -94,6 +136,10 @@ export function PromptEditor({
handlePromptModelChange, handlePromptModelChange,
}: PromptEditorProps) { }: PromptEditorProps) {
const openingMessage = form.startup.openingMessage; const openingMessage = form.startup.openingMessage;
const openingMode = form.startup.openingMode;
const openingModeDescription = OPENING_MODE_OPTIONS.find(
(option) => option.value === openingMode,
)?.description;
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
const sectionRefs = useRef<Record<PromptSectionId, HTMLElement | null>>({ const sectionRefs = useRef<Record<PromptSectionId, HTMLElement | null>>({
conversation: null, conversation: null,
@@ -192,15 +238,15 @@ export function PromptEditor({
}); });
} }
function setOpeningMessage(enabled: boolean) { function setOpeningMode(nextMode: PromptOpeningMode) {
updateForm("startup", { updateForm("startup", {
...form.startup, ...form.startup,
openingMessage: enabled openingMode: nextMode,
openingMessage: nextMode === "confirmation"
? { ? {
title: "重要提示", title: openingMessage?.title ?? "重要提示",
message: "请确认已阅读以上信息。", message: openingMessage?.message ?? "请确认已阅读以上信息。",
confirmLabel: "确认", confirmLabel: openingMessage?.confirmLabel ?? "确认",
skipSpeechOnConfirm: true,
} }
: null, : null,
}); });
@@ -322,14 +368,33 @@ export function PromptEditor({
/> />
{form.runtimeMode === "pipeline" && ( {form.runtimeMode === "pipeline" && (
<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 <div>
title="开场确认弹窗" <div className="mb-1.5 text-sm font-medium text-foreground">
description="与开场白同时显示,用户确认后按下方策略进入对话。"
checked={Boolean(openingMessage)} </div>
onChange={setOpeningMessage} <Select
/> value={openingMode}
onValueChange={(value: PromptOpeningMode) =>
setOpeningMode(value)
}
>
<SelectTrigger className="w-full border-hairline-strong bg-background text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPENING_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
{openingModeDescription}
</p>
</div>
{openingMessage && ( {openingMode === "confirmation" && openingMessage && (
<div className="space-y-3 rounded-xl border border-hairline bg-canvas-soft p-4"> <div className="space-y-3 rounded-xl border border-hairline bg-canvas-soft p-4">
<label className="block"> <label className="block">
<span className="mb-1.5 block text-sm font-medium text-foreground"> <span className="mb-1.5 block text-sm font-medium text-foreground">
@@ -368,33 +433,39 @@ 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">
Agent
</p> </p>
</div> </div>
)} )}
<div className="border-t border-hairline pt-3"> <div className="border-t border-hairline pt-3">
<ToggleRow <div className="mb-1.5 text-sm font-medium text-foreground">
title="进入后立即回复" Agent
description="开启后由助手主动生成首句;关闭后等待用户下一轮输入。" </div>
checked={form.startup.entryMode === "generate"} <Select
onChange={(generate) => value={form.startup.entryMode}
onValueChange={(entryMode: PromptEntryMode) =>
updateForm("startup", { updateForm("startup", {
...form.startup, ...form.startup,
entryMode: generate ? "generate" : "wait_user", entryMode,
}) })
} }
/> >
<SelectTrigger className="w-full border-hairline-strong bg-background text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ENTRY_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
Agent
</p>
</div> </div>
</div> </div>
)} )}
@@ -423,10 +494,13 @@ export function PromptEditor({
if ( if (
runtimeMode === "realtime" && runtimeMode === "realtime" &&
(form.startup.actions.length || (form.startup.actions.length ||
form.startup.openingMode !== "interruptible" ||
form.startup.entryMode !== "wait_user" ||
form.startup.openingMessage) form.startup.openingMessage)
) { ) {
updateForm("startup", { updateForm("startup", {
executionMode: "sequential", executionMode: "sequential",
openingMode: "interruptible",
entryMode: "wait_user", 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",
openingMode: "interruptible",
entryMode: "wait_user", entryMode: "wait_user",
actions: [], actions: [],
openingMessage: null, openingMessage: null,
@@ -473,6 +474,7 @@ export function AssistantPage(props: AssistantPageProps) {
turnConfig: normalizeTurnConfig(a.turnConfig), turnConfig: normalizeTurnConfig(a.turnConfig),
startup: { startup: {
executionMode: "sequential", executionMode: "sequential",
openingMode: a.startup?.openingMode ?? "interruptible",
entryMode: a.startup?.entryMode ?? "wait_user", entryMode: a.startup?.entryMode ?? "wait_user",
actions: a.startup?.actions ?? [], actions: a.startup?.actions ?? [],
openingMessage: a.startup?.openingMessage ?? null, openingMessage: a.startup?.openingMessage ?? null,
@@ -548,6 +550,7 @@ export function AssistantPage(props: AssistantPageProps) {
turnConfig: defaultTurnConfig(), turnConfig: defaultTurnConfig(),
startup: { startup: {
executionMode: "sequential", executionMode: "sequential",
openingMode: "interruptible",
entryMode: "wait_user", entryMode: "wait_user",
actions: [], actions: [],
openingMessage: null, openingMessage: null,

View File

@@ -206,13 +206,13 @@ export type StartupAction = {
export type StartupConfig = { export type StartupConfig = {
executionMode: "sequential"; executionMode: "sequential";
openingMode: "interruptible" | "playback" | "confirmation";
entryMode: "wait_user" | "generate"; 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;
}; };