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:
Xin Wang
2026-07-12 23:42:56 +08:00
parent 7c9a18c806
commit deaf3d7730
21 changed files with 1396 additions and 12 deletions

View File

@@ -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 兼容服务

View File

@@ -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

View File

@@ -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")

View File

@@ -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)

View File

@@ -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,

View File

@@ -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")

View File

@@ -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")

View File

@@ -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

View File

@@ -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 {}

View File

@@ -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(),

View File

@@ -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 {},

View File

@@ -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(

View File

@@ -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(),
]

View File

@@ -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:

View 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

View File

@@ -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):

View 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()

View File

@@ -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,

View File

@@ -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>
);
}

View File

@@ -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 ?? {},
},
}),
);

View File

@@ -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>;
};
};