Add presence probe configuration to Assistant model and API. Introduce new fields for enabling presence probes, idle and cooldown durations, maximum prompts, context inclusion, and custom questions. Update schemas, routers, and frontend components to support these features, along with corresponding tests to ensure functionality.

This commit is contained in:
Xin Wang
2026-02-28 15:47:53 +08:00
parent 0821d73e7c
commit 8f1317860f
11 changed files with 1006 additions and 3 deletions

View File

@@ -128,6 +128,12 @@ class Assistant(Base):
tools: Mapped[dict] = mapped_column(JSON, default=list) tools: Mapped[dict] = mapped_column(JSON, default=list)
bot_cannot_be_interrupted: Mapped[bool] = mapped_column(default=False) bot_cannot_be_interrupted: Mapped[bool] = mapped_column(default=False)
interruption_sensitivity: Mapped[int] = mapped_column(Integer, default=500) interruption_sensitivity: Mapped[int] = mapped_column(Integer, default=500)
presence_probe_enabled: Mapped[bool] = mapped_column(default=False)
presence_probe_idle_seconds: Mapped[float] = mapped_column(Float, default=20.0)
presence_probe_cooldown_seconds: Mapped[float] = mapped_column(Float, default=45.0)
presence_probe_max_prompts: Mapped[int] = mapped_column(Integer, default=2)
presence_probe_include_context: Mapped[bool] = mapped_column(default=True)
presence_probe_question: Mapped[str] = mapped_column(Text, default="")
config_mode: Mapped[str] = mapped_column(String(32), default="platform") config_mode: Mapped[str] = mapped_column(String(32), default="platform")
api_url: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) api_url: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
api_key: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) api_key: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)

View File

@@ -8,6 +8,7 @@ import httpx
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import inspect, text
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import uuid import uuid
from datetime import datetime from datetime import datetime
@@ -35,6 +36,14 @@ router = APIRouter(prefix="/assistants", tags=["Assistants"])
OPENAI_COMPATIBLE_DEFAULT_MODEL = "FunAudioLLM/CosyVoice2-0.5B" OPENAI_COMPATIBLE_DEFAULT_MODEL = "FunAudioLLM/CosyVoice2-0.5B"
OPENAI_COMPATIBLE_DEFAULT_BASE_URL = "https://api.siliconflow.cn/v1" OPENAI_COMPATIBLE_DEFAULT_BASE_URL = "https://api.siliconflow.cn/v1"
OPENER_AUDIO_DIR = Path(__file__).resolve().parents[2] / "data" / "opener_audio" OPENER_AUDIO_DIR = Path(__file__).resolve().parents[2] / "data" / "opener_audio"
PRESENCE_PROBE_MIN_IDLE_SECONDS = 5.0
PRESENCE_PROBE_MAX_IDLE_SECONDS = 3600.0
PRESENCE_PROBE_DEFAULT_IDLE_SECONDS = 20.0
PRESENCE_PROBE_MIN_COOLDOWN_SECONDS = 5.0
PRESENCE_PROBE_MAX_COOLDOWN_SECONDS = 7200.0
PRESENCE_PROBE_DEFAULT_COOLDOWN_SECONDS = 45.0
PRESENCE_PROBE_MAX_PROMPTS_CAP = 10
PRESENCE_PROBE_DEFAULT_MAX_PROMPTS = 2
OPENAI_COMPATIBLE_KNOWN_VOICES = { OPENAI_COMPATIBLE_KNOWN_VOICES = {
"alex", "alex",
"anna", "anna",
@@ -85,6 +94,125 @@ def _config_version_id(assistant: Assistant) -> str:
return f"asst_{assistant.id}_{updated.strftime('%Y%m%d%H%M%S')}" return f"asst_{assistant.id}_{updated.strftime('%Y%m%d%H%M%S')}"
def _ensure_assistant_schema(db: Session) -> None:
"""Apply lightweight SQLite migrations for newly added assistant columns."""
bind = db.get_bind()
inspector = inspect(bind)
try:
columns = {col["name"] for col in inspector.get_columns("assistants")}
except Exception:
return
altered = False
if "presence_probe_enabled" not in columns:
db.execute(text("ALTER TABLE assistants ADD COLUMN presence_probe_enabled BOOLEAN DEFAULT 0"))
altered = True
if "presence_probe_idle_seconds" not in columns:
db.execute(
text(
"ALTER TABLE assistants ADD COLUMN presence_probe_idle_seconds FLOAT DEFAULT 20.0"
)
)
altered = True
if "presence_probe_cooldown_seconds" not in columns:
db.execute(
text(
"ALTER TABLE assistants ADD COLUMN presence_probe_cooldown_seconds FLOAT DEFAULT 45.0"
)
)
altered = True
if "presence_probe_max_prompts" not in columns:
db.execute(text("ALTER TABLE assistants ADD COLUMN presence_probe_max_prompts INTEGER DEFAULT 2"))
altered = True
if "presence_probe_include_context" not in columns:
db.execute(
text("ALTER TABLE assistants ADD COLUMN presence_probe_include_context BOOLEAN DEFAULT 1")
)
altered = True
if "presence_probe_question" not in columns:
db.execute(text("ALTER TABLE assistants ADD COLUMN presence_probe_question TEXT DEFAULT ''"))
altered = True
if altered:
db.commit()
def _coerce_bounded_float(
raw_value: Any,
*,
default_value: float,
min_value: float,
max_value: float,
) -> float:
if isinstance(raw_value, (int, float)):
parsed = float(raw_value)
elif isinstance(raw_value, str):
try:
parsed = float(raw_value.strip())
except ValueError:
parsed = default_value
else:
parsed = default_value
if parsed < min_value:
return min_value
if parsed > max_value:
return max_value
return parsed
def _coerce_bounded_int(
raw_value: Any,
*,
default_value: int,
min_value: int,
max_value: int,
) -> int:
if isinstance(raw_value, (int, float)):
parsed = int(raw_value)
elif isinstance(raw_value, str):
try:
parsed = int(raw_value.strip())
except ValueError:
parsed = default_value
else:
parsed = default_value
if parsed < min_value:
return min_value
if parsed > max_value:
return max_value
return parsed
def _resolve_presence_probe_config_from_assistant(assistant: Assistant) -> Dict[str, Any]:
question = str(assistant.presence_probe_question or "").strip()
if len(question) > 160:
question = question[:160]
include_context_raw = getattr(assistant, "presence_probe_include_context", True)
include_context = True if include_context_raw is None else bool(include_context_raw)
return {
"enabled": bool(assistant.presence_probe_enabled),
"idleSeconds": _coerce_bounded_float(
assistant.presence_probe_idle_seconds,
default_value=PRESENCE_PROBE_DEFAULT_IDLE_SECONDS,
min_value=PRESENCE_PROBE_MIN_IDLE_SECONDS,
max_value=PRESENCE_PROBE_MAX_IDLE_SECONDS,
),
"cooldownSeconds": _coerce_bounded_float(
assistant.presence_probe_cooldown_seconds,
default_value=PRESENCE_PROBE_DEFAULT_COOLDOWN_SECONDS,
min_value=PRESENCE_PROBE_MIN_COOLDOWN_SECONDS,
max_value=PRESENCE_PROBE_MAX_COOLDOWN_SECONDS,
),
"maxPrompts": _coerce_bounded_int(
assistant.presence_probe_max_prompts,
default_value=PRESENCE_PROBE_DEFAULT_MAX_PROMPTS,
min_value=1,
max_value=PRESENCE_PROBE_MAX_PROMPTS_CAP,
),
"includeContext": include_context,
"question": question,
}
def _normalize_runtime_tool_schema(tool_id: str, raw_schema: Any) -> Dict[str, Any]: def _normalize_runtime_tool_schema(tool_id: str, raw_schema: Any) -> Dict[str, Any]:
schema = dict(raw_schema) if isinstance(raw_schema, dict) else {} schema = dict(raw_schema) if isinstance(raw_schema, dict) else {}
if not schema: if not schema:
@@ -182,6 +310,7 @@ def _resolve_runtime_tools(db: Session, selected_tool_ids: List[str], warnings:
def _resolve_runtime_metadata(db: Session, assistant: Assistant) -> tuple[Dict[str, Any], List[str]]: def _resolve_runtime_metadata(db: Session, assistant: Assistant) -> tuple[Dict[str, Any], List[str]]:
warnings: List[str] = [] warnings: List[str] = []
presence_probe_cfg = _resolve_presence_probe_config_from_assistant(assistant)
metadata: Dict[str, Any] = { metadata: Dict[str, Any] = {
"systemPrompt": _compose_runtime_system_prompt(assistant.prompt), "systemPrompt": _compose_runtime_system_prompt(assistant.prompt),
"firstTurnMode": assistant.first_turn_mode or "bot_first", "firstTurnMode": assistant.first_turn_mode or "bot_first",
@@ -199,6 +328,20 @@ def _resolve_runtime_metadata(db: Session, assistant: Assistant) -> tuple[Dict[s
"userId": int(assistant.user_id or 1), "userId": int(assistant.user_id or 1),
"source": "debug", "source": "debug",
}, },
"presenceProbe": {
"enabled": bool(presence_probe_cfg.get("enabled")),
"idleSeconds": float(presence_probe_cfg.get("idleSeconds") or PRESENCE_PROBE_DEFAULT_IDLE_SECONDS),
"cooldownSeconds": float(
presence_probe_cfg.get("cooldownSeconds") or PRESENCE_PROBE_DEFAULT_COOLDOWN_SECONDS
),
"maxPrompts": int(presence_probe_cfg.get("maxPrompts") or PRESENCE_PROBE_DEFAULT_MAX_PROMPTS),
"includeContext": bool(presence_probe_cfg.get("includeContext", True)),
**(
{"question": str(presence_probe_cfg.get("question") or "")}
if str(presence_probe_cfg.get("question") or "").strip()
else {}
),
},
} }
config_mode = str(assistant.config_mode or "platform").strip().lower() config_mode = str(assistant.config_mode or "platform").strip().lower()
@@ -321,6 +464,7 @@ def _build_engine_assistant_config(db: Session, assistant: Assistant) -> Dict[st
def assistant_to_dict(assistant: Assistant) -> dict: def assistant_to_dict(assistant: Assistant) -> dict:
opener_audio = assistant.opener_audio opener_audio = assistant.opener_audio
opener_audio_ready = bool(opener_audio and opener_audio.file_path and Path(opener_audio.file_path).exists()) opener_audio_ready = bool(opener_audio and opener_audio.file_path and Path(opener_audio.file_path).exists())
presence_probe_cfg = _resolve_presence_probe_config_from_assistant(assistant)
return { return {
"id": assistant.id, "id": assistant.id,
"name": assistant.name, "name": assistant.name,
@@ -342,6 +486,18 @@ def assistant_to_dict(assistant: Assistant) -> dict:
"tools": assistant.tools or [], "tools": assistant.tools or [],
"botCannotBeInterrupted": bool(assistant.bot_cannot_be_interrupted), "botCannotBeInterrupted": bool(assistant.bot_cannot_be_interrupted),
"interruptionSensitivity": assistant.interruption_sensitivity, "interruptionSensitivity": assistant.interruption_sensitivity,
"presenceProbeEnabled": bool(presence_probe_cfg.get("enabled")),
"presenceProbeIdleSeconds": float(
presence_probe_cfg.get("idleSeconds") or PRESENCE_PROBE_DEFAULT_IDLE_SECONDS
),
"presenceProbeCooldownSeconds": float(
presence_probe_cfg.get("cooldownSeconds") or PRESENCE_PROBE_DEFAULT_COOLDOWN_SECONDS
),
"presenceProbeMaxPrompts": int(
presence_probe_cfg.get("maxPrompts") or PRESENCE_PROBE_DEFAULT_MAX_PROMPTS
),
"presenceProbeIncludeContext": bool(presence_probe_cfg.get("includeContext", True)),
"presenceProbeQuestion": str(presence_probe_cfg.get("question") or ""),
"configMode": assistant.config_mode, "configMode": assistant.config_mode,
"apiUrl": assistant.api_url, "apiUrl": assistant.api_url,
"apiKey": assistant.api_key, "apiKey": assistant.api_key,
@@ -360,6 +516,12 @@ def _apply_assistant_update(assistant: Assistant, update_data: dict) -> None:
"firstTurnMode": "first_turn_mode", "firstTurnMode": "first_turn_mode",
"interruptionSensitivity": "interruption_sensitivity", "interruptionSensitivity": "interruption_sensitivity",
"botCannotBeInterrupted": "bot_cannot_be_interrupted", "botCannotBeInterrupted": "bot_cannot_be_interrupted",
"presenceProbeEnabled": "presence_probe_enabled",
"presenceProbeIdleSeconds": "presence_probe_idle_seconds",
"presenceProbeCooldownSeconds": "presence_probe_cooldown_seconds",
"presenceProbeMaxPrompts": "presence_probe_max_prompts",
"presenceProbeIncludeContext": "presence_probe_include_context",
"presenceProbeQuestion": "presence_probe_question",
"configMode": "config_mode", "configMode": "config_mode",
"voiceOutputEnabled": "voice_output_enabled", "voiceOutputEnabled": "voice_output_enabled",
"generatedOpenerEnabled": "generated_opener_enabled", "generatedOpenerEnabled": "generated_opener_enabled",
@@ -371,7 +533,37 @@ def _apply_assistant_update(assistant: Assistant, update_data: dict) -> None:
"rerankModelId": "rerank_model_id", "rerankModelId": "rerank_model_id",
} }
for field, value in update_data.items(): for field, value in update_data.items():
setattr(assistant, field_map.get(field, field), value) target = field_map.get(field, field)
if target == "presence_probe_idle_seconds":
value = _coerce_bounded_float(
value,
default_value=PRESENCE_PROBE_DEFAULT_IDLE_SECONDS,
min_value=PRESENCE_PROBE_MIN_IDLE_SECONDS,
max_value=PRESENCE_PROBE_MAX_IDLE_SECONDS,
)
elif target == "presence_probe_cooldown_seconds":
value = _coerce_bounded_float(
value,
default_value=PRESENCE_PROBE_DEFAULT_COOLDOWN_SECONDS,
min_value=PRESENCE_PROBE_MIN_COOLDOWN_SECONDS,
max_value=PRESENCE_PROBE_MAX_COOLDOWN_SECONDS,
)
elif target == "presence_probe_max_prompts":
value = _coerce_bounded_int(
value,
default_value=PRESENCE_PROBE_DEFAULT_MAX_PROMPTS,
min_value=1,
max_value=PRESENCE_PROBE_MAX_PROMPTS_CAP,
)
elif target == "presence_probe_question":
value = str(value or "").strip()
if len(value) > 160:
value = value[:160]
elif target == "presence_probe_enabled":
value = bool(value)
elif target == "presence_probe_include_context":
value = bool(value)
setattr(assistant, target, value)
def _ensure_assistant_opener_audio(db: Session, assistant: Assistant) -> AssistantOpenerAudio: def _ensure_assistant_opener_audio(db: Session, assistant: Assistant) -> AssistantOpenerAudio:
@@ -490,6 +682,7 @@ def list_assistants(
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""获取助手列表""" """获取助手列表"""
_ensure_assistant_schema(db)
query = db.query(Assistant) query = db.query(Assistant)
total = query.count() total = query.count()
assistants = query.order_by(Assistant.created_at.desc()) \ assistants = query.order_by(Assistant.created_at.desc()) \
@@ -505,6 +698,7 @@ def list_assistants(
@router.get("/{id}", response_model=AssistantOut) @router.get("/{id}", response_model=AssistantOut)
def get_assistant(id: str, db: Session = Depends(get_db)): def get_assistant(id: str, db: Session = Depends(get_db)):
"""获取单个助手详情""" """获取单个助手详情"""
_ensure_assistant_schema(db)
assistant = db.query(Assistant).filter(Assistant.id == id).first() assistant = db.query(Assistant).filter(Assistant.id == id).first()
if not assistant: if not assistant:
raise HTTPException(status_code=404, detail="Assistant not found") raise HTTPException(status_code=404, detail="Assistant not found")
@@ -514,6 +708,7 @@ def get_assistant(id: str, db: Session = Depends(get_db)):
@router.get("/{id}/config", response_model=AssistantEngineConfigResponse) @router.get("/{id}/config", response_model=AssistantEngineConfigResponse)
def get_assistant_config(id: str, db: Session = Depends(get_db)): def get_assistant_config(id: str, db: Session = Depends(get_db)):
"""Canonical engine config endpoint consumed by engine backend adapter.""" """Canonical engine config endpoint consumed by engine backend adapter."""
_ensure_assistant_schema(db)
assistant = db.query(Assistant).filter(Assistant.id == id).first() assistant = db.query(Assistant).filter(Assistant.id == id).first()
if not assistant: if not assistant:
raise HTTPException(status_code=404, detail="Assistant not found") raise HTTPException(status_code=404, detail="Assistant not found")
@@ -523,6 +718,7 @@ def get_assistant_config(id: str, db: Session = Depends(get_db)):
@router.get("/{id}/runtime-config", response_model=AssistantEngineConfigResponse) @router.get("/{id}/runtime-config", response_model=AssistantEngineConfigResponse)
def get_assistant_runtime_config(id: str, db: Session = Depends(get_db)): def get_assistant_runtime_config(id: str, db: Session = Depends(get_db)):
"""Legacy alias for resolved engine runtime config.""" """Legacy alias for resolved engine runtime config."""
_ensure_assistant_schema(db)
assistant = db.query(Assistant).filter(Assistant.id == id).first() assistant = db.query(Assistant).filter(Assistant.id == id).first()
if not assistant: if not assistant:
raise HTTPException(status_code=404, detail="Assistant not found") raise HTTPException(status_code=404, detail="Assistant not found")
@@ -532,6 +728,7 @@ def get_assistant_runtime_config(id: str, db: Session = Depends(get_db)):
@router.post("", response_model=AssistantOut) @router.post("", response_model=AssistantOut)
def create_assistant(data: AssistantCreate, db: Session = Depends(get_db)): def create_assistant(data: AssistantCreate, db: Session = Depends(get_db)):
"""创建新助手""" """创建新助手"""
_ensure_assistant_schema(db)
assistant = Assistant( assistant = Assistant(
id=str(uuid.uuid4())[:8], id=str(uuid.uuid4())[:8],
user_id=1, # 默认用户,后续添加认证 user_id=1, # 默认用户,后续添加认证
@@ -549,6 +746,27 @@ def create_assistant(data: AssistantCreate, db: Session = Depends(get_db)):
tools=data.tools, tools=data.tools,
bot_cannot_be_interrupted=data.botCannotBeInterrupted, bot_cannot_be_interrupted=data.botCannotBeInterrupted,
interruption_sensitivity=data.interruptionSensitivity, interruption_sensitivity=data.interruptionSensitivity,
presence_probe_enabled=bool(data.presenceProbeEnabled),
presence_probe_idle_seconds=_coerce_bounded_float(
data.presenceProbeIdleSeconds,
default_value=PRESENCE_PROBE_DEFAULT_IDLE_SECONDS,
min_value=PRESENCE_PROBE_MIN_IDLE_SECONDS,
max_value=PRESENCE_PROBE_MAX_IDLE_SECONDS,
),
presence_probe_cooldown_seconds=_coerce_bounded_float(
data.presenceProbeCooldownSeconds,
default_value=PRESENCE_PROBE_DEFAULT_COOLDOWN_SECONDS,
min_value=PRESENCE_PROBE_MIN_COOLDOWN_SECONDS,
max_value=PRESENCE_PROBE_MAX_COOLDOWN_SECONDS,
),
presence_probe_max_prompts=_coerce_bounded_int(
data.presenceProbeMaxPrompts,
default_value=PRESENCE_PROBE_DEFAULT_MAX_PROMPTS,
min_value=1,
max_value=PRESENCE_PROBE_MAX_PROMPTS_CAP,
),
presence_probe_include_context=bool(data.presenceProbeIncludeContext),
presence_probe_question=str(data.presenceProbeQuestion or "").strip()[:160],
config_mode=data.configMode, config_mode=data.configMode,
api_url=data.apiUrl, api_url=data.apiUrl,
api_key=data.apiKey, api_key=data.apiKey,
@@ -570,6 +788,7 @@ def create_assistant(data: AssistantCreate, db: Session = Depends(get_db)):
@router.get("/{id}/opener-audio", response_model=AssistantOpenerAudioOut) @router.get("/{id}/opener-audio", response_model=AssistantOpenerAudioOut)
def get_assistant_opener_audio(id: str, db: Session = Depends(get_db)): def get_assistant_opener_audio(id: str, db: Session = Depends(get_db)):
_ensure_assistant_schema(db)
assistant = db.query(Assistant).filter(Assistant.id == id).first() assistant = db.query(Assistant).filter(Assistant.id == id).first()
if not assistant: if not assistant:
raise HTTPException(status_code=404, detail="Assistant not found") raise HTTPException(status_code=404, detail="Assistant not found")
@@ -578,6 +797,7 @@ def get_assistant_opener_audio(id: str, db: Session = Depends(get_db)):
@router.get("/{id}/opener-audio/pcm") @router.get("/{id}/opener-audio/pcm")
def get_assistant_opener_audio_pcm(id: str, db: Session = Depends(get_db)): def get_assistant_opener_audio_pcm(id: str, db: Session = Depends(get_db)):
_ensure_assistant_schema(db)
assistant = db.query(Assistant).filter(Assistant.id == id).first() assistant = db.query(Assistant).filter(Assistant.id == id).first()
if not assistant: if not assistant:
raise HTTPException(status_code=404, detail="Assistant not found") raise HTTPException(status_code=404, detail="Assistant not found")
@@ -600,6 +820,7 @@ def generate_assistant_opener_audio(
data: AssistantOpenerAudioGenerateRequest, data: AssistantOpenerAudioGenerateRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
_ensure_assistant_schema(db)
assistant = db.query(Assistant).filter(Assistant.id == id).first() assistant = db.query(Assistant).filter(Assistant.id == id).first()
if not assistant: if not assistant:
raise HTTPException(status_code=404, detail="Assistant not found") raise HTTPException(status_code=404, detail="Assistant not found")
@@ -689,6 +910,7 @@ def generate_assistant_opener_audio(
@router.put("/{id}") @router.put("/{id}")
def update_assistant(id: str, data: AssistantUpdate, db: Session = Depends(get_db)): def update_assistant(id: str, data: AssistantUpdate, db: Session = Depends(get_db)):
"""更新助手""" """更新助手"""
_ensure_assistant_schema(db)
assistant = db.query(Assistant).filter(Assistant.id == id).first() assistant = db.query(Assistant).filter(Assistant.id == id).first()
if not assistant: if not assistant:
raise HTTPException(status_code=404, detail="Assistant not found") raise HTTPException(status_code=404, detail="Assistant not found")
@@ -710,6 +932,7 @@ def update_assistant(id: str, data: AssistantUpdate, db: Session = Depends(get_d
@router.delete("/{id}") @router.delete("/{id}")
def delete_assistant(id: str, db: Session = Depends(get_db)): def delete_assistant(id: str, db: Session = Depends(get_db)):
"""删除助手""" """删除助手"""
_ensure_assistant_schema(db)
assistant = db.query(Assistant).filter(Assistant.id == id).first() assistant = db.query(Assistant).filter(Assistant.id == id).first()
if not assistant: if not assistant:
raise HTTPException(status_code=404, detail="Assistant not found") raise HTTPException(status_code=404, detail="Assistant not found")

View File

@@ -292,6 +292,12 @@ class AssistantBase(BaseModel):
tools: List[str] = [] tools: List[str] = []
botCannotBeInterrupted: bool = False botCannotBeInterrupted: bool = False
interruptionSensitivity: int = 500 interruptionSensitivity: int = 500
presenceProbeEnabled: bool = False
presenceProbeIdleSeconds: float = 20.0
presenceProbeCooldownSeconds: float = 45.0
presenceProbeMaxPrompts: int = 2
presenceProbeIncludeContext: bool = True
presenceProbeQuestion: str = ""
configMode: str = "platform" configMode: str = "platform"
apiUrl: Optional[str] = None apiUrl: Optional[str] = None
apiKey: Optional[str] = None apiKey: Optional[str] = None
@@ -322,6 +328,12 @@ class AssistantUpdate(BaseModel):
tools: Optional[List[str]] = None tools: Optional[List[str]] = None
botCannotBeInterrupted: Optional[bool] = None botCannotBeInterrupted: Optional[bool] = None
interruptionSensitivity: Optional[int] = None interruptionSensitivity: Optional[int] = None
presenceProbeEnabled: Optional[bool] = None
presenceProbeIdleSeconds: Optional[float] = None
presenceProbeCooldownSeconds: Optional[float] = None
presenceProbeMaxPrompts: Optional[int] = None
presenceProbeIncludeContext: Optional[bool] = None
presenceProbeQuestion: Optional[str] = None
configMode: Optional[str] = None configMode: Optional[str] = None
apiUrl: Optional[str] = None apiUrl: Optional[str] = None
apiKey: Optional[str] = None apiKey: Optional[str] = None

View File

@@ -307,6 +307,37 @@ class TestAssistantAPI:
assert tts["apiKey"] == "dashscope-key" assert tts["apiKey"] == "dashscope-key"
assert tts["baseUrl"] == "wss://dashscope.aliyuncs.com/api-ws/v1/realtime" assert tts["baseUrl"] == "wss://dashscope.aliyuncs.com/api-ws/v1/realtime"
def test_presence_probe_config_is_persisted_and_exposed(self, client, sample_assistant_data):
sample_assistant_data.update({
"presenceProbeEnabled": True,
"presenceProbeIdleSeconds": 18,
"presenceProbeCooldownSeconds": 52,
"presenceProbeMaxPrompts": 3,
"presenceProbeIncludeContext": False,
"presenceProbeQuestion": "你还在吗?",
})
assistant_resp = client.post("/api/assistants", json=sample_assistant_data)
assert assistant_resp.status_code == 200
payload = assistant_resp.json()
assistant_id = payload["id"]
assert payload["presenceProbeEnabled"] is True
assert payload["presenceProbeIdleSeconds"] == 18
assert payload["presenceProbeCooldownSeconds"] == 52
assert payload["presenceProbeMaxPrompts"] == 3
assert payload["presenceProbeIncludeContext"] is False
assert payload["presenceProbeQuestion"] == "你还在吗?"
runtime_resp = client.get(f"/api/assistants/{assistant_id}/runtime-config")
assert runtime_resp.status_code == 200
metadata = runtime_resp.json()["sessionStartMetadata"]
probe = metadata["presenceProbe"]
assert probe["enabled"] is True
assert probe["idleSeconds"] == 18
assert probe["cooldownSeconds"] == 52
assert probe["maxPrompts"] == 3
assert probe["includeContext"] is False
assert probe["question"] == "你还在吗?"
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", "firstTurnMode": "user_first",

View File

@@ -74,6 +74,25 @@ class DuplexPipeline:
_LLM_DELTA_THROTTLE_MS = 80 _LLM_DELTA_THROTTLE_MS = 80
_ASR_CAPTURE_MAX_MS = 15000 _ASR_CAPTURE_MAX_MS = 15000
_OPENER_PRE_ROLL_MS = 180 _OPENER_PRE_ROLL_MS = 180
_PRESENCE_PROBE_LOOP_INTERVAL_SECONDS = 1.0
_PRESENCE_PROBE_MIN_IDLE_SECONDS = 5
_PRESENCE_PROBE_MAX_IDLE_SECONDS = 3600
_PRESENCE_PROBE_DEFAULT_IDLE_SECONDS = 30
_PRESENCE_PROBE_MIN_COOLDOWN_SECONDS = 5
_PRESENCE_PROBE_MAX_COOLDOWN_SECONDS = 7200
_PRESENCE_PROBE_DEFAULT_COOLDOWN_SECONDS = 90
_PRESENCE_PROBE_DEFAULT_MAX_PROMPTS = 2
_PRESENCE_PROBE_MAX_PROMPTS_CAP = 10
_PRESENCE_PROBE_CONTEXT_CHARS = 36
_PRESENCE_PROBE_AWAY_VALUE_HINTS = (
"away",
"later",
"not_now",
"leave",
"离开",
"稍后",
"不在",
)
_DEFAULT_TOOL_SCHEMAS: Dict[str, Dict[str, Any]] = { _DEFAULT_TOOL_SCHEMAS: Dict[str, Dict[str, Any]] = {
"current_time": { "current_time": {
"name": "current_time", "name": "current_time",
@@ -168,6 +187,23 @@ class DuplexPipeline:
"required": ["msg"], "required": ["msg"],
}, },
}, },
"choice_prompt": {
"name": "choice_prompt",
"description": "Show a multiple-choice prompt dialog on client side",
"parameters": {
"type": "object",
"properties": {
"question": {"type": "string", "description": "Question text to ask"},
"options": {
"type": "array",
"description": "Selectable options",
"items": {"type": "string"},
"minItems": 2,
},
},
"required": ["question", "options"],
},
},
} }
_DEFAULT_CLIENT_EXECUTORS = frozenset({ _DEFAULT_CLIENT_EXECUTORS = frozenset({
"turn_on_camera", "turn_on_camera",
@@ -176,6 +212,7 @@ class DuplexPipeline:
"decrease_volume", "decrease_volume",
"voice_message_prompt", "voice_message_prompt",
"text_msg_prompt", "text_msg_prompt",
"choice_prompt",
}) })
def __init__( def __init__(
@@ -308,6 +345,16 @@ class DuplexPipeline:
self._runtime_barge_in_min_duration_ms: Optional[int] = None self._runtime_barge_in_min_duration_ms: Optional[int] = None
self._runtime_knowledge: Dict[str, Any] = {} self._runtime_knowledge: Dict[str, Any] = {}
self._runtime_knowledge_base_id: Optional[str] = None self._runtime_knowledge_base_id: Optional[str] = None
self._runtime_presence_probe: Dict[str, Any] = {
"enabled": False,
"idleSeconds": self._PRESENCE_PROBE_DEFAULT_IDLE_SECONDS,
"cooldownSeconds": self._PRESENCE_PROBE_DEFAULT_COOLDOWN_SECONDS,
"maxPrompts": self._PRESENCE_PROBE_DEFAULT_MAX_PROMPTS,
"includeContext": True,
"question": "",
"waitForResponse": True,
"options": self._default_presence_probe_options(),
}
raw_default_tools = settings.tools if isinstance(settings.tools, list) else [] raw_default_tools = settings.tools if isinstance(settings.tools, list) else []
self._runtime_tools: List[Any] = list(raw_default_tools) self._runtime_tools: List[Any] = list(raw_default_tools)
self._runtime_tool_executor: Dict[str, str] = {} self._runtime_tool_executor: Dict[str, str] = {}
@@ -333,6 +380,13 @@ class DuplexPipeline:
self._current_tts_id: Optional[str] = None self._current_tts_id: Optional[str] = None
self._pending_llm_delta: str = "" self._pending_llm_delta: str = ""
self._last_llm_delta_emit_ms: float = 0.0 self._last_llm_delta_emit_ms: float = 0.0
now_ms = time.monotonic() * 1000.0
self._last_user_activity_ms: float = now_ms
self._last_presence_probe_ms: float = 0.0
self._presence_probe_attempts: int = 0
self._presence_probe_seq: int = 0
self._active_presence_probe_call_id: Optional[str] = None
self._presence_probe_task: Optional[asyncio.Task] = None
self._runtime_tool_executor = self._resolved_tool_executor_map() self._runtime_tool_executor = self._resolved_tool_executor_map()
self._runtime_tool_default_args = self._resolved_tool_default_args_map() self._runtime_tool_default_args = self._resolved_tool_default_args_map()
@@ -432,7 +486,10 @@ class DuplexPipeline:
opener_audio = metadata.get("openerAudio") opener_audio = metadata.get("openerAudio")
if isinstance(opener_audio, dict): if isinstance(opener_audio, dict):
self._runtime_opener_audio = dict(opener_audio) self._runtime_opener_audio = dict(opener_audio)
kb_id = str(knowledge.get("kbId") or knowledge.get("knowledgeBaseId") or "").strip() knowledge_payload = knowledge if isinstance(knowledge, dict) else {}
kb_id = str(
knowledge_payload.get("kbId") or knowledge_payload.get("knowledgeBaseId") or ""
).strip()
if kb_id: if kb_id:
self._runtime_knowledge_base_id = kb_id self._runtime_knowledge_base_id = kb_id
@@ -452,10 +509,20 @@ class DuplexPipeline:
self._runtime_tool_display_names = {} self._runtime_tool_display_names = {}
self._runtime_tool_wait_for_response = {} self._runtime_tool_wait_for_response = {}
if "presenceProbe" in metadata or "presence_probe" in metadata:
raw_presence_probe = metadata.get("presenceProbe")
if raw_presence_probe is None and "presence_probe" in metadata:
raw_presence_probe = metadata.get("presence_probe")
self._runtime_presence_probe = self._resolved_presence_probe_config(raw_presence_probe)
self._presence_probe_attempts = 0
self._last_presence_probe_ms = 0.0
self._active_presence_probe_call_id = None
if self.llm_service and hasattr(self.llm_service, "set_knowledge_config"): if self.llm_service and hasattr(self.llm_service, "set_knowledge_config"):
self.llm_service.set_knowledge_config(self._resolved_knowledge_config()) self.llm_service.set_knowledge_config(self._resolved_knowledge_config())
if self.llm_service and hasattr(self.llm_service, "set_tool_schemas"): if self.llm_service and hasattr(self.llm_service, "set_tool_schemas"):
self.llm_service.set_tool_schemas(self._resolved_tool_schemas()) self.llm_service.set_tool_schemas(self._resolved_tool_schemas())
self._refresh_presence_probe_task()
def resolved_runtime_config(self) -> Dict[str, Any]: def resolved_runtime_config(self) -> Dict[str, Any]:
"""Return current effective runtime configuration without secrets.""" """Return current effective runtime configuration without secrets."""
@@ -505,6 +572,14 @@ class DuplexPipeline:
"tools": { "tools": {
"allowlist": self._resolved_tool_allowlist(), "allowlist": self._resolved_tool_allowlist(),
}, },
"presenceProbe": {
"enabled": bool(self._runtime_presence_probe.get("enabled")),
"idleSeconds": float(self._runtime_presence_probe.get("idleSeconds") or 0),
"cooldownSeconds": float(self._runtime_presence_probe.get("cooldownSeconds") or 0),
"maxPrompts": int(self._runtime_presence_probe.get("maxPrompts") or 0),
"includeContext": bool(self._runtime_presence_probe.get("includeContext", True)),
"waitForResponse": bool(self._runtime_presence_probe.get("waitForResponse", True)),
},
"tracks": { "tracks": {
"audio_in": self.track_audio_in, "audio_in": self.track_audio_in,
"audio_out": self.track_audio_out, "audio_out": self.track_audio_out,
@@ -710,6 +785,308 @@ class DuplexPipeline:
chunk_ms = max(1, settings.chunk_size_ms) chunk_ms = max(1, settings.chunk_size_ms)
return max(1, int(np.ceil(self._barge_in_silence_tolerance_ms / chunk_ms))) return max(1, int(np.ceil(self._barge_in_silence_tolerance_ms / chunk_ms)))
def _default_presence_probe_options(self) -> List[Dict[str, str]]:
return [
{"id": "still_here", "label": "我在,继续", "value": "continue"},
{"id": "away", "label": "我先离开", "value": "away"},
]
def _normalize_presence_probe_options(self, raw_options: Any) -> List[Dict[str, str]]:
if not isinstance(raw_options, list):
return self._default_presence_probe_options()
normalized: List[Dict[str, str]] = []
used_ids: set[str] = set()
for index, raw in enumerate(raw_options):
option_id = f"opt_{index + 1}"
label = ""
value = ""
if isinstance(raw, (str, int, float, bool)):
label = str(raw).strip()
value = label
elif isinstance(raw, dict):
label = str(raw.get("label") or raw.get("text") or raw.get("name") or "").strip()
option_id = str(raw.get("id") or option_id).strip() or option_id
value_candidate = raw.get("value")
if value_candidate is None:
value = label
else:
value = str(value_candidate)
if not label:
continue
if option_id in used_ids:
suffix = 2
while f"{option_id}_{suffix}" in used_ids:
suffix += 1
option_id = f"{option_id}_{suffix}"
used_ids.add(option_id)
normalized.append({"id": option_id, "label": label, "value": value})
if len(normalized) < 2:
return self._default_presence_probe_options()
return normalized
def _coerce_bounded_float(
self,
raw_value: Any,
*,
default_value: float,
min_value: float,
max_value: float,
) -> float:
if isinstance(raw_value, (int, float)):
parsed = float(raw_value)
elif isinstance(raw_value, str):
try:
parsed = float(raw_value.strip())
except ValueError:
parsed = default_value
else:
parsed = default_value
if parsed < min_value:
return min_value
if parsed > max_value:
return max_value
return parsed
def _coerce_bounded_int(
self,
raw_value: Any,
*,
default_value: int,
min_value: int,
max_value: int,
) -> int:
if isinstance(raw_value, (int, float)):
parsed = int(raw_value)
elif isinstance(raw_value, str):
try:
parsed = int(raw_value.strip())
except ValueError:
parsed = default_value
else:
parsed = default_value
if parsed < min_value:
return min_value
if parsed > max_value:
return max_value
return parsed
def _resolved_presence_probe_config(self, raw_config: Any) -> Dict[str, Any]:
default_options = self._default_presence_probe_options()
default_config: Dict[str, Any] = {
"enabled": False,
"idleSeconds": float(self._PRESENCE_PROBE_DEFAULT_IDLE_SECONDS),
"cooldownSeconds": float(self._PRESENCE_PROBE_DEFAULT_COOLDOWN_SECONDS),
"maxPrompts": int(self._PRESENCE_PROBE_DEFAULT_MAX_PROMPTS),
"includeContext": True,
"question": "",
"waitForResponse": True,
"options": default_options,
}
if not isinstance(raw_config, dict):
return default_config
enabled = self._coerce_bool(raw_config.get("enabled"))
idle_seconds = self._coerce_bounded_float(
raw_config.get("idleSeconds", raw_config.get("idle_seconds")),
default_value=float(self._PRESENCE_PROBE_DEFAULT_IDLE_SECONDS),
min_value=float(self._PRESENCE_PROBE_MIN_IDLE_SECONDS),
max_value=float(self._PRESENCE_PROBE_MAX_IDLE_SECONDS),
)
cooldown_seconds = self._coerce_bounded_float(
raw_config.get("cooldownSeconds", raw_config.get("cooldown_seconds")),
default_value=float(self._PRESENCE_PROBE_DEFAULT_COOLDOWN_SECONDS),
min_value=float(self._PRESENCE_PROBE_MIN_COOLDOWN_SECONDS),
max_value=float(self._PRESENCE_PROBE_MAX_COOLDOWN_SECONDS),
)
max_prompts = self._coerce_bounded_int(
raw_config.get("maxPrompts", raw_config.get("max_prompts")),
default_value=self._PRESENCE_PROBE_DEFAULT_MAX_PROMPTS,
min_value=1,
max_value=self._PRESENCE_PROBE_MAX_PROMPTS_CAP,
)
include_context = self._coerce_bool(
raw_config.get("includeContext", raw_config.get("include_context"))
)
wait_for_response = self._coerce_bool(
raw_config.get("waitForResponse", raw_config.get("wait_for_response"))
)
question = str(raw_config.get("question") or "").strip()
if len(question) > 160:
question = question[:160]
resolved = dict(default_config)
resolved["enabled"] = bool(enabled) if enabled is not None else False
resolved["idleSeconds"] = idle_seconds
resolved["cooldownSeconds"] = cooldown_seconds
resolved["maxPrompts"] = max_prompts
resolved["includeContext"] = include_context if include_context is not None else True
resolved["waitForResponse"] = wait_for_response if wait_for_response is not None else True
resolved["question"] = question
resolved["options"] = self._normalize_presence_probe_options(raw_config.get("options"))
return resolved
def _presence_probe_enabled(self) -> bool:
return bool(self._runtime_presence_probe.get("enabled"))
def _presence_probe_idle_ms(self) -> float:
return max(1000.0, float(self._runtime_presence_probe.get("idleSeconds") or 0.0) * 1000.0)
def _presence_probe_cooldown_ms(self) -> float:
return max(1000.0, float(self._runtime_presence_probe.get("cooldownSeconds") or 0.0) * 1000.0)
def _presence_probe_max_prompts(self) -> int:
return max(1, int(self._runtime_presence_probe.get("maxPrompts") or 1))
def _presence_probe_wait_for_response(self) -> bool:
return bool(self._runtime_presence_probe.get("waitForResponse", True))
def _touch_user_activity(self) -> None:
self._last_user_activity_ms = time.monotonic() * 1000.0
self._active_presence_probe_call_id = None
def _presence_probe_in_progress(self) -> bool:
return bool(self._active_presence_probe_call_id)
def _has_active_turn(self) -> bool:
return bool(self._current_turn_task and not self._current_turn_task.done())
def _presence_probe_due(self, now_ms: float) -> bool:
if not self._presence_probe_enabled():
return False
if self._presence_probe_attempts >= self._presence_probe_max_prompts():
return False
if self._presence_probe_in_progress():
return False
if self._has_active_turn():
return False
if self._is_bot_speaking or self._interrupt_event.is_set():
return False
if self.conversation.state != ConversationState.IDLE:
return False
if self._pending_client_tool_call_ids:
return False
if self.conversation.turn_count <= 0:
return False
if now_ms - self._last_user_activity_ms < self._presence_probe_idle_ms():
return False
if self._last_presence_probe_ms > 0.0 and now_ms - self._last_presence_probe_ms < self._presence_probe_cooldown_ms():
return False
return True
def _clip_presence_context(self, text: str) -> str:
compact = " ".join(str(text or "").strip().split())
if not compact:
return ""
if len(compact) <= self._PRESENCE_PROBE_CONTEXT_CHARS:
return compact
return f"{compact[:self._PRESENCE_PROBE_CONTEXT_CHARS]}..."
def _build_presence_probe_question(self) -> str:
manual_question = str(self._runtime_presence_probe.get("question") or "").strip()
if manual_question:
return manual_question
include_context = bool(self._runtime_presence_probe.get("includeContext", True))
if include_context:
last_assistant = self._clip_presence_context(self.conversation.last_assistant_text or "")
if last_assistant:
return f"我们刚聊到“{last_assistant}”,你还在吗?"
last_user = self._clip_presence_context(self.conversation.last_user_text or "")
if last_user:
return f"关于你刚才说的“{last_user}”,你还在吗?"
return "我还在这边,你还在线吗?"
async def _run_presence_probe_once(self, now_ms: Optional[float] = None) -> bool:
current_ms = now_ms if now_ms is not None else (time.monotonic() * 1000.0)
if not self._presence_probe_due(current_ms):
return False
probe_id = self._new_id("presence_probe", self._presence_probe_seq + 1)
self._presence_probe_seq += 1
self._active_presence_probe_call_id = probe_id
self._last_presence_probe_ms = current_ms
self._presence_probe_attempts += 1
question = self._build_presence_probe_question()
probe_turn_id = self._start_turn()
probe_response_id = self._start_response()
try:
await self._send_event(
{
**ev(
"assistant.response.final",
trackId=self.track_audio_out,
text=question,
),
"turn_id": probe_turn_id,
"response_id": probe_response_id,
},
priority=20,
)
if self._tts_output_enabled():
await self._speak(question, audio_event_priority=30)
logger.info(
"[PresenceProbe] sent probe_id={} idle_ms={} question={}",
probe_id,
int(max(0.0, current_ms - self._last_user_activity_ms)),
question,
)
return True
finally:
self._active_presence_probe_call_id = None
self._current_response_id = None
self._current_tts_id = None
async def _presence_probe_loop(self) -> None:
try:
while self._running:
if not self._presence_probe_enabled():
await asyncio.sleep(self._PRESENCE_PROBE_LOOP_INTERVAL_SECONDS)
continue
try:
await self._run_presence_probe_once()
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning(f"Presence probe iteration failed: {exc}")
await asyncio.sleep(self._PRESENCE_PROBE_LOOP_INTERVAL_SECONDS)
except asyncio.CancelledError:
logger.debug("Presence probe loop cancelled")
raise
def _refresh_presence_probe_task(self) -> None:
task = self._presence_probe_task
if not self._running or not self._presence_probe_enabled():
if task and not task.done():
task.cancel()
self._presence_probe_task = None
return
if task and not task.done():
return
try:
asyncio.get_running_loop()
except RuntimeError:
return
self._presence_probe_task = asyncio.create_task(
self._presence_probe_loop(),
name=f"presence_probe_{self.session_id}",
)
async def _shutdown_presence_probe_task(self) -> None:
task = self._presence_probe_task
self._presence_probe_task = None
if not task or task.done():
return
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
async def _generate_runtime_greeting(self) -> Optional[str]: async def _generate_runtime_greeting(self) -> Optional[str]:
if not self.llm_service: if not self.llm_service:
return None return None
@@ -871,6 +1248,8 @@ class DuplexPipeline:
logger.info("DuplexPipeline services connected") logger.info("DuplexPipeline services connected")
if not self._outbound_task or self._outbound_task.done(): if not self._outbound_task or self._outbound_task.done():
self._outbound_task = asyncio.create_task(self._outbound_loop()) self._outbound_task = asyncio.create_task(self._outbound_loop())
self._touch_user_activity()
self._refresh_presence_probe_task()
except Exception as e: except Exception as e:
logger.error(f"Failed to start pipeline: {e}") logger.error(f"Failed to start pipeline: {e}")
@@ -1182,6 +1561,8 @@ class DuplexPipeline:
# 2. Check for barge-in (user speaking while bot speaking) # 2. Check for barge-in (user speaking while bot speaking)
# Filter false interruptions by requiring minimum speech duration # Filter false interruptions by requiring minimum speech duration
if vad_status == "Speech":
self._touch_user_activity()
if self._is_bot_speaking and self._barge_in_enabled(): if self._is_bot_speaking and self._barge_in_enabled():
if vad_status == "Speech": if vad_status == "Speech":
# User is speaking while bot is speaking # User is speaking while bot is speaking
@@ -1279,6 +1660,7 @@ class DuplexPipeline:
return return
logger.info(f"Processing text input: {text[:50]}...") logger.info(f"Processing text input: {text[:50]}...")
self._touch_user_activity()
# Cancel any current speaking # Cancel any current speaking
await self._stop_current_speech() await self._stop_current_speech()
@@ -1312,6 +1694,8 @@ class DuplexPipeline:
self._last_sent_transcript = text self._last_sent_transcript = text
if is_final: if is_final:
if text.strip():
self._touch_user_activity()
self._pending_transcript_delta = "" self._pending_transcript_delta = ""
self._last_transcript_delta_emit_ms = 0.0 self._last_transcript_delta_emit_ms = 0.0
await self._send_event( await self._send_event(
@@ -1433,6 +1817,7 @@ class DuplexPipeline:
return return
logger.info(f"[EOU] Detected - user said: {user_text[:100]}...") logger.info(f"[EOU] Detected - user said: {user_text[:100]}...")
self._touch_user_activity()
self._finalize_utterance() self._finalize_utterance()
# For ASR backends that already emitted final via callback, # For ASR backends that already emitted final via callback,
@@ -2364,6 +2749,7 @@ class DuplexPipeline:
return return
logger.info("Barge-in detected - interrupting bot speech") logger.info("Barge-in detected - interrupting bot speech")
self._touch_user_activity()
# Reset barge-in tracking # Reset barge-in tracking
self._barge_in_speech_start_time = None self._barge_in_speech_start_time = None
@@ -2438,6 +2824,7 @@ class DuplexPipeline:
logger.info(f"Cleaning up DuplexPipeline for session {self.session_id}") logger.info(f"Cleaning up DuplexPipeline for session {self.session_id}")
self._running = False self._running = False
await self._shutdown_presence_probe_task()
await self._stop_current_speech() await self._stop_current_speech()
if self._outbound_task and not self._outbound_task.done(): if self._outbound_task and not self._outbound_task.done():
await self._enqueue_outbound("stop", None, priority=-1000) await self._enqueue_outbound("stop", None, priority=-1000)

View File

@@ -72,6 +72,8 @@ class Session:
"userId", "userId",
"assistantId", "assistantId",
"source", "source",
"presenceProbe",
"presence_probe",
} }
_CLIENT_METADATA_ID_KEYS = { _CLIENT_METADATA_ID_KEYS = {
"appId", "appId",
@@ -998,6 +1000,8 @@ class Session:
"source", "source",
"tools", "tools",
"services", "services",
"presenceProbe",
"presence_probe",
"configVersionId", "configVersionId",
"config_version_id", "config_version_id",
} }

View File

@@ -145,6 +145,7 @@
- `assistantId` - `assistantId`
- `source` - `source`
- `dynamicVariables` - `dynamicVariables`
- `presenceProbe`(或 `presence_probe`
- 客户端传入 `metadata.services` 会被忽略(服务端会记录 warning服务配置由后端/环境变量决定。 - 客户端传入 `metadata.services` 会被忽略(服务端会记录 warning服务配置由后端/环境变量决定。
`metadata.dynamicVariables` 规则: `metadata.dynamicVariables` 规则:
@@ -159,6 +160,16 @@
- 若模板引用了缺失变量,`session.start` 会被拒绝,错误码 `protocol.dynamic_variables_missing` - 若模板引用了缺失变量,`session.start` 会被拒绝,错误码 `protocol.dynamic_variables_missing`
-`dynamicVariables` 结构/内容非法,`session.start` 会被拒绝,错误码 `protocol.dynamic_variables_invalid` -`dynamicVariables` 结构/内容非法,`session.start` 会被拒绝,错误码 `protocol.dynamic_variables_invalid`
`metadata.presenceProbe` 规则(可选):
- 用于空闲探询“你是否还在”,由 engine 直接发送 assistant 回复(文本;若 TTS 开启则同时语音播报)。
- 字段建议:
- `enabled`boolean是否开启。
- `idleSeconds`number连续空闲多久后触发最小 5 秒)。
- `cooldownSeconds`number两次探询的最小间隔。
- `maxPrompts`number每个会话最多探询次数。
- `includeContext`boolean是否拼接最近上下文片段到问句。
- `question`string自定义问句留空则服务端自动生成。
`output.mode` 用法: `output.mode` 用法:
- `"audio"`(默认语音输出) - `"audio"`(默认语音输出)
- `"text"`(纯文本输出) - `"text"`(纯文本输出)

View File

@@ -1,5 +1,6 @@
import asyncio import asyncio
import json import json
import time
from typing import Any, Dict, List from typing import Any, Dict, List
import pytest import pytest
@@ -86,7 +87,12 @@ def _build_pipeline(monkeypatch, llm_rounds: List[List[LLMStreamEvent]]) -> tupl
async def _capture_event(event: Dict[str, Any], priority: int = 20): async def _capture_event(event: Dict[str, Any], priority: int = 20):
events.append(event) events.append(event)
async def _noop_speak(_text: str, fade_in_ms: int = 0, fade_out_ms: int = 8): async def _noop_speak(
_text: str,
fade_in_ms: int = 0,
fade_out_ms: int = 8,
**_kwargs,
):
return None return None
monkeypatch.setattr(pipeline, "_send_event", _capture_event) monkeypatch.setattr(pipeline, "_send_event", _capture_event)
@@ -362,6 +368,89 @@ async def test_duplicate_tool_results_are_ignored(monkeypatch):
assert result.get("output", {}).get("value") == 1 assert result.get("output", {}).get("value") == 1
@pytest.mark.asyncio
async def test_presence_probe_emits_contextual_direct_prompt(monkeypatch):
pipeline, events = _build_pipeline(monkeypatch, [[LLMStreamEvent(type="done")]])
pipeline.apply_runtime_overrides(
{
"presenceProbe": {
"enabled": True,
"idleSeconds": 5,
"cooldownSeconds": 5,
"maxPrompts": 2,
"includeContext": True,
}
}
)
await pipeline._shutdown_presence_probe_task()
await pipeline.conversation.add_assistant_turn("请把你的订单号告诉我,我继续帮你处理。")
pipeline._last_user_activity_ms = (time.monotonic() * 1000.0) - 8000.0
fired = await pipeline._run_presence_probe_once()
assert fired is True
probe_text_events = [e for e in events if e.get("type") == "assistant.response.final"]
assert probe_text_events
assert "订单号" in str(probe_text_events[-1].get("text") or "")
assert any(e.get("type") == "output.audio.start" for e in events)
assert not any(e.get("type") == "assistant.tool_call" for e in events)
@pytest.mark.asyncio
async def test_presence_probe_respects_max_prompts_limit(monkeypatch):
pipeline, events = _build_pipeline(monkeypatch, [[LLMStreamEvent(type="done")]])
pipeline.apply_runtime_overrides(
{
"presenceProbe": {
"enabled": True,
"idleSeconds": 5,
"cooldownSeconds": 5,
"maxPrompts": 1,
"waitForResponse": False,
}
}
)
await pipeline._shutdown_presence_probe_task()
await pipeline.conversation.add_assistant_turn("我们继续。")
pipeline._last_user_activity_ms = (time.monotonic() * 1000.0) - 8000.0
first_fired = await pipeline._run_presence_probe_once()
second_fired = await pipeline._run_presence_probe_once(
now_ms=(time.monotonic() * 1000.0) + 10000.0
)
assert first_fired is True
assert second_fired is False
assert len([e for e in events if e.get("type") == "assistant.response.final"]) == 1
@pytest.mark.asyncio
async def test_presence_probe_text_mode_emits_text_only(monkeypatch):
pipeline, events = _build_pipeline(monkeypatch, [[LLMStreamEvent(type="done")]])
pipeline.apply_runtime_overrides(
{
"output": {"mode": "text"},
"presenceProbe": {
"enabled": True,
"idleSeconds": 5,
"cooldownSeconds": 5,
"maxPrompts": 1,
"waitForResponse": False,
},
}
)
await pipeline._shutdown_presence_probe_task()
await pipeline.conversation.add_assistant_turn("我们继续。")
pipeline._last_user_activity_ms = (time.monotonic() * 1000.0) - 8000.0
fired = await pipeline._run_presence_probe_once()
assert fired is True
assert any(e.get("type") == "assistant.response.final" for e in events)
assert not any(e.get("type") == "assistant.tool_call" for e in events)
assert not any(e.get("type") == "output.audio.start" for e in events)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_server_calculator_emits_tool_result(monkeypatch): async def test_server_calculator_emits_tool_result(monkeypatch):
pipeline, events = _build_pipeline( pipeline, events = _build_pipeline(

View File

@@ -185,6 +185,12 @@ export const AssistantsPage: React.FC = () => {
tools: [], tools: [],
botCannotBeInterrupted: false, botCannotBeInterrupted: false,
interruptionSensitivity: 180, interruptionSensitivity: 180,
presenceProbeEnabled: false,
presenceProbeIdleSeconds: 20,
presenceProbeCooldownSeconds: 45,
presenceProbeMaxPrompts: 2,
presenceProbeIncludeContext: true,
presenceProbeQuestion: '',
configMode: 'platform', configMode: 'platform',
}; };
try { try {
@@ -271,6 +277,18 @@ export const AssistantsPage: React.FC = () => {
} }
}; };
const coerceBoundedNumber = (raw: string, fallback: number, min: number, max: number) => {
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(min, Math.min(max, parsed));
};
const coerceBoundedInt = (raw: string, fallback: number, min: number, max: number) => {
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(min, Math.min(max, parsed));
};
const updateTemplateSuggestionState = ( const updateTemplateSuggestionState = (
field: 'prompt' | 'opener', field: 'prompt' | 'opener',
value: string, value: string,
@@ -858,6 +876,128 @@ export const AssistantsPage: React.FC = () => {
</p> </p>
</div> </div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<label className="text-sm font-medium text-white flex items-center">
<Timer className="w-4 h-4 mr-2 text-primary" /> Presence Probe线
</label>
<div className="inline-flex rounded-lg border border-white/10 bg-white/5 p-1">
<button
type="button"
onClick={() => updateAssistant('presenceProbeEnabled', false)}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
selectedAssistant.presenceProbeEnabled === true
? 'text-muted-foreground hover:text-foreground'
: 'bg-primary text-primary-foreground shadow-sm'
}`}
>
</button>
<button
type="button"
onClick={() => updateAssistant('presenceProbeEnabled', true)}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
selectedAssistant.presenceProbeEnabled === true
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
</button>
</div>
</div>
<p className="text-xs text-muted-foreground">
assistant 线TTS +
</p>
{selectedAssistant.presenceProbeEnabled === true && (
<div className="space-y-3 rounded-lg border border-white/10 bg-white/[0.03] p-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<label className="space-y-1">
<span className="text-[10px] text-muted-foreground uppercase tracking-wider">Idle(s)</span>
<Input
type="number"
min={5}
max={3600}
step={1}
value={selectedAssistant.presenceProbeIdleSeconds ?? 20}
onChange={(e) =>
updateAssistant(
'presenceProbeIdleSeconds',
coerceBoundedNumber(
e.target.value,
Number(selectedAssistant.presenceProbeIdleSeconds ?? 20),
5,
3600
)
)
}
className="h-9 bg-white/5 border-white/10"
/>
</label>
<label className="space-y-1">
<span className="text-[10px] text-muted-foreground uppercase tracking-wider">Cooldown(s)</span>
<Input
type="number"
min={5}
max={7200}
step={1}
value={selectedAssistant.presenceProbeCooldownSeconds ?? 45}
onChange={(e) =>
updateAssistant(
'presenceProbeCooldownSeconds',
coerceBoundedNumber(
e.target.value,
Number(selectedAssistant.presenceProbeCooldownSeconds ?? 45),
5,
7200
)
)
}
className="h-9 bg-white/5 border-white/10"
/>
</label>
<label className="space-y-1">
<span className="text-[10px] text-muted-foreground uppercase tracking-wider">Max Prompts</span>
<Input
type="number"
min={1}
max={10}
step={1}
value={selectedAssistant.presenceProbeMaxPrompts ?? 2}
onChange={(e) =>
updateAssistant(
'presenceProbeMaxPrompts',
coerceBoundedInt(
e.target.value,
Number(selectedAssistant.presenceProbeMaxPrompts ?? 2),
1,
10
)
)
}
className="h-9 bg-white/5 border-white/10"
/>
</label>
</div>
<label className="inline-flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={selectedAssistant.presenceProbeIncludeContext !== false}
onChange={(e) => updateAssistant('presenceProbeIncludeContext', e.target.checked)}
className="accent-primary"
/>
</label>
<Input
value={selectedAssistant.presenceProbeQuestion || ''}
onChange={(e) => updateAssistant('presenceProbeQuestion', e.target.value.slice(0, 160))}
placeholder="可选:自定义问句(留空则自动生成)"
className="h-9 bg-white/5 border-white/10"
/>
</div>
)}
</div>
{isBotFirstTurn && ( {isBotFirstTurn && (
<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">
@@ -2199,6 +2339,38 @@ export const DebugDrawer: React.FC<{
}; };
}).filter(Boolean) as Array<Record<string, any>>; }).filter(Boolean) as Array<Record<string, any>>;
}, [assistant.tools, tools, clientToolEnabledMap]); }, [assistant.tools, tools, clientToolEnabledMap]);
const presenceProbeConfig = useMemo(() => {
if (assistant.presenceProbeEnabled !== true) return null;
const toBoundedNumber = (raw: unknown, fallback: number, min: number, max: number) => {
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(min, Math.min(max, parsed));
};
const toBoundedInt = (raw: unknown, fallback: number, min: number, max: number) => {
const parsed = Number.parseInt(String(raw), 10);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(min, Math.min(max, parsed));
};
const idleSeconds = toBoundedNumber(assistant.presenceProbeIdleSeconds, 20, 5, 3600);
const cooldownSeconds = toBoundedNumber(assistant.presenceProbeCooldownSeconds, 45, 5, 7200);
const maxPrompts = toBoundedInt(assistant.presenceProbeMaxPrompts, 2, 1, 10);
const question = String(assistant.presenceProbeQuestion || '').trim();
return {
enabled: true,
idleSeconds,
cooldownSeconds,
maxPrompts,
includeContext: assistant.presenceProbeIncludeContext !== false,
...(question ? { question } : {}),
};
}, [
assistant.presenceProbeEnabled,
assistant.presenceProbeIdleSeconds,
assistant.presenceProbeCooldownSeconds,
assistant.presenceProbeMaxPrompts,
assistant.presenceProbeIncludeContext,
assistant.presenceProbeQuestion,
]);
const clearResponseTracking = () => { const clearResponseTracking = () => {
assistantDraftIndexRef.current = null; assistantDraftIndexRef.current = null;
@@ -3027,6 +3199,9 @@ export const DebugDrawer: React.FC<{
if (Object.keys(dynamicVariablesResult.variables).length > 0) { if (Object.keys(dynamicVariablesResult.variables).length > 0) {
mergedMetadata.dynamicVariables = dynamicVariablesResult.variables; mergedMetadata.dynamicVariables = dynamicVariablesResult.variables;
} }
if (presenceProbeConfig) {
mergedMetadata.presenceProbe = presenceProbeConfig;
}
// Engine resolves trusted runtime config by top-level assistant/app ID. // Engine resolves trusted runtime config by top-level assistant/app ID.
// Keep these IDs at metadata root so backend /assistants/{id}/config is reachable. // Keep these IDs at metadata root so backend /assistants/{id}/config is reachable.
if (!mergedMetadata.assistantId && assistant.id) { if (!mergedMetadata.assistantId && assistant.id) {
@@ -3805,6 +3980,47 @@ export const DebugDrawer: React.FC<{
})} })}
</div> </div>
</div> </div>
<div className="rounded-md border border-white/10 bg-black/20 p-2 space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground">Presence Probe</p>
<Badge variant={presenceProbeConfig ? 'outline' : 'secondary'} className="text-xs">
{presenceProbeConfig ? 'ON' : 'OFF'}
</Badge>
</div>
<p className="text-[11px] text-muted-foreground">
Global Debug Drawer
</p>
{presenceProbeConfig ? (
<div className="space-y-2">
<div className="grid grid-cols-3 gap-2 text-[11px]">
<div className="rounded border border-white/10 bg-black/20 px-2 py-1.5">
<div className="text-[10px] text-muted-foreground uppercase tracking-wider">Idle</div>
<div className="font-mono text-foreground">{Number(presenceProbeConfig.idleSeconds)}s</div>
</div>
<div className="rounded border border-white/10 bg-black/20 px-2 py-1.5">
<div className="text-[10px] text-muted-foreground uppercase tracking-wider">Cooldown</div>
<div className="font-mono text-foreground">{Number(presenceProbeConfig.cooldownSeconds)}s</div>
</div>
<div className="rounded border border-white/10 bg-black/20 px-2 py-1.5">
<div className="text-[10px] text-muted-foreground uppercase tracking-wider">Max</div>
<div className="font-mono text-foreground">{Number(presenceProbeConfig.maxPrompts)}</div>
</div>
</div>
<p className="text-[11px] text-muted-foreground">
{presenceProbeConfig.includeContext ? '结合上下文' : '固定模板'}
</p>
{presenceProbeConfig.question && (
<p className="text-[11px] text-muted-foreground break-words">
{presenceProbeConfig.question}
</p>
)}
</div>
) : (
<p className="text-[11px] text-muted-foreground">
Presence Probe
</p>
)}
</div>
<div className="rounded-md border border-white/10 bg-black/20 p-2 space-y-2"> <div className="rounded-md border border-white/10 bg-black/20 p-2 space-y-2">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground">Dynamic Variables</p> <p className="text-[10px] uppercase tracking-widest text-muted-foreground">Dynamic Variables</p>

View File

@@ -50,6 +50,12 @@ const mapAssistant = (raw: AnyRecord): Assistant => ({
tools: readField(raw, ['tools'], []), tools: readField(raw, ['tools'], []),
botCannotBeInterrupted: Boolean(readField(raw, ['botCannotBeInterrupted', 'bot_cannot_be_interrupted'], false)), botCannotBeInterrupted: Boolean(readField(raw, ['botCannotBeInterrupted', 'bot_cannot_be_interrupted'], false)),
interruptionSensitivity: Number(readField(raw, ['interruptionSensitivity', 'interruption_sensitivity'], 500)), interruptionSensitivity: Number(readField(raw, ['interruptionSensitivity', 'interruption_sensitivity'], 500)),
presenceProbeEnabled: Boolean(readField(raw, ['presenceProbeEnabled', 'presence_probe_enabled'], false)),
presenceProbeIdleSeconds: Number(readField(raw, ['presenceProbeIdleSeconds', 'presence_probe_idle_seconds'], 20)),
presenceProbeCooldownSeconds: Number(readField(raw, ['presenceProbeCooldownSeconds', 'presence_probe_cooldown_seconds'], 45)),
presenceProbeMaxPrompts: Number(readField(raw, ['presenceProbeMaxPrompts', 'presence_probe_max_prompts'], 2)),
presenceProbeIncludeContext: Boolean(readField(raw, ['presenceProbeIncludeContext', 'presence_probe_include_context'], true)),
presenceProbeQuestion: readField(raw, ['presenceProbeQuestion', 'presence_probe_question'], ''),
configMode: readField(raw, ['configMode', 'config_mode'], 'platform') as 'platform' | 'dify' | 'fastgpt' | 'none', configMode: readField(raw, ['configMode', 'config_mode'], 'platform') as 'platform' | 'dify' | 'fastgpt' | 'none',
apiUrl: readField(raw, ['apiUrl', 'api_url'], ''), apiUrl: readField(raw, ['apiUrl', 'api_url'], ''),
apiKey: readField(raw, ['apiKey', 'api_key'], ''), apiKey: readField(raw, ['apiKey', 'api_key'], ''),
@@ -246,6 +252,12 @@ export const createAssistant = async (data: Partial<Assistant>): Promise<Assista
tools: data.tools || [], tools: data.tools || [],
botCannotBeInterrupted: data.botCannotBeInterrupted ?? false, botCannotBeInterrupted: data.botCannotBeInterrupted ?? false,
interruptionSensitivity: data.interruptionSensitivity ?? 500, interruptionSensitivity: data.interruptionSensitivity ?? 500,
presenceProbeEnabled: data.presenceProbeEnabled ?? false,
presenceProbeIdleSeconds: data.presenceProbeIdleSeconds ?? 20,
presenceProbeCooldownSeconds: data.presenceProbeCooldownSeconds ?? 45,
presenceProbeMaxPrompts: data.presenceProbeMaxPrompts ?? 2,
presenceProbeIncludeContext: data.presenceProbeIncludeContext ?? true,
presenceProbeQuestion: data.presenceProbeQuestion ?? '',
configMode: data.configMode || 'platform', configMode: data.configMode || 'platform',
apiUrl: data.apiUrl || '', apiUrl: data.apiUrl || '',
apiKey: data.apiKey || '', apiKey: data.apiKey || '',
@@ -275,6 +287,12 @@ export const updateAssistant = async (id: string, data: Partial<Assistant>): Pro
tools: data.tools, tools: data.tools,
botCannotBeInterrupted: data.botCannotBeInterrupted, botCannotBeInterrupted: data.botCannotBeInterrupted,
interruptionSensitivity: data.interruptionSensitivity, interruptionSensitivity: data.interruptionSensitivity,
presenceProbeEnabled: data.presenceProbeEnabled,
presenceProbeIdleSeconds: data.presenceProbeIdleSeconds,
presenceProbeCooldownSeconds: data.presenceProbeCooldownSeconds,
presenceProbeMaxPrompts: data.presenceProbeMaxPrompts,
presenceProbeIncludeContext: data.presenceProbeIncludeContext,
presenceProbeQuestion: data.presenceProbeQuestion,
configMode: data.configMode, configMode: data.configMode,
apiUrl: data.apiUrl, apiUrl: data.apiUrl,
apiKey: data.apiKey, apiKey: data.apiKey,

View File

@@ -20,6 +20,12 @@ export interface Assistant {
tools?: string[]; // IDs of enabled tools tools?: string[]; // IDs of enabled tools
botCannotBeInterrupted?: boolean; botCannotBeInterrupted?: boolean;
interruptionSensitivity?: number; // In ms interruptionSensitivity?: number; // In ms
presenceProbeEnabled?: boolean;
presenceProbeIdleSeconds?: number;
presenceProbeCooldownSeconds?: number;
presenceProbeMaxPrompts?: number;
presenceProbeIncludeContext?: boolean;
presenceProbeQuestion?: string;
configMode?: 'platform' | 'dify' | 'fastgpt' | 'none'; configMode?: 'platform' | 'dify' | 'fastgpt' | 'none';
apiUrl?: string; apiUrl?: string;
apiKey?: string; apiKey?: string;