Add reusable tools and assistant bindings
- Introduce a new `Tool` model and `AssistantToolBinding` for managing reusable tools within the application. - Implement CRUD operations for tools in the new `tools` route, allowing for the creation, retrieval, updating, and deletion of tools. - Update the `Assistant` model to include a list of tool IDs, enabling assistants to utilize these tools. - Enhance the backend routes to synchronize tool bindings with assistants, ensuring proper management of tool associations. - Add frontend components for tool management, including a tool picker in the assistant configuration, improving user experience in tool selection. - Create a mobile call page to facilitate video calls, integrating camera and microphone selection for enhanced communication capabilities. - Update API definitions to include tool-related types and operations, ensuring consistency across the application. - Add a migration script to create the necessary database tables for tools and bindings, supporting the new functionality.
This commit is contained in:
@@ -33,6 +33,9 @@ import {
|
||||
Waves,
|
||||
Bug,
|
||||
Video,
|
||||
Smartphone,
|
||||
Wrench,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -40,6 +43,14 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -87,11 +98,13 @@ import {
|
||||
assistantsApi,
|
||||
knowledgeBasesApi,
|
||||
modelResourcesApi,
|
||||
toolsApi,
|
||||
type Assistant,
|
||||
type AssistantType as ApiAssistantType,
|
||||
type AssistantUpsert,
|
||||
type KnowledgeBase,
|
||||
type ModelResource,
|
||||
type Tool,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
useVoicePreview,
|
||||
@@ -127,6 +140,7 @@ type AssistantForm = {
|
||||
enableInterrupt: boolean;
|
||||
visionEnabled: boolean;
|
||||
visionModelResourceId: string;
|
||||
toolIds: string[];
|
||||
};
|
||||
|
||||
type FastGptForm = {
|
||||
@@ -216,6 +230,7 @@ function blankPromptForm(name: string): AssistantForm {
|
||||
enableInterrupt: true,
|
||||
visionEnabled: false,
|
||||
visionModelResourceId: "",
|
||||
toolIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -350,6 +365,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
// 下拉数据源:模型资源 + 知识库
|
||||
const [modelResources, setModelResources] = useState<ModelResource[]>([]);
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||
const [tools, setTools] = useState<Tool[]>([]);
|
||||
// 视图由路由模式决定;仅编辑模式需要先 loading,等拿到助手类型后切换
|
||||
const [view, setView] = useState<View>(() => {
|
||||
if (props.mode === "choose") return "choose";
|
||||
@@ -403,12 +419,14 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
// 进入创建/编辑前加载下拉数据源(模型资源 + 知识库)
|
||||
const loadResources = useCallback(async () => {
|
||||
try {
|
||||
const [creds, kbs] = await Promise.all([
|
||||
const [creds, kbs, toolRows] = await Promise.all([
|
||||
modelResourcesApi.list(),
|
||||
knowledgeBasesApi.list(),
|
||||
toolsApi.list(),
|
||||
]);
|
||||
setModelResources(creds);
|
||||
setKnowledgeBases(kbs);
|
||||
setTools(toolRows);
|
||||
} catch {
|
||||
// 拉取失败时下拉为空,不阻塞表单
|
||||
}
|
||||
@@ -488,6 +506,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
enableInterrupt: a.enableInterrupt,
|
||||
visionEnabled: a.visionEnabled,
|
||||
visionModelResourceId: a.visionModelResourceId ?? "",
|
||||
toolIds: a.toolIds ?? [],
|
||||
};
|
||||
setForm(next);
|
||||
return next;
|
||||
@@ -552,6 +571,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
visionModelResourceId: null,
|
||||
modelResourceIds: {},
|
||||
knowledgeBaseId: null,
|
||||
toolIds: [],
|
||||
prompt: "",
|
||||
apiUrl: "",
|
||||
apiKey: "",
|
||||
@@ -617,6 +637,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
...(form.realtimeModel ? { Realtime: form.realtimeModel } : {}),
|
||||
},
|
||||
knowledgeBaseId: form.knowledgeBase || null,
|
||||
toolIds: form.toolIds,
|
||||
prompt: form.prompt,
|
||||
}),
|
||||
);
|
||||
@@ -1863,6 +1884,18 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<Wrench size={18} />}
|
||||
title="工具"
|
||||
description="配置该提示词助手可以调用的工具"
|
||||
>
|
||||
<ToolPicker
|
||||
tools={tools.filter((tool) => tool.status === "active")}
|
||||
selectedIds={form.toolIds}
|
||||
onChange={(toolIds) => updateForm("toolIds", toolIds)}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<Sparkles size={18} />}
|
||||
title="交互策略"
|
||||
@@ -2010,6 +2043,7 @@ function DebugDrawer({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<CallPreviewLink assistantId={assistantId} />
|
||||
{SHOW_VOICE_VIZ && view === "chat" && (
|
||||
<>
|
||||
{!showTranscript && (
|
||||
@@ -2081,6 +2115,71 @@ function DebugDrawer({
|
||||
);
|
||||
}
|
||||
|
||||
function CallPreviewLink({ assistantId }: { assistantId: string | null }) {
|
||||
const [callUrl, setCallUrl] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyLink = useCallback(async () => {
|
||||
if (!callUrl) return;
|
||||
await navigator.clipboard.writeText(callUrl);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1600);
|
||||
}, [callUrl]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
onOpenChange={(open) => {
|
||||
if (open && assistantId) {
|
||||
setCallUrl(
|
||||
`${window.location.origin}/call/${encodeURIComponent(assistantId)}`,
|
||||
);
|
||||
setCopied(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!assistantId}
|
||||
aria-label="打开手机通话链接"
|
||||
title={assistantId ? "手机通话链接" : "请先保存助手"}
|
||||
className="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"
|
||||
>
|
||||
<Smartphone size={15} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" side="bottom" className="w-80 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium text-foreground">手机通话预览</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
复制链接并在浏览器中打开,即可进入全屏视频通话界面。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
readOnly
|
||||
value={callUrl}
|
||||
aria-label="手机通话链接"
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
className="h-9 min-w-0 flex-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
className="h-9 w-9"
|
||||
onClick={() => void copyLink()}
|
||||
aria-label="复制手机通话链接"
|
||||
>
|
||||
{copied ? <Check size={15} /> : <Copy size={15} />}
|
||||
</Button>
|
||||
</div>
|
||||
{copied && <p className="text-xs text-success">链接已复制</p>}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceSelectField({
|
||||
icon,
|
||||
ariaLabel,
|
||||
@@ -3055,6 +3154,129 @@ function ResourceSelectField({
|
||||
);
|
||||
}
|
||||
|
||||
function ToolPicker({
|
||||
tools,
|
||||
selectedIds,
|
||||
onChange,
|
||||
}: {
|
||||
tools: Tool[];
|
||||
selectedIds: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
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);
|
||||
}
|
||||
|
||||
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 === "end_call" ? <PhoneOff 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>
|
||||
|
||||
{tools.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-hairline-strong px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
暂无可用工具
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-80 divide-y divide-hairline overflow-y-auto rounded-xl border border-hairline">
|
||||
{tools.map((tool) => {
|
||||
const checked = draftIds.includes(tool.id);
|
||||
return (
|
||||
<label
|
||||
key={tool.id}
|
||||
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-surface-strong/40"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() =>
|
||||
setDraftIds((current) =>
|
||||
checked
|
||||
? current.filter((id) => id !== tool.id)
|
||||
: [...current, tool.id],
|
||||
)
|
||||
}
|
||||
className="size-4 accent-primary"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium text-foreground">
|
||||
{tool.name}
|
||||
</span>
|
||||
<Badge variant="secondary">
|
||||
{tool.type === "end_call" ? "End Call" : "HTTP"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
|
||||
{tool.functionName}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onChange(draftIds);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
确认
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
icon,
|
||||
title,
|
||||
|
||||
Reference in New Issue
Block a user