Add dynamic variable support to Assistant model and related components
- Introduce dynamic variable definitions in AssistantConfig and Assistant models, allowing for flexible prompt customization. - Implement validation for dynamic variable names and types in the schema. - Update backend services and routes to handle dynamic variables in assistant configurations and runtime processing. - Enhance frontend components to support dynamic variable definitions, including a new editor for managing variables. - Add tests to ensure proper functionality and validation of dynamic variables in various scenarios.
This commit is contained in:
@@ -158,6 +158,27 @@ docker compose --profile remote up -d
|
||||
当前后台处理使用 FastAPI 进程内任务,适合 MVP。服务重启时未完成任务会转为失败,
|
||||
可在页面重试;需要多实例或高吞吐时再迁移到 Redis/ARQ worker。
|
||||
|
||||
## Prompt 动态变量
|
||||
|
||||
提示词助手的 Pipeline 与 Realtime 模式都支持在开场白和系统提示词中使用
|
||||
`{{ variable_name }}`。Pipeline 模式还支持在 HTTP 工具的 URL、Header 和 Body
|
||||
中引用变量。助手页维护变量类型、默认值和必填规则;调试面板在每次通话开始前
|
||||
传入会话值。Realtime 会把渲染后的提示词作为模型 instructions。
|
||||
|
||||
内置系统变量包括 `system__conversation_id`、`system__time`、
|
||||
`system__agent_turns` 和 `system__conversation_history`。客户端不能提交
|
||||
`system__*` 或 `secret__*`。HTTP 工具的密钥变量必须在工具的服务端密钥配置中
|
||||
以 `secret__` 开头声明,而且只能用于 Header。
|
||||
|
||||
HTTP 工具可通过“响应变量赋值”把 JSON 路径写回普通会话变量,例如:
|
||||
|
||||
```json
|
||||
{"order_status": "response.order.status"}
|
||||
```
|
||||
|
||||
后续轮次会使用新值重新渲染系统提示词。上线前执行 `make db-migrate`,应用
|
||||
`20260712_0007` 迁移。
|
||||
|
||||
## 待联调 / TODO
|
||||
|
||||
- [ ] 联调 Pipecat 1.3.0 语音链路与各 OpenAI 兼容服务
|
||||
|
||||
@@ -171,6 +171,7 @@ class Assistant(Base):
|
||||
|
||||
# ---- 瘦类型专属字段(真列,稀疏:按 type 用其中几列) ----
|
||||
prompt: Mapped[str] = mapped_column(String(8192), default="") # prompt / opencode
|
||||
dynamic_variable_definitions: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
api_url: Mapped[str] = mapped_column(String(512), default="") # dify / fastgpt / opencode
|
||||
api_key: Mapped[str] = mapped_column(String(512), default="") # dify / fastgpt / opencode(打码/哨兵)
|
||||
app_id: Mapped[str] = mapped_column(String(128), default="") # fastgpt
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""add assistant dynamic variable definitions
|
||||
|
||||
Revision ID: 20260712_0007
|
||||
Revises: 20260712_0006
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "20260712_0007"
|
||||
down_revision: str | Sequence[str] | None = "20260712_0006"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"assistants",
|
||||
sa.Column(
|
||||
"dynamic_variable_definitions",
|
||||
sa.JSON(),
|
||||
server_default=sa.text("'{}'"),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("assistants", "dynamic_variable_definitions")
|
||||
@@ -24,6 +24,7 @@ class RuntimeTool(BaseModel):
|
||||
type: str
|
||||
description: str = ""
|
||||
definition: dict = Field(default_factory=dict)
|
||||
secrets: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AssistantConfig(BaseModel):
|
||||
@@ -34,6 +35,10 @@ class AssistantConfig(BaseModel):
|
||||
type: str = "prompt"
|
||||
greeting: str = "您好,我是 AI 视频助手,请问有什么可以帮您?"
|
||||
prompt: str = "你是一个有帮助的助手。"
|
||||
dynamic_variable_definitions: dict = Field(default_factory=dict)
|
||||
dynamic_variables: dict = Field(default_factory=dict)
|
||||
secret_dynamic_variables: dict = Field(default_factory=dict)
|
||||
conversation_id: str = ""
|
||||
runtimeMode: RuntimeMode = "pipeline"
|
||||
|
||||
# 模型/音色选项
|
||||
@@ -121,3 +126,4 @@ class SignalingOffer(BaseModel):
|
||||
assistant_id: str | None = None
|
||||
inline_config: AssistantConfig | None = None
|
||||
vision_enabled: bool = False
|
||||
dynamic_variables: dict = Field(default_factory=dict)
|
||||
|
||||
@@ -154,6 +154,7 @@ async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut:
|
||||
knowledge_retrieval_config=assistant.knowledge_retrieval_config or {},
|
||||
tool_ids=await _tool_ids(session, assistant.id),
|
||||
prompt=assistant.prompt,
|
||||
dynamic_variable_definitions=assistant.dynamic_variable_definitions or {},
|
||||
api_url=assistant.api_url,
|
||||
api_key=mask(assistant.api_key),
|
||||
app_id=assistant.app_id,
|
||||
@@ -220,6 +221,7 @@ async def duplicate_assistant(
|
||||
knowledge_base_id=source.knowledge_base_id,
|
||||
knowledge_retrieval_config=dict(source.knowledge_retrieval_config or {}),
|
||||
prompt=source.prompt,
|
||||
dynamic_variable_definitions=dict(source.dynamic_variable_definitions or {}),
|
||||
api_url=source.api_url,
|
||||
api_key=source.api_key,
|
||||
app_id=source.app_id,
|
||||
|
||||
@@ -16,6 +16,7 @@ from loguru import logger
|
||||
from models import AssistantConfig, SignalingOffer
|
||||
from services.auth import require_admin, require_admin_websocket
|
||||
from services.config_resolver import resolve_runtime_config
|
||||
from services.runtime_variables import DynamicVariableError, prepare_dynamic_config
|
||||
from starlette.websockets import WebSocketDisconnect, WebSocketState
|
||||
|
||||
from services.webrtc_ice import aiortc_ice_servers, client_ice_servers
|
||||
@@ -52,7 +53,11 @@ async def voice_signaling(websocket: WebSocket):
|
||||
{
|
||||
"type": "error",
|
||||
"payload": {
|
||||
"message": f"语音会话启动失败: {type(e).__name__}"
|
||||
"message": (
|
||||
str(e)
|
||||
if isinstance(e, DynamicVariableError)
|
||||
else f"语音会话启动失败: {type(e).__name__}"
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -71,9 +76,18 @@ async def _resolve_config(offer: SignalingOffer) -> AssistantConfig:
|
||||
"""优先用 assistant_id 从 DB 解析(含真 key);否则用调试内联配置。"""
|
||||
if offer.assistant_id:
|
||||
async with SessionLocal() as session:
|
||||
return await resolve_runtime_config(session, offer.assistant_id)
|
||||
cfg = await resolve_runtime_config(session, offer.assistant_id)
|
||||
return prepare_dynamic_config(
|
||||
cfg,
|
||||
offer.dynamic_variables,
|
||||
assistant_id=offer.assistant_id,
|
||||
)
|
||||
if offer.inline_config:
|
||||
return offer.inline_config
|
||||
return prepare_dynamic_config(
|
||||
offer.inline_config,
|
||||
offer.dynamic_variables,
|
||||
assistant_id=None,
|
||||
)
|
||||
raise ValueError("offer 缺少 assistant_id 或 inline_config")
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from loguru import logger
|
||||
from models import AssistantConfig
|
||||
from services.auth import require_admin_websocket
|
||||
from services.config_resolver import resolve_runtime_config
|
||||
from services.runtime_variables import prepare_dynamic_config
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
# pipecat 重依赖,惰性导入(见 voice_webrtc.py 说明)
|
||||
@@ -28,12 +29,24 @@ async def _resolve_start_config(raw: str) -> tuple[AssistantConfig, str | None]:
|
||||
data = json.loads(raw)
|
||||
if data.get("assistant_id"):
|
||||
async with SessionLocal() as session:
|
||||
cfg = await resolve_runtime_config(session, data["assistant_id"])
|
||||
return (
|
||||
await resolve_runtime_config(session, data["assistant_id"]),
|
||||
prepare_dynamic_config(
|
||||
cfg,
|
||||
data.get("dynamic_variables"),
|
||||
assistant_id=data["assistant_id"],
|
||||
),
|
||||
data["assistant_id"],
|
||||
)
|
||||
if data.get("inline_config"):
|
||||
return AssistantConfig(**data["inline_config"]), None
|
||||
return (
|
||||
prepare_dynamic_config(
|
||||
AssistantConfig(**data["inline_config"]),
|
||||
data.get("dynamic_variables"),
|
||||
assistant_id=None,
|
||||
),
|
||||
None,
|
||||
)
|
||||
raise ValueError("启动参数缺少 assistant_id 或 inline_config")
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ JSON 用 camelCase(modelId/interfaceType/apiUrl/apiKey),Python 内部用 snake_c
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import re
|
||||
from typing import Annotated, Any, Literal, Union
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
@@ -22,6 +23,7 @@ ToolType = Literal["end_call", "http"]
|
||||
ToolStatus = Literal["active", "archived", "draft"]
|
||||
ToolParameterType = Literal["string", "number", "integer", "boolean", "object", "array"]
|
||||
ToolParameterLocation = Literal["path", "query", "body", "header"]
|
||||
DynamicVariableType = Literal["string", "number", "boolean"]
|
||||
|
||||
# 外部应用类型:其 config.apiKey 是该助手私有密钥,读时打码 / 写时哨兵
|
||||
EXTERNAL_TYPES = {"dify", "fastgpt", "opencode"}
|
||||
@@ -106,12 +108,28 @@ class AssistantUpsert(CamelModel):
|
||||
|
||||
# 瘦类型专属(真列);按 type 取用,无关字段写入时清零
|
||||
prompt: str = ""
|
||||
dynamic_variable_definitions: dict[str, "DynamicVariableDefinition"] = Field(
|
||||
default_factory=dict
|
||||
)
|
||||
api_url: str = ""
|
||||
api_key: str = "" # 写时:占位符/空 → 保留旧(哨兵)
|
||||
app_id: str = ""
|
||||
# workflow 专属:图
|
||||
graph: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("dynamic_variable_definitions")
|
||||
@classmethod
|
||||
def validate_dynamic_variable_names(cls, value):
|
||||
if len(value) > 50:
|
||||
raise ValueError("动态变量最多允许 50 个")
|
||||
for name in value:
|
||||
if (
|
||||
not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,63}", name)
|
||||
or name.startswith(("system__", "secret__"))
|
||||
):
|
||||
raise ValueError(f"动态变量名称不合法: {name}")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _strip_irrelevant_fields(self):
|
||||
allowed = ALLOWED_FIELDS[self.type]
|
||||
@@ -122,6 +140,7 @@ class AssistantUpsert(CamelModel):
|
||||
self.graph = {}
|
||||
if self.type != "prompt":
|
||||
self.tool_ids = []
|
||||
self.dynamic_variable_definitions = {}
|
||||
# 外部托管大脑只能 cascade,拦住不兼容的 realtime
|
||||
if self.runtime_mode == "realtime" and self.type not in REALTIME_CAPABLE_TYPES:
|
||||
raise ValueError(f"类型 {self.type} 不支持 realtime 运行模式")
|
||||
@@ -133,6 +152,29 @@ class AssistantOut(AssistantUpsert):
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class DynamicVariableDefinition(CamelModel):
|
||||
type: DynamicVariableType = "string"
|
||||
required: bool = False
|
||||
default: str | int | float | bool | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_default_type(self):
|
||||
if self.default is None:
|
||||
return self
|
||||
valid = (
|
||||
(self.type == "string" and isinstance(self.default, str))
|
||||
or (
|
||||
self.type == "number"
|
||||
and isinstance(self.default, (int, float))
|
||||
and not isinstance(self.default, bool)
|
||||
)
|
||||
or (self.type == "boolean" and isinstance(self.default, bool))
|
||||
)
|
||||
if not valid:
|
||||
raise ValueError(f"默认值类型应为 {self.type}")
|
||||
return self
|
||||
|
||||
|
||||
# ---------- 可复用工具 ----------
|
||||
class ToolParameter(CamelModel):
|
||||
name: str
|
||||
@@ -155,6 +197,7 @@ class HttpToolConfig(CamelModel):
|
||||
headers: dict[str, str] = Field(default_factory=dict)
|
||||
parameters: list[ToolParameter] = Field(default_factory=list)
|
||||
body: dict[str, Any] = Field(default_factory=dict)
|
||||
dynamic_variable_assignments: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from urllib.parse import quote
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
from models import AssistantConfig
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.frames.frames import OutputTransportMessageUrgentFrame, TTSSpeakFrame
|
||||
@@ -16,6 +19,11 @@ from pipecat.services.llm_service import (
|
||||
from pipecat.utils.time import time_now_iso8601
|
||||
|
||||
from services.brains.base import BaseBrain, BrainRuntime, BrainSpec
|
||||
from services.runtime_variables import (
|
||||
DynamicVariableError,
|
||||
DynamicVariableStore,
|
||||
value_at_path,
|
||||
)
|
||||
|
||||
|
||||
class PromptBrain(BaseBrain):
|
||||
@@ -25,21 +33,184 @@ class PromptBrain(BaseBrain):
|
||||
owns_context=True,
|
||||
)
|
||||
|
||||
def __init__(self, cfg: AssistantConfig):
|
||||
self._cfg = cfg
|
||||
self._dynamic_enabled = True
|
||||
self._store = DynamicVariableStore.from_config(cfg)
|
||||
self._runtime: BrainRuntime | None = None
|
||||
|
||||
async def greeting(self, cfg: AssistantConfig) -> str:
|
||||
return self._store.render(cfg.greeting) if self._dynamic_enabled else cfg.greeting
|
||||
|
||||
def system_prompt(self, cfg: AssistantConfig) -> str:
|
||||
return self._store.render(cfg.prompt) if self._dynamic_enabled else cfg.prompt
|
||||
|
||||
def build_llm(self, cfg: AssistantConfig, context: LLMContext) -> FrameProcessor:
|
||||
from services.pipecat.service_factory import create_llm
|
||||
|
||||
return create_llm(cfg)
|
||||
|
||||
async def setup(self, cfg: AssistantConfig, runtime: BrainRuntime) -> None:
|
||||
self._runtime = runtime
|
||||
schemas: list[FunctionSchema] = []
|
||||
for tool in cfg.tools:
|
||||
if tool.type != "end_call":
|
||||
if tool.type == "end_call":
|
||||
schema, handler = self._make_end_call_tool(tool, runtime)
|
||||
elif tool.type == "http":
|
||||
schema, handler = self._make_http_tool(tool, runtime)
|
||||
else:
|
||||
continue
|
||||
schema, handler = self._make_end_call_tool(tool, runtime)
|
||||
schemas.append(schema)
|
||||
runtime.llm.register_function(tool.function_name, handler)
|
||||
runtime.set_tools(schemas)
|
||||
|
||||
def record_user_message(self, content: str) -> None:
|
||||
if not self._dynamic_enabled:
|
||||
return
|
||||
self._store.record("user", content)
|
||||
self._refresh_prompt()
|
||||
|
||||
async def on_assistant_text_end(
|
||||
self,
|
||||
_turn_id: str,
|
||||
content: str,
|
||||
interrupted: bool,
|
||||
) -> None:
|
||||
if content and not interrupted:
|
||||
self._store.record("agent", content, completed_agent_turn=True)
|
||||
self._refresh_prompt()
|
||||
|
||||
def _refresh_prompt(self) -> None:
|
||||
if self._dynamic_enabled and self._runtime is not None:
|
||||
self._runtime.set_system_prompt(self._store.render(self._cfg.prompt))
|
||||
|
||||
def _make_http_tool(self, tool, runtime: BrainRuntime):
|
||||
config = (tool.definition or {}).get("config") or {}
|
||||
parameters = list(config.get("parameters") or [])
|
||||
properties = {
|
||||
str(parameter.get("name")): {
|
||||
"type": str(parameter.get("type") or "string"),
|
||||
"description": str(parameter.get("description") or ""),
|
||||
}
|
||||
for parameter in parameters
|
||||
if parameter.get("name")
|
||||
}
|
||||
required = [
|
||||
str(parameter["name"])
|
||||
for parameter in parameters
|
||||
if parameter.get("name") and parameter.get("required", True)
|
||||
]
|
||||
|
||||
tool_secrets = tool.secrets or {}
|
||||
dynamic_secrets = tool_secrets.get("dynamic_variables") or {}
|
||||
for name, value in dynamic_secrets.items():
|
||||
if not str(name).startswith("secret__"):
|
||||
raise DynamicVariableError(f"工具密钥变量必须以 secret__ 开头: {name}")
|
||||
self._store.secrets[str(name)] = str(value)
|
||||
|
||||
async def call_http(params: FunctionCallParams) -> None:
|
||||
arguments = params.arguments or {}
|
||||
url = self._store.render(str(config.get("url") or ""))
|
||||
configured_headers = self._store.render_data(
|
||||
deepcopy(config.get("headers") or {}), allow_secrets=True
|
||||
)
|
||||
secret_headers = self._store.render_data(
|
||||
deepcopy(tool_secrets.get("headers") or {}), allow_secrets=True
|
||||
)
|
||||
headers: dict[str, str] = {}
|
||||
query: dict[str, object] = {}
|
||||
body = self._store.render_data(deepcopy(config.get("body") or {}))
|
||||
|
||||
for parameter in parameters:
|
||||
name = str(parameter.get("name") or "")
|
||||
if not name or name not in arguments:
|
||||
continue
|
||||
value = arguments[name]
|
||||
location = str(parameter.get("location") or "body")
|
||||
if location == "path":
|
||||
encoded = quote(str(value), safe="")
|
||||
url = url.replace(f"{{{name}}}", encoded)
|
||||
elif location == "query":
|
||||
query[name] = value
|
||||
elif location == "header":
|
||||
headers[name] = str(value)
|
||||
else:
|
||||
body[name] = value
|
||||
|
||||
# Admin-configured headers win over model-provided arguments; secret
|
||||
# headers are applied last so an LLM can never replace credentials.
|
||||
headers.update(
|
||||
{str(key): str(value) for key, value in configured_headers.items()}
|
||||
)
|
||||
headers.update({str(key): str(value) for key, value in secret_headers.items()})
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=float(config.get("timeout_seconds") or 15),
|
||||
follow_redirects=False,
|
||||
) as client:
|
||||
response = await client.request(
|
||||
str(config.get("method") or "GET"),
|
||||
url,
|
||||
headers=headers,
|
||||
params=query,
|
||||
json=body if body else None,
|
||||
)
|
||||
response.raise_for_status()
|
||||
if len(response.content) > 1_000_000:
|
||||
raise DynamicVariableError("HTTP 工具响应超过 1 MB 限制")
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
payload = {"text": response.text[:8000]}
|
||||
|
||||
updated: list[str] = []
|
||||
assignments = config.get("dynamic_variable_assignments") or {}
|
||||
for variable_name, path in assignments.items():
|
||||
try:
|
||||
value = value_at_path(payload, str(path))
|
||||
except KeyError:
|
||||
try:
|
||||
value = value_at_path({"response": payload}, str(path))
|
||||
except KeyError:
|
||||
continue
|
||||
self._store.assign(str(variable_name), value)
|
||||
updated.append(str(variable_name))
|
||||
if updated:
|
||||
self._refresh_prompt()
|
||||
await params.result_callback(
|
||||
{
|
||||
"status": "ok",
|
||||
"status_code": response.status_code,
|
||||
"data": payload,
|
||||
"updated_variables": updated,
|
||||
}
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
await params.result_callback(
|
||||
{"status": "error", "message": "HTTP 工具调用超时"}
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
await params.result_callback(
|
||||
{
|
||||
"status": "error",
|
||||
"status_code": exc.response.status_code,
|
||||
"message": "HTTP 工具返回错误状态",
|
||||
}
|
||||
)
|
||||
except (httpx.RequestError, DynamicVariableError) as exc:
|
||||
await params.result_callback(
|
||||
{"status": "error", "message": f"HTTP 工具调用失败: {exc}"}
|
||||
)
|
||||
|
||||
schema = FunctionSchema(
|
||||
name=tool.function_name,
|
||||
description=tool.description or f"调用 {tool.name}",
|
||||
properties=properties,
|
||||
required=required,
|
||||
)
|
||||
return schema, call_http
|
||||
|
||||
@staticmethod
|
||||
def _make_end_call_tool(tool, runtime: BrainRuntime):
|
||||
config = (tool.definition or {}).get("config") or {}
|
||||
|
||||
@@ -18,7 +18,7 @@ def _workflow(cfg: AssistantConfig) -> Brain:
|
||||
|
||||
|
||||
BRAIN_FACTORIES: dict[str, Callable[[AssistantConfig], Brain]] = {
|
||||
"prompt": lambda _cfg: PromptBrain(),
|
||||
"prompt": lambda cfg: PromptBrain(cfg),
|
||||
"workflow": _workflow,
|
||||
"dify": lambda _cfg: DifyBrain(),
|
||||
"fastgpt": lambda _cfg: FastGPTBrain(),
|
||||
|
||||
@@ -104,6 +104,7 @@ async def _tools_for(session: AsyncSession, assistant: Assistant) -> list[Runtim
|
||||
type=tool.type,
|
||||
description=tool.description,
|
||||
definition=tool.definition or {},
|
||||
secrets=tool.secrets or {},
|
||||
)
|
||||
for tool in tools
|
||||
]
|
||||
@@ -139,6 +140,7 @@ async def resolve_runtime_config(
|
||||
greeting=assistant.greeting,
|
||||
# prompt 现在是真列;外部类型由其平台编排,这里给个兜底
|
||||
prompt=assistant.prompt or "你是一个有帮助的助手。",
|
||||
dynamic_variable_definitions=assistant.dynamic_variable_definitions or {},
|
||||
runtimeMode=assistant.runtime_mode, # type: ignore[arg-type]
|
||||
enableInterrupt=assistant.enable_interrupt,
|
||||
turnConfig=assistant.turn_config or {},
|
||||
|
||||
@@ -40,8 +40,9 @@ class ConversationRecorder:
|
||||
assistant_name: str,
|
||||
channel: str,
|
||||
runtime_mode: str,
|
||||
session_id: str | None = None,
|
||||
) -> "ConversationRecorder | None":
|
||||
session_id = f"conv_{uuid4().hex[:20]}"
|
||||
session_id = session_id or f"conv_{uuid4().hex[:20]}"
|
||||
try:
|
||||
async with SessionLocal() as db:
|
||||
db.add(
|
||||
|
||||
@@ -308,6 +308,41 @@ class VisionCaptureProcessor(FrameProcessor):
|
||||
await self.push_frame(frame, direction)
|
||||
|
||||
|
||||
class RealtimeDynamicVariableProcessor(FrameProcessor):
|
||||
"""Keep realtime system turn/history variables current between responses."""
|
||||
|
||||
def __init__(self, brain: Brain, cfg: AssistantConfig, realtime):
|
||||
super().__init__()
|
||||
self._brain = brain
|
||||
self._cfg = cfg
|
||||
self._realtime = realtime
|
||||
|
||||
async def _refresh_instructions(self) -> None:
|
||||
update = getattr(self._realtime, "update_instructions", None)
|
||||
if callable(update):
|
||||
await update(self._brain.system_prompt(self._cfg))
|
||||
|
||||
async def process_frame(self, frame, direction: FrameDirection):
|
||||
await super().process_frame(frame, direction)
|
||||
if isinstance(frame, OutputTransportMessageUrgentFrame):
|
||||
message = frame.message
|
||||
if isinstance(message, dict):
|
||||
event_type = message.get("type")
|
||||
if event_type == "transcript" and message.get("role") == "user":
|
||||
content = str(message.get("content") or "").strip()
|
||||
if content:
|
||||
self._brain.record_user_message(content)
|
||||
await self._refresh_instructions()
|
||||
elif event_type == "assistant-text-end":
|
||||
await self._brain.on_assistant_text_end(
|
||||
str(message.get("turn_id") or ""),
|
||||
str(message.get("content") or ""),
|
||||
bool(message.get("interrupted", False)),
|
||||
)
|
||||
await self._refresh_instructions()
|
||||
await self.push_frame(frame, direction)
|
||||
|
||||
|
||||
class RealtimeTextInputProcessor(FrameProcessor):
|
||||
"""Route text input directly to a realtime service without cascade semantics."""
|
||||
|
||||
@@ -709,6 +744,7 @@ async def run_pipeline(
|
||||
assistant_name=cfg.name,
|
||||
channel=channel,
|
||||
runtime_mode=cfg.runtimeMode,
|
||||
session_id=cfg.conversation_id or None,
|
||||
)
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
@@ -911,6 +947,7 @@ async def run_realtime_pipeline(
|
||||
instructions=brain.system_prompt(cfg),
|
||||
)
|
||||
text_input = RealtimeTextInputProcessor()
|
||||
dynamic_variables = RealtimeDynamicVariableProcessor(brain, cfg, realtime)
|
||||
greeting = await brain.greeting(cfg)
|
||||
|
||||
recorder = await ConversationRecorder.start(
|
||||
@@ -918,12 +955,14 @@ async def run_realtime_pipeline(
|
||||
assistant_name=cfg.name,
|
||||
channel=channel,
|
||||
runtime_mode=cfg.runtimeMode,
|
||||
session_id=cfg.conversation_id or None,
|
||||
)
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
transport.input(),
|
||||
text_input,
|
||||
realtime,
|
||||
dynamic_variables,
|
||||
ConversationHistoryProcessor(recorder),
|
||||
transport.output(),
|
||||
]
|
||||
|
||||
@@ -289,6 +289,12 @@ class StepFunRealtimeService(AIService):
|
||||
wait_until_ready=False,
|
||||
)
|
||||
|
||||
async def update_instructions(self, instructions: str) -> None:
|
||||
"""Refresh model instructions without rebuilding the realtime session."""
|
||||
self._instructions = instructions
|
||||
if self._session_ready.is_set():
|
||||
await self._send_session_update()
|
||||
|
||||
async def _send_event(
|
||||
self, payload: dict[str, Any], *, wait_until_ready: bool = True
|
||||
) -> None:
|
||||
|
||||
233
backend/services/runtime_variables.py
Normal file
233
backend/services/runtime_variables.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""Conversation-scoped dynamic variables for prompt pipeline assistants.
|
||||
|
||||
The renderer is deliberately small: it only understands ``{{ name }}``
|
||||
placeholders and never evaluates expressions. A value is substituted once,
|
||||
so user input cannot introduce a second template expansion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from models import AssistantConfig
|
||||
|
||||
|
||||
Primitive = str | int | float | bool
|
||||
VARIABLE_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,63}$")
|
||||
PLACEHOLDER = re.compile(r"{{\s*([A-Za-z][A-Za-z0-9_]*)\s*}}")
|
||||
MAX_VARIABLES = 50
|
||||
MAX_VALUE_LENGTH = 2048
|
||||
MAX_HISTORY_ENTRIES = 50
|
||||
MAX_HISTORY_JSON_LENGTH = 32_000
|
||||
|
||||
|
||||
class DynamicVariableError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _primitive(value: Any, name: str) -> Primitive:
|
||||
if not isinstance(value, (str, int, float, bool)) or value is None:
|
||||
raise DynamicVariableError(f"动态变量 {name} 仅支持字符串、数字或布尔值")
|
||||
if len(str(value)) > MAX_VALUE_LENGTH:
|
||||
raise DynamicVariableError(f"动态变量 {name} 超过 {MAX_VALUE_LENGTH} 字符")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_public(values: dict[str, Any] | None) -> dict[str, Primitive]:
|
||||
values = values or {}
|
||||
if len(values) > MAX_VARIABLES:
|
||||
raise DynamicVariableError(f"动态变量最多允许 {MAX_VARIABLES} 个")
|
||||
result: dict[str, Primitive] = {}
|
||||
for name, value in values.items():
|
||||
if not VARIABLE_NAME.fullmatch(name):
|
||||
raise DynamicVariableError(f"动态变量名称不合法: {name}")
|
||||
if name.startswith(("system__", "secret__")):
|
||||
raise DynamicVariableError(f"客户端不能设置保留变量: {name}")
|
||||
result[name] = _primitive(value, name)
|
||||
return result
|
||||
|
||||
|
||||
def _type_matches(value: Primitive, expected: str) -> bool:
|
||||
if expected == "string":
|
||||
return isinstance(value, str)
|
||||
if expected == "boolean":
|
||||
return isinstance(value, bool)
|
||||
if expected == "number":
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
return False
|
||||
|
||||
|
||||
def _system_values(
|
||||
*, assistant_id: str | None, conversation_id: str, timezone: str
|
||||
) -> dict[str, Primitive]:
|
||||
try:
|
||||
zone = ZoneInfo(timezone)
|
||||
except ZoneInfoNotFoundError as exc:
|
||||
raise DynamicVariableError(f"无效时区: {timezone}") from exc
|
||||
now = datetime.now(zone)
|
||||
return {
|
||||
"system__agent_id": assistant_id or "",
|
||||
"system__conversation_id": conversation_id,
|
||||
"system__time": now.strftime("%A, %H:%M %d %B %Y"),
|
||||
"system__time_utc": now.astimezone(ZoneInfo("UTC")).isoformat(),
|
||||
"system__timezone": timezone,
|
||||
"system__agent_turns": 0,
|
||||
"system__conversation_history": json.dumps(
|
||||
{"entries": []}, ensure_ascii=False, separators=(",", ":")
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class DynamicVariableStore:
|
||||
"""Mutable state owned by exactly one conversation."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
values: dict[str, Primitive],
|
||||
secrets: dict[str, str] | None = None,
|
||||
):
|
||||
self.values = dict(values)
|
||||
self.secrets = dict(secrets or {})
|
||||
self.history: list[dict[str, str]] = []
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, cfg: AssistantConfig) -> "DynamicVariableStore":
|
||||
return cls(cfg.dynamic_variables, cfg.secret_dynamic_variables)
|
||||
|
||||
def render(self, template: str, *, allow_secrets: bool = False) -> str:
|
||||
if not template:
|
||||
return template
|
||||
timezone = str(self.values.get("system__timezone") or "Asia/Shanghai")
|
||||
try:
|
||||
now = datetime.now(ZoneInfo(timezone))
|
||||
self.values["system__time"] = now.strftime("%A, %H:%M %d %B %Y")
|
||||
self.values["system__time_utc"] = now.astimezone(ZoneInfo("UTC")).isoformat()
|
||||
except ZoneInfoNotFoundError:
|
||||
pass
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
name = match.group(1)
|
||||
if name.startswith("secret__"):
|
||||
if not allow_secrets:
|
||||
raise DynamicVariableError(f"密钥变量 {name} 只能用于 HTTP Header")
|
||||
if name not in self.secrets:
|
||||
raise DynamicVariableError(f"缺少密钥变量: {name}")
|
||||
return self.secrets[name]
|
||||
if name not in self.values:
|
||||
raise DynamicVariableError(f"缺少动态变量: {name}")
|
||||
value = self.values[name]
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
return str(value)
|
||||
|
||||
return PLACEHOLDER.sub(replace, template)
|
||||
|
||||
def render_data(self, value: Any, *, allow_secrets: bool = False) -> Any:
|
||||
if isinstance(value, str):
|
||||
return self.render(value, allow_secrets=allow_secrets)
|
||||
if isinstance(value, list):
|
||||
return [self.render_data(item, allow_secrets=allow_secrets) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: self.render_data(item, allow_secrets=allow_secrets)
|
||||
for key, item in value.items()
|
||||
}
|
||||
return deepcopy(value)
|
||||
|
||||
def record(self, role: str, content: str, *, completed_agent_turn: bool = False) -> None:
|
||||
if content:
|
||||
self.history.append({"role": role, "message": content})
|
||||
self.history = self.history[-MAX_HISTORY_ENTRIES:]
|
||||
if completed_agent_turn:
|
||||
self.values["system__agent_turns"] = int(
|
||||
self.values.get("system__agent_turns", 0)
|
||||
) + 1
|
||||
serialized = json.dumps(
|
||||
{"entries": self.history}, ensure_ascii=False, separators=(",", ":")
|
||||
)
|
||||
while len(serialized) > MAX_HISTORY_JSON_LENGTH and len(self.history) > 1:
|
||||
self.history.pop(0)
|
||||
serialized = json.dumps(
|
||||
{"entries": self.history},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
self.values["system__conversation_history"] = serialized
|
||||
|
||||
def assign(self, name: str, value: Any) -> None:
|
||||
if name.startswith(("system__", "secret__")) or not VARIABLE_NAME.fullmatch(name):
|
||||
raise DynamicVariableError(f"工具不能更新保留变量: {name}")
|
||||
self.values[name] = _primitive(value, name)
|
||||
|
||||
|
||||
def prepare_dynamic_config(
|
||||
cfg: AssistantConfig,
|
||||
client_values: dict[str, Any] | None,
|
||||
*,
|
||||
assistant_id: str | None,
|
||||
timezone: str = "Asia/Shanghai",
|
||||
trusted_values: dict[str, Any] | None = None,
|
||||
) -> AssistantConfig:
|
||||
"""Validate and merge one call's values without mutating stored config."""
|
||||
if cfg.type != "prompt":
|
||||
if client_values:
|
||||
raise DynamicVariableError("动态变量目前仅支持 prompt 助手")
|
||||
return cfg
|
||||
|
||||
supplied = _validate_public(client_values)
|
||||
# Server-side CRM/order/auth providers can pass trusted_values. They have
|
||||
# precedence over browser input but still cannot claim reserved prefixes.
|
||||
supplied.update(_validate_public(trusted_values))
|
||||
definitions = cfg.dynamic_variable_definitions or {}
|
||||
merged: dict[str, Primitive] = {}
|
||||
for name, definition in definitions.items():
|
||||
if name.startswith(("system__", "secret__")) or not VARIABLE_NAME.fullmatch(name):
|
||||
raise DynamicVariableError(f"动态变量定义名称不合法: {name}")
|
||||
if name in supplied:
|
||||
value = supplied.pop(name)
|
||||
elif "default" in definition and definition.get("default") is not None:
|
||||
value = _primitive(definition["default"], name)
|
||||
elif definition.get("required", False):
|
||||
raise DynamicVariableError(f"缺少必填动态变量: {name}")
|
||||
else:
|
||||
continue
|
||||
expected = str(definition.get("type") or "string")
|
||||
if not _type_matches(value, expected):
|
||||
raise DynamicVariableError(f"动态变量 {name} 类型应为 {expected}")
|
||||
merged[name] = value
|
||||
merged.update(supplied)
|
||||
|
||||
conversation_id = f"conv_{uuid4().hex[:20]}"
|
||||
merged.update(
|
||||
_system_values(
|
||||
assistant_id=assistant_id,
|
||||
conversation_id=conversation_id,
|
||||
timezone=timezone,
|
||||
)
|
||||
)
|
||||
prepared = cfg.model_copy(deep=True)
|
||||
prepared.dynamic_variables = merged
|
||||
prepared.conversation_id = conversation_id
|
||||
# Validate prompt and greeting before media resources are allocated.
|
||||
store = DynamicVariableStore.from_config(prepared)
|
||||
store.render(prepared.prompt)
|
||||
store.render(prepared.greeting)
|
||||
return prepared
|
||||
|
||||
|
||||
def value_at_path(payload: Any, path: str) -> Any:
|
||||
current = payload
|
||||
for part in path.split("."):
|
||||
if isinstance(current, dict) and part in current:
|
||||
current = current[part]
|
||||
elif isinstance(current, list) and part.isdigit() and int(part) < len(current):
|
||||
current = current[int(part)]
|
||||
else:
|
||||
raise KeyError(path)
|
||||
return current
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from models import AssistantConfig, RuntimeTool
|
||||
from pipecat.frames.frames import (
|
||||
@@ -20,6 +21,7 @@ from services.brains.dify_llm import (
|
||||
normalize_api_base,
|
||||
)
|
||||
from services.brains.workflow_brain import WorkflowBrain
|
||||
from services.runtime_variables import prepare_dynamic_config
|
||||
|
||||
|
||||
class FakeLLM:
|
||||
@@ -94,6 +96,21 @@ class BrainRegistryTests(unittest.TestCase):
|
||||
runtimeMode="realtime",
|
||||
)
|
||||
|
||||
def test_prompt_realtime_keeps_dynamic_variable_definitions(self):
|
||||
assistant = AssistantUpsert(
|
||||
name="realtime prompt",
|
||||
type="prompt",
|
||||
runtimeMode="realtime",
|
||||
dynamicVariableDefinitions={
|
||||
"user_name": {
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"default": None,
|
||||
}
|
||||
},
|
||||
)
|
||||
self.assertIn("user_name", assistant.dynamic_variable_definitions)
|
||||
|
||||
|
||||
class DifyHelpersTests(unittest.TestCase):
|
||||
def test_normalize_api_base(self):
|
||||
@@ -176,6 +193,24 @@ class DifyLLMServiceTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
|
||||
class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_realtime_prompt_brain_renders_dynamic_variables(self):
|
||||
cfg = prepare_dynamic_config(
|
||||
AssistantConfig(
|
||||
type="prompt",
|
||||
runtimeMode="realtime",
|
||||
prompt="服务用户 {{user_name}}",
|
||||
greeting="您好,{{user_name}}",
|
||||
dynamic_variable_definitions={
|
||||
"user_name": {"type": "string", "required": True}
|
||||
},
|
||||
),
|
||||
{"user_name": "王先生"},
|
||||
assistant_id="asst_realtime",
|
||||
)
|
||||
brain = build_brain(cfg)
|
||||
self.assertEqual(brain.system_prompt(cfg), "服务用户 王先生")
|
||||
self.assertEqual(await brain.greeting(cfg), "您好,王先生")
|
||||
|
||||
async def test_end_call_tool_is_owned_by_prompt_brain(self):
|
||||
brain = build_brain(
|
||||
AssistantConfig(
|
||||
@@ -233,6 +268,111 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertTrue(call_end.finished)
|
||||
self.assertEqual(params.result["action"], "ending_call")
|
||||
|
||||
async def test_http_tool_renders_secrets_and_updates_prompt_variable(self):
|
||||
requests = []
|
||||
|
||||
class FakeResponse:
|
||||
status_code = 200
|
||||
content = b'{"order":{"status":"paid"}}'
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return {"order": {"status": "paid"}}
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, **_kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
async def request(self, method, url, **kwargs):
|
||||
requests.append((method, url, kwargs))
|
||||
return FakeResponse()
|
||||
|
||||
cfg = prepare_dynamic_config(
|
||||
AssistantConfig(
|
||||
type="prompt",
|
||||
runtimeMode="pipeline",
|
||||
prompt="订单状态:{{order_status}}",
|
||||
dynamic_variable_definitions={
|
||||
"order_status": {"type": "string", "default": "unknown"}
|
||||
},
|
||||
tools=[
|
||||
RuntimeTool(
|
||||
id="lookup",
|
||||
name="查询订单",
|
||||
function_name="lookup_order",
|
||||
type="http",
|
||||
description="查询订单状态",
|
||||
definition={
|
||||
"config": {
|
||||
"method": "GET",
|
||||
"url": "https://example.test/orders/{order_id}",
|
||||
"headers": {"Authorization": "Bearer {{secret__token}}"},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "order_id",
|
||||
"type": "string",
|
||||
"location": "path",
|
||||
"required": True,
|
||||
},
|
||||
{
|
||||
"name": "Authorization",
|
||||
"type": "string",
|
||||
"location": "header",
|
||||
"required": False,
|
||||
},
|
||||
],
|
||||
"dynamic_variable_assignments": {
|
||||
"order_status": "response.order.status"
|
||||
},
|
||||
}
|
||||
},
|
||||
secrets={"dynamic_variables": {"secret__token": "server-token"}},
|
||||
)
|
||||
],
|
||||
),
|
||||
{},
|
||||
assistant_id="asst_1",
|
||||
)
|
||||
brain = build_brain(cfg)
|
||||
llm = FakeLLM()
|
||||
prompts = []
|
||||
visible_tools = []
|
||||
|
||||
async def queue_frame(_frame):
|
||||
pass
|
||||
|
||||
await brain.setup(
|
||||
cfg,
|
||||
BrainRuntime(
|
||||
context=LLMContext(messages=[]),
|
||||
llm=llm,
|
||||
queue_frame=queue_frame,
|
||||
set_system_prompt=prompts.append,
|
||||
set_tools=lambda tools: visible_tools.extend(tools or []),
|
||||
call_end=FakeCallEnd(),
|
||||
),
|
||||
)
|
||||
params = FakeFunctionParams(
|
||||
{"order_id": "A/1", "Authorization": "attacker-value"}
|
||||
)
|
||||
with patch("services.brains.prompt_brain.httpx.AsyncClient", FakeClient):
|
||||
await llm.functions["lookup_order"](params)
|
||||
|
||||
self.assertEqual(requests[0][1], "https://example.test/orders/A%2F1")
|
||||
self.assertEqual(
|
||||
requests[0][2]["headers"]["Authorization"], "Bearer server-token"
|
||||
)
|
||||
self.assertEqual(params.result["updated_variables"], ["order_status"])
|
||||
self.assertEqual(prompts[-1], "订单状态:paid")
|
||||
|
||||
|
||||
class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_transition_and_end_are_owned_by_workflow_brain(self):
|
||||
|
||||
99
backend/tests/test_runtime_variables.py
Normal file
99
backend/tests/test_runtime_variables.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from models import AssistantConfig
|
||||
from services.runtime_variables import (
|
||||
DynamicVariableError,
|
||||
DynamicVariableStore,
|
||||
prepare_dynamic_config,
|
||||
value_at_path,
|
||||
)
|
||||
|
||||
|
||||
class DynamicVariableTests(unittest.TestCase):
|
||||
def test_defaults_client_values_and_system_values_are_merged(self):
|
||||
cfg = AssistantConfig(
|
||||
type="prompt",
|
||||
runtimeMode="pipeline",
|
||||
prompt="服务 {{user_name}} / {{tier}} / {{system__conversation_id}}",
|
||||
greeting="您好 {{user_name}}",
|
||||
dynamic_variable_definitions={
|
||||
"user_name": {"type": "string", "required": True},
|
||||
"tier": {"type": "string", "default": "普通"},
|
||||
},
|
||||
)
|
||||
prepared = prepare_dynamic_config(
|
||||
cfg, {"user_name": "王先生"}, assistant_id="asst_1"
|
||||
)
|
||||
store = DynamicVariableStore.from_config(prepared)
|
||||
self.assertEqual(store.render(prepared.greeting), "您好 王先生")
|
||||
self.assertIn("普通", store.render(prepared.prompt))
|
||||
self.assertTrue(prepared.conversation_id.startswith("conv_"))
|
||||
self.assertEqual(
|
||||
prepared.dynamic_variables["system__conversation_id"],
|
||||
prepared.conversation_id,
|
||||
)
|
||||
|
||||
def test_realtime_prompt_supports_dynamic_variables(self):
|
||||
cfg = AssistantConfig(
|
||||
type="prompt",
|
||||
runtimeMode="realtime",
|
||||
prompt="请称呼用户为 {{user_name}}",
|
||||
greeting="您好,{{user_name}}",
|
||||
dynamic_variable_definitions={
|
||||
"user_name": {"type": "string", "required": True}
|
||||
},
|
||||
)
|
||||
prepared = prepare_dynamic_config(
|
||||
cfg, {"user_name": "王先生"}, assistant_id="asst_realtime"
|
||||
)
|
||||
store = DynamicVariableStore.from_config(prepared)
|
||||
self.assertEqual(store.render(prepared.greeting), "您好,王先生")
|
||||
self.assertEqual(store.render(prepared.prompt), "请称呼用户为 王先生")
|
||||
|
||||
def test_client_cannot_set_reserved_or_wrong_typed_values(self):
|
||||
cfg = AssistantConfig(
|
||||
type="prompt",
|
||||
runtimeMode="pipeline",
|
||||
dynamic_variable_definitions={
|
||||
"count": {"type": "number", "required": True}
|
||||
},
|
||||
)
|
||||
with self.assertRaisesRegex(DynamicVariableError, "保留变量"):
|
||||
prepare_dynamic_config(
|
||||
cfg, {"system__agent_turns": 99}, assistant_id="asst_1"
|
||||
)
|
||||
with self.assertRaisesRegex(DynamicVariableError, "类型"):
|
||||
prepare_dynamic_config(cfg, {"count": "1"}, assistant_id="asst_1")
|
||||
|
||||
def test_secret_is_header_only_and_expansion_is_single_pass(self):
|
||||
store = DynamicVariableStore(
|
||||
{"name": "{{secret__token}}"}, {"secret__token": "private"}
|
||||
)
|
||||
self.assertEqual(store.render("{{name}}"), "{{secret__token}}")
|
||||
with self.assertRaisesRegex(DynamicVariableError, "只能用于 HTTP Header"):
|
||||
store.render("{{secret__token}}")
|
||||
self.assertEqual(
|
||||
store.render("Bearer {{secret__token}}", allow_secrets=True),
|
||||
"Bearer private",
|
||||
)
|
||||
|
||||
def test_tool_assignment_and_system_history(self):
|
||||
store = DynamicVariableStore(
|
||||
{
|
||||
"order_status": "unknown",
|
||||
"system__agent_turns": 0,
|
||||
"system__conversation_history": "",
|
||||
}
|
||||
)
|
||||
store.assign("order_status", value_at_path({"order": {"status": "paid"}}, "order.status"))
|
||||
store.record("user", "查订单")
|
||||
store.record("agent", "已付款", completed_agent_turn=True)
|
||||
self.assertEqual(store.values["order_status"], "paid")
|
||||
self.assertEqual(store.values["system__agent_turns"], 1)
|
||||
self.assertIn("查订单", store.values["system__conversation_history"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
Video,
|
||||
Smartphone,
|
||||
Wrench,
|
||||
Braces,
|
||||
Settings2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
@@ -77,6 +78,12 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs";
|
||||
import { AuraVisualizer } from "@/components/ui/aura-visualizer";
|
||||
import { NebulaVisualizer } from "@/components/ui/nebula-visualizer";
|
||||
import { SpectrumVisualizer } from "@/components/ui/spectrum-visualizer";
|
||||
@@ -103,6 +110,7 @@ import {
|
||||
type Assistant,
|
||||
type AssistantType as ApiAssistantType,
|
||||
type AssistantUpsert,
|
||||
type DynamicVariableDefinition,
|
||||
type KnowledgeBase,
|
||||
type KnowledgeRetrievalConfig,
|
||||
type ModelResource,
|
||||
@@ -134,6 +142,7 @@ type AssistantForm = {
|
||||
name: string;
|
||||
greeting: string;
|
||||
prompt: string;
|
||||
dynamicVariableDefinitions: Record<string, DynamicVariableDefinition>;
|
||||
runtimeMode: RuntimeMode;
|
||||
realtimeModel: string;
|
||||
model: string;
|
||||
@@ -255,6 +264,7 @@ function blankPromptForm(name: string): AssistantForm {
|
||||
name,
|
||||
greeting: "",
|
||||
prompt: "",
|
||||
dynamicVariableDefinitions: {},
|
||||
runtimeMode: "pipeline",
|
||||
realtimeModel: "",
|
||||
model: "",
|
||||
@@ -270,6 +280,44 @@ function blankPromptForm(name: string): AssistantForm {
|
||||
};
|
||||
}
|
||||
|
||||
const DYNAMIC_VARIABLE_PATTERN = /{{\s*([A-Za-z][A-Za-z0-9_]{0,63})\s*}}/g;
|
||||
const SYSTEM_DYNAMIC_VARIABLES = [
|
||||
["system__conversation_id", "会话 ID"],
|
||||
["system__time", "当前时间"],
|
||||
["system__timezone", "会话时区"],
|
||||
["system__agent_turns", "助手轮次"],
|
||||
["system__conversation_history", "会话历史"],
|
||||
] as const;
|
||||
|
||||
function extractDynamicVariableNames(...templates: string[]): string[] {
|
||||
const names = new Set<string>();
|
||||
for (const template of templates) {
|
||||
for (const match of template.matchAll(DYNAMIC_VARIABLE_PATTERN)) {
|
||||
const name = match[1];
|
||||
if (!name.startsWith("system__") && !name.startsWith("secret__")) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...names];
|
||||
}
|
||||
|
||||
function activeDynamicVariableDefinitions(
|
||||
templates: string[],
|
||||
saved: Record<string, DynamicVariableDefinition>,
|
||||
): Record<string, DynamicVariableDefinition> {
|
||||
return Object.fromEntries(
|
||||
extractDynamicVariableNames(...templates).map((name) => [
|
||||
name,
|
||||
saved[name] ?? {
|
||||
type: "string",
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function blankFastGptForm(name: string): FastGptForm {
|
||||
return {
|
||||
name,
|
||||
@@ -435,6 +483,12 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
});
|
||||
const [debugOpen, setDebugOpen] = useState(false);
|
||||
const [activeNodeId, setActiveNodeId] = useState<string | null>(null);
|
||||
const [dynamicVariablesOpen, setDynamicVariablesOpen] = useState(false);
|
||||
|
||||
const effectiveDynamicVariableDefinitions = activeDynamicVariableDefinitions(
|
||||
[form.prompt, form.greeting],
|
||||
form.dynamicVariableDefinitions,
|
||||
);
|
||||
|
||||
const loadAssistants = useCallback(async () => {
|
||||
setListLoading(true);
|
||||
@@ -536,6 +590,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
name: a.name,
|
||||
greeting: a.greeting,
|
||||
prompt: a.prompt,
|
||||
dynamicVariableDefinitions: a.dynamicVariableDefinitions ?? {},
|
||||
runtimeMode: a.runtimeMode,
|
||||
realtimeModel: a.modelResourceIds.Realtime ?? "",
|
||||
model: a.modelResourceIds.LLM ?? "",
|
||||
@@ -617,6 +672,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
|
||||
toolIds: [],
|
||||
prompt: "",
|
||||
dynamicVariableDefinitions: {},
|
||||
apiUrl: "",
|
||||
apiKey: "",
|
||||
appId: "",
|
||||
@@ -683,6 +739,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
knowledgeRetrievalConfig: form.knowledgeRetrievalConfig,
|
||||
toolIds: form.toolIds,
|
||||
prompt: form.prompt,
|
||||
dynamicVariableDefinitions: effectiveDynamicVariableDefinitions,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1754,6 +1811,18 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DynamicVariablesDialog
|
||||
open={dynamicVariablesOpen}
|
||||
onOpenChange={setDynamicVariablesOpen}
|
||||
definitions={effectiveDynamicVariableDefinitions}
|
||||
onChange={(dynamicVariableDefinitions) =>
|
||||
updateForm(
|
||||
"dynamicVariableDefinitions",
|
||||
dynamicVariableDefinitions,
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-1 gap-6">
|
||||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
||||
<SectionCard>
|
||||
@@ -1841,6 +1910,10 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
placeholder="请输入提示词,描述助手的角色、能力和回答要求"
|
||||
rows={8}
|
||||
/>
|
||||
<DynamicVariableEditorHint
|
||||
count={Object.keys(effectiveDynamicVariableDefinitions).length}
|
||||
onOpen={() => setDynamicVariablesOpen(true)}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
{form.runtimeMode === "pipeline" ? (
|
||||
@@ -1914,6 +1987,10 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
onChange={(value) => updateForm("greeting", value)}
|
||||
placeholder="请输入助手开场白"
|
||||
/>
|
||||
<DynamicVariableEditorHint
|
||||
count={Object.keys(effectiveDynamicVariableDefinitions).length}
|
||||
onOpen={() => setDynamicVariablesOpen(true)}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
{form.runtimeMode === "pipeline" && (
|
||||
@@ -1979,6 +2056,8 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
assistantId={editingId}
|
||||
hasUnsavedChanges={dirty}
|
||||
vision={form.visionEnabled}
|
||||
dynamicVariablesEnabled
|
||||
dynamicVariableDefinitions={effectiveDynamicVariableDefinitions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2057,23 +2136,43 @@ function DebugDrawer({
|
||||
hasUnsavedChanges = false,
|
||||
onNodeActive,
|
||||
vision = false,
|
||||
dynamicVariablesEnabled = false,
|
||||
dynamicVariableDefinitions = {},
|
||||
}: {
|
||||
assistantId: string | null;
|
||||
asSheet?: boolean;
|
||||
hasUnsavedChanges?: boolean;
|
||||
onNodeActive?: (nodeId: string | null) => void;
|
||||
vision?: boolean;
|
||||
dynamicVariablesEnabled?: boolean;
|
||||
dynamicVariableDefinitions?: Record<string, DynamicVariableDefinition>;
|
||||
}) {
|
||||
const preview = useVoicePreview(assistantId, onNodeActive);
|
||||
const camera = useCameraPreview();
|
||||
const [showTranscript, setShowTranscript] = useState(false);
|
||||
const [vizStyle, setVizStyle] = useState<VizStyle>("aura");
|
||||
const [view, setView] = useState<DebugView>("chat");
|
||||
const [dynamicVariableValues, setDynamicVariableValues] = useState<
|
||||
Record<string, string | number | boolean>
|
||||
>({});
|
||||
const stopCamera = camera.stop;
|
||||
const startCamera = camera.start;
|
||||
const recording =
|
||||
preview.status === "connecting" || preview.status === "connected";
|
||||
const shouldKeepCamera = vision && recording;
|
||||
const dynamicVariableEntries = Object.entries(dynamicVariableDefinitions);
|
||||
const resolvedDynamicVariables: Record<string, string | number | boolean> = {};
|
||||
let dynamicVariablesError = "";
|
||||
for (const [name, definition] of dynamicVariableEntries) {
|
||||
const value = dynamicVariableValues[name] ?? definition.default;
|
||||
if (value === null || value === undefined || value === "") {
|
||||
if (definition.required && !dynamicVariablesError) {
|
||||
dynamicVariablesError = `请先填写必填变量 ${name}`;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
resolvedDynamicVariables[name] = value;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldKeepCamera) {
|
||||
@@ -2113,6 +2212,14 @@ function DebugDrawer({
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<CallPreviewLink assistantId={assistantId} />
|
||||
{dynamicVariablesEnabled && (
|
||||
<DynamicVariableValuesPopover
|
||||
entries={dynamicVariableEntries}
|
||||
values={dynamicVariableValues}
|
||||
disabled={recording}
|
||||
onChange={setDynamicVariableValues}
|
||||
/>
|
||||
)}
|
||||
{SHOW_VOICE_VIZ && view === "chat" && (
|
||||
<>
|
||||
{!showTranscript && (
|
||||
@@ -2179,11 +2286,143 @@ function DebugDrawer({
|
||||
camera={camera}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
vision={vision}
|
||||
dynamicVariables={resolvedDynamicVariables}
|
||||
dynamicVariablesError={dynamicVariablesError}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function DynamicVariableValuesPopover({
|
||||
entries,
|
||||
values,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
entries: [string, DynamicVariableDefinition][];
|
||||
values: Record<string, string | number | boolean>;
|
||||
disabled: boolean;
|
||||
onChange: React.Dispatch<
|
||||
React.SetStateAction<Record<string, string | number | boolean>>
|
||||
>;
|
||||
}) {
|
||||
function setValue(name: string, value: string | number | boolean | undefined) {
|
||||
onChange((current) => {
|
||||
const next = { ...current };
|
||||
if (value === undefined) delete next[name];
|
||||
else next[name] = value;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label="设置本次会话变量"
|
||||
title="本次会话变量"
|
||||
className="relative flex h-8 w-8 items-center justify-center rounded-full border border-hairline bg-canvas-soft text-muted-foreground transition-colors hover:bg-surface-strong hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Braces size={15} />
|
||||
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full border border-card bg-surface-strong px-1 text-[9px] tabular-nums text-foreground">
|
||||
{entries.length}
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
side="bottom"
|
||||
className="w-80 space-y-3 rounded-2xl p-4"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<Braces size={15} />
|
||||
本次会话变量
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
这些值只用于下一次调试会话,不会修改助手配置。
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-h-72 space-y-3 overflow-y-auto pr-1">
|
||||
{entries.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft px-4 py-4 text-center">
|
||||
<div className="text-xs font-medium text-foreground">
|
||||
当前没有会话变量
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] leading-5 text-muted-foreground">
|
||||
在提示词或开场白中添加 {"{{variable_name}}"} 后,可在这里设置调试值。
|
||||
</p>
|
||||
</div>
|
||||
) : entries.map(([name, definition]) => {
|
||||
const value = values[name] ?? definition.default ?? "";
|
||||
return (
|
||||
<label key={name} className="block space-y-1.5">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-foreground">
|
||||
<code className="font-mono">{name}</code>
|
||||
{definition.required && (
|
||||
<span className="text-destructive">*</span>
|
||||
)}
|
||||
<span className="font-normal text-muted-soft">
|
||||
{definition.type === "string"
|
||||
? "文本"
|
||||
: definition.type === "number"
|
||||
? "数字"
|
||||
: "布尔值"}
|
||||
</span>
|
||||
</span>
|
||||
{definition.type === "boolean" ? (
|
||||
<Select
|
||||
value={value === "" ? "unset" : String(value)}
|
||||
onValueChange={(next) =>
|
||||
setValue(
|
||||
name,
|
||||
next === "unset" ? undefined : next === "true",
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full border-hairline-strong bg-background"
|
||||
aria-label={`会话变量 ${name}`}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="unset">未设置</SelectItem>
|
||||
<SelectItem value="true">True</SelectItem>
|
||||
<SelectItem value="false">False</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
type={definition.type === "number" ? "number" : "text"}
|
||||
value={typeof value === "boolean" ? String(value) : value}
|
||||
onChange={(event) => {
|
||||
const raw = event.target.value;
|
||||
setValue(
|
||||
name,
|
||||
raw === ""
|
||||
? undefined
|
||||
: definition.type === "number"
|
||||
? Number(raw)
|
||||
: raw,
|
||||
);
|
||||
}}
|
||||
aria-label={`会话变量 ${name}`}
|
||||
placeholder={definition.required ? "必填" : "可选"}
|
||||
className="h-9 border-hairline-strong bg-background text-xs"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function CallPreviewLink({ assistantId }: { assistantId: string | null }) {
|
||||
const [callUrl, setCallUrl] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -2375,6 +2614,8 @@ function DebugVoicePanel({
|
||||
camera,
|
||||
hasUnsavedChanges,
|
||||
vision,
|
||||
dynamicVariables,
|
||||
dynamicVariablesError,
|
||||
}: {
|
||||
view: DebugView;
|
||||
showTranscript: boolean;
|
||||
@@ -2384,6 +2625,8 @@ function DebugVoicePanel({
|
||||
camera: CameraPreview;
|
||||
hasUnsavedChanges: boolean;
|
||||
vision: boolean;
|
||||
dynamicVariables: Record<string, string | number | boolean>;
|
||||
dynamicVariablesError: string;
|
||||
}) {
|
||||
const {
|
||||
status,
|
||||
@@ -2409,6 +2652,8 @@ function DebugVoicePanel({
|
||||
? "请先保存助手,再开始对话。"
|
||||
: hasUnsavedChanges
|
||||
? "请先保存当前改动,再开始对话。"
|
||||
: dynamicVariablesError
|
||||
? dynamicVariablesError
|
||||
: "";
|
||||
const startDisabled = status === "connecting" || Boolean(startBlockedMessage);
|
||||
|
||||
@@ -2420,12 +2665,22 @@ function DebugVoicePanel({
|
||||
|
||||
const startConversation = useCallback(async () => {
|
||||
if (!assistantId || hasUnsavedChanges) return;
|
||||
if (dynamicVariablesError) return;
|
||||
const videoStream = vision ? await camera.start() : null;
|
||||
await connect({
|
||||
visionEnabled: vision,
|
||||
videoStream,
|
||||
dynamicVariables,
|
||||
});
|
||||
}, [assistantId, camera, connect, hasUnsavedChanges, vision]);
|
||||
}, [
|
||||
assistantId,
|
||||
camera,
|
||||
connect,
|
||||
dynamicVariables,
|
||||
dynamicVariablesError,
|
||||
hasUnsavedChanges,
|
||||
vision,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
@@ -3007,6 +3262,263 @@ function HelpHint({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function DynamicVariableEditorHint({
|
||||
count,
|
||||
onOpen,
|
||||
}: {
|
||||
count: number;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl border border-dashed border-hairline-strong bg-canvas-soft px-3 py-2 text-xs text-muted-foreground">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span>
|
||||
输入 <code className="rounded bg-surface-strong px-1 py-0.5 font-mono text-foreground">{"{{"}</code>{" "}
|
||||
添加变量,保存时自动识别
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="shrink-0 font-medium text-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
{count > 0 ? `管理 ${count} 个变量` : "查看变量"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DynamicVariablesDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
definitions,
|
||||
onChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
definitions: Record<string, DynamicVariableDefinition>;
|
||||
onChange: (definitions: Record<string, DynamicVariableDefinition>) => void;
|
||||
}) {
|
||||
const entries = Object.entries(definitions);
|
||||
const [copiedSystemVariable, setCopiedSystemVariable] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
function updateDefinition(
|
||||
name: string,
|
||||
patch: Partial<DynamicVariableDefinition>,
|
||||
) {
|
||||
onChange({
|
||||
...definitions,
|
||||
[name]: { ...definitions[name], ...patch },
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[78vh] overflow-hidden rounded-2xl border-hairline bg-card p-0 sm:max-w-[520px]">
|
||||
<DialogHeader className="border-b border-hairline px-6 py-5 text-left">
|
||||
<DialogTitle className="flex items-center gap-2 text-base font-medium">
|
||||
<Braces size={17} />
|
||||
动态变量
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs leading-5">
|
||||
提示词和开场白中使用的自定义变量会自动出现在这里。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="custom" className="min-h-0 px-6 pb-6">
|
||||
<TabsList className="grid w-full shrink-0 grid-cols-2 bg-surface-strong">
|
||||
<TabsTrigger value="custom">
|
||||
自定义变量
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground">
|
||||
{entries.length}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="system">
|
||||
系统变量
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground">
|
||||
{SYSTEM_DYNAMIC_VARIABLES.length}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent
|
||||
value="custom"
|
||||
className="max-h-[calc(78vh-168px)] overflow-y-auto pt-3"
|
||||
>
|
||||
{entries.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft px-4 py-5 text-center">
|
||||
<div className="text-sm font-medium text-foreground">还没有自定义变量</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
在提示词或开场白中输入 {"{{customer_name}}"} 即可添加。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{entries.map(([name, definition]) => (
|
||||
<div
|
||||
key={name}
|
||||
className="space-y-3 rounded-xl border border-hairline bg-canvas-soft p-3.5"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<code className="truncate font-mono text-sm font-medium text-foreground">
|
||||
{`{{${name}}}`}
|
||||
</code>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium"
|
||||
>
|
||||
已引用
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-[120px_1fr] gap-2">
|
||||
<Select
|
||||
value={definition.type}
|
||||
onValueChange={(type: DynamicVariableDefinition["type"]) =>
|
||||
updateDefinition(name, { type, default: null })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full border-hairline-strong bg-background">
|
||||
<span className="sr-only">变量 {name} 类型</span>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string">文本</SelectItem>
|
||||
<SelectItem value="number">数字</SelectItem>
|
||||
<SelectItem value="boolean">布尔值</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{definition.type === "boolean" ? (
|
||||
<Select
|
||||
value={
|
||||
definition.default == null
|
||||
? "unset"
|
||||
: String(definition.default)
|
||||
}
|
||||
onValueChange={(value) =>
|
||||
updateDefinition(name, {
|
||||
default:
|
||||
value === "unset" ? null : value === "true",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={`变量 ${name} 默认值`}
|
||||
className="w-full border-hairline-strong bg-background"
|
||||
>
|
||||
<SelectValue placeholder="无默认值" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="unset">无默认值</SelectItem>
|
||||
<SelectItem value="true">True</SelectItem>
|
||||
<SelectItem value="false">False</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
type={definition.type === "number" ? "number" : "text"}
|
||||
value={
|
||||
typeof definition.default === "boolean"
|
||||
? String(definition.default)
|
||||
: definition.default ?? ""
|
||||
}
|
||||
onChange={(event) => {
|
||||
const raw = event.target.value;
|
||||
updateDefinition(name, {
|
||||
default:
|
||||
raw === ""
|
||||
? null
|
||||
: definition.type === "number"
|
||||
? Number(raw)
|
||||
: raw,
|
||||
});
|
||||
}}
|
||||
placeholder="默认值(可选)"
|
||||
aria-label={`变量 ${name} 默认值`}
|
||||
className="border-hairline-strong bg-background"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
会话开始时必须提供
|
||||
</span>
|
||||
<Switch
|
||||
checked={definition.required}
|
||||
aria-label={`变量 ${name} 必填`}
|
||||
onCheckedChange={(required) =>
|
||||
updateDefinition(name, { required })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="system"
|
||||
className="max-h-[calc(78vh-168px)] space-y-3 overflow-y-auto pt-3"
|
||||
>
|
||||
<div>
|
||||
<p className="text-[11px] leading-5 text-muted-foreground">
|
||||
将完整引用复制到提示词或开场白中,不能只填写中文名称。
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{SYSTEM_DYNAMIC_VARIABLES.map(([name, label]) => (
|
||||
<div
|
||||
key={name}
|
||||
className="flex items-center justify-between gap-3 rounded-xl border border-hairline bg-background px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<code className="block truncate font-mono text-[11px] text-muted-foreground">
|
||||
{`{{${name}}}`}
|
||||
</code>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void navigator.clipboard
|
||||
.writeText(`{{${name}}}`)
|
||||
.then(() => {
|
||||
setCopiedSystemVariable(name);
|
||||
window.setTimeout(
|
||||
() => setCopiedSystemVariable(null),
|
||||
1400,
|
||||
);
|
||||
});
|
||||
}}
|
||||
aria-label={
|
||||
copiedSystemVariable === name
|
||||
? `${label} 已复制`
|
||||
: `复制系统变量 ${label}`
|
||||
}
|
||||
title="复制引用"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
|
||||
>
|
||||
{copiedSystemVariable === name ? (
|
||||
<Check size={13} />
|
||||
) : (
|
||||
<Copy size={13} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({
|
||||
icon,
|
||||
title,
|
||||
|
||||
@@ -75,6 +75,8 @@ type ToolForm = {
|
||||
secretHeaders: string;
|
||||
parameters: string;
|
||||
body: string;
|
||||
dynamicVariableAssignments: string;
|
||||
secretDynamicVariables: string;
|
||||
};
|
||||
|
||||
const EMPTY_OBJECT = "{}";
|
||||
@@ -97,6 +99,8 @@ function blankForm(): ToolForm {
|
||||
secretHeaders: EMPTY_OBJECT,
|
||||
parameters: EMPTY_ARRAY,
|
||||
body: EMPTY_OBJECT,
|
||||
dynamicVariableAssignments: EMPTY_OBJECT,
|
||||
secretDynamicVariables: EMPTY_OBJECT,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,8 +125,19 @@ function formFromTool(tool: Tool): ToolForm {
|
||||
base.headers = pretty(tool.definition.config.headers, EMPTY_OBJECT);
|
||||
base.parameters = pretty(tool.definition.config.parameters, EMPTY_ARRAY);
|
||||
base.body = pretty(tool.definition.config.body, EMPTY_OBJECT);
|
||||
const secrets = tool.secrets as { headers?: Record<string, string> };
|
||||
base.dynamicVariableAssignments = pretty(
|
||||
tool.definition.config.dynamicVariableAssignments ?? {},
|
||||
EMPTY_OBJECT,
|
||||
);
|
||||
const secrets = tool.secrets as {
|
||||
headers?: Record<string, string>;
|
||||
dynamic_variables?: Record<string, string>;
|
||||
};
|
||||
base.secretHeaders = pretty(secrets.headers ?? {}, EMPTY_OBJECT);
|
||||
base.secretDynamicVariables = pretty(
|
||||
secrets.dynamic_variables ?? {},
|
||||
EMPTY_OBJECT,
|
||||
);
|
||||
return base;
|
||||
}
|
||||
|
||||
@@ -191,6 +206,14 @@ function payloadFromForm(form: ToolForm): ToolUpsert {
|
||||
const headers = parseObject(form.headers, "Header");
|
||||
const secretHeaders = parseObject(form.secretHeaders, "敏感 Header");
|
||||
const body = parseObject(form.body, "固定 Body");
|
||||
const dynamicVariableAssignments = parseObject(
|
||||
form.dynamicVariableAssignments,
|
||||
"变量赋值",
|
||||
);
|
||||
const secretDynamicVariables = parseObject(
|
||||
form.secretDynamicVariables,
|
||||
"密钥变量",
|
||||
);
|
||||
const parameters = parseParameters(form.parameters);
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
@@ -207,9 +230,13 @@ function payloadFromForm(form: ToolForm): ToolUpsert {
|
||||
headers: headers as Record<string, string>,
|
||||
parameters,
|
||||
body,
|
||||
dynamicVariableAssignments: dynamicVariableAssignments as Record<string, string>,
|
||||
},
|
||||
},
|
||||
secrets: { headers: secretHeaders },
|
||||
secrets: {
|
||||
headers: secretHeaders,
|
||||
dynamic_variables: secretDynamicVariables,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -741,6 +768,20 @@ function HttpFields({
|
||||
<JsonField label="敏感 Header" value={form.secretHeaders} onChange={(secretHeaders) => setForm((current) => ({ ...current, secretHeaders }))} />
|
||||
<JsonField label="参数定义" value={form.parameters} onChange={(parameters) => setForm((current) => ({ ...current, parameters }))} rows={6} />
|
||||
<JsonField label="固定 Body" value={form.body} onChange={(body) => setForm((current) => ({ ...current, body }))} />
|
||||
<JsonField
|
||||
label="响应变量赋值"
|
||||
value={form.dynamicVariableAssignments}
|
||||
onChange={(dynamicVariableAssignments) =>
|
||||
setForm((current) => ({ ...current, dynamicVariableAssignments }))
|
||||
}
|
||||
/>
|
||||
<JsonField
|
||||
label="密钥变量"
|
||||
value={form.secretDynamicVariables}
|
||||
onChange={(secretDynamicVariables) =>
|
||||
setForm((current) => ({ ...current, secretDynamicVariables }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ type VideoNetworkMetrics = {
|
||||
type ConnectOptions = {
|
||||
visionEnabled?: boolean;
|
||||
videoStream?: MediaStream | null;
|
||||
dynamicVariables?: Record<string, string | number | boolean>;
|
||||
};
|
||||
|
||||
export type ChatMessage = {
|
||||
@@ -519,6 +520,7 @@ export function useVoicePreview(
|
||||
type: localDescription.type,
|
||||
assistant_id: assistantId,
|
||||
vision_enabled: Boolean(options.visionEnabled),
|
||||
dynamic_variables: options.dynamicVariables ?? {},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -183,6 +183,7 @@ export type Assistant = {
|
||||
knowledgeRetrievalConfig: KnowledgeRetrievalConfig;
|
||||
toolIds: string[];
|
||||
prompt: string;
|
||||
dynamicVariableDefinitions: Record<string, DynamicVariableDefinition>;
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
appId: string;
|
||||
@@ -190,6 +191,12 @@ export type Assistant = {
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
export type DynamicVariableDefinition = {
|
||||
type: "string" | "number" | "boolean";
|
||||
required: boolean;
|
||||
default: string | number | boolean | null;
|
||||
};
|
||||
|
||||
export type KnowledgeRetrievalConfig = {
|
||||
mode: "automatic" | "on_demand";
|
||||
topN: number;
|
||||
@@ -307,6 +314,7 @@ export type HttpToolDefinition = {
|
||||
headers: Record<string, string>;
|
||||
parameters: ToolParameter[];
|
||||
body: Record<string, unknown>;
|
||||
dynamicVariableAssignments: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user