From 6178cc05bb1ba3b3ac455f0c5bf07c8cb05d00ce Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Fri, 27 Feb 2026 12:08:18 +0800 Subject: [PATCH] Add system-level dynamic variables support in session management. Implement methods to generate and apply built-in variables for current session time, UTC time, and timezone. Update documentation to reflect new variables and enhance tests for dynamic variable handling in the UI components. --- engine/core/session.py | 23 ++- engine/docs/ws_v1_schema.md | 4 + engine/docs/ws_v1_schema_zh.md | 4 + engine/tests/test_dynamic_variables.py | 36 +++++ web/pages/Assistants.tsx | 196 +++++++++++++++++++++++-- 5 files changed, 242 insertions(+), 21 deletions(-) diff --git a/engine/core/session.py b/engine/core/session.py index 98b8aba..6ce8539 100644 --- a/engine/core/session.py +++ b/engine/core/session.py @@ -5,6 +5,7 @@ import hashlib import json import re import time +from datetime import datetime, timezone from enum import Enum from typing import Optional, Dict, Any, List from loguru import logger @@ -32,6 +33,7 @@ _DYNAMIC_VARIABLE_KEY_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$") _DYNAMIC_VARIABLE_PLACEHOLDER_RE = re.compile(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}") _DYNAMIC_VARIABLE_MAX_ITEMS = 30 _DYNAMIC_VARIABLE_VALUE_MAX_CHARS = 1000 +_SYSTEM_DYNAMIC_VARIABLE_KEYS = {"system__time", "system_utc", "system_timezone"} class WsSessionState(str, Enum): @@ -845,6 +847,16 @@ class Session: rendered = _DYNAMIC_VARIABLE_PLACEHOLDER_RE.sub(_replace, str(template or "")) return rendered, sorted(missing) + def _system_dynamic_variables(self) -> Dict[str, str]: + """Build system-level dynamic variables for the current session timestamp.""" + local_now = datetime.now().astimezone() + utc_now = local_now.astimezone(timezone.utc) + return { + "system__time": local_now.strftime("%Y-%m-%d %H:%M:%S"), + "system_utc": utc_now.strftime("%Y-%m-%d %H:%M:%S"), + "system_timezone": str(local_now.tzinfo or ""), + } + def _apply_dynamic_variables( self, metadata: Dict[str, Any], @@ -859,7 +871,7 @@ class Session: """ merged = dict(metadata or {}) raw_dynamic_vars = client_metadata.get("dynamicVariables") - dynamic_vars: Dict[str, str] = {} + dynamic_vars: Dict[str, str] = self._system_dynamic_variables() if raw_dynamic_vars is not None: if not isinstance(raw_dynamic_vars, dict): @@ -888,6 +900,9 @@ class Session: f"'{raw_key}'. Expected ^[a-zA-Z_][a-zA-Z0-9_]{{0,63}}$" ), } + if key in _SYSTEM_DYNAMIC_VARIABLE_KEYS: + # Reserved system variables are generated by server time context. + continue if key in dynamic_vars: return merged, { "code": "protocol.dynamic_variables_invalid", @@ -912,12 +927,6 @@ class Session: template_keys.update(self._extract_dynamic_template_keys(merged.get("greeting"))) if not template_keys: return merged, None - if raw_dynamic_vars is None: - missing = ", ".join(sorted(template_keys)) - return merged, { - "code": "protocol.dynamic_variables_missing", - "message": f"Missing dynamic variables for placeholders: {missing}", - } missing_keys = sorted([key for key in template_keys if key not in dynamic_vars]) if missing_keys: diff --git a/engine/docs/ws_v1_schema.md b/engine/docs/ws_v1_schema.md index 732c8cb..f5cf12c 100644 --- a/engine/docs/ws_v1_schema.md +++ b/engine/docs/ws_v1_schema.md @@ -83,6 +83,10 @@ Rules: - Max entries: 30 - Max value length: 1000 chars - Placeholder format in `systemPrompt` and `greeting`: `{{variable_name}}`. + - Built-in system variables (always available): `{{system__time}}`, `{{system_utc}}`, `{{system_timezone}}`. + - `system__time`: current local time (`YYYY-MM-DD HH:mm:ss`) + - `system_utc`: current UTC time (`YYYY-MM-DD HH:mm:ss`) + - `system_timezone`: current local timezone - Missing referenced placeholders reject `session.start` with `protocol.dynamic_variables_missing`. - Invalid `dynamicVariables` payload rejects `session.start` with `protocol.dynamic_variables_invalid`. diff --git a/engine/docs/ws_v1_schema_zh.md b/engine/docs/ws_v1_schema_zh.md index 5095d08..145f061 100644 --- a/engine/docs/ws_v1_schema_zh.md +++ b/engine/docs/ws_v1_schema_zh.md @@ -152,6 +152,10 @@ - key 正则:`^[a-zA-Z_][a-zA-Z0-9_]{0,63}$` - 最多 30 个变量,单个 value 最长 1000 字符。 - `systemPrompt` / `greeting` 中支持占位符语法:`{{variable_name}}`。 +- 内置系统变量(始终可用):`{{system__time}}`、`{{system_utc}}`、`{{system_timezone}}`。 + - `system__time`:会话开始时的本地时间(`YYYY-MM-DD HH:mm:ss`) + - `system_utc`:会话开始时的 UTC 时间(`YYYY-MM-DD HH:mm:ss`) + - `system_timezone`:会话开始时的本地时区 - 若模板引用了缺失变量,`session.start` 会被拒绝,错误码 `protocol.dynamic_variables_missing`。 - 若 `dynamicVariables` 结构/内容非法,`session.start` 会被拒绝,错误码 `protocol.dynamic_variables_invalid`。 diff --git a/engine/tests/test_dynamic_variables.py b/engine/tests/test_dynamic_variables.py index 78a270e..f7982c7 100644 --- a/engine/tests/test_dynamic_variables.py +++ b/engine/tests/test_dynamic_variables.py @@ -94,3 +94,39 @@ def test_apply_dynamic_variables_no_placeholder_keeps_metadata(): assert error is None assert resolved == metadata + + +def test_apply_dynamic_variables_supports_system_time_variables_without_client_payload(): + session = _session() + metadata = { + "systemPrompt": "Now={{system__time}} utc={{system_utc}} tz={{system_timezone}}", + "greeting": "Clock {{system__time}}", + } + + resolved, error = session._apply_dynamic_variables(metadata, {}) + + assert error is None + assert "{{system__time}}" not in resolved["systemPrompt"] + assert "{{system_utc}}" not in resolved["systemPrompt"] + assert "{{system_timezone}}" not in resolved["systemPrompt"] + assert "{{system__time}}" not in resolved["greeting"] + + +def test_apply_dynamic_variables_keeps_system_variables_reserved(): + session = _session() + metadata = { + "systemPrompt": "Clock={{system__time}} Name={{customer_name}}", + "greeting": "Hi {{customer_name}}", + } + client_metadata = { + "dynamicVariables": { + "system__time": "manual_override", + "customer_name": "Alice", + } + } + + resolved, error = session._apply_dynamic_variables(metadata, client_metadata) + + assert error is None + assert "manual_override" not in resolved["systemPrompt"] + assert "Alice" in resolved["systemPrompt"] diff --git a/web/pages/Assistants.tsx b/web/pages/Assistants.tsx index cb79bfd..453c2f8 100644 --- a/web/pages/Assistants.tsx +++ b/web/pages/Assistants.tsx @@ -106,6 +106,7 @@ export const AssistantsPage: React.FC = () => { const [copySuccess, setCopySuccess] = useState(false); const [saveLoading, setSaveLoading] = useState(false); const [isLoading, setIsLoading] = useState(true); + const [templateSuggestion, setTemplateSuggestion] = useState(null); const [persistedAssistantSnapshotById, setPersistedAssistantSnapshotById] = useState>({}); const [unsavedDebugConfirmOpen, setUnsavedDebugConfirmOpen] = useState(false); const [openerAudioGenerating, setOpenerAudioGenerating] = useState(false); @@ -124,6 +125,10 @@ export const AssistantsPage: React.FC = () => { a.name.toLowerCase().includes(searchTerm.toLowerCase()) ); + useEffect(() => { + setTemplateSuggestion(null); + }, [selectedId, activeTab]); + useEffect(() => { const loadInitialData = async () => { setIsLoading(true); @@ -265,6 +270,57 @@ export const AssistantsPage: React.FC = () => { } }; + const updateTemplateSuggestionState = ( + field: 'prompt' | 'opener', + value: string, + caret: number | null + ) => { + if (caret === null || caret < 0) { + setTemplateSuggestion(null); + return; + } + const probe = value.slice(0, caret); + const start = probe.lastIndexOf('{{'); + if (start < 0) { + setTemplateSuggestion(null); + return; + } + const token = value.slice(start + 2, caret); + if (token.includes('}')) { + setTemplateSuggestion(null); + return; + } + if (!/^[a-zA-Z0-9_]*$/.test(token)) { + setTemplateSuggestion(null); + return; + } + setTemplateSuggestion({ + field, + start, + end: caret, + query: token, + }); + }; + + const applySystemTemplateVariable = (field: 'prompt' | 'opener', key: string) => { + if (!selectedAssistant || !templateSuggestion || templateSuggestion.field !== field) { + return; + } + const current = String(selectedAssistant[field] || ''); + const before = current.slice(0, templateSuggestion.start); + const after = current.slice(templateSuggestion.end); + const nextValue = `${before}{{${key}}}${after}`; + updateAssistant(field, nextValue); + setTemplateSuggestion(null); + }; + + const filteredSystemTemplateVariables = useMemo(() => { + if (!templateSuggestion) return []; + const query = templateSuggestion.query.trim().toLowerCase(); + if (!query) return SYSTEM_DYNAMIC_VARIABLE_OPTIONS; + return SYSTEM_DYNAMIC_VARIABLE_OPTIONS.filter((item) => item.key.toLowerCase().includes(query)); + }, [templateSuggestion]); + const handleOpenDebug = () => { if (!selectedAssistant) return; if (selectedAssistantHasUnsavedChanges) { @@ -702,12 +758,50 @@ export const AssistantsPage: React.FC = () => { -