Add dynamic variables support in session management and UI components. Implement validation rules for dynamic variables in metadata, including key format and value constraints. Enhance session start handling to manage dynamic variable errors. Update documentation and tests to reflect new functionality.
This commit is contained in:
@@ -1528,6 +1528,29 @@ const TOOL_PARAMETER_HINTS: Record<string, any> = {
|
||||
const getDefaultToolParameters = (toolId: string) =>
|
||||
TOOL_PARAMETER_HINTS[toolId] || { type: 'object', properties: {} };
|
||||
|
||||
type DynamicVariableEntry = {
|
||||
id: string;
|
||||
key: string;
|
||||
value: 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 extractDynamicTemplateKeys = (text: string): string[] => {
|
||||
if (!text) return [];
|
||||
const keys = new Set<string>();
|
||||
const pattern = new RegExp(DYNAMIC_VARIABLE_PLACEHOLDER_RE);
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
if (match[1]) {
|
||||
keys.add(match[1]);
|
||||
}
|
||||
}
|
||||
return Array.from(keys);
|
||||
};
|
||||
|
||||
// Stable transcription log so the scroll container is not recreated on every render (avoids scroll jumping)
|
||||
const TranscriptionLog: React.FC<{
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
@@ -1619,12 +1642,30 @@ export const DebugDrawer: React.FC<{
|
||||
const [captureConfigOpen, setCaptureConfigOpen] = useState(false);
|
||||
const [captureConfigView, setCaptureConfigView] = useState<string>('');
|
||||
const [settingsDrawerOpen, setSettingsDrawerOpen] = useState(false);
|
||||
const [dynamicVariables, setDynamicVariables] = useState<DynamicVariableEntry[]>([]);
|
||||
const dynamicVariableSeqRef = useRef(0);
|
||||
const [wsUrl, setWsUrl] = useState<string>(() => {
|
||||
const fromStorage = localStorage.getItem('debug_ws_url');
|
||||
if (fromStorage) return fromStorage;
|
||||
const defaultHost = window.location.hostname || 'localhost';
|
||||
return `ws://${defaultHost}:8000/ws`;
|
||||
});
|
||||
const nextDynamicVariableId = () => {
|
||||
dynamicVariableSeqRef.current += 1;
|
||||
return `var_${dynamicVariableSeqRef.current}`;
|
||||
};
|
||||
const isDynamicVariablesLocked =
|
||||
wsStatus === 'connecting'
|
||||
|| wsStatus === 'ready'
|
||||
|| callStatus === 'calling'
|
||||
|| callStatus === 'active'
|
||||
|| textSessionStarted;
|
||||
const requiredTemplateVariableKeys = useMemo(() => {
|
||||
const keys = new Set<string>();
|
||||
extractDynamicTemplateKeys(String(assistant.prompt || '')).forEach((key) => keys.add(key));
|
||||
extractDynamicTemplateKeys(String(assistant.opener || '')).forEach((key) => keys.add(key));
|
||||
return Array.from(keys).sort();
|
||||
}, [assistant.opener, assistant.prompt]);
|
||||
|
||||
// Media State
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
@@ -1714,6 +1755,11 @@ export const DebugDrawer: React.FC<{
|
||||
wsStatusRef.current = wsStatus;
|
||||
}, [wsStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
dynamicVariableSeqRef.current = 0;
|
||||
setDynamicVariables([]);
|
||||
}, [assistant.id, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('debug_audio_aec', aecEnabled ? '1' : '0');
|
||||
}, [aecEnabled]);
|
||||
@@ -2068,7 +2114,9 @@ export const DebugDrawer: React.FC<{
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setMessages(prev => [...prev, { role: 'model', text: "Error: Failed to connect to AI service." }]);
|
||||
const errMessage = (e as Error)?.message || 'Failed to connect to AI service.';
|
||||
setMessages(prev => [...prev, { role: 'model', text: `Error: ${errMessage}` }]);
|
||||
setWsError(errMessage);
|
||||
setIsLoading(false);
|
||||
} finally {
|
||||
if (mode !== 'text') setIsLoading(false);
|
||||
@@ -2096,6 +2144,82 @@ export const DebugDrawer: React.FC<{
|
||||
}
|
||||
};
|
||||
|
||||
const addDynamicVariableRow = () => {
|
||||
if (isDynamicVariablesLocked) return;
|
||||
setDynamicVariables((prev) => {
|
||||
if (prev.length >= DYNAMIC_VARIABLE_MAX_ITEMS) return prev;
|
||||
return [...prev, { id: nextDynamicVariableId(), key: '', value: '' }];
|
||||
});
|
||||
};
|
||||
|
||||
const updateDynamicVariableRow = (rowId: string, field: 'key' | 'value', value: string) => {
|
||||
if (isDynamicVariablesLocked) return;
|
||||
setDynamicVariables((prev) => prev.map((item) => (item.id === rowId ? { ...item, [field]: value } : item)));
|
||||
};
|
||||
|
||||
const removeDynamicVariableRow = (rowId: string) => {
|
||||
if (isDynamicVariablesLocked) return;
|
||||
setDynamicVariables((prev) => prev.filter((item) => item.id !== rowId));
|
||||
};
|
||||
|
||||
const buildDynamicVariablesPayload = (): { variables: Record<string, string>; error?: string } => {
|
||||
const variables: Record<string, string> = {};
|
||||
const nonEmptyRows = dynamicVariables
|
||||
.map((row, index) => ({ ...row, index, key: row.key.trim() }))
|
||||
.filter((row) => row.key !== '' || row.value !== '');
|
||||
|
||||
if (nonEmptyRows.length > DYNAMIC_VARIABLE_MAX_ITEMS) {
|
||||
return {
|
||||
variables,
|
||||
error: `Dynamic variable count cannot exceed ${DYNAMIC_VARIABLE_MAX_ITEMS}.`,
|
||||
};
|
||||
}
|
||||
|
||||
for (const row of nonEmptyRows) {
|
||||
if (!row.key) {
|
||||
return {
|
||||
variables,
|
||||
error: `Dynamic variable row ${row.index + 1} is missing key.`,
|
||||
};
|
||||
}
|
||||
if (!DYNAMIC_VARIABLE_KEY_RE.test(row.key)) {
|
||||
return {
|
||||
variables,
|
||||
error: `Invalid dynamic variable key "${row.key}". Use letters, digits, underscore and start with a letter/underscore.`,
|
||||
};
|
||||
}
|
||||
if (row.value === '') {
|
||||
return {
|
||||
variables,
|
||||
error: `Dynamic variable "${row.key}" is missing value.`,
|
||||
};
|
||||
}
|
||||
if (row.value.length > DYNAMIC_VARIABLE_MAX_VALUE_LENGTH) {
|
||||
return {
|
||||
variables,
|
||||
error: `Dynamic variable "${row.key}" exceeds ${DYNAMIC_VARIABLE_MAX_VALUE_LENGTH} characters.`,
|
||||
};
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(variables, row.key)) {
|
||||
return {
|
||||
variables,
|
||||
error: `Duplicate dynamic variable key "${row.key}".`,
|
||||
};
|
||||
}
|
||||
variables[row.key] = row.value;
|
||||
}
|
||||
|
||||
const missingTemplateKeys = requiredTemplateVariableKeys.filter((key) => !Object.prototype.hasOwnProperty.call(variables, key));
|
||||
if (missingTemplateKeys.length > 0) {
|
||||
return {
|
||||
variables,
|
||||
error: `Missing required dynamic variables: ${missingTemplateKeys.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { variables };
|
||||
};
|
||||
|
||||
const buildLocalResolvedRuntime = () => {
|
||||
const warnings: string[] = [];
|
||||
const services: Record<string, any> = {};
|
||||
@@ -2203,11 +2327,18 @@ export const DebugDrawer: React.FC<{
|
||||
};
|
||||
|
||||
const fetchRuntimeMetadata = async (): Promise<Record<string, any>> => {
|
||||
const dynamicVariablesResult = buildDynamicVariablesPayload();
|
||||
if (dynamicVariablesResult.error) {
|
||||
throw new Error(dynamicVariablesResult.error);
|
||||
}
|
||||
const localResolved = buildLocalResolvedRuntime();
|
||||
const mergedMetadata: Record<string, any> = {
|
||||
...localResolved.sessionStartMetadata,
|
||||
...(sessionMetadataExtras || {}),
|
||||
};
|
||||
if (Object.keys(dynamicVariablesResult.variables).length > 0) {
|
||||
mergedMetadata.dynamicVariables = dynamicVariablesResult.variables;
|
||||
}
|
||||
// Engine resolves trusted runtime config by top-level assistant/app ID.
|
||||
// Keep these IDs at metadata root so backend /assistants/{id}/config is reachable.
|
||||
if (!mergedMetadata.assistantId && assistant.id) {
|
||||
@@ -2654,6 +2785,70 @@ export const DebugDrawer: React.FC<{
|
||||
Auto Gain Control (AGC)
|
||||
</label>
|
||||
</div>
|
||||
<div className="rounded-md border border-white/10 bg-black/20 p-2 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[10px] uppercase tracking-widest text-muted-foreground">Dynamic Variables</p>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6"
|
||||
onClick={addDynamicVariableRow}
|
||||
disabled={isDynamicVariablesLocked || dynamicVariables.length >= DYNAMIC_VARIABLE_MAX_ITEMS}
|
||||
title={isDynamicVariablesLocked ? 'Disable editing while session is active' : 'Add variable'}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Use placeholders like {'{{customer_name}}'} in prompt/opener.
|
||||
</p>
|
||||
{requiredTemplateVariableKeys.length > 0 && (
|
||||
<p className="text-[11px] text-amber-300/90">
|
||||
Required: {requiredTemplateVariableKeys.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{dynamicVariables.length === 0 ? (
|
||||
<div className="text-[11px] text-muted-foreground/80 border border-dashed border-white/10 rounded-md px-2 py-2">
|
||||
No variables added.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-52 overflow-auto pr-1">
|
||||
{dynamicVariables.map((row, index) => (
|
||||
<div key={row.id} className="grid grid-cols-[1fr_1fr_auto] gap-2 items-center">
|
||||
<Input
|
||||
value={row.key}
|
||||
onChange={(e) => updateDynamicVariableRow(row.id, 'key', e.target.value)}
|
||||
placeholder={`key_${index + 1}`}
|
||||
disabled={isDynamicVariablesLocked}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={row.value}
|
||||
onChange={(e) => updateDynamicVariableRow(row.id, 'value', e.target.value)}
|
||||
placeholder="value"
|
||||
disabled={isDynamicVariablesLocked}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-red-300"
|
||||
onClick={() => removeDynamicVariableRow(row.id)}
|
||||
disabled={isDynamicVariablesLocked}
|
||||
title="Remove variable"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isDynamicVariablesLocked && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Editing is locked while conversation is starting/active.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-md border border-white/10 bg-black/30">
|
||||
<button
|
||||
className="w-full px-3 py-2 text-left text-xs text-muted-foreground hover:text-foreground flex items-center justify-between"
|
||||
|
||||
Reference in New Issue
Block a user