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.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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<TemplateSuggestionState | null>(null);
|
||||
const [persistedAssistantSnapshotById, setPersistedAssistantSnapshotById] = useState<Record<string, string>>({});
|
||||
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 = () => {
|
||||
<label className="text-sm font-medium text-white flex items-center">
|
||||
<BotIcon className="w-4 h-4 mr-2 text-primary"/> 提示词 (Prompt)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
className="flex min-h-[200px] w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 resize-y text-white"
|
||||
value={selectedAssistant.prompt}
|
||||
onChange={(e) => updateAssistant('prompt', e.target.value)}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
updateAssistant('prompt', next);
|
||||
updateTemplateSuggestionState('prompt', next, e.currentTarget.selectionStart);
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
updateTemplateSuggestionState('prompt', e.currentTarget.value, e.currentTarget.selectionStart);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
updateTemplateSuggestionState('prompt', e.currentTarget.value, e.currentTarget.selectionStart);
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
updateTemplateSuggestionState('prompt', e.currentTarget.value, e.currentTarget.selectionStart);
|
||||
}}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => {
|
||||
setTemplateSuggestion((prev) => (prev?.field === 'prompt' ? null : prev));
|
||||
}, 120);
|
||||
}}
|
||||
placeholder="设定小助手的人设、语气、行为规范以及业务逻辑..."
|
||||
/>
|
||||
{templateSuggestion?.field === 'prompt' && filteredSystemTemplateVariables.length > 0 && (
|
||||
<div className="absolute z-20 mt-1 w-full rounded-md border border-white/15 bg-black/95 shadow-xl backdrop-blur-md max-h-48 overflow-auto">
|
||||
{filteredSystemTemplateVariables.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2 hover:bg-white/10 transition-colors"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
applySystemTemplateVariable('prompt', item.key);
|
||||
}}
|
||||
>
|
||||
<div className="text-xs text-cyan-100">{`{{${item.key}}}`}</div>
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5">{item.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -776,13 +870,55 @@ export const AssistantsPage: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={selectedAssistant.opener}
|
||||
onChange={(e) => updateAssistant('opener', e.target.value)}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
updateAssistant('opener', next);
|
||||
if (selectedAssistant.generatedOpenerEnabled === true) return;
|
||||
updateTemplateSuggestionState('opener', next, e.currentTarget.selectionStart);
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (selectedAssistant.generatedOpenerEnabled === true) return;
|
||||
updateTemplateSuggestionState('opener', e.currentTarget.value, e.currentTarget.selectionStart);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (selectedAssistant.generatedOpenerEnabled === true) return;
|
||||
updateTemplateSuggestionState('opener', e.currentTarget.value, e.currentTarget.selectionStart);
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
if (selectedAssistant.generatedOpenerEnabled === true) return;
|
||||
updateTemplateSuggestionState('opener', e.currentTarget.value, e.currentTarget.selectionStart);
|
||||
}}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => {
|
||||
setTemplateSuggestion((prev) => (prev?.field === 'opener' ? null : prev));
|
||||
}, 120);
|
||||
}}
|
||||
placeholder={selectedAssistant.generatedOpenerEnabled === true ? '将基于提示词自动生成开场白' : '例如:您好,我是您的专属AI助手...'}
|
||||
disabled={selectedAssistant.generatedOpenerEnabled === true}
|
||||
className="bg-white/5 border-white/10 focus:border-primary/50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
{templateSuggestion?.field === 'opener' && filteredSystemTemplateVariables.length > 0 && selectedAssistant.generatedOpenerEnabled !== true && (
|
||||
<div className="absolute z-20 mt-1 w-full rounded-md border border-white/15 bg-black/95 shadow-xl backdrop-blur-md max-h-48 overflow-auto">
|
||||
{filteredSystemTemplateVariables.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2 hover:bg-white/10 transition-colors"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
applySystemTemplateVariable('opener', item.key);
|
||||
}}
|
||||
>
|
||||
<div className="text-xs text-cyan-100">{`{{${item.key}}}`}</div>
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5">{item.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedAssistant.generatedOpenerEnabled === true
|
||||
? '通话接通后将根据提示词自动生成开场白。'
|
||||
@@ -1534,10 +1670,32 @@ type DynamicVariableEntry = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
type TemplateSuggestionState = {
|
||||
field: 'prompt' | 'opener';
|
||||
start: number;
|
||||
end: number;
|
||||
query: string;
|
||||
};
|
||||
|
||||
const DYNAMIC_VARIABLE_KEY_RE = /^[a-zA-Z_][a-zA-Z0-9_]{0,63}$/;
|
||||
const DYNAMIC_VARIABLE_PLACEHOLDER_RE = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g;
|
||||
const DYNAMIC_VARIABLE_MAX_ITEMS = 30;
|
||||
const DYNAMIC_VARIABLE_MAX_VALUE_LENGTH = 1000;
|
||||
const SYSTEM_DYNAMIC_VARIABLE_OPTIONS = [
|
||||
{
|
||||
key: 'system__time',
|
||||
description: 'Session local time (YYYY-MM-DD HH:mm:ss)',
|
||||
},
|
||||
{
|
||||
key: 'system_utc',
|
||||
description: 'Session UTC time (YYYY-MM-DD HH:mm:ss)',
|
||||
},
|
||||
{
|
||||
key: 'system_timezone',
|
||||
description: 'Session local timezone',
|
||||
},
|
||||
];
|
||||
const SYSTEM_DYNAMIC_VARIABLE_KEY_SET = new Set(SYSTEM_DYNAMIC_VARIABLE_OPTIONS.map((item) => item.key));
|
||||
|
||||
const extractDynamicTemplateKeys = (text: string): string[] => {
|
||||
if (!text) return [];
|
||||
@@ -1675,6 +1833,7 @@ export const DebugDrawer: React.FC<{
|
||||
valuesByKey.set(key, row.value);
|
||||
}
|
||||
return requiredTemplateVariableKeys.filter((key) => {
|
||||
if (SYSTEM_DYNAMIC_VARIABLE_KEY_SET.has(key)) return false;
|
||||
if (!valuesByKey.has(key)) return true;
|
||||
return valuesByKey.get(key) === '';
|
||||
});
|
||||
@@ -2258,6 +2417,9 @@ export const DebugDrawer: React.FC<{
|
||||
error: `Invalid dynamic variable key "${row.key}". Use letters, digits, underscore and start with a letter/underscore.`,
|
||||
};
|
||||
}
|
||||
if (SYSTEM_DYNAMIC_VARIABLE_KEY_SET.has(row.key)) {
|
||||
continue;
|
||||
}
|
||||
if (row.value === '') {
|
||||
return {
|
||||
variables,
|
||||
@@ -2279,7 +2441,10 @@ export const DebugDrawer: React.FC<{
|
||||
variables[row.key] = row.value;
|
||||
}
|
||||
|
||||
const missingTemplateKeys = requiredTemplateVariableKeys.filter((key) => !Object.prototype.hasOwnProperty.call(variables, key));
|
||||
const missingTemplateKeys = requiredTemplateVariableKeys.filter((key) => {
|
||||
if (SYSTEM_DYNAMIC_VARIABLE_KEY_SET.has(key)) return false;
|
||||
return !Object.prototype.hasOwnProperty.call(variables, key);
|
||||
});
|
||||
if (missingTemplateKeys.length > 0) {
|
||||
return {
|
||||
variables,
|
||||
@@ -2886,6 +3051,9 @@ export const DebugDrawer: React.FC<{
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Use placeholders like {'{{customer_name}}'} in prompt/opener.
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Built-in system vars: {'{{system__time}}'}, {'{{system_utc}}'}, {'{{system_timezone}}'}.
|
||||
</p>
|
||||
{requiredTemplateVariableKeys.length > 0 && (
|
||||
<p className="text-[11px] text-amber-300/90">
|
||||
Required: {requiredTemplateVariableKeys.join(', ')}
|
||||
|
||||
Reference in New Issue
Block a user