From deaf3d77304d05b0d605817dfe6ad22306b109fc Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Sun, 12 Jul 2026 23:42:56 +0800 Subject: [PATCH] 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. --- backend/README.md | 21 + backend/db/models.py | 1 + .../20260712_0007_add_dynamic_variables.py | 30 + backend/models.py | 6 + backend/routes/assistants.py | 2 + backend/routes/voice_webrtc.py | 20 +- backend/routes/voice_ws.py | 17 +- backend/schemas.py | 43 ++ backend/services/brains/prompt_brain.py | 175 +++++- backend/services/brains/registry.py | 2 +- backend/services/config_resolver.py | 2 + backend/services/conversation_history.py | 3 +- backend/services/pipecat/pipeline.py | 39 ++ backend/services/pipecat/stepfun_realtime.py | 6 + backend/services/runtime_variables.py | 233 ++++++++ backend/tests/test_brains.py | 140 +++++ backend/tests/test_runtime_variables.py | 99 ++++ .../src/components/pages/AssistantPage.tsx | 514 +++++++++++++++++- .../components/pages/ComponentsToolsPage.tsx | 45 +- frontend/src/hooks/use-voice-preview.ts | 2 + frontend/src/lib/api.ts | 8 + 21 files changed, 1396 insertions(+), 12 deletions(-) create mode 100644 backend/migrations/versions/20260712_0007_add_dynamic_variables.py create mode 100644 backend/services/runtime_variables.py create mode 100644 backend/tests/test_runtime_variables.py diff --git a/backend/README.md b/backend/README.md index 8a59f90..0ac434b 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 兼容服务 diff --git a/backend/db/models.py b/backend/db/models.py index 404f516..d363660 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -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 diff --git a/backend/migrations/versions/20260712_0007_add_dynamic_variables.py b/backend/migrations/versions/20260712_0007_add_dynamic_variables.py new file mode 100644 index 0000000..c3769d8 --- /dev/null +++ b/backend/migrations/versions/20260712_0007_add_dynamic_variables.py @@ -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") diff --git a/backend/models.py b/backend/models.py index 1fecaa5..e93f2c9 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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) diff --git a/backend/routes/assistants.py b/backend/routes/assistants.py index 0295b15..3161911 100644 --- a/backend/routes/assistants.py +++ b/backend/routes/assistants.py @@ -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, diff --git a/backend/routes/voice_webrtc.py b/backend/routes/voice_webrtc.py index 658b8b6..798621e 100644 --- a/backend/routes/voice_webrtc.py +++ b/backend/routes/voice_webrtc.py @@ -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") diff --git a/backend/routes/voice_ws.py b/backend/routes/voice_ws.py index eeb57ec..03431c8 100644 --- a/backend/routes/voice_ws.py +++ b/backend/routes/voice_ws.py @@ -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") diff --git a/backend/schemas.py b/backend/schemas.py index e6446d0..75ff066 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -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 diff --git a/backend/services/brains/prompt_brain.py b/backend/services/brains/prompt_brain.py index 24448cc..8abf092 100644 --- a/backend/services/brains/prompt_brain.py +++ b/backend/services/brains/prompt_brain.py @@ -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 {} diff --git a/backend/services/brains/registry.py b/backend/services/brains/registry.py index cfa3fe1..eebe5aa 100644 --- a/backend/services/brains/registry.py +++ b/backend/services/brains/registry.py @@ -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(), diff --git a/backend/services/config_resolver.py b/backend/services/config_resolver.py index 11459fa..197ea19 100644 --- a/backend/services/config_resolver.py +++ b/backend/services/config_resolver.py @@ -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 {}, diff --git a/backend/services/conversation_history.py b/backend/services/conversation_history.py index a2b5aa9..28a7f3c 100644 --- a/backend/services/conversation_history.py +++ b/backend/services/conversation_history.py @@ -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( diff --git a/backend/services/pipecat/pipeline.py b/backend/services/pipecat/pipeline.py index a748151..6394d20 100644 --- a/backend/services/pipecat/pipeline.py +++ b/backend/services/pipecat/pipeline.py @@ -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(), ] diff --git a/backend/services/pipecat/stepfun_realtime.py b/backend/services/pipecat/stepfun_realtime.py index c272d8b..adf49f2 100644 --- a/backend/services/pipecat/stepfun_realtime.py +++ b/backend/services/pipecat/stepfun_realtime.py @@ -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: diff --git a/backend/services/runtime_variables.py b/backend/services/runtime_variables.py new file mode 100644 index 0000000..84dc52f --- /dev/null +++ b/backend/services/runtime_variables.py @@ -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 diff --git a/backend/tests/test_brains.py b/backend/tests/test_brains.py index 48d6a2c..f0990aa 100644 --- a/backend/tests/test_brains.py +++ b/backend/tests/test_brains.py @@ -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): diff --git a/backend/tests/test_runtime_variables.py b/backend/tests/test_runtime_variables.py new file mode 100644 index 0000000..6a3dad4 --- /dev/null +++ b/backend/tests/test_runtime_variables.py @@ -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() diff --git a/frontend/src/components/pages/AssistantPage.tsx b/frontend/src/components/pages/AssistantPage.tsx index a1f6b45..a13ff37 100644 --- a/frontend/src/components/pages/AssistantPage.tsx +++ b/frontend/src/components/pages/AssistantPage.tsx @@ -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; 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(); + 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, +): Record { + 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(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) { + + updateForm( + "dynamicVariableDefinitions", + dynamicVariableDefinitions, + ) + } + /> +
@@ -1841,6 +1910,10 @@ export function AssistantPage(props: AssistantPageProps) { placeholder="请输入提示词,描述助手的角色、能力和回答要求" rows={8} /> + setDynamicVariablesOpen(true)} + /> {form.runtimeMode === "pipeline" ? ( @@ -1914,6 +1987,10 @@ export function AssistantPage(props: AssistantPageProps) { onChange={(value) => updateForm("greeting", value)} placeholder="请输入助手开场白" /> + setDynamicVariablesOpen(true)} + /> {form.runtimeMode === "pipeline" && ( @@ -1979,6 +2056,8 @@ export function AssistantPage(props: AssistantPageProps) { assistantId={editingId} hasUnsavedChanges={dirty} vision={form.visionEnabled} + dynamicVariablesEnabled + dynamicVariableDefinitions={effectiveDynamicVariableDefinitions} />
@@ -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; }) { const preview = useVoicePreview(assistantId, onNodeActive); const camera = useCameraPreview(); const [showTranscript, setShowTranscript] = useState(false); const [vizStyle, setVizStyle] = useState("aura"); const [view, setView] = useState("chat"); + const [dynamicVariableValues, setDynamicVariableValues] = useState< + Record + >({}); 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 = {}; + 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({
+ {dynamicVariablesEnabled && ( + + )} {SHOW_VOICE_VIZ && view === "chat" && ( <> {!showTranscript && ( @@ -2179,11 +2286,143 @@ function DebugDrawer({ camera={camera} hasUnsavedChanges={hasUnsavedChanges} vision={vision} + dynamicVariables={resolvedDynamicVariables} + dynamicVariablesError={dynamicVariablesError} /> ); } +function DynamicVariableValuesPopover({ + entries, + values, + disabled, + onChange, +}: { + entries: [string, DynamicVariableDefinition][]; + values: Record; + disabled: boolean; + onChange: React.Dispatch< + React.SetStateAction> + >; +}) { + 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 ( + + + + + +
+
+ + 本次会话变量 +
+

+ 这些值只用于下一次调试会话,不会修改助手配置。 +

+
+
+ {entries.length === 0 ? ( +
+
+ 当前没有会话变量 +
+

+ 在提示词或开场白中添加 {"{{variable_name}}"} 后,可在这里设置调试值。 +

+
+ ) : entries.map(([name, definition]) => { + const value = values[name] ?? definition.default ?? ""; + return ( + + ); + })} +
+
+
+ ); +} + 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; + 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 (
@@ -3007,6 +3262,263 @@ function HelpHint({ text }: { text: string }) { ); } +function DynamicVariableEditorHint({ + count, + onOpen, +}: { + count: number; + onOpen: () => void; +}) { + return ( +
+
+ + 输入 {"{{"}{" "} + 添加变量,保存时自动识别 + +
+ +
+ ); +} + +function DynamicVariablesDialog({ + open, + onOpenChange, + definitions, + onChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + definitions: Record; + onChange: (definitions: Record) => void; +}) { + const entries = Object.entries(definitions); + const [copiedSystemVariable, setCopiedSystemVariable] = useState( + null, + ); + + function updateDefinition( + name: string, + patch: Partial, + ) { + onChange({ + ...definitions, + [name]: { ...definitions[name], ...patch }, + }); + } + + return ( + + + + + + 动态变量 + + + 提示词和开场白中使用的自定义变量会自动出现在这里。 + + + + + + + 自定义变量 + + {entries.length} + + + + 系统变量 + + {SYSTEM_DYNAMIC_VARIABLES.length} + + + + + + {entries.length === 0 ? ( +
+
还没有自定义变量
+

+ 在提示词或开场白中输入 {"{{customer_name}}"} 即可添加。 +

+
+ ) : ( +
+ {entries.map(([name, definition]) => ( +
+
+ + {`{{${name}}}`} + + + 已引用 + +
+
+ + + {definition.type === "boolean" ? ( + + ) : ( + { + 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" + /> + )} +
+
+ + 会话开始时必须提供 + + + updateDefinition(name, { required }) + } + /> +
+
+ ))} +
+ )} +
+ + +
+

+ 将完整引用复制到提示词或开场白中,不能只填写中文名称。 +

+
+
+ {SYSTEM_DYNAMIC_VARIABLES.map(([name, label]) => ( +
+
+
+ {label} +
+ + {`{{${name}}}`} + +
+ +
+ ))} +
+
+
+
+
+ ); +} + function SectionCard({ icon, title, diff --git a/frontend/src/components/pages/ComponentsToolsPage.tsx b/frontend/src/components/pages/ComponentsToolsPage.tsx index d6b2123..0eef270 100644 --- a/frontend/src/components/pages/ComponentsToolsPage.tsx +++ b/frontend/src/components/pages/ComponentsToolsPage.tsx @@ -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 }; + base.dynamicVariableAssignments = pretty( + tool.definition.config.dynamicVariableAssignments ?? {}, + EMPTY_OBJECT, + ); + const secrets = tool.secrets as { + headers?: Record; + dynamic_variables?: Record; + }; 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, parameters, body, + dynamicVariableAssignments: dynamicVariableAssignments as Record, }, }, - secrets: { headers: secretHeaders }, + secrets: { + headers: secretHeaders, + dynamic_variables: secretDynamicVariables, + }, }; } @@ -741,6 +768,20 @@ function HttpFields({ setForm((current) => ({ ...current, secretHeaders }))} /> setForm((current) => ({ ...current, parameters }))} rows={6} /> setForm((current) => ({ ...current, body }))} /> + + setForm((current) => ({ ...current, dynamicVariableAssignments })) + } + /> + + setForm((current) => ({ ...current, secretDynamicVariables })) + } + />
); } diff --git a/frontend/src/hooks/use-voice-preview.ts b/frontend/src/hooks/use-voice-preview.ts index 37519bb..199a8d9 100644 --- a/frontend/src/hooks/use-voice-preview.ts +++ b/frontend/src/hooks/use-voice-preview.ts @@ -50,6 +50,7 @@ type VideoNetworkMetrics = { type ConnectOptions = { visionEnabled?: boolean; videoStream?: MediaStream | null; + dynamicVariables?: Record; }; 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 ?? {}, }, }), ); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1a2e9b3..30137e6 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -183,6 +183,7 @@ export type Assistant = { knowledgeRetrievalConfig: KnowledgeRetrievalConfig; toolIds: string[]; prompt: string; + dynamicVariableDefinitions: Record; 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; parameters: ToolParameter[]; body: Record; + dynamicVariableAssignments: Record; }; };