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:
@@ -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(', ')}
|
||||
|
||||
Reference in New Issue
Block a user