+
{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 (
+
{
+ if (open && assistantId) {
+ setCallUrl(
+ `${window.location.origin}/call/${encodeURIComponent(assistantId)}`,
+ );
+ setCopied(false);
+ }
+ }}
+ >
+
+
+
+
+
+
手机通话预览
+
+ 复制链接并在浏览器中打开,即可进入全屏视频通话界面。
+
+
+
+ event.currentTarget.select()}
+ className="h-9 min-w-0 flex-1 text-xs"
+ />
+
+
+ {copied && 链接已复制
}
+
+
+ );
+}
+
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
(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 (
+ <>
+
+ {selectedTools.map((tool) => (
+
+ {tool.type === "end_call" ?
:
}
+
{tool.name}
+
+
+ ))}
+
+
+
+
+ >
+ );
+}
+
function ToggleRow({
icon,
title,
diff --git a/frontend/src/components/pages/ComponentsToolsPage.tsx b/frontend/src/components/pages/ComponentsToolsPage.tsx
index 6401eb3..d535ca3 100644
--- a/frontend/src/components/pages/ComponentsToolsPage.tsx
+++ b/frontend/src/components/pages/ComponentsToolsPage.tsx
@@ -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 };
+ base.secretHeaders = pretty(secrets.headers ?? {}, EMPTY_OBJECT);
+ return base;
+}
+
+function parseObject(value: string, label: string): Record {
+ 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;
+}
+
+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,
+ 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([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [search, setSearch] = useState("");
+ const [filter, setFilter] = useState("全部");
+ const [sortOrder, setSortOrder] = useState("newest");
+ const [currentPage, setCurrentPage] = useState(1);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [editing, setEditing] = useState(null);
+ const [form, setForm] = useState(blankForm);
+ const [saving, setSaving] = useState(false);
+ const [formError, setFormError] = useState(null);
+ const [functionNameTouched, setFunctionNameTouched] = useState(false);
+ const [deletingId, setDeletingId] = useState(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[] = [
+ {
+ key: "name",
+ header: "工具名称",
+ width: "md:w-[360px]",
+ cell: (tool) => (
+ <>
+
+ {tool.name}
+
+
+ {tool.functionName}
+
+ >
+ ),
+ },
+ {
+ key: "type",
+ header: "类型",
+ width: "md:w-[128px]",
+ cell: (tool) => (
+
+ {tool.type === "end_call" ? "End Call" : "HTTP"}
+
+ ),
+ },
+ {
+ key: "status",
+ header: "状态",
+ width: "md:w-[156px]",
+ cell: (tool) => (
+
+ {tool.status === "active" ? "启用" : tool.status === "draft" ? "草稿" : "已归档"}
+
+ ),
+ },
+ {
+ key: "updated",
+ width: "md:w-[176px]",
+ header: (
+
+ ),
+ 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) => (
+ <>
+
+
+
+
+
+
+ {
+ event.preventDefault();
+ void removeTool(tool);
+ }}
+ >
+ {deletingId === tool.id ? (
+
+ ) : (
+
+ )}
+ 删除
+
+
+
+ >
+ ),
+ },
+ ];
+
return (
-
+
+
+
+ 添加工具
+
+ }
+ />
+
+
+
+ }
+ search={
+
+ }
+ />
+
+
+
+
+ );
+}
+
+function EndCallFields({
+ form,
+ setForm,
+}: {
+ form: ToolForm;
+ setForm: React.Dispatch>;
+}) {
+ return (
+
+
+
+
+ {form.messageType === "custom" && (
+
+
+ )}
+
+
+
记录结束原因
+
要求模型在调用时提供 reason 参数
+
+
+ setForm((current) => ({ ...current, captureReason }))
+ }
+ />
+
+
+ );
+}
+
+function HttpFields({
+ form,
+ setForm,
+}: {
+ form: ToolForm;
+ setForm: React.Dispatch>;
+}) {
+ return (
+
+
+
+
+
+
+ setForm((current) => ({ ...current, url: event.target.value }))}
+ placeholder="https://api.example.com/orders/{order_id}"
+ />
+
+
+
+
+ setForm((current) => ({ ...current, timeoutSeconds: event.target.value }))
+ }
+ />
+
+
setForm((current) => ({ ...current, headers }))} />
+ setForm((current) => ({ ...current, secretHeaders }))} />
+ setForm((current) => ({ ...current, parameters }))} rows={6} />
+ setForm((current) => ({ ...current, body }))} />
+
+ );
+}
+
+function Field({
+ label,
+ required,
+ children,
+}: {
+ label: string;
+ required?: boolean;
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
+
+function FieldSection({
+ title,
+ scrollable,
+ tall,
+ children,
+}: {
+ title: string;
+ scrollable?: boolean;
+ tall?: boolean;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {title}
+
+
+ {children}
+
+
+ );
+}
+
+function JsonField({
+ label,
+ value,
+ onChange,
+ rows = 4,
+}: {
+ label: string;
+ value: string;
+ onChange: (value: string) => void;
+ rows?: number;
+}) {
+ return (
+
+
);
}
diff --git a/frontend/src/components/pages/MobileCallPage.tsx b/frontend/src/components/pages/MobileCallPage.tsx
new file mode 100644
index 0000000..511edd0
--- /dev/null
+++ b/frontend/src/components/pages/MobileCallPage.tsx
@@ -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;
+ disabled?: boolean;
+}) {
+ const isMic = kind === "microphone";
+ const label = isMic ? "选择麦克风" : "选择摄像头";
+
+ return (
+
+ );
+}
+
+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(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 (
+
+
+
+
+
+
+
+
+ {status === "connected"
+ ? "通话中"
+ : connecting
+ ? "正在连接…"
+ : "通话已结束"}
+
+
+ {!cameraStream && inCall && (
+
+
+ {cameraStarting ? (
+
+ ) : (
+
+ )}
+
+ {cameraStarting
+ ? "正在开启摄像头…"
+ : cameraError || "等待摄像头画面…"}
+
+
+
+ )}
+
+ {!inCall && (
+
+
+
+ {error &&
{error}
}
+
+
+ )}
+
+ {inCall && (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index c8607eb..f6bf602 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -159,6 +159,7 @@ export type Assistant = {
visionModelResourceId: string | null;
modelResourceIds: Partial>;
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;
+ parameters: ToolParameter[];
+ body: Record;
+ };
+};
+
+export type Tool = {
+ id: string;
+ name: string;
+ functionName: string;
+ type: "end_call" | "http";
+ description: string;
+ definition: EndCallToolDefinition | HttpToolDefinition;
+ secrets: Record;
+ status: ToolStatus;
+ updatedAt?: string | null;
+};
+
+export type ToolUpsert = Omit;
+
+export const toolsApi = {
+ list: () => request("/api/tools"),
+ create: (body: ToolUpsert) =>
+ request("/api/tools", {
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ update: (id: string, body: ToolUpsert) =>
+ request(`/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;