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:
Xin Wang
2026-02-27 12:08:18 +08:00
parent 71cbfa2b48
commit 6178cc05bb
5 changed files with 242 additions and 21 deletions

View File

@@ -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>
<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)}
placeholder="设定小助手的人设、语气、行为规范以及业务逻辑..."
/>
<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) => {
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>
<Input
value={selectedAssistant.opener}
onChange={(e) => updateAssistant('opener', e.target.value)}
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"
/>
<div className="relative">
<Input
value={selectedAssistant.opener}
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(', ')}