Add dynamic variable support to Assistant model and related components

- Introduce dynamic variable definitions in AssistantConfig and Assistant models, allowing for flexible prompt customization.
- Implement validation for dynamic variable names and types in the schema.
- Update backend services and routes to handle dynamic variables in assistant configurations and runtime processing.
- Enhance frontend components to support dynamic variable definitions, including a new editor for managing variables.
- Add tests to ensure proper functionality and validation of dynamic variables in various scenarios.
This commit is contained in:
Xin Wang
2026-07-12 23:42:56 +08:00
parent 7c9a18c806
commit deaf3d7730
21 changed files with 1396 additions and 12 deletions

View File

@@ -33,6 +33,7 @@ import {
Video,
Smartphone,
Wrench,
Braces,
Settings2,
X,
} from "lucide-react";
@@ -77,6 +78,12 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import { AuraVisualizer } from "@/components/ui/aura-visualizer";
import { NebulaVisualizer } from "@/components/ui/nebula-visualizer";
import { SpectrumVisualizer } from "@/components/ui/spectrum-visualizer";
@@ -103,6 +110,7 @@ import {
type Assistant,
type AssistantType as ApiAssistantType,
type AssistantUpsert,
type DynamicVariableDefinition,
type KnowledgeBase,
type KnowledgeRetrievalConfig,
type ModelResource,
@@ -134,6 +142,7 @@ type AssistantForm = {
name: string;
greeting: string;
prompt: string;
dynamicVariableDefinitions: Record<string, DynamicVariableDefinition>;
runtimeMode: RuntimeMode;
realtimeModel: string;
model: string;
@@ -255,6 +264,7 @@ function blankPromptForm(name: string): AssistantForm {
name,
greeting: "",
prompt: "",
dynamicVariableDefinitions: {},
runtimeMode: "pipeline",
realtimeModel: "",
model: "",
@@ -270,6 +280,44 @@ function blankPromptForm(name: string): AssistantForm {
};
}
const DYNAMIC_VARIABLE_PATTERN = /{{\s*([A-Za-z][A-Za-z0-9_]{0,63})\s*}}/g;
const SYSTEM_DYNAMIC_VARIABLES = [
["system__conversation_id", "会话 ID"],
["system__time", "当前时间"],
["system__timezone", "会话时区"],
["system__agent_turns", "助手轮次"],
["system__conversation_history", "会话历史"],
] as const;
function extractDynamicVariableNames(...templates: string[]): string[] {
const names = new Set<string>();
for (const template of templates) {
for (const match of template.matchAll(DYNAMIC_VARIABLE_PATTERN)) {
const name = match[1];
if (!name.startsWith("system__") && !name.startsWith("secret__")) {
names.add(name);
}
}
}
return [...names];
}
function activeDynamicVariableDefinitions(
templates: string[],
saved: Record<string, DynamicVariableDefinition>,
): Record<string, DynamicVariableDefinition> {
return Object.fromEntries(
extractDynamicVariableNames(...templates).map((name) => [
name,
saved[name] ?? {
type: "string",
required: false,
default: null,
},
]),
);
}
function blankFastGptForm(name: string): FastGptForm {
return {
name,
@@ -435,6 +483,12 @@ export function AssistantPage(props: AssistantPageProps) {
});
const [debugOpen, setDebugOpen] = useState(false);
const [activeNodeId, setActiveNodeId] = useState<string | null>(null);
const [dynamicVariablesOpen, setDynamicVariablesOpen] = useState(false);
const effectiveDynamicVariableDefinitions = activeDynamicVariableDefinitions(
[form.prompt, form.greeting],
form.dynamicVariableDefinitions,
);
const loadAssistants = useCallback(async () => {
setListLoading(true);
@@ -536,6 +590,7 @@ export function AssistantPage(props: AssistantPageProps) {
name: a.name,
greeting: a.greeting,
prompt: a.prompt,
dynamicVariableDefinitions: a.dynamicVariableDefinitions ?? {},
runtimeMode: a.runtimeMode,
realtimeModel: a.modelResourceIds.Realtime ?? "",
model: a.modelResourceIds.LLM ?? "",
@@ -617,6 +672,7 @@ export function AssistantPage(props: AssistantPageProps) {
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
toolIds: [],
prompt: "",
dynamicVariableDefinitions: {},
apiUrl: "",
apiKey: "",
appId: "",
@@ -683,6 +739,7 @@ export function AssistantPage(props: AssistantPageProps) {
knowledgeRetrievalConfig: form.knowledgeRetrievalConfig,
toolIds: form.toolIds,
prompt: form.prompt,
dynamicVariableDefinitions: effectiveDynamicVariableDefinitions,
}),
);
}
@@ -1754,6 +1811,18 @@ export function AssistantPage(props: AssistantPageProps) {
</div>
</div>
<DynamicVariablesDialog
open={dynamicVariablesOpen}
onOpenChange={setDynamicVariablesOpen}
definitions={effectiveDynamicVariableDefinitions}
onChange={(dynamicVariableDefinitions) =>
updateForm(
"dynamicVariableDefinitions",
dynamicVariableDefinitions,
)
}
/>
<div className="flex min-h-0 flex-1 gap-6">
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
<SectionCard>
@@ -1841,6 +1910,10 @@ export function AssistantPage(props: AssistantPageProps) {
placeholder="请输入提示词,描述助手的角色、能力和回答要求"
rows={8}
/>
<DynamicVariableEditorHint
count={Object.keys(effectiveDynamicVariableDefinitions).length}
onOpen={() => setDynamicVariablesOpen(true)}
/>
</SectionCard>
{form.runtimeMode === "pipeline" ? (
@@ -1914,6 +1987,10 @@ export function AssistantPage(props: AssistantPageProps) {
onChange={(value) => updateForm("greeting", value)}
placeholder="请输入助手开场白"
/>
<DynamicVariableEditorHint
count={Object.keys(effectiveDynamicVariableDefinitions).length}
onOpen={() => setDynamicVariablesOpen(true)}
/>
</SectionCard>
{form.runtimeMode === "pipeline" && (
@@ -1979,6 +2056,8 @@ export function AssistantPage(props: AssistantPageProps) {
assistantId={editingId}
hasUnsavedChanges={dirty}
vision={form.visionEnabled}
dynamicVariablesEnabled
dynamicVariableDefinitions={effectiveDynamicVariableDefinitions}
/>
</div>
</div>
@@ -2057,23 +2136,43 @@ function DebugDrawer({
hasUnsavedChanges = false,
onNodeActive,
vision = false,
dynamicVariablesEnabled = false,
dynamicVariableDefinitions = {},
}: {
assistantId: string | null;
asSheet?: boolean;
hasUnsavedChanges?: boolean;
onNodeActive?: (nodeId: string | null) => void;
vision?: boolean;
dynamicVariablesEnabled?: boolean;
dynamicVariableDefinitions?: Record<string, DynamicVariableDefinition>;
}) {
const preview = useVoicePreview(assistantId, onNodeActive);
const camera = useCameraPreview();
const [showTranscript, setShowTranscript] = useState(false);
const [vizStyle, setVizStyle] = useState<VizStyle>("aura");
const [view, setView] = useState<DebugView>("chat");
const [dynamicVariableValues, setDynamicVariableValues] = useState<
Record<string, string | number | boolean>
>({});
const stopCamera = camera.stop;
const startCamera = camera.start;
const recording =
preview.status === "connecting" || preview.status === "connected";
const shouldKeepCamera = vision && recording;
const dynamicVariableEntries = Object.entries(dynamicVariableDefinitions);
const resolvedDynamicVariables: Record<string, string | number | boolean> = {};
let dynamicVariablesError = "";
for (const [name, definition] of dynamicVariableEntries) {
const value = dynamicVariableValues[name] ?? definition.default;
if (value === null || value === undefined || value === "") {
if (definition.required && !dynamicVariablesError) {
dynamicVariablesError = `请先填写必填变量 ${name}`;
}
continue;
}
resolvedDynamicVariables[name] = value;
}
useEffect(() => {
if (shouldKeepCamera) {
@@ -2113,6 +2212,14 @@ function DebugDrawer({
</div>
<div className="flex shrink-0 items-center gap-2">
<CallPreviewLink assistantId={assistantId} />
{dynamicVariablesEnabled && (
<DynamicVariableValuesPopover
entries={dynamicVariableEntries}
values={dynamicVariableValues}
disabled={recording}
onChange={setDynamicVariableValues}
/>
)}
{SHOW_VOICE_VIZ && view === "chat" && (
<>
{!showTranscript && (
@@ -2179,11 +2286,143 @@ function DebugDrawer({
camera={camera}
hasUnsavedChanges={hasUnsavedChanges}
vision={vision}
dynamicVariables={resolvedDynamicVariables}
dynamicVariablesError={dynamicVariablesError}
/>
</aside>
);
}
function DynamicVariableValuesPopover({
entries,
values,
disabled,
onChange,
}: {
entries: [string, DynamicVariableDefinition][];
values: Record<string, string | number | boolean>;
disabled: boolean;
onChange: React.Dispatch<
React.SetStateAction<Record<string, string | number | boolean>>
>;
}) {
function setValue(name: string, value: string | number | boolean | undefined) {
onChange((current) => {
const next = { ...current };
if (value === undefined) delete next[name];
else next[name] = value;
return next;
});
}
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
aria-label="设置本次会话变量"
title="本次会话变量"
className="relative flex h-8 w-8 items-center justify-center rounded-full border border-hairline bg-canvas-soft text-muted-foreground transition-colors hover:bg-surface-strong hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
>
<Braces size={15} />
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full border border-card bg-surface-strong px-1 text-[9px] tabular-nums text-foreground">
{entries.length}
</span>
</button>
</PopoverTrigger>
<PopoverContent
align="end"
side="bottom"
className="w-80 space-y-3 rounded-2xl p-4"
>
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<Braces size={15} />
</div>
<p className="text-xs leading-5 text-muted-foreground">
</p>
</div>
<div className="max-h-72 space-y-3 overflow-y-auto pr-1">
{entries.length === 0 ? (
<div className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft px-4 py-4 text-center">
<div className="text-xs font-medium text-foreground">
</div>
<p className="mt-1 text-[11px] leading-5 text-muted-foreground">
{"{{variable_name}}"}
</p>
</div>
) : entries.map(([name, definition]) => {
const value = values[name] ?? definition.default ?? "";
return (
<label key={name} className="block space-y-1.5">
<span className="flex items-center gap-1.5 text-xs font-medium text-foreground">
<code className="font-mono">{name}</code>
{definition.required && (
<span className="text-destructive">*</span>
)}
<span className="font-normal text-muted-soft">
{definition.type === "string"
? "文本"
: definition.type === "number"
? "数字"
: "布尔值"}
</span>
</span>
{definition.type === "boolean" ? (
<Select
value={value === "" ? "unset" : String(value)}
onValueChange={(next) =>
setValue(
name,
next === "unset" ? undefined : next === "true",
)
}
>
<SelectTrigger
className="w-full border-hairline-strong bg-background"
aria-label={`会话变量 ${name}`}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="unset"></SelectItem>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
) : (
<Input
type={definition.type === "number" ? "number" : "text"}
value={typeof value === "boolean" ? String(value) : value}
onChange={(event) => {
const raw = event.target.value;
setValue(
name,
raw === ""
? undefined
: definition.type === "number"
? Number(raw)
: raw,
);
}}
aria-label={`会话变量 ${name}`}
placeholder={definition.required ? "必填" : "可选"}
className="h-9 border-hairline-strong bg-background text-xs"
/>
)}
</label>
);
})}
</div>
</PopoverContent>
</Popover>
);
}
function CallPreviewLink({ assistantId }: { assistantId: string | null }) {
const [callUrl, setCallUrl] = useState("");
const [copied, setCopied] = useState(false);
@@ -2375,6 +2614,8 @@ function DebugVoicePanel({
camera,
hasUnsavedChanges,
vision,
dynamicVariables,
dynamicVariablesError,
}: {
view: DebugView;
showTranscript: boolean;
@@ -2384,6 +2625,8 @@ function DebugVoicePanel({
camera: CameraPreview;
hasUnsavedChanges: boolean;
vision: boolean;
dynamicVariables: Record<string, string | number | boolean>;
dynamicVariablesError: string;
}) {
const {
status,
@@ -2409,6 +2652,8 @@ function DebugVoicePanel({
? "请先保存助手,再开始对话。"
: hasUnsavedChanges
? "请先保存当前改动,再开始对话。"
: dynamicVariablesError
? dynamicVariablesError
: "";
const startDisabled = status === "connecting" || Boolean(startBlockedMessage);
@@ -2420,12 +2665,22 @@ function DebugVoicePanel({
const startConversation = useCallback(async () => {
if (!assistantId || hasUnsavedChanges) return;
if (dynamicVariablesError) return;
const videoStream = vision ? await camera.start() : null;
await connect({
visionEnabled: vision,
videoStream,
dynamicVariables,
});
}, [assistantId, camera, connect, hasUnsavedChanges, vision]);
}, [
assistantId,
camera,
connect,
dynamicVariables,
dynamicVariablesError,
hasUnsavedChanges,
vision,
]);
return (
<div className="flex min-h-0 flex-1 flex-col">
@@ -3007,6 +3262,263 @@ function HelpHint({ text }: { text: string }) {
);
}
function DynamicVariableEditorHint({
count,
onOpen,
}: {
count: number;
onOpen: () => void;
}) {
return (
<div className="flex items-center justify-between gap-3 rounded-xl border border-dashed border-hairline-strong bg-canvas-soft px-3 py-2 text-xs text-muted-foreground">
<div className="flex min-w-0 items-center gap-2">
<span>
<code className="rounded bg-surface-strong px-1 py-0.5 font-mono text-foreground">{"{{"}</code>{" "}
</span>
</div>
<button
type="button"
onClick={onOpen}
className="shrink-0 font-medium text-foreground underline-offset-4 hover:underline"
>
{count > 0 ? `管理 ${count} 个变量` : "查看变量"}
</button>
</div>
);
}
function DynamicVariablesDialog({
open,
onOpenChange,
definitions,
onChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
definitions: Record<string, DynamicVariableDefinition>;
onChange: (definitions: Record<string, DynamicVariableDefinition>) => void;
}) {
const entries = Object.entries(definitions);
const [copiedSystemVariable, setCopiedSystemVariable] = useState<string | null>(
null,
);
function updateDefinition(
name: string,
patch: Partial<DynamicVariableDefinition>,
) {
onChange({
...definitions,
[name]: { ...definitions[name], ...patch },
});
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[78vh] overflow-hidden rounded-2xl border-hairline bg-card p-0 sm:max-w-[520px]">
<DialogHeader className="border-b border-hairline px-6 py-5 text-left">
<DialogTitle className="flex items-center gap-2 text-base font-medium">
<Braces size={17} />
</DialogTitle>
<DialogDescription className="text-xs leading-5">
使
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="custom" className="min-h-0 px-6 pb-6">
<TabsList className="grid w-full shrink-0 grid-cols-2 bg-surface-strong">
<TabsTrigger value="custom">
<span className="text-[11px] tabular-nums text-muted-foreground">
{entries.length}
</span>
</TabsTrigger>
<TabsTrigger value="system">
<span className="text-[11px] tabular-nums text-muted-foreground">
{SYSTEM_DYNAMIC_VARIABLES.length}
</span>
</TabsTrigger>
</TabsList>
<TabsContent
value="custom"
className="max-h-[calc(78vh-168px)] overflow-y-auto pt-3"
>
{entries.length === 0 ? (
<div className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft px-4 py-5 text-center">
<div className="text-sm font-medium text-foreground"></div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{"{{customer_name}}"}
</p>
</div>
) : (
<div className="space-y-3">
{entries.map(([name, definition]) => (
<div
key={name}
className="space-y-3 rounded-xl border border-hairline bg-canvas-soft p-3.5"
>
<div className="flex items-center justify-between gap-3">
<code className="truncate font-mono text-sm font-medium text-foreground">
{`{{${name}}}`}
</code>
<Badge
variant="secondary"
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium"
>
</Badge>
</div>
<div className="grid grid-cols-[120px_1fr] gap-2">
<Select
value={definition.type}
onValueChange={(type: DynamicVariableDefinition["type"]) =>
updateDefinition(name, { type, default: null })
}
>
<SelectTrigger className="w-full border-hairline-strong bg-background">
<span className="sr-only"> {name} </span>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string"></SelectItem>
<SelectItem value="number"></SelectItem>
<SelectItem value="boolean"></SelectItem>
</SelectContent>
</Select>
{definition.type === "boolean" ? (
<Select
value={
definition.default == null
? "unset"
: String(definition.default)
}
onValueChange={(value) =>
updateDefinition(name, {
default:
value === "unset" ? null : value === "true",
})
}
>
<SelectTrigger
aria-label={`变量 ${name} 默认值`}
className="w-full border-hairline-strong bg-background"
>
<SelectValue placeholder="无默认值" />
</SelectTrigger>
<SelectContent>
<SelectItem value="unset"></SelectItem>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
) : (
<Input
type={definition.type === "number" ? "number" : "text"}
value={
typeof definition.default === "boolean"
? String(definition.default)
: definition.default ?? ""
}
onChange={(event) => {
const raw = event.target.value;
updateDefinition(name, {
default:
raw === ""
? null
: definition.type === "number"
? Number(raw)
: raw,
});
}}
placeholder="默认值(可选)"
aria-label={`变量 ${name} 默认值`}
className="border-hairline-strong bg-background"
/>
)}
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-xs text-muted-foreground">
</span>
<Switch
checked={definition.required}
aria-label={`变量 ${name} 必填`}
onCheckedChange={(required) =>
updateDefinition(name, { required })
}
/>
</div>
</div>
))}
</div>
)}
</TabsContent>
<TabsContent
value="system"
className="max-h-[calc(78vh-168px)] space-y-3 overflow-y-auto pt-3"
>
<div>
<p className="text-[11px] leading-5 text-muted-foreground">
</p>
</div>
<div className="space-y-1.5">
{SYSTEM_DYNAMIC_VARIABLES.map(([name, label]) => (
<div
key={name}
className="flex items-center justify-between gap-3 rounded-xl border border-hairline bg-background px-3 py-2"
>
<div className="min-w-0">
<div className="text-xs font-medium text-foreground">
{label}
</div>
<code className="block truncate font-mono text-[11px] text-muted-foreground">
{`{{${name}}}`}
</code>
</div>
<button
type="button"
onClick={() => {
void navigator.clipboard
.writeText(`{{${name}}}`)
.then(() => {
setCopiedSystemVariable(name);
window.setTimeout(
() => setCopiedSystemVariable(null),
1400,
);
});
}}
aria-label={
copiedSystemVariable === name
? `${label} 已复制`
: `复制系统变量 ${label}`
}
title="复制引用"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
>
{copiedSystemVariable === name ? (
<Check size={13} />
) : (
<Copy size={13} />
)}
</button>
</div>
))}
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}
function SectionCard({
icon,
title,

View File

@@ -75,6 +75,8 @@ type ToolForm = {
secretHeaders: string;
parameters: string;
body: string;
dynamicVariableAssignments: string;
secretDynamicVariables: string;
};
const EMPTY_OBJECT = "{}";
@@ -97,6 +99,8 @@ function blankForm(): ToolForm {
secretHeaders: EMPTY_OBJECT,
parameters: EMPTY_ARRAY,
body: EMPTY_OBJECT,
dynamicVariableAssignments: EMPTY_OBJECT,
secretDynamicVariables: EMPTY_OBJECT,
};
}
@@ -121,8 +125,19 @@ function formFromTool(tool: Tool): ToolForm {
base.headers = pretty(tool.definition.config.headers, EMPTY_OBJECT);
base.parameters = pretty(tool.definition.config.parameters, EMPTY_ARRAY);
base.body = pretty(tool.definition.config.body, EMPTY_OBJECT);
const secrets = tool.secrets as { headers?: Record<string, string> };
base.dynamicVariableAssignments = pretty(
tool.definition.config.dynamicVariableAssignments ?? {},
EMPTY_OBJECT,
);
const secrets = tool.secrets as {
headers?: Record<string, string>;
dynamic_variables?: Record<string, string>;
};
base.secretHeaders = pretty(secrets.headers ?? {}, EMPTY_OBJECT);
base.secretDynamicVariables = pretty(
secrets.dynamic_variables ?? {},
EMPTY_OBJECT,
);
return base;
}
@@ -191,6 +206,14 @@ function payloadFromForm(form: ToolForm): ToolUpsert {
const headers = parseObject(form.headers, "Header");
const secretHeaders = parseObject(form.secretHeaders, "敏感 Header");
const body = parseObject(form.body, "固定 Body");
const dynamicVariableAssignments = parseObject(
form.dynamicVariableAssignments,
"变量赋值",
);
const secretDynamicVariables = parseObject(
form.secretDynamicVariables,
"密钥变量",
);
const parameters = parseParameters(form.parameters);
return {
name: form.name.trim(),
@@ -207,9 +230,13 @@ function payloadFromForm(form: ToolForm): ToolUpsert {
headers: headers as Record<string, string>,
parameters,
body,
dynamicVariableAssignments: dynamicVariableAssignments as Record<string, string>,
},
},
secrets: { headers: secretHeaders },
secrets: {
headers: secretHeaders,
dynamic_variables: secretDynamicVariables,
},
};
}
@@ -741,6 +768,20 @@ function HttpFields({
<JsonField label="敏感 Header" value={form.secretHeaders} onChange={(secretHeaders) => setForm((current) => ({ ...current, secretHeaders }))} />
<JsonField label="参数定义" value={form.parameters} onChange={(parameters) => setForm((current) => ({ ...current, parameters }))} rows={6} />
<JsonField label="固定 Body" value={form.body} onChange={(body) => setForm((current) => ({ ...current, body }))} />
<JsonField
label="响应变量赋值"
value={form.dynamicVariableAssignments}
onChange={(dynamicVariableAssignments) =>
setForm((current) => ({ ...current, dynamicVariableAssignments }))
}
/>
<JsonField
label="密钥变量"
value={form.secretDynamicVariables}
onChange={(secretDynamicVariables) =>
setForm((current) => ({ ...current, secretDynamicVariables }))
}
/>
</div>
);
}