Add first turn option

This commit is contained in:
Xin Wang
2026-02-12 15:23:32 +08:00
parent 56ca95c200
commit edcbc2cec7
9 changed files with 97 additions and 24 deletions

View File

@@ -112,6 +112,7 @@ class Assistant(Base):
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), index=True) user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False)
call_count: Mapped[int] = mapped_column(Integer, default=0) call_count: Mapped[int] = mapped_column(Integer, default=0)
first_turn_mode: Mapped[str] = mapped_column(String(32), default="bot_first")
opener: Mapped[str] = mapped_column(Text, default="") opener: Mapped[str] = mapped_column(Text, default="")
generated_opener_enabled: Mapped[bool] = mapped_column(default=False) generated_opener_enabled: Mapped[bool] = mapped_column(default=False)
prompt: Mapped[str] = mapped_column(Text, default="") prompt: Mapped[str] = mapped_column(Text, default="")

View File

@@ -20,6 +20,7 @@ def _is_siliconflow_vendor(vendor: Optional[str]) -> bool:
def _resolve_runtime_metadata(db: Session, assistant: Assistant) -> dict: def _resolve_runtime_metadata(db: Session, assistant: Assistant) -> dict:
metadata = { metadata = {
"systemPrompt": assistant.prompt or "", "systemPrompt": assistant.prompt or "",
"firstTurnMode": assistant.first_turn_mode or "bot_first",
"greeting": assistant.opener or "", "greeting": assistant.opener or "",
"generatedOpenerEnabled": bool(assistant.generated_opener_enabled), "generatedOpenerEnabled": bool(assistant.generated_opener_enabled),
"output": {"mode": "audio" if assistant.voice_output_enabled else "text"}, "output": {"mode": "audio" if assistant.voice_output_enabled else "text"},
@@ -104,6 +105,7 @@ def assistant_to_dict(assistant: Assistant) -> dict:
"id": assistant.id, "id": assistant.id,
"name": assistant.name, "name": assistant.name,
"callCount": assistant.call_count, "callCount": assistant.call_count,
"firstTurnMode": assistant.first_turn_mode or "bot_first",
"opener": assistant.opener or "", "opener": assistant.opener or "",
"generatedOpenerEnabled": bool(assistant.generated_opener_enabled), "generatedOpenerEnabled": bool(assistant.generated_opener_enabled),
"prompt": assistant.prompt or "", "prompt": assistant.prompt or "",
@@ -131,6 +133,7 @@ def assistant_to_dict(assistant: Assistant) -> dict:
def _apply_assistant_update(assistant: Assistant, update_data: dict) -> None: def _apply_assistant_update(assistant: Assistant, update_data: dict) -> None:
field_map = { field_map = {
"knowledgeBaseId": "knowledge_base_id", "knowledgeBaseId": "knowledge_base_id",
"firstTurnMode": "first_turn_mode",
"interruptionSensitivity": "interruption_sensitivity", "interruptionSensitivity": "interruption_sensitivity",
"botCannotBeInterrupted": "bot_cannot_be_interrupted", "botCannotBeInterrupted": "bot_cannot_be_interrupted",
"configMode": "config_mode", "configMode": "config_mode",
@@ -192,6 +195,7 @@ def create_assistant(data: AssistantCreate, db: Session = Depends(get_db)):
id=str(uuid.uuid4())[:8], id=str(uuid.uuid4())[:8],
user_id=1, # 默认用户,后续添加认证 user_id=1, # 默认用户,后续添加认证
name=data.name, name=data.name,
first_turn_mode=data.firstTurnMode,
opener=data.opener, opener=data.opener,
generated_opener_enabled=data.generatedOpenerEnabled, generated_opener_enabled=data.generatedOpenerEnabled,
prompt=data.prompt, prompt=data.prompt,

View File

@@ -272,6 +272,7 @@ class ToolResourceOut(ToolResourceBase):
# ============ Assistant ============ # ============ Assistant ============
class AssistantBase(BaseModel): class AssistantBase(BaseModel):
name: str name: str
firstTurnMode: str = "bot_first"
opener: str = "" opener: str = ""
generatedOpenerEnabled: bool = False generatedOpenerEnabled: bool = False
prompt: str = "" prompt: str = ""
@@ -300,6 +301,7 @@ class AssistantCreate(AssistantBase):
class AssistantUpdate(BaseModel): class AssistantUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
firstTurnMode: Optional[str] = None
opener: Optional[str] = None opener: Optional[str] = None
generatedOpenerEnabled: Optional[bool] = None generatedOpenerEnabled: Optional[bool] = None
prompt: Optional[str] = None prompt: Optional[str] = None

View File

@@ -38,6 +38,10 @@ def migrate_db_schema():
alter_statements.append( alter_statements.append(
"ALTER TABLE assistants ADD COLUMN generated_opener_enabled BOOLEAN DEFAULT 0" "ALTER TABLE assistants ADD COLUMN generated_opener_enabled BOOLEAN DEFAULT 0"
) )
if "first_turn_mode" not in columns:
alter_statements.append(
"ALTER TABLE assistants ADD COLUMN first_turn_mode VARCHAR(32) DEFAULT 'bot_first'"
)
if "bot_cannot_be_interrupted" not in columns: if "bot_cannot_be_interrupted" not in columns:
alter_statements.append( alter_statements.append(
"ALTER TABLE assistants ADD COLUMN bot_cannot_be_interrupted BOOLEAN DEFAULT 0" "ALTER TABLE assistants ADD COLUMN bot_cannot_be_interrupted BOOLEAN DEFAULT 0"

View File

@@ -24,6 +24,7 @@ class TestAssistantAPI:
assert data["prompt"] == sample_assistant_data["prompt"] assert data["prompt"] == sample_assistant_data["prompt"]
assert data["language"] == sample_assistant_data["language"] assert data["language"] == sample_assistant_data["language"]
assert data["voiceOutputEnabled"] is True assert data["voiceOutputEnabled"] is True
assert data["firstTurnMode"] == "bot_first"
assert data["generatedOpenerEnabled"] is False assert data["generatedOpenerEnabled"] is False
assert data["botCannotBeInterrupted"] is False assert data["botCannotBeInterrupted"] is False
assert "id" in data assert "id" in data
@@ -230,6 +231,7 @@ class TestAssistantAPI:
def test_assistant_interrupt_and_generated_opener_flags(self, client, sample_assistant_data): def test_assistant_interrupt_and_generated_opener_flags(self, client, sample_assistant_data):
sample_assistant_data.update({ sample_assistant_data.update({
"firstTurnMode": "user_first",
"generatedOpenerEnabled": True, "generatedOpenerEnabled": True,
"botCannotBeInterrupted": True, "botCannotBeInterrupted": True,
"interruptionSensitivity": 900, "interruptionSensitivity": 900,
@@ -241,6 +243,7 @@ class TestAssistantAPI:
get_resp = client.get(f"/api/assistants/{assistant_id}") get_resp = client.get(f"/api/assistants/{assistant_id}")
assert get_resp.status_code == 200 assert get_resp.status_code == 200
payload = get_resp.json() payload = get_resp.json()
assert payload["firstTurnMode"] == "user_first"
assert payload["generatedOpenerEnabled"] is True assert payload["generatedOpenerEnabled"] is True
assert payload["botCannotBeInterrupted"] is True assert payload["botCannotBeInterrupted"] is True
assert payload["interruptionSensitivity"] == 900 assert payload["interruptionSensitivity"] == 900
@@ -248,6 +251,7 @@ class TestAssistantAPI:
runtime_resp = client.get(f"/api/assistants/{assistant_id}/runtime-config") runtime_resp = client.get(f"/api/assistants/{assistant_id}/runtime-config")
assert runtime_resp.status_code == 200 assert runtime_resp.status_code == 200
metadata = runtime_resp.json()["sessionStartMetadata"] metadata = runtime_resp.json()["sessionStartMetadata"]
assert metadata["firstTurnMode"] == "user_first"
assert metadata["generatedOpenerEnabled"] is True assert metadata["generatedOpenerEnabled"] is True
assert metadata["bargeIn"]["enabled"] is False assert metadata["bargeIn"]["enabled"] is False
assert metadata["bargeIn"]["minDurationMs"] == 900 assert metadata["bargeIn"]["minDurationMs"] == 900

View File

@@ -267,6 +267,7 @@ class DuplexPipeline:
self._runtime_tts: Dict[str, Any] = {} self._runtime_tts: Dict[str, Any] = {}
self._runtime_output: Dict[str, Any] = {} self._runtime_output: Dict[str, Any] = {}
self._runtime_system_prompt: Optional[str] = None self._runtime_system_prompt: Optional[str] = None
self._runtime_first_turn_mode: str = "bot_first"
self._runtime_greeting: Optional[str] = None self._runtime_greeting: Optional[str] = None
self._runtime_generated_opener_enabled: Optional[bool] = None self._runtime_generated_opener_enabled: Optional[bool] = None
self._runtime_barge_in_enabled: Optional[bool] = None self._runtime_barge_in_enabled: Optional[bool] = None
@@ -303,6 +304,9 @@ class DuplexPipeline:
self._runtime_system_prompt = str(metadata.get("systemPrompt") or "") self._runtime_system_prompt = str(metadata.get("systemPrompt") or "")
if self._runtime_system_prompt: if self._runtime_system_prompt:
self.conversation.system_prompt = self._runtime_system_prompt self.conversation.system_prompt = self._runtime_system_prompt
if "firstTurnMode" in metadata:
raw_mode = str(metadata.get("firstTurnMode") or "").strip().lower()
self._runtime_first_turn_mode = "user_first" if raw_mode == "user_first" else "bot_first"
if "greeting" in metadata: if "greeting" in metadata:
greeting_payload = metadata.get("greeting") greeting_payload = metadata.get("greeting")
if isinstance(greeting_payload, dict): if isinstance(greeting_payload, dict):
@@ -393,6 +397,9 @@ class DuplexPipeline:
def _generated_opener_enabled(self) -> bool: def _generated_opener_enabled(self) -> bool:
return self._runtime_generated_opener_enabled is True return self._runtime_generated_opener_enabled is True
def _bot_starts_first(self) -> bool:
return self._runtime_first_turn_mode != "user_first"
def _barge_in_enabled(self) -> bool: def _barge_in_enabled(self) -> bool:
if self._runtime_barge_in_enabled is not None: if self._runtime_barge_in_enabled is not None:
return self._runtime_barge_in_enabled return self._runtime_barge_in_enabled
@@ -540,6 +547,7 @@ class DuplexPipeline:
# Resolve greeting once per session start. # Resolve greeting once per session start.
# Always emit text opener event so text-only sessions can display it. # Always emit text opener event so text-only sessions can display it.
if self._bot_starts_first():
greeting_to_speak = self.conversation.greeting greeting_to_speak = self.conversation.greeting
if self._generated_opener_enabled(): if self._generated_opener_enabled():
generated_greeting = await self._generate_runtime_greeting() generated_greeting = await self._generate_runtime_greeting()

View File

@@ -117,6 +117,7 @@ export const AssistantsPage: React.FC = () => {
const handleCreate = async () => { const handleCreate = async () => {
const newAssistantPayload: Partial<Assistant> = { const newAssistantPayload: Partial<Assistant> = {
name: 'New Assistant', name: 'New Assistant',
firstTurnMode: 'bot_first',
opener: '', opener: '',
generatedOpenerEnabled: false, generatedOpenerEnabled: false,
prompt: '', prompt: '',
@@ -247,6 +248,7 @@ export const AssistantsPage: React.FC = () => {
const isExternalConfig = selectedAssistant?.configMode === 'dify' || selectedAssistant?.configMode === 'fastgpt'; const isExternalConfig = selectedAssistant?.configMode === 'dify' || selectedAssistant?.configMode === 'fastgpt';
const isNoneConfig = selectedAssistant?.configMode === 'none' || !selectedAssistant?.configMode; const isNoneConfig = selectedAssistant?.configMode === 'none' || !selectedAssistant?.configMode;
const canAdjustInterruptionSensitivity = selectedAssistant?.botCannotBeInterrupted !== true; const canAdjustInterruptionSensitivity = selectedAssistant?.botCannotBeInterrupted !== true;
const isBotFirstTurn = selectedAssistant?.firstTurnMode !== 'user_first';
return ( return (
<div className="flex h-full min-h-0 gap-6 animate-in fade-in"> <div className="flex h-full min-h-0 gap-6 animate-in fade-in">
@@ -522,28 +524,63 @@ export const AssistantsPage: React.FC = () => {
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<label className="text-sm font-medium text-white flex items-center"> <label className="text-sm font-medium text-white flex items-center">
<PhoneCall className="w-4 h-4 mr-2 text-primary"/>
</label>
<div className="inline-flex rounded-lg border border-white/10 bg-white/5 p-1">
<button
type="button"
onClick={() => updateAssistant('firstTurnMode', 'bot_first')}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
isBotFirstTurn
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
</button>
<button
type="button"
onClick={() => updateAssistant('firstTurnMode', 'user_first')}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
isBotFirstTurn
? 'text-muted-foreground hover:text-foreground'
: 'bg-primary text-primary-foreground shadow-sm'
}`}
>
</button>
</div>
</div>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className={`space-y-2 transition-opacity ${isBotFirstTurn ? 'opacity-100' : 'opacity-60'}`}>
<div className="flex items-center justify-between gap-3">
<label className={`text-sm font-medium flex items-center ${isBotFirstTurn ? 'text-white' : 'text-muted-foreground'}`}>
<MessageSquare className="w-4 h-4 mr-2 text-primary"/> <MessageSquare className="w-4 h-4 mr-2 text-primary"/>
</label> </label>
<div className="inline-flex rounded-lg border border-white/10 bg-white/5 p-1"> <div className="inline-flex rounded-lg border border-white/10 bg-white/5 p-1">
<button <button
type="button" type="button"
disabled={!isBotFirstTurn}
onClick={() => updateAssistant('generatedOpenerEnabled', false)} onClick={() => updateAssistant('generatedOpenerEnabled', false)}
className={`px-3 py-1 text-xs rounded-md transition-colors ${ className={`px-3 py-1 text-xs rounded-md transition-colors ${
selectedAssistant.generatedOpenerEnabled === true selectedAssistant.generatedOpenerEnabled === true
? 'text-muted-foreground hover:text-foreground' ? 'text-muted-foreground hover:text-foreground'
: 'bg-primary text-primary-foreground shadow-sm' : 'bg-primary text-primary-foreground shadow-sm'
}`} } disabled:opacity-50 disabled:cursor-not-allowed`}
> >
</button> </button>
<button <button
type="button" type="button"
disabled={!isBotFirstTurn}
onClick={() => updateAssistant('generatedOpenerEnabled', true)} onClick={() => updateAssistant('generatedOpenerEnabled', true)}
className={`px-3 py-1 text-xs rounded-md transition-colors ${ className={`px-3 py-1 text-xs rounded-md transition-colors ${
selectedAssistant.generatedOpenerEnabled === true selectedAssistant.generatedOpenerEnabled === true
? 'bg-primary text-primary-foreground shadow-sm' ? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground' : 'text-muted-foreground hover:text-foreground'
}`} } disabled:opacity-50 disabled:cursor-not-allowed`}
> >
</button> </button>
@@ -552,12 +589,20 @@ export const AssistantsPage: React.FC = () => {
<Input <Input
value={selectedAssistant.opener} value={selectedAssistant.opener}
onChange={(e) => updateAssistant('opener', e.target.value)} onChange={(e) => updateAssistant('opener', e.target.value)}
placeholder={selectedAssistant.generatedOpenerEnabled === true ? '将基于提示词自动生成开场白' : '例如您好我是您的专属AI助手...'} placeholder={
disabled={selectedAssistant.generatedOpenerEnabled === true} !isBotFirstTurn
? '当前为用户先说,开场白不会在首轮触发'
: selectedAssistant.generatedOpenerEnabled === true
? '将基于提示词自动生成开场白'
: '例如您好我是您的专属AI助手...'
}
disabled={!isBotFirstTurn || selectedAssistant.generatedOpenerEnabled === true}
className="bg-white/5 border-white/10 focus:border-primary/50 disabled:opacity-50 disabled:cursor-not-allowed" className="bg-white/5 border-white/10 focus:border-primary/50 disabled:opacity-50 disabled:cursor-not-allowed"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{selectedAssistant.generatedOpenerEnabled === true {!isBotFirstTurn
? '已切换为“用户先说”,首轮不会发送开场白。'
: selectedAssistant.generatedOpenerEnabled === true
? '通话接通后将根据提示词自动生成开场白。' ? '通话接通后将根据提示词自动生成开场白。'
: '接通通话后的第一句话。'} : '接通通话后的第一句话。'}
</p> </p>
@@ -1836,6 +1881,7 @@ export const DebugDrawer: React.FC<{
mode: ttsEnabled ? 'audio' : 'text', mode: ttsEnabled ? 'audio' : 'text',
}, },
systemPrompt: assistant.prompt || '', systemPrompt: assistant.prompt || '',
firstTurnMode: assistant.firstTurnMode || 'bot_first',
greeting: assistant.opener || '', greeting: assistant.opener || '',
generatedOpenerEnabled: assistant.generatedOpenerEnabled === true, generatedOpenerEnabled: assistant.generatedOpenerEnabled === true,
bargeIn: { bargeIn: {

View File

@@ -29,6 +29,7 @@ const mapAssistant = (raw: AnyRecord): Assistant => ({
id: String(readField(raw, ['id'], '')), id: String(readField(raw, ['id'], '')),
name: readField(raw, ['name'], ''), name: readField(raw, ['name'], ''),
callCount: Number(readField(raw, ['callCount', 'call_count'], 0)), callCount: Number(readField(raw, ['callCount', 'call_count'], 0)),
firstTurnMode: readField(raw, ['firstTurnMode', 'first_turn_mode'], 'bot_first') as 'bot_first' | 'user_first',
opener: readField(raw, ['opener'], ''), opener: readField(raw, ['opener'], ''),
generatedOpenerEnabled: Boolean(readField(raw, ['generatedOpenerEnabled', 'generated_opener_enabled'], false)), generatedOpenerEnabled: Boolean(readField(raw, ['generatedOpenerEnabled', 'generated_opener_enabled'], false)),
prompt: readField(raw, ['prompt'], ''), prompt: readField(raw, ['prompt'], ''),
@@ -213,6 +214,7 @@ export const fetchAssistants = async (): Promise<Assistant[]> => {
export const createAssistant = async (data: Partial<Assistant>): Promise<Assistant> => { export const createAssistant = async (data: Partial<Assistant>): Promise<Assistant> => {
const payload = { const payload = {
name: data.name || 'New Assistant', name: data.name || 'New Assistant',
firstTurnMode: data.firstTurnMode || 'bot_first',
opener: data.opener || '', opener: data.opener || '',
generatedOpenerEnabled: data.generatedOpenerEnabled ?? false, generatedOpenerEnabled: data.generatedOpenerEnabled ?? false,
prompt: data.prompt || '', prompt: data.prompt || '',
@@ -240,6 +242,7 @@ export const createAssistant = async (data: Partial<Assistant>): Promise<Assista
export const updateAssistant = async (id: string, data: Partial<Assistant>): Promise<Assistant> => { export const updateAssistant = async (id: string, data: Partial<Assistant>): Promise<Assistant> => {
const payload = { const payload = {
name: data.name, name: data.name,
firstTurnMode: data.firstTurnMode,
opener: data.opener, opener: data.opener,
generatedOpenerEnabled: data.generatedOpenerEnabled, generatedOpenerEnabled: data.generatedOpenerEnabled,
prompt: data.prompt, prompt: data.prompt,

View File

@@ -3,6 +3,7 @@ export interface Assistant {
id: string; id: string;
name: string; name: string;
callCount: number; callCount: number;
firstTurnMode?: 'bot_first' | 'user_first';
opener: string; opener: string;
generatedOpenerEnabled?: boolean; generatedOpenerEnabled?: boolean;
prompt: string; prompt: string;