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