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:
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`(纯文本输出)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user