feat: organize tools and enable qwen realtime calls

This commit is contained in:
Xin Wang
2026-08-14 00:15:00 +08:00
parent eb111a3890
commit 5be21f5ccc
71 changed files with 5771 additions and 2026 deletions
@@ -0,0 +1,205 @@
"use client";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import type { BuiltInTools, DynamicVariableDefinition } from "@/lib/api";
const DEFAULTS = {
endConversation: {
description: "礼貌地结束本次对话。",
messageType: "none" as const,
customMessage: "",
captureReason: true,
allowEndInterruption: false,
},
updateState: { description: "更新已声明的会话状态。", updates: [] },
skipTurn: { description: "跳过当前轮次,不生成语音回复。" },
requestHumanHandoff: { description: "提交人工接管请求。" },
};
function UpdateRulesEditor({
value,
onChange,
}: {
value: NonNullable<BuiltInTools["updateState"]>["updates"];
onChange: (value: NonNullable<BuiltInTools["updateState"]>["updates"]) => void;
}) {
const [draft, setDraft] = useState(JSON.stringify(value, null, 2));
const [error, setError] = useState("");
function commit() {
try {
const parsed = JSON.parse(draft) as unknown;
if (!Array.isArray(parsed)) throw new Error();
setError("");
onChange(parsed);
} catch {
setError("更新规则必须是 JSON 数组");
}
}
return (
<div className="mt-3 space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">
更新规则(JSON,支持 constant / variable / llm / if / 运算表达式)
</div>
<Textarea
rows={8}
className="font-mono text-xs"
value={draft}
onChange={(event) => setDraft(event.target.value)}
onBlur={commit}
/>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}
export function BuiltInToolsEditor({
value,
variables,
onChange,
allowedKeys,
}: {
value: BuiltInTools;
variables: Record<string, DynamicVariableDefinition>;
onChange: (value: BuiltInTools) => void;
allowedKeys?: Array<keyof BuiltInTools>;
}) {
const items = [
["endConversation", "结束对话"],
["updateState", "更新状态"],
["skipTurn", "跳过本轮"],
["requestHumanHandoff", "转接人工"],
] as const;
function toggle(key: keyof BuiltInTools, enabled: boolean) {
if (!enabled) {
onChange({ ...value, [key]: null });
return;
}
if (key === "updateState") {
const updates = Object.entries(variables).map(([name, definition]) => ({
variableName: name,
expression: {
type: "llm" as const,
parameterName: name,
valueType: definition.type,
description: `从当前对话中提取并确认 ${name}`,
},
}));
onChange({
...value,
updateState: { ...DEFAULTS.updateState, updates },
});
return;
}
onChange({ ...value, [key]: { ...DEFAULTS[key] } });
}
return (
<div className="space-y-3">
{items
.filter(([key]) => !allowedKeys || allowedKeys.includes(key))
.map(([key, label]) => {
const config = value[key];
return (
<div key={key} className="rounded-xl border border-hairline p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-foreground">{label}</div>
<div className="text-xs text-muted-foreground">平台内建能力,不创建工具资源。</div>
</div>
<Switch checked={Boolean(config)} onCheckedChange={(enabled) => toggle(key, enabled)} />
</div>
{config && (
<Input
className="mt-3"
value={config.description}
onChange={(event) =>
onChange({ ...value, [key]: { ...config, description: event.target.value } })
}
placeholder="模型何时调用该能力"
/>
)}
{key === "endConversation" && value.endConversation && (
<div className="mt-3 space-y-3 text-sm text-foreground">
<label className="flex items-center justify-between gap-3">
<span>播报自定义结束语</span>
<Switch
checked={value.endConversation.messageType === "custom"}
onCheckedChange={(enabled) =>
onChange({
...value,
endConversation: {
...value.endConversation!,
messageType: enabled ? "custom" : "none",
},
})
}
/>
</label>
{value.endConversation.messageType === "custom" && (
<Input
value={value.endConversation.customMessage}
onChange={(event) =>
onChange({
...value,
endConversation: {
...value.endConversation!,
customMessage: event.target.value,
},
})
}
placeholder="例如:感谢您的时间,再见。"
/>
)}
<label className="flex items-center justify-between gap-3">
<span>记录结束原因</span>
<Switch
checked={value.endConversation.captureReason}
onCheckedChange={(captureReason) =>
onChange({
...value,
endConversation: { ...value.endConversation!, captureReason },
})
}
/>
</label>
<label className="flex items-center justify-between gap-3">
<span>结束语允许用户打断</span>
<Switch
checked={value.endConversation.allowEndInterruption}
onCheckedChange={(allowEndInterruption) =>
onChange({
...value,
endConversation: {
...value.endConversation!,
allowEndInterruption,
},
})
}
/>
</label>
</div>
)}
{key === "updateState" && value.updateState && (
<UpdateRulesEditor
value={value.updateState.updates}
onChange={(updates) =>
onChange({
...value,
updateState: { ...value.updateState!, updates },
})
}
/>
)}
</div>
);
})}
</div>
);
}
@@ -18,10 +18,7 @@ import {
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import {
actionResultAssignmentMode,
type WorkflowGraph,
} from "@/components/workflow/specs";
import type { WorkflowGraph } from "@/components/workflow/specs";
import type { DynamicVariableDefinition } from "@/lib/api";
const DYNAMIC_VARIABLE_PATTERN = /{{\s*([A-Za-z][A-Za-z0-9_]{0,63})\s*}}/g;
@@ -51,6 +48,33 @@ function extractDynamicVariableNames(...templates: string[]): string[] {
return [...names];
}
function collectStructuredVariableNames(value: unknown, names: Set<string>): void {
if (Array.isArray(value)) {
value.forEach((item) => collectStructuredVariableNames(item, names));
return;
}
if (!value || typeof value !== "object") return;
const record = value as Record<string, unknown>;
if (
typeof record.variableName === "string" &&
isPublicDynamicVariableName(record.variableName)
) {
names.add(record.variableName);
}
if (
record.resultAssignments &&
!Array.isArray(record.resultAssignments) &&
typeof record.resultAssignments === "object"
) {
Object.keys(record.resultAssignments).forEach((name) => {
if (isPublicDynamicVariableName(name)) names.add(name);
});
}
Object.values(record).forEach((item) =>
collectStructuredVariableNames(item, names),
);
}
function definitionFor(
name: string,
saved: Record<string, DynamicVariableDefinition>,
@@ -67,9 +91,12 @@ function definitionFor(
export function activeDynamicVariableDefinitions(
templates: string[],
saved: Record<string, DynamicVariableDefinition>,
structuredValues: unknown[] = [],
): Record<string, DynamicVariableDefinition> {
const names = new Set(extractDynamicVariableNames(...templates));
structuredValues.forEach((value) => collectStructuredVariableNames(value, names));
return Object.fromEntries(
extractDynamicVariableNames(...templates).map((name) => [
[...names].sort().map((name) => [
name,
definitionFor(name, saved),
]),
@@ -81,24 +108,13 @@ export function activeWorkflowDynamicVariableDefinitions(
saved: Record<string, DynamicVariableDefinition>,
): Record<string, DynamicVariableDefinition> {
const names = new Set(extractDynamicVariableNames(JSON.stringify(graph)));
collectStructuredVariableNames(graph, names);
for (const edge of graph.edges) {
for (const rule of edge.data.expression?.rules ?? []) {
if (isPublicDynamicVariableName(rule.variable)) names.add(rule.variable);
}
}
for (const node of graph.nodes) {
if (
node.type === "action" &&
actionResultAssignmentMode(node.data) !== "override"
) {
continue;
}
for (const name of Object.keys(node.data.resultAssignments ?? {})) {
if (isPublicDynamicVariableName(name)) names.add(name);
}
}
return Object.fromEntries(
[...names]
.sort()
@@ -8,13 +8,8 @@ import {
ChevronLeft,
Copy,
Pencil,
Plus,
ServerCog,
Settings2,
Sparkles,
Waypoints,
Wrench,
X,
} from "lucide-react";
import { HelpHint } from "@/components/editor/section-card";
@@ -36,17 +31,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import type {
KnowledgeRetrievalConfig,
Tool,
} from "@/lib/api";
import type { KnowledgeRetrievalConfig } from "@/lib/api";
import type { RuntimeMode } from "./types";
@@ -493,171 +479,6 @@ export function KnowledgeRetrievalConfigDialog({
);
}
export function ToolPicker({
tools,
selectedIds,
onChange,
}: {
tools: Tool[];
selectedIds: string[];
onChange: (ids: string[]) => void;
}) {
const [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState<Tool["type"]>("system");
const [draftIds, setDraftIds] = useState<string[]>(selectedIds);
const selectedTools = selectedIds
.map((id) => tools.find((tool) => tool.id === id))
.filter((tool): tool is Tool => Boolean(tool));
function openPicker() {
setDraftIds(selectedIds);
setOpen(true);
}
function toggleResourceTool(toolId: string) {
setDraftIds((current) =>
current.includes(toolId)
? current.filter((id) => id !== toolId)
: [...current, toolId],
);
}
const tabs: Array<{ value: Tool["type"]; label: string }> = [
{ value: "system", label: "System" },
{ value: "http", label: "HTTP" },
{ value: "client", label: "Client" },
{ value: "mcp", label: "MCP" },
];
return (
<>
<div className="flex min-h-9 flex-wrap items-center gap-2">
{selectedTools.map((tool) => (
<div
key={tool.id}
className="flex h-8 items-center gap-2 rounded-lg border border-hairline-strong bg-background px-2.5 text-sm"
>
{tool.type === "system" ? (
<Sparkles size={14} />
) : tool.type === "mcp" ? (
<ServerCog size={14} />
) : (
<Wrench size={14} />
)}
<span className="max-w-48 truncate">{tool.name}</span>
<button
type="button"
onClick={() => onChange(selectedIds.filter((id) => id !== tool.id))}
className="text-muted-soft transition-colors hover:text-foreground"
aria-label={`移除工具 ${tool.name}`}
>
<X size={13} />
</button>
</div>
))}
<Button
type="button"
variant="outline"
size="icon-sm"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={openPicker}
aria-label="添加工具"
title="添加工具"
>
<Plus size={15} />
</Button>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>选择工具</DialogTitle>
<DialogDescription>
按类型选择已经启用的工具资源。
</DialogDescription>
</DialogHeader>
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as Tool["type"])}
>
<TabsList
variant="line"
className="w-full justify-start border-b border-hairline px-1"
>
{tabs.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="flex-none px-4"
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => {
const resources = tools.filter((tool) => tool.type === tab.value);
const isEmpty = resources.length === 0;
return (
<TabsContent key={tab.value} value={tab.value} className="pt-3">
{isEmpty ? (
<div className="rounded-xl border border-dashed border-hairline-strong px-4 py-10 text-center text-sm text-muted-foreground">
暂无可用的 {tab.label} 工具
</div>
) : (
<div className="max-h-[280px] divide-y divide-hairline overflow-y-auto rounded-xl border border-hairline">
{resources.map((tool) => {
const checked = draftIds.includes(tool.id);
return (
<label
key={tool.id}
className="flex h-14 cursor-pointer items-center gap-3 px-4 transition-colors hover:bg-surface-strong/40"
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleResourceTool(tool.id)}
className="size-4 accent-primary"
/>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-foreground">
{tool.name}
</div>
<div className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
{tool.functionName}
</div>
</div>
</label>
);
})}
</div>
)}
</TabsContent>
);
})}
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
取消
</Button>
<Button
onClick={() => {
onChange(draftIds);
setOpen(false);
}}
>
确认
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export function ToggleRow({
icon,
title,
@@ -33,12 +33,13 @@ import {
ResourceSelectField,
RuntimeModeSelector,
TextAreaField,
ToolPicker,
} from "@/components/assistant-editor/editor-controls";
import type { AssistantForm } from "@/components/assistant-editor/types";
import { SectionAnchorTabs } from "@/components/editor/section-anchor-tabs";
import { SectionCard } from "@/components/editor/section-card";
import { VisionConfigSection } from "@/components/editor/vision-config-section";
import { BuiltInToolsEditor } from "@/components/assistant-editor/built-in-tools";
import { ToolBindingEditor } from "@/components/tools/tool-binding-editor";
import { TurnConfigEditor } from "@/components/turn-config-editor";
import { Button } from "@/components/ui/button";
import {
@@ -56,7 +57,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { DynamicVariableDefinition, StartupConfig, Tool } from "@/lib/api";
import type {
BuiltInTools,
DynamicVariableDefinition,
RealtimePromptToolFeatures,
StartupConfig,
Tool,
} from "@/lib/api";
type ResourceOption = { value: string; label: string };
type PromptOpeningMode = StartupConfig["openingMode"];
@@ -117,6 +124,7 @@ type PromptEditorProps = {
visionModelOptions: ResourceOption[];
knowledgeOptions: ResourceOption[];
tools: Tool[];
realtimeToolFeatures?: RealtimePromptToolFeatures;
onBack: () => void;
onSave: () => void;
onDelete?: () => void | Promise<void>;
@@ -142,6 +150,7 @@ export function PromptEditor({
visionModelOptions,
knowledgeOptions: kbOptions,
tools,
realtimeToolFeatures,
onBack,
onSave,
onDelete,
@@ -587,16 +596,6 @@ export function PromptEditor({
openingMessage: null,
});
}
if (runtimeMode === "realtime") {
updateForm(
"toolIds",
form.toolIds.filter(
(id) =>
tools.find((tool) => tool.id === id)?.type !==
"system",
),
);
}
}}
/>
@@ -692,20 +691,121 @@ export function PromptEditor({
</SectionCard>
)}
{(form.runtimeMode === "pipeline" ||
Boolean(realtimeToolFeatures)) && (
<SectionCard
icon={<Sparkles size={15} />}
title="内建能力"
description="配置平台提供的结束、状态更新与人工接管能力"
>
<BuiltInToolsEditor
value={form.builtInTools}
variables={dynamicVariableDefinitions}
allowedKeys={
form.runtimeMode === "realtime"
? realtimeToolFeatures?.builtIns
: undefined
}
onChange={(builtInTools) =>
updateForm("builtInTools", builtInTools)
}
/>
</SectionCard>
)}
{form.runtimeMode === "realtime" && !realtimeToolFeatures && (
<SectionCard
icon={<Sparkles size={15} />}
title="Realtime 工具"
description="当前 Realtime 接口尚未开放 Prompt 工具调用"
>
<p className="text-sm text-muted-foreground">
已有配置会被保留,但保存前需要切换到支持工具的模型或清理这些配置。
</p>
</SectionCard>
)}
{form.runtimeMode === "realtime" && (() => {
const allowedBuiltIns = new Set(
realtimeToolFeatures?.builtIns ?? [],
);
const unsupportedBuiltIns = (
Object.keys(form.builtInTools) as Array<keyof BuiltInTools>
).filter(
(key) =>
Boolean(form.builtInTools[key]) &&
!allowedBuiltIns.has(key),
);
const allowedExternal = new Set(
realtimeToolFeatures?.externalTypes ?? [],
);
const unsupportedToolIds = new Set(
form.toolBindings
.filter((binding) => {
const tool = tools.find((item) => item.id === binding.toolId);
return !tool || !allowedExternal.has(tool.type);
})
.map((binding) => binding.toolId),
);
if (!unsupportedBuiltIns.length && !unsupportedToolIds.size) {
return null;
}
return (
<SectionCard
icon={<Wrench size={15} />}
title="存在不兼容工具配置"
description="这些配置不会被自动删除,后端也会阻止保存"
>
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{unsupportedBuiltIns.length} 个内建工具,
{unsupportedToolIds.size} 个外部工具不受当前接口支持。
</p>
<Button
type="button"
variant="outline"
onClick={() => {
updateForm(
"builtInTools",
Object.fromEntries(
Object.entries(form.builtInTools).filter(([key]) =>
allowedBuiltIns.has(key as keyof BuiltInTools),
),
) as BuiltInTools,
);
updateForm(
"toolBindings",
form.toolBindings.filter(
(binding) => !unsupportedToolIds.has(binding.toolId),
),
);
}}
>
清理不兼容配置
</Button>
</div>
</SectionCard>
);
})()}
<SectionCard
icon={<Wrench size={15} />}
title="工具"
description="配置该提示词助手可以调用的工具"
title="外部工具"
description="选择可复用资源,并配置当前助手的参数来源与结果映射"
>
<ToolPicker
tools={tools.filter(
(tool) =>
tool.status === "active" &&
(form.runtimeMode === "pipeline" ||
tool.type !== "system"),
)}
selectedIds={form.toolIds}
onChange={(toolIds) => updateForm("toolIds", toolIds)}
<ToolBindingEditor
tools={
form.runtimeMode === "realtime"
? tools.filter((tool) =>
realtimeToolFeatures?.externalTypes.includes(tool.type),
)
: tools
}
value={form.toolBindings}
variables={dynamicVariableDefinitions}
onChange={(toolBindings) =>
updateForm("toolBindings", toolBindings)
}
/>
</SectionCard>
</section>
@@ -3,6 +3,8 @@ import type {
DynamicVariableDefinition,
KnowledgeRetrievalConfig,
StartupConfig,
BuiltInTools,
ToolBinding,
TurnConfig,
VisionPromptMode,
} from "@/lib/api";
@@ -29,5 +31,6 @@ export type AssistantForm = {
visionModelResourceId: string;
visionPromptMode: VisionPromptMode;
visionPrompt: string;
toolIds: string[];
toolBindings: ToolBinding[];
builtInTools: BuiltInTools;
};
@@ -12,7 +12,7 @@ import {
import { Button } from "@/components/ui/button";
import { TopbarPortal } from "@/components/layout/topbar-portal";
import { WorkflowEditor } from "@/components/workflow/WorkflowEditor";
import type { WorkflowSettings } from "@/components/workflow/types";
import type { ModelOption, WorkflowSettings } from "@/components/workflow/types";
import {
workflowUsesVision,
type WorkflowGraph,
@@ -43,7 +43,7 @@ type WorkflowPageProps = {
tts: ResourceOption[];
vision: ResourceOption[];
};
toolOptions: ResourceOption[];
toolOptions: ModelOption[];
knowledgeOptions: ResourceOption[];
onBack: () => void;
onSave: () => void;
@@ -195,6 +195,7 @@ export function WorkflowPage({
value,
label: `${value} · ${definition.type}`,
}))}
dynamicVariableDefinitions={dynamicVariableDefinitions}
/>
</div>
</>