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:
Xin Wang
2026-07-10 10:05:41 +08:00
parent 919325505a
commit 3ed9e1b388
14 changed files with 1815 additions and 22 deletions

View File

@@ -0,0 +1,10 @@
import { MobileCallPage } from "@/components/pages/MobileCallPage";
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <MobileCallPage assistantId={id} />;
}

View File

@@ -11,6 +11,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
const router = useRouter();
const { user, loading } = useAuth();
const isLoginPage = pathname === "/login";
const isCallPage = pathname.startsWith("/call/");
useEffect(() => {
if (!loading && !user && !isLoginPage) {
@@ -30,5 +31,10 @@ export function AuthGate({ children }: { children: ReactNode }) {
);
}
// 手机通话预览沿用登录态,但不显示管理台导航框架。
if (isCallPage) {
return <>{children}</>;
}
return <AppShell>{children}</AppShell>;
}

View File

@@ -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,

View File

@@ -1,10 +1,795 @@
import { PlaceholderPage } from "./PlaceholderPage";
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ChevronDown,
ChevronUp,
Loader2,
MoreHorizontal,
Pencil,
Plus,
Trash2,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DataList, type DataListColumn } from "@/components/ui/data-list";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { FilterPills } from "@/components/ui/filter-pills";
import { ListToolbar } from "@/components/ui/list-toolbar";
import { PageHeader } from "@/components/ui/page-header";
import { SearchInput } from "@/components/ui/search-input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
toolsApi,
type HttpToolDefinition,
type Tool,
type ToolParameter,
type ToolStatus,
type ToolUpsert,
} from "@/lib/api";
type ToolKind = "end_call" | "http";
type HttpMethod = HttpToolDefinition["config"]["method"];
type ToolFilter = "全部" | "End Call" | "HTTP";
type SortOrder = "newest" | "oldest";
const toolFilters: readonly ToolFilter[] = ["全部", "End Call", "HTTP"];
type ToolForm = {
name: string;
functionName: string;
type: ToolKind;
description: string;
status: ToolStatus;
messageType: "none" | "custom";
customMessage: string;
captureReason: boolean;
method: HttpMethod;
url: string;
timeoutSeconds: string;
headers: string;
secretHeaders: string;
parameters: string;
body: string;
};
const EMPTY_OBJECT = "{}";
const EMPTY_ARRAY = "[]";
function blankForm(): ToolForm {
return {
name: "",
functionName: "",
type: "end_call",
description: "",
status: "active",
messageType: "none",
customMessage: "",
captureReason: true,
method: "GET",
url: "",
timeoutSeconds: "15",
headers: EMPTY_OBJECT,
secretHeaders: EMPTY_OBJECT,
parameters: EMPTY_ARRAY,
body: EMPTY_OBJECT,
};
}
function pretty(value: unknown, fallback: string): string {
return JSON.stringify(value ?? JSON.parse(fallback), null, 2);
}
function formFromTool(tool: Tool): ToolForm {
const base = { ...blankForm(), name: tool.name, functionName: tool.functionName };
base.type = tool.type;
base.description = tool.description;
base.status = tool.status;
if (tool.definition.type === "end_call") {
base.messageType = tool.definition.config.messageType;
base.customMessage = tool.definition.config.customMessage;
base.captureReason = tool.definition.config.captureReason;
return base;
}
base.method = tool.definition.config.method;
base.url = tool.definition.config.url;
base.timeoutSeconds = String(tool.definition.config.timeoutSeconds);
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.secretHeaders = pretty(secrets.headers ?? {}, EMPTY_OBJECT);
return base;
}
function parseObject(value: string, label: string): Record<string, unknown> {
let parsed: unknown;
try {
parsed = JSON.parse(value || EMPTY_OBJECT);
} catch {
throw new Error(`${label}不是有效的 JSON`);
}
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
throw new Error(`${label}必须是 JSON 对象`);
}
return parsed as Record<string, unknown>;
}
function parseParameters(value: string): ToolParameter[] {
let parsed: unknown;
try {
parsed = JSON.parse(value || EMPTY_ARRAY);
} catch {
throw new Error("参数定义不是有效的 JSON");
}
if (!Array.isArray(parsed)) throw new Error("参数定义必须是 JSON 数组");
return parsed as ToolParameter[];
}
function functionNameFrom(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 64);
}
function payloadFromForm(form: ToolForm): ToolUpsert {
if (!form.name.trim()) throw new Error("请输入工具名称");
if (!/^[a-z][a-z0-9_]{0,63}$/.test(form.functionName)) {
throw new Error("函数名必须以小写字母开头,且只能包含小写字母、数字和下划线");
}
if (form.type === "end_call") {
return {
name: form.name.trim(),
functionName: form.functionName,
description: form.description.trim(),
status: form.status,
secrets: {},
definition: {
schemaVersion: 1,
type: "end_call",
config: {
messageType: form.messageType,
customMessage: form.messageType === "custom" ? form.customMessage : "",
captureReason: form.captureReason,
},
},
};
}
const timeoutSeconds = Number(form.timeoutSeconds);
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 120) {
throw new Error("超时时间必须是 1 到 120 秒之间的整数");
}
const headers = parseObject(form.headers, "Header");
const secretHeaders = parseObject(form.secretHeaders, "敏感 Header");
const body = parseObject(form.body, "固定 Body");
const parameters = parseParameters(form.parameters);
return {
name: form.name.trim(),
functionName: form.functionName,
description: form.description.trim(),
status: form.status,
definition: {
schemaVersion: 1,
type: "http",
config: {
method: form.method,
url: form.url.trim(),
timeoutSeconds,
headers: headers as Record<string, string>,
parameters,
body,
},
},
secrets: { headers: secretHeaders },
};
}
function formatTimestamp(value?: string | null): string {
if (!value) return "—";
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
}
function updatedAtValue(value?: string | null): number {
const time = value ? new Date(value).getTime() : NaN;
return Number.isNaN(time) ? 0 : time;
}
export function ComponentsToolsPage() {
const [tools, setTools] = useState<Tool[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<ToolFilter>("全部");
const [sortOrder, setSortOrder] = useState<SortOrder>("newest");
const [currentPage, setCurrentPage] = useState(1);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Tool | null>(null);
const [form, setForm] = useState<ToolForm>(blankForm);
const [saving, setSaving] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [functionNameTouched, setFunctionNameTouched] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const loadTools = useCallback(async () => {
setLoading(true);
setError(null);
try {
setTools(await toolsApi.list());
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "加载工具失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadTools();
}, [loadTools]);
const filteredTools = useMemo(() => {
const query = search.trim().toLowerCase();
return tools.filter((tool) =>
(filter === "全部" ||
(filter === "End Call" && tool.type === "end_call") ||
(filter === "HTTP" && tool.type === "http")) &&
(!query ||
[tool.name, tool.functionName, tool.description].some((value) =>
value.toLowerCase().includes(query),
)),
);
}, [filter, search, tools]);
const sortedTools = useMemo(() => {
return [...filteredTools].sort((a, b) => {
const diff = updatedAtValue(b.updatedAt) - updatedAtValue(a.updatedAt);
if (diff !== 0) return sortOrder === "newest" ? diff : -diff;
return a.id.localeCompare(b.id);
});
}, [filteredTools, sortOrder]);
const pageSize = 5;
const totalPages = Math.max(1, Math.ceil(sortedTools.length / pageSize));
const safeCurrentPage = Math.min(currentPage, totalPages);
const pageStart = (safeCurrentPage - 1) * pageSize;
const pageEnd = pageStart + pageSize;
const paginatedTools = sortedTools.slice(pageStart, pageEnd);
function changeFilter(value: ToolFilter) {
setFilter(value);
setCurrentPage(1);
}
function changeSearch(value: string) {
setSearch(value);
setCurrentPage(1);
}
function openCreate() {
setEditing(null);
setForm(blankForm());
setFunctionNameTouched(false);
setFormError(null);
setDialogOpen(true);
}
function openEdit(tool: Tool) {
setEditing(tool);
setForm(formFromTool(tool));
setFunctionNameTouched(true);
setFormError(null);
setDialogOpen(true);
}
async function saveTool() {
if (saving) return;
setSaving(true);
setFormError(null);
try {
const payload = payloadFromForm(form);
if (editing) await toolsApi.update(editing.id, payload);
else await toolsApi.create(payload);
setDialogOpen(false);
await loadTools();
} catch (saveError) {
setFormError(saveError instanceof Error ? saveError.message : "保存失败");
} finally {
setSaving(false);
}
}
async function removeTool(tool: Tool) {
if (!window.confirm(`确认删除工具“${tool.name}”?`)) return;
setDeletingId(tool.id);
try {
await toolsApi.remove(tool.id);
await loadTools();
} catch (removeError) {
setError(removeError instanceof Error ? removeError.message : "删除失败");
} finally {
setDeletingId(null);
}
}
const columns: DataListColumn<Tool>[] = [
{
key: "name",
header: "工具名称",
width: "md:w-[360px]",
cell: (tool) => (
<>
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium text-foreground">{tool.name}</span>
</div>
<div className="mt-1 truncate font-mono text-xs text-muted-soft">
{tool.functionName}
</div>
</>
),
},
{
key: "type",
header: "类型",
width: "md:w-[128px]",
cell: (tool) => (
<Badge
variant="secondary"
className="h-6 bg-surface-strong px-3 text-muted-foreground"
>
{tool.type === "end_call" ? "End Call" : "HTTP"}
</Badge>
),
},
{
key: "status",
header: "状态",
width: "md:w-[156px]",
cell: (tool) => (
<Badge variant="outline" className="h-6 px-3">
{tool.status === "active" ? "启用" : tool.status === "draft" ? "草稿" : "已归档"}
</Badge>
),
},
{
key: "updated",
width: "md:w-[176px]",
header: (
<button
type="button"
onClick={() => {
setSortOrder((current) => (current === "newest" ? "oldest" : "newest"));
setCurrentPage(1);
}}
className="caption-label -mx-2 inline-flex items-center gap-1 rounded-md px-2 py-1 text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
aria-label={sortOrder === "newest" ? "当前按最近更新排序" : "当前按最早更新排序"}
>
{sortOrder === "newest" ? <ChevronDown size={13} /> : <ChevronUp size={13} />}
</button>
),
cellClassName: "whitespace-nowrap tabular-nums text-muted-foreground",
cell: (tool) => formatTimestamp(tool.updatedAt),
},
{
key: "actions",
header: "操作",
align: "right",
cellClassName: "flex justify-end gap-2",
cell: (tool) => (
<>
<Button
variant="outline"
size="sm"
className="gap-1.5 border-hairline-strong text-xs text-muted-foreground hover:text-foreground"
onClick={() => openEdit(tool)}
>
<Pencil size={14} />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon-sm"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
aria-label={`${tool.name} 更多操作`}
>
<MoreHorizontal size={15} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1"
>
<DropdownMenuItem
variant="destructive"
className="rounded-lg"
disabled={deletingId === tool.id}
onSelect={(event) => {
event.preventDefault();
void removeTool(tool);
}}
>
{deletingId === tool.id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Trash2 size={14} />
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
),
},
];
return (
<PlaceholderPage
title="工具资源"
description="统一管理大语言模型、语音识别、声音资源、知识库与工具插件。"
/>
<div className="mx-auto flex w-full max-w-[1440px] flex-col gap-8">
<PageHeader
title="工具资源"
description="管理可复用的助手工具,并将启用的工具绑定到提示词助手。"
action={
<Button size="lg" className="w-full gap-2 sm:w-auto" onClick={openCreate}>
<Plus size={16} />
</Button>
}
/>
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
<ListToolbar
filters={
<FilterPills options={toolFilters} value={filter} onChange={changeFilter} />
}
search={
<SearchInput
value={search}
onChange={changeSearch}
placeholder="搜索名称、函数名或描述"
className="lg:w-[320px]"
/>
}
/>
<DataList<Tool>
columns={columns}
rows={paginatedTools}
rowKey={(tool) => tool.id}
loading={loading}
loadingText="正在加载工具…"
error={error}
onRetry={() => void loadTools()}
empty={{
title: tools.length === 0 ? "暂无工具资源" : "未找到匹配的工具资源",
description:
tools.length === 0
? "点击右上角「添加工具」开始。"
: "请调整关键词或筛选条件后再试。",
}}
pagination={{
page: safeCurrentPage,
totalPages,
onPageChange: setCurrentPage,
summary:
filteredTools.length === 0
? "没有数据"
: `显示 ${pageStart + 1}-${Math.min(pageEnd, filteredTools.length)} / 共 ${filteredTools.length} 个工具资源`,
}}
/>
</section>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-6xl lg:overflow-hidden">
<DialogHeader>
<DialogTitle>{editing ? "编辑工具资源" : "添加工具资源"}</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="grid gap-5 lg:grid-cols-2">
<FieldSection title="基本信息" tall>
<Field label="工具名称" required>
<Input
value={form.name}
onChange={(event) => {
const name = event.target.value;
setForm((current) => ({
...current,
name,
functionName: functionNameTouched
? current.functionName
: functionNameFrom(name),
}));
}}
/>
</Field>
<Field label="函数名" required>
<Input
value={form.functionName}
onChange={(event) => {
setFunctionNameTouched(true);
setForm((current) => ({ ...current, functionName: event.target.value }));
}}
placeholder="query_order"
/>
</Field>
<Field label="工具类型" required>
<Select
value={form.type}
onValueChange={(type: ToolKind) =>
setForm((current) => ({ ...current, type }))
}
>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="end_call">End Call</SelectItem>
<SelectItem value="http">HTTP</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="状态">
<Select
value={form.status}
onValueChange={(status: ToolStatus) =>
setForm((current) => ({ ...current, status }))
}
>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="active"></SelectItem>
<SelectItem value="draft">稿</SelectItem>
<SelectItem value="archived"></SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="描述">
<Textarea
value={form.description}
onChange={(event) =>
setForm((current) => ({ ...current, description: event.target.value }))
}
rows={5}
/>
</Field>
</FieldSection>
<FieldSection title="参数配置" scrollable tall>
{form.type === "end_call" ? (
<EndCallFields form={form} setForm={setForm} />
) : (
<HttpFields form={form} setForm={setForm} />
)}
</FieldSection>
</div>
{formError && <div className="text-sm text-destructive">{formError}</div>}
<DialogFooter>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setDialogOpen(false)}></Button>
<Button onClick={() => void saveTool()} disabled={saving}>
{saving && <Loader2 size={15} className="animate-spin" />}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function EndCallFields({
form,
setForm,
}: {
form: ToolForm;
setForm: React.Dispatch<React.SetStateAction<ToolForm>>;
}) {
return (
<div className="space-y-4">
<Field label="结束语">
<Select
value={form.messageType}
onValueChange={(messageType: "none" | "custom") =>
setForm((current) => ({ ...current, messageType }))
}
>
<SelectTrigger className="w-full border-hairline-strong bg-background"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="custom"></SelectItem>
</SelectContent>
</Select>
</Field>
{form.messageType === "custom" && (
<Field label="自定义结束语">
<Textarea
value={form.customMessage}
onChange={(event) =>
setForm((current) => ({ ...current, customMessage: event.target.value }))
}
rows={3}
/>
</Field>
)}
<div className="flex items-center justify-between gap-4 rounded-lg border border-hairline-strong px-4 py-3">
<div>
<div className="font-medium text-foreground"></div>
<div className="mt-0.5 text-xs text-muted-foreground"> reason </div>
</div>
<Switch
checked={form.captureReason}
onCheckedChange={(captureReason) =>
setForm((current) => ({ ...current, captureReason }))
}
/>
</div>
</div>
);
}
function HttpFields({
form,
setForm,
}: {
form: ToolForm;
setForm: React.Dispatch<React.SetStateAction<ToolForm>>;
}) {
return (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-[140px_1fr]">
<Field label="方法">
<Select
value={form.method}
onValueChange={(method: HttpMethod) =>
setForm((current) => ({ ...current, method }))
}
>
<SelectTrigger className="w-full border-hairline-strong bg-background"><SelectValue /></SelectTrigger>
<SelectContent>
{(["GET", "POST", "PUT", "PATCH", "DELETE"] as HttpMethod[]).map((method) => (
<SelectItem key={method} value={method}>{method}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="URL">
<Input
value={form.url}
onChange={(event) => setForm((current) => ({ ...current, url: event.target.value }))}
placeholder="https://api.example.com/orders/{order_id}"
/>
</Field>
</div>
<Field label="超时时间(秒)">
<Input
type="number"
min={1}
max={120}
value={form.timeoutSeconds}
onChange={(event) =>
setForm((current) => ({ ...current, timeoutSeconds: event.target.value }))
}
/>
</Field>
<JsonField label="Header" value={form.headers} onChange={(headers) => setForm((current) => ({ ...current, headers }))} />
<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 }))} />
</div>
);
}
function Field({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}) {
return (
<label className="block space-y-2">
<span className="text-sm font-medium text-foreground">
{label}
{required && <span className="ml-1 text-destructive">*</span>}
</span>
{children}
</label>
);
}
function FieldSection({
title,
scrollable,
tall,
children,
}: {
title: string;
scrollable?: boolean;
tall?: boolean;
children: React.ReactNode;
}) {
return (
<section
className={[
"rounded-xl border border-hairline bg-surface-strong/20",
tall ? "lg:flex lg:h-[38rem] lg:flex-col" : "",
].join(" ")}
>
<div className="border-b border-hairline px-4 py-3 text-sm font-medium">
{title}
</div>
<div
className={[
"space-y-4 p-4",
scrollable ? "max-h-72 overflow-y-auto" : "",
tall ? "lg:max-h-none lg:flex-1" : "",
].join(" ")}
>
{children}
</div>
</section>
);
}
function JsonField({
label,
value,
onChange,
rows = 4,
}: {
label: string;
value: string;
onChange: (value: string) => void;
rows?: number;
}) {
return (
<Field label={label}>
<Textarea
value={value}
onChange={(event) => onChange(event.target.value)}
rows={rows}
className="font-mono text-xs"
spellCheck={false}
/>
</Field>
);
}

View File

@@ -0,0 +1,233 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { Camera, Loader2, Mic, Phone, PhoneOff, Video } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useCameraPreview } from "@/hooks/use-camera-preview";
import { useVoicePreview } from "@/hooks/use-voice-preview";
function CallDeviceSelect({
kind,
value,
devices,
onSelect,
disabled,
}: {
kind: "microphone" | "camera";
value: string;
devices: MediaDeviceInfo[];
onSelect: (deviceId: string) => void | Promise<void>;
disabled?: boolean;
}) {
const isMic = kind === "microphone";
const label = isMic ? "选择麦克风" : "选择摄像头";
return (
<Select
value={value || "default"}
onValueChange={(next) =>
void onSelect(next === "default" ? "" : next)
}
disabled={disabled}
>
<SelectTrigger
size="default"
aria-label={label}
title={label}
className="w-12 justify-center overflow-hidden rounded-full border-white/15 bg-black/45 p-0 text-white shadow-xl backdrop-blur-md hover:bg-black/60 focus-visible:ring-white/40 data-[size=default]:h-12 min-[380px]:w-14 min-[380px]:data-[size=default]:h-14 [&>svg:last-child]:hidden"
>
<span className="sr-only">
<SelectValue />
</span>
{isMic ? (
<Mic className="size-5" />
) : (
<Camera className="size-5" />
)}
</SelectTrigger>
<SelectContent
side="top"
align={isMic ? "start" : "end"}
className="max-w-[min(20rem,calc(100vw-2rem))]"
>
<SelectItem value="default">
{isMic ? "默认麦克风" : "默认摄像头"}
</SelectItem>
{devices.map((device, index) => (
<SelectItem key={device.deviceId} value={device.deviceId}>
{device.label || `${isMic ? "麦克风" : "摄像头"} ${index + 1}`}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export function MobileCallPage({ assistantId }: { assistantId: string }) {
const preview = useVoicePreview(assistantId);
const camera = useCameraPreview();
const {
status,
error: previewError,
audioInputs,
selectedDeviceId,
selectDevice,
connect,
replaceVideoStream,
disconnect,
audioRef,
} = preview;
const {
stream: cameraStream,
error: cameraError,
starting: cameraStarting,
devices: cameraDevices,
deviceId: cameraDeviceId,
start: startCamera,
stop: stopCamera,
selectCamera: changeCamera,
} = camera;
const videoRef = useRef<HTMLVideoElement>(null);
const connecting = status === "connecting";
const inCall = connecting || status === "connected";
useEffect(() => {
if (videoRef.current) videoRef.current.srcObject = cameraStream;
}, [cameraStream]);
const startCall = useCallback(async () => {
const videoStream = await startCamera();
await connect({
visionEnabled: true,
videoStream,
});
}, [connect, startCamera]);
const endCall = useCallback(() => {
disconnect();
stopCamera();
}, [disconnect, stopCamera]);
const selectCamera = useCallback(
async (deviceId: string) => {
const nextStream = await changeCamera(deviceId);
await replaceVideoStream(nextStream);
},
[changeCamera, replaceVideoStream],
);
useEffect(() => {
void startCall();
// 首次打开页面时自动发起通话hooks 会在卸载时各自释放媒体资源。
}, [startCall]);
useEffect(() => {
if (status === "idle" || status === "failed") stopCamera();
}, [status, stopCamera]);
const error = previewError || cameraError;
return (
<main className="relative isolate h-dvh min-h-80 w-full overflow-hidden bg-[#07101a] text-white">
<audio ref={audioRef} autoPlay playsInline className="hidden" />
<video
ref={videoRef}
autoPlay
playsInline
muted
className={[
"absolute inset-0 h-full w-full -scale-x-100 object-cover transition-opacity duration-300",
cameraStream ? "opacity-100" : "opacity-0",
].join(" ")}
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-black/35 via-transparent to-black/65" />
<div className="absolute left-1/2 top-[max(1rem,env(safe-area-inset-top))] flex -translate-x-1/2 items-center gap-2 rounded-full border border-white/10 bg-black/30 px-3 py-1.5 text-xs text-white/85 backdrop-blur-md">
<span
className={[
"h-2 w-2 rounded-full",
status === "connected"
? "animate-pulse bg-emerald-400"
: connecting
? "animate-pulse bg-amber-300"
: "bg-white/40",
].join(" ")}
/>
{status === "connected"
? "通话中"
: connecting
? "正在连接…"
: "通话已结束"}
</div>
{!cameraStream && inCall && (
<div className="absolute inset-0 flex items-center justify-center px-8 text-center">
<div className="flex max-w-sm flex-col items-center gap-3">
{cameraStarting ? (
<Loader2 size={34} className="animate-spin text-white/70" />
) : (
<Video size={34} className="text-white/60" />
)}
<p className="text-sm leading-6 text-white/75">
{cameraStarting
? "正在开启摄像头…"
: cameraError || "等待摄像头画面…"}
</p>
</div>
</div>
)}
{!inCall && (
<div className="absolute inset-0 flex items-center justify-center px-8">
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
<Button
type="button"
onClick={() => void startCall()}
className="h-12 gap-2 rounded-full bg-white px-6 text-[#07101a] shadow-2xl hover:bg-white/90"
>
<Phone size={19} />
{status === "failed" ? "重新开始通话" : "开始通话"}
</Button>
{error && <p className="text-xs leading-5 text-white/65">{error}</p>}
</div>
</div>
)}
{inCall && (
<div className="absolute bottom-[max(1.25rem,env(safe-area-inset-bottom))] left-1/2 flex -translate-x-1/2 items-center gap-3 min-[380px]:gap-5 landscape:bottom-[max(1rem,env(safe-area-inset-bottom))]">
<CallDeviceSelect
kind="microphone"
value={selectedDeviceId}
devices={audioInputs}
onSelect={selectDevice}
disabled={connecting}
/>
<button
type="button"
onClick={endCall}
aria-label="挂断电话"
title="挂断电话"
className="flex size-14 items-center justify-center rounded-full bg-red-500 text-white shadow-2xl transition-transform hover:scale-105 hover:bg-red-600 active:scale-95 min-[380px]:size-16"
>
<PhoneOff className="size-6 min-[380px]:size-7" />
</button>
<CallDeviceSelect
kind="camera"
value={cameraDeviceId}
devices={cameraDevices}
onSelect={selectCamera}
disabled={connecting}
/>
</div>
)}
</main>
);
}

View File

@@ -159,6 +159,7 @@ export type Assistant = {
visionModelResourceId: string | null;
modelResourceIds: Partial<Record<ModelType, string>>;
knowledgeBaseId: string | null;
toolIds: string[];
prompt: string;
apiUrl: string;
apiKey: string;
@@ -189,6 +190,69 @@ export const assistantsApi = {
request<{ ok: boolean }>(`/api/assistants/${id}`, { method: "DELETE" }),
};
// ---------- 工具 ----------
export type ToolStatus = "active" | "archived" | "draft";
export type ToolParameter = {
name: string;
type: "string" | "number" | "integer" | "boolean" | "object" | "array";
location: "path" | "query" | "body" | "header";
description: string;
required: boolean;
};
export type EndCallToolDefinition = {
schemaVersion: number;
type: "end_call";
config: {
messageType: "none" | "custom";
customMessage: string;
captureReason: boolean;
};
};
export type HttpToolDefinition = {
schemaVersion: number;
type: "http";
config: {
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
url: string;
timeoutSeconds: number;
headers: Record<string, string>;
parameters: ToolParameter[];
body: Record<string, unknown>;
};
};
export type Tool = {
id: string;
name: string;
functionName: string;
type: "end_call" | "http";
description: string;
definition: EndCallToolDefinition | HttpToolDefinition;
secrets: Record<string, unknown>;
status: ToolStatus;
updatedAt?: string | null;
};
export type ToolUpsert = Omit<Tool, "id" | "type" | "updatedAt">;
export const toolsApi = {
list: () => request<Tool[]>("/api/tools"),
create: (body: ToolUpsert) =>
request<Tool>("/api/tools", {
method: "POST",
body: JSON.stringify(body),
}),
update: (id: string, body: ToolUpsert) =>
request<Tool>(`/api/tools/${id}`, {
method: "PUT",
body: JSON.stringify(body),
}),
remove: (id: string) =>
request<{ ok: boolean }>(`/api/tools/${id}`, { method: "DELETE" }),
};
// ---------- 知识库 ----------
export type KnowledgeBase = {
id: string;