diff --git a/api/app/models.py b/api/app/models.py index 2b553ee..476055e 100644 --- a/api/app/models.py +++ b/api/app/models.py @@ -128,6 +128,12 @@ class Assistant(Base): tools: Mapped[dict] = mapped_column(JSON, default=list) bot_cannot_be_interrupted: Mapped[bool] = mapped_column(default=False) 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") api_url: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) api_key: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) diff --git a/api/app/routers/assistants.py b/api/app/routers/assistants.py index 6ce52de..236b439 100644 --- a/api/app/routers/assistants.py +++ b/api/app/routers/assistants.py @@ -8,6 +8,7 @@ import httpx from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import FileResponse from sqlalchemy.orm import Session +from sqlalchemy import inspect, text from typing import Any, Dict, List, Optional import uuid 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_BASE_URL = "https://api.siliconflow.cn/v1" 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 = { "alex", "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')}" +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]: schema = dict(raw_schema) if isinstance(raw_schema, dict) else {} 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]]: warnings: List[str] = [] + presence_probe_cfg = _resolve_presence_probe_config_from_assistant(assistant) metadata: Dict[str, Any] = { "systemPrompt": _compose_runtime_system_prompt(assistant.prompt), "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), "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() @@ -321,6 +464,7 @@ def _build_engine_assistant_config(db: Session, assistant: Assistant) -> Dict[st def assistant_to_dict(assistant: Assistant) -> dict: opener_audio = assistant.opener_audio 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 { "id": assistant.id, "name": assistant.name, @@ -342,6 +486,18 @@ def assistant_to_dict(assistant: Assistant) -> dict: "tools": assistant.tools or [], "botCannotBeInterrupted": bool(assistant.bot_cannot_be_interrupted), "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, "apiUrl": assistant.api_url, "apiKey": assistant.api_key, @@ -360,6 +516,12 @@ def _apply_assistant_update(assistant: Assistant, update_data: dict) -> None: "firstTurnMode": "first_turn_mode", "interruptionSensitivity": "interruption_sensitivity", "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", "voiceOutputEnabled": "voice_output_enabled", "generatedOpenerEnabled": "generated_opener_enabled", @@ -371,7 +533,37 @@ def _apply_assistant_update(assistant: Assistant, update_data: dict) -> None: "rerankModelId": "rerank_model_id", } 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: @@ -490,6 +682,7 @@ def list_assistants( db: Session = Depends(get_db) ): """获取助手列表""" + _ensure_assistant_schema(db) query = db.query(Assistant) total = query.count() assistants = query.order_by(Assistant.created_at.desc()) \ @@ -505,6 +698,7 @@ def list_assistants( @router.get("/{id}", response_model=AssistantOut) def get_assistant(id: str, db: Session = Depends(get_db)): """获取单个助手详情""" + _ensure_assistant_schema(db) assistant = db.query(Assistant).filter(Assistant.id == id).first() if not assistant: 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) def get_assistant_config(id: str, db: Session = Depends(get_db)): """Canonical engine config endpoint consumed by engine backend adapter.""" + _ensure_assistant_schema(db) assistant = db.query(Assistant).filter(Assistant.id == id).first() if not assistant: 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) def get_assistant_runtime_config(id: str, db: Session = Depends(get_db)): """Legacy alias for resolved engine runtime config.""" + _ensure_assistant_schema(db) assistant = db.query(Assistant).filter(Assistant.id == id).first() if not assistant: 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) def create_assistant(data: AssistantCreate, db: Session = Depends(get_db)): """创建新助手""" + _ensure_assistant_schema(db) assistant = Assistant( id=str(uuid.uuid4())[:8], user_id=1, # 默认用户,后续添加认证 @@ -549,6 +746,27 @@ def create_assistant(data: AssistantCreate, db: Session = Depends(get_db)): tools=data.tools, bot_cannot_be_interrupted=data.botCannotBeInterrupted, 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, api_url=data.apiUrl, 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) 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() if not assistant: 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") 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() if not assistant: raise HTTPException(status_code=404, detail="Assistant not found") @@ -600,6 +820,7 @@ def generate_assistant_opener_audio( data: AssistantOpenerAudioGenerateRequest, db: Session = Depends(get_db), ): + _ensure_assistant_schema(db) assistant = db.query(Assistant).filter(Assistant.id == id).first() if not assistant: raise HTTPException(status_code=404, detail="Assistant not found") @@ -689,6 +910,7 @@ def generate_assistant_opener_audio( @router.put("/{id}") 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() if not assistant: 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}") def delete_assistant(id: str, db: Session = Depends(get_db)): """删除助手""" + _ensure_assistant_schema(db) assistant = db.query(Assistant).filter(Assistant.id == id).first() if not assistant: raise HTTPException(status_code=404, detail="Assistant not found") diff --git a/api/app/schemas.py b/api/app/schemas.py index 91b81a5..4b4cab3 100644 --- a/api/app/schemas.py +++ b/api/app/schemas.py @@ -292,6 +292,12 @@ class AssistantBase(BaseModel): tools: List[str] = [] botCannotBeInterrupted: bool = False 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" apiUrl: Optional[str] = None apiKey: Optional[str] = None @@ -322,6 +328,12 @@ class AssistantUpdate(BaseModel): tools: Optional[List[str]] = None botCannotBeInterrupted: Optional[bool] = 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 apiUrl: Optional[str] = None apiKey: Optional[str] = None diff --git a/api/tests/test_assistants.py b/api/tests/test_assistants.py index d1fc52d..d2a2137 100644 --- a/api/tests/test_assistants.py +++ b/api/tests/test_assistants.py @@ -307,6 +307,37 @@ class TestAssistantAPI: assert tts["apiKey"] == "dashscope-key" 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): sample_assistant_data.update({ "firstTurnMode": "user_first", diff --git a/engine/core/duplex_pipeline.py b/engine/core/duplex_pipeline.py index 60bdc45..6359011 100644 --- a/engine/core/duplex_pipeline.py +++ b/engine/core/duplex_pipeline.py @@ -74,6 +74,25 @@ class DuplexPipeline: _LLM_DELTA_THROTTLE_MS = 80 _ASR_CAPTURE_MAX_MS = 15000 _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]] = { "current_time": { "name": "current_time", @@ -168,6 +187,23 @@ class DuplexPipeline: "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({ "turn_on_camera", @@ -176,6 +212,7 @@ class DuplexPipeline: "decrease_volume", "voice_message_prompt", "text_msg_prompt", + "choice_prompt", }) def __init__( @@ -308,6 +345,16 @@ class DuplexPipeline: self._runtime_barge_in_min_duration_ms: Optional[int] = None self._runtime_knowledge: Dict[str, Any] = {} 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 [] self._runtime_tools: List[Any] = list(raw_default_tools) self._runtime_tool_executor: Dict[str, str] = {} @@ -333,6 +380,13 @@ class DuplexPipeline: self._current_tts_id: Optional[str] = None self._pending_llm_delta: str = "" 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_default_args = self._resolved_tool_default_args_map() @@ -432,7 +486,10 @@ class DuplexPipeline: opener_audio = metadata.get("openerAudio") if isinstance(opener_audio, dict): 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: self._runtime_knowledge_base_id = kb_id @@ -452,10 +509,20 @@ class DuplexPipeline: self._runtime_tool_display_names = {} 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"): self.llm_service.set_knowledge_config(self._resolved_knowledge_config()) if self.llm_service and hasattr(self.llm_service, "set_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]: """Return current effective runtime configuration without secrets.""" @@ -505,6 +572,14 @@ class DuplexPipeline: "tools": { "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": { "audio_in": self.track_audio_in, "audio_out": self.track_audio_out, @@ -710,6 +785,308 @@ class DuplexPipeline: chunk_ms = max(1, settings.chunk_size_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]: if not self.llm_service: return None @@ -871,6 +1248,8 @@ class DuplexPipeline: logger.info("DuplexPipeline services connected") if not self._outbound_task or self._outbound_task.done(): self._outbound_task = asyncio.create_task(self._outbound_loop()) + self._touch_user_activity() + self._refresh_presence_probe_task() except Exception as 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) # 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 vad_status == "Speech": # User is speaking while bot is speaking @@ -1279,6 +1660,7 @@ class DuplexPipeline: return logger.info(f"Processing text input: {text[:50]}...") + self._touch_user_activity() # Cancel any current speaking await self._stop_current_speech() @@ -1312,6 +1694,8 @@ class DuplexPipeline: self._last_sent_transcript = text if is_final: + if text.strip(): + self._touch_user_activity() self._pending_transcript_delta = "" self._last_transcript_delta_emit_ms = 0.0 await self._send_event( @@ -1433,6 +1817,7 @@ class DuplexPipeline: return logger.info(f"[EOU] Detected - user said: {user_text[:100]}...") + self._touch_user_activity() self._finalize_utterance() # For ASR backends that already emitted final via callback, @@ -2364,6 +2749,7 @@ class DuplexPipeline: return logger.info("Barge-in detected - interrupting bot speech") + self._touch_user_activity() # Reset barge-in tracking self._barge_in_speech_start_time = None @@ -2438,6 +2824,7 @@ class DuplexPipeline: logger.info(f"Cleaning up DuplexPipeline for session {self.session_id}") self._running = False + await self._shutdown_presence_probe_task() await self._stop_current_speech() if self._outbound_task and not self._outbound_task.done(): await self._enqueue_outbound("stop", None, priority=-1000) diff --git a/engine/core/session.py b/engine/core/session.py index 6ce8539..e066087 100644 --- a/engine/core/session.py +++ b/engine/core/session.py @@ -72,6 +72,8 @@ class Session: "userId", "assistantId", "source", + "presenceProbe", + "presence_probe", } _CLIENT_METADATA_ID_KEYS = { "appId", @@ -998,6 +1000,8 @@ class Session: "source", "tools", "services", + "presenceProbe", + "presence_probe", "configVersionId", "config_version_id", } diff --git a/engine/docs/ws_v1_schema_zh.md b/engine/docs/ws_v1_schema_zh.md index 145f061..2e7ef85 100644 --- a/engine/docs/ws_v1_schema_zh.md +++ b/engine/docs/ws_v1_schema_zh.md @@ -145,6 +145,7 @@ - `assistantId` - `source` - `dynamicVariables` + - `presenceProbe`(或 `presence_probe`) - 客户端传入 `metadata.services` 会被忽略(服务端会记录 warning),服务配置由后端/环境变量决定。 `metadata.dynamicVariables` 规则: @@ -159,6 +160,16 @@ - 若模板引用了缺失变量,`session.start` 会被拒绝,错误码 `protocol.dynamic_variables_missing`。 - 若 `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` 用法: - `"audio"`(默认语音输出) - `"text"`(纯文本输出) diff --git a/engine/tests/test_tool_call_flow.py b/engine/tests/test_tool_call_flow.py index 236e86a..c691c39 100644 --- a/engine/tests/test_tool_call_flow.py +++ b/engine/tests/test_tool_call_flow.py @@ -1,5 +1,6 @@ import asyncio import json +import time from typing import Any, Dict, List 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): 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 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 +@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 async def test_server_calculator_emits_tool_result(monkeypatch): pipeline, events = _build_pipeline( diff --git a/web/pages/Assistants.tsx b/web/pages/Assistants.tsx index 6ec63c4..3b36944 100644 --- a/web/pages/Assistants.tsx +++ b/web/pages/Assistants.tsx @@ -185,6 +185,12 @@ export const AssistantsPage: React.FC = () => { tools: [], botCannotBeInterrupted: false, interruptionSensitivity: 180, + presenceProbeEnabled: false, + presenceProbeIdleSeconds: 20, + presenceProbeCooldownSeconds: 45, + presenceProbeMaxPrompts: 2, + presenceProbeIncludeContext: true, + presenceProbeQuestion: '', configMode: 'platform', }; 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 = ( field: 'prompt' | 'opener', value: string, @@ -858,6 +876,128 @@ export const AssistantsPage: React.FC = () => {
++ 会话空闲时,assistant 会试探性询问用户是否还在线;TTS 开启时语音+文字,关闭时仅文字。 +
+ {selectedAssistant.presenceProbeEnabled === true && ( +Presence Probe
++ 在助手 Global 设置中配置;Debug Drawer 只用于体验当前配置。 +
+ {presenceProbeConfig ? ( ++ 问句生成:{presenceProbeConfig.includeContext ? '结合上下文' : '固定模板'} +
+ {presenceProbeConfig.question && ( ++ 自定义问句:{presenceProbeConfig.question} +
+ )} ++ 当前助手未开启 Presence Probe,可在「全局设置」中打开。 +
+ )} +Dynamic Variables
diff --git a/web/services/backendApi.ts b/web/services/backendApi.ts index c310b8e..d72b00b 100644 --- a/web/services/backendApi.ts +++ b/web/services/backendApi.ts @@ -50,6 +50,12 @@ const mapAssistant = (raw: AnyRecord): Assistant => ({ tools: readField(raw, ['tools'], []), botCannotBeInterrupted: Boolean(readField(raw, ['botCannotBeInterrupted', 'bot_cannot_be_interrupted'], false)), 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', apiUrl: readField(raw, ['apiUrl', 'api_url'], ''), apiKey: readField(raw, ['apiKey', 'api_key'], ''), @@ -246,6 +252,12 @@ export const createAssistant = async (data: Partial