feat: add system tools and state updates

This commit is contained in:
Xin Wang
2026-08-04 15:27:39 +08:00
parent 5bf5987fe4
commit 731a372df9
33 changed files with 1787 additions and 124 deletions
@@ -72,6 +72,8 @@ export function GenericNode({ id, type, data, selected }: NodeProps) {
messagePolicy === "playback" ? "播放完继续" : null,
messagePolicy === "confirmation" ? "确认后继续" : null,
].filter(Boolean)
: type === "update_state"
? [`${Object.keys(nodeData.assignments ?? {}).length} 个变量`]
: [];
return (
@@ -72,6 +72,8 @@ function defaultNodeData(spec: RuntimeNodeSpec): WorkflowNodeData {
contextPolicy: "inherit",
inheritGlobalConfig: true,
entryMode: "wait_user",
systemTools: [],
stateVariableNames: [],
});
} else if (spec.type === "action") {
Object.assign(data, {
@@ -79,6 +81,8 @@ function defaultNodeData(spec: RuntimeNodeSpec): WorkflowNodeData {
resultAssignmentMode: "inherit",
userInputPolicy: "queue",
});
} else if (spec.type === "update_state") {
Object.assign(data, { assignments: {} });
} else if (spec.type === "message") {
Object.assign(data, {
speech: "",
@@ -170,6 +174,7 @@ export function WorkflowCanvas({
specsByType,
toolOptions = [],
knowledgeOptions = [],
dynamicVariableOptions,
}: WorkflowEditorProps & { specsByType: NodeSpecMap }) {
const initial = useMemo(() => toFlow(value ?? defaultGraph()), [value]);
const [nodes, setNodes, onNodesChange] = useNodesState(initial.nodes);
@@ -780,6 +785,7 @@ export function WorkflowCanvas({
asrOptions={modelOptions.asr}
ttsOptions={modelOptions.tts}
visionOptions={modelOptions.vision}
dynamicVariableOptions={dynamicVariableOptions}
workflowSettings={settings}
onChange={(patch) =>
updateNodeData(editingNode.id, patch)
@@ -16,7 +16,8 @@ import { VisionConfigSection } from "@/components/editor/vision-config-section";
import { TurnConfigEditor } from "@/components/turn-config-editor";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import type { KnowledgeRetrievalConfig } from "@/lib/api";
import type { KnowledgeRetrievalConfig, SystemToolKind } from "@/lib/api";
import { SYSTEM_TOOL_OPTIONS } from "@/lib/system-tools";
import { normalizeTurnConfig } from "@/lib/turn-config";
import { NodeSelect, ToolOptionPicker } from "./controls";
@@ -38,6 +39,7 @@ export function AgentNodePanel({
asrOptions,
ttsOptions,
visionOptions,
dynamicVariableOptions,
}: {
draft: WorkflowNodeData;
set: (key: string, val: unknown) => void;
@@ -49,6 +51,7 @@ export function AgentNodePanel({
asrOptions: ModelOption[];
ttsOptions: ModelOption[];
visionOptions: ModelOption[];
dynamicVariableOptions: ModelOption[];
}) {
const inheritsGlobal = draft.inheritGlobalConfig !== false;
const knowledgeConfig: KnowledgeRetrievalConfig = {
@@ -101,6 +104,27 @@ export function AgentNodePanel({
turnConfig: agentTurnConfig,
});
};
const toggleSystemTool = (kind: SystemToolKind, enabled: boolean) => {
const current = draft.systemTools ?? [];
const systemTools = enabled
? [...new Set([...current, kind])]
: current.filter((item) => item !== kind);
setPatch({
systemTools,
...(!enabled && kind === "update_state"
? { stateVariableNames: [] }
: {}),
});
};
const toggleStateVariable = (name: string, enabled: boolean) => {
const current = draft.stateVariableNames ?? [];
set(
"stateVariableNames",
enabled
? [...new Set([...current, name])]
: current.filter((item) => item !== name),
);
};
return (
<PanelAnchorNavigation
@@ -109,6 +133,7 @@ export function AgentNodePanel({
{ id: "scope", label: "配置范围" },
{ id: "prompt", label: inheritsGlobal ? "任务" : "提示词" },
{ id: "entry", label: "进入行为" },
{ id: "system-tools", label: "系统工具" },
...(!inheritsGlobal
? [
{ id: "models", label: "模型与语音" },
@@ -186,6 +211,78 @@ export function AgentNodePanel({
</SectionCard>
</PanelAnchor>
<PanelAnchor id="system-tools">
<SectionCard
icon={<Sparkles size={15} />}
title="系统工具"
description="只对当前 Agent 生效的内置会话控制能力"
>
<div className="space-y-3">
{SYSTEM_TOOL_OPTIONS.map((option) => {
const enabled = (draft.systemTools ?? []).includes(option.value);
return (
<div
key={option.value}
className="rounded-xl border border-hairline bg-canvas-soft p-3.5"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">
{option.label}
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{option.description}
</p>
</div>
<Switch
checked={enabled}
onCheckedChange={(checked) =>
toggleSystemTool(option.value, checked)
}
aria-label={`启用${option.label}`}
/>
</div>
{option.value === "update_state" && enabled && (
<div className="mt-3 border-t border-hairline pt-3">
<div className="mb-2 text-xs font-medium text-foreground">
允许更新的变量
</div>
{dynamicVariableOptions.length ? (
<div className="space-y-2">
{dynamicVariableOptions.map((variable) => (
<div
key={variable.value}
className="flex items-center justify-between gap-3 rounded-lg border border-hairline bg-background px-3 py-2"
>
<span className="truncate text-xs text-foreground">
{variable.label}
</span>
<Switch
checked={(draft.stateVariableNames ?? []).includes(
variable.value,
)}
onCheckedChange={(checked) =>
toggleStateVariable(variable.value, checked)
}
aria-label={`允许更新${variable.value}`}
/>
</div>
))}
</div>
) : (
<p className="text-xs leading-5 text-muted-foreground">
请先在工作流的动态变量面板中声明变量。
</p>
)}
</div>
)}
</div>
);
})}
</div>
</SectionCard>
</PanelAnchor>
{!inheritsGlobal && (
<>
<PanelAnchor id="models">
@@ -12,6 +12,7 @@ import { Textarea } from "@/components/ui/textarea";
import { ActionNodePanel } from "./ActionNodePanel";
import { AgentNodePanel } from "./AgentNodePanel";
import { MessageNodePanel } from "./MessageNodePanel";
import { UpdateStateNodePanel } from "./UpdateStateNodePanel";
import { NodeSelect, ToolOptionPicker } from "./controls";
import {
PanelAnchor,
@@ -31,6 +32,7 @@ export function NodeSettingsPanel({
asrOptions,
ttsOptions,
visionOptions,
dynamicVariableOptions,
workflowSettings,
onChange,
}: {
@@ -43,6 +45,7 @@ export function NodeSettingsPanel({
asrOptions: ModelOption[];
ttsOptions: ModelOption[];
visionOptions: ModelOption[];
dynamicVariableOptions: ModelOption[];
workflowSettings: WorkflowSettings;
onChange: (patch: WorkflowNodeData) => void;
}) {
@@ -91,6 +94,8 @@ export function NodeSettingsPanel({
]
: spec.type === "action"
? [{ id: "execution", label: "工具执行" }]
: spec.type === "update_state"
? [{ id: "state", label: "状态更新" }]
: spec.type === "handoff"
? [{ id: "handoff", label: "转交配置" }]
: spec.type === "end"
@@ -114,6 +119,7 @@ export function NodeSettingsPanel({
setArgumentsJson={setArgumentsJson}
setAssignmentsJson={setAssignmentsJson}
commitActionJson={commitActionJson}
dynamicVariableOptions={dynamicVariableOptions}
/>
</PanelAnchorNavigation>
);
@@ -130,6 +136,7 @@ export function NodeSettingsPanel({
asrOptions={asrOptions}
ttsOptions={ttsOptions}
visionOptions={visionOptions}
dynamicVariableOptions={dynamicVariableOptions}
/>
);
}
@@ -262,6 +269,14 @@ export function NodeSettingsPanel({
<MessageNodePanel draft={draft} set={set} setPatch={setPatch} />
)}
{spec.type === "update_state" && (
<UpdateStateNodePanel
draft={draft}
set={set}
dynamicVariableOptions={dynamicVariableOptions}
/>
)}
{spec.type === "handoff" && (
<NodeSelect
label="转交类型"
@@ -306,6 +321,7 @@ function WorkflowNodePanelForm({
setArgumentsJson,
setAssignmentsJson,
commitActionJson,
dynamicVariableOptions,
}: {
spec: RuntimeNodeSpec;
draft: WorkflowNodeData;
@@ -318,6 +334,7 @@ function WorkflowNodePanelForm({
setArgumentsJson: (value: string) => void;
setAssignmentsJson: (value: string) => void;
commitActionJson: (argumentsValue: string, assignmentsValue: string) => void;
dynamicVariableOptions: ModelOption[];
}) {
return (
<div className="space-y-3">
@@ -341,6 +358,16 @@ function WorkflowNodePanelForm({
<MessageNodePanel draft={draft} set={set} setPatch={setPatch} />
)}
{spec.type === "update_state" && (
<PanelAnchor id="state">
<UpdateStateNodePanel
draft={draft}
set={set}
dynamicVariableOptions={dynamicVariableOptions}
/>
</PanelAnchor>
)}
{spec.type === "handoff" && (
<PanelAnchor id="handoff">
<SectionCard
@@ -0,0 +1,79 @@
"use client";
import { Braces } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { SectionCard } from "@/components/editor/section-card";
import type { WorkflowNodeData } from "../specs";
import type { ModelOption } from "../types";
export function UpdateStateNodePanel({
draft,
set,
dynamicVariableOptions,
}: {
draft: WorkflowNodeData;
set: (key: string, value: unknown) => void;
dynamicVariableOptions: ModelOption[];
}) {
const [value, setValue] = useState(
JSON.stringify(draft.assignments ?? {}, null, 2),
);
const [error, setError] = useState("");
const commit = () => {
try {
const assignments = JSON.parse(value) as Record<string, unknown>;
if (
!assignments ||
Array.isArray(assignments) ||
typeof assignments !== "object"
) {
throw new Error();
}
setError("");
set("assignments", assignments);
} catch {
setError("变量赋值必须是 JSON 对象");
}
};
return (
<SectionCard
icon={<Braces size={15} />}
title="状态更新"
description="一次性更新已声明的动态变量,然后继续执行后续节点"
>
<div>
<div className="mb-1.5 text-sm font-medium text-foreground">
变量赋值
</div>
<Textarea
rows={8}
value={value}
onChange={(event) => setValue(event.target.value)}
onBlur={commit}
placeholder={'{\n "customer_name": "张三",\n "confirmed": true\n}'}
className="field-sizing-fixed min-h-44 resize-y border-hairline-strong bg-background font-mono text-xs text-foreground placeholder:text-muted-soft"
/>
{error && <p className="mt-1.5 text-xs text-destructive">{error}</p>}
</div>
<Button type="button" variant="outline" onClick={commit}>
应用变量赋值
</Button>
<div className="rounded-xl border border-hairline bg-canvas-soft p-3 text-xs leading-5 text-muted-foreground">
可用变量:
{dynamicVariableOptions.length
? dynamicVariableOptions.map((option) => option.label).join("、")
: "暂无,请先在动态变量面板中声明"}
</div>
<p className="text-xs leading-5 text-muted-foreground">
值支持字符串、数字、布尔值,也可使用完整占位符,例如
{" {{source_name}}"}。
</p>
</SectionCard>
);
}
+7 -1
View File
@@ -3,7 +3,7 @@
import * as LucideIcons from "lucide-react";
import { Circle, type LucideIcon } from "lucide-react";
import type { NodeSpecDto, TurnConfig } from "@/lib/api";
import type { NodeSpecDto, SystemToolKind, TurnConfig } from "@/lib/api";
import { defaultTurnConfig } from "@/lib/turn-config";
export type WorkflowNodeType =
@@ -11,6 +11,7 @@ export type WorkflowNodeType =
| "agent"
| "message"
| "action"
| "update_state"
| "handoff"
| "end";
export type ContextPolicy = "inherit" | "fresh";
@@ -40,6 +41,8 @@ export type WorkflowNodeData = {
contextPolicy?: ContextPolicy;
inheritGlobalConfig?: boolean;
entryMode?: AgentEntryMode;
systemTools?: SystemToolKind[];
stateVariableNames?: string[];
toolIds?: string[];
knowledgeBaseId?: string;
knowledgeMode?: KnowledgeMode;
@@ -56,6 +59,7 @@ export type WorkflowNodeData = {
arguments?: Record<string, unknown>;
resultAssignmentMode?: ActionResultAssignmentMode;
resultAssignments?: Record<string, string>;
assignments?: Record<string, unknown>;
userInputPolicy?: ActionUserInputPolicy;
speech?: string;
/** Legacy field read only while an older graph is open in the editor. */
@@ -279,6 +283,8 @@ export function defaultGraph(): WorkflowGraph {
contextPolicy: "inherit",
inheritGlobalConfig: true,
entryMode: "wait_user",
systemTools: [],
stateVariableNames: [],
},
},
{
@@ -33,6 +33,7 @@ export type WorkflowEditorProps = {
};
toolOptions?: ModelOption[];
knowledgeOptions?: ModelOption[];
dynamicVariableOptions: ModelOption[];
dynamicVariablesOpen: boolean;
onDynamicVariablesOpenChange: (open: boolean) => void;
dynamicVariablesPanel: ReactNode;