2026-07-10 10:05:41 +08:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
|
|
import {
|
|
|
|
|
ChevronDown,
|
|
|
|
|
ChevronUp,
|
2026-07-10 16:45:51 +08:00
|
|
|
Copy,
|
2026-07-10 10:05:41 +08:00
|
|
|
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;
|
|
|
|
|
}
|
2026-06-08 13:51:28 +08:00
|
|
|
|
|
|
|
|
export function ComponentsToolsPage() {
|
2026-07-10 10:05:41 +08:00
|
|
|
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);
|
2026-07-10 16:45:51 +08:00
|
|
|
const [duplicatingId, setDuplicatingId] = useState<string | null>(null);
|
2026-07-10 10:05:41 +08:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-10 16:45:51 +08:00
|
|
|
async function duplicateTool(id: string) {
|
|
|
|
|
setDuplicatingId(id);
|
|
|
|
|
try {
|
|
|
|
|
await toolsApi.duplicate(id);
|
|
|
|
|
await loadTools();
|
|
|
|
|
} catch (duplicateError) {
|
|
|
|
|
setError(duplicateError instanceof Error ? duplicateError.message : "复制失败");
|
|
|
|
|
} finally {
|
|
|
|
|
setDuplicatingId(null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-10 10:05:41 +08:00
|
|
|
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"
|
|
|
|
|
>
|
2026-07-10 16:45:51 +08:00
|
|
|
<DropdownMenuItem
|
|
|
|
|
className="rounded-lg"
|
|
|
|
|
disabled={duplicatingId === tool.id}
|
|
|
|
|
onSelect={(event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
void duplicateTool(tool.id);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{duplicatingId === tool.id ? (
|
|
|
|
|
<Loader2 size={14} className="animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<Copy size={14} />
|
|
|
|
|
)}
|
|
|
|
|
复制
|
|
|
|
|
</DropdownMenuItem>
|
2026-07-10 10:05:41 +08:00
|
|
|
<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>
|
|
|
|
|
</>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
2026-06-08 13:51:28 +08:00
|
|
|
return (
|
2026-07-10 10:05:41 +08:00
|
|
|
<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>
|
2026-06-08 13:51:28 +08:00
|
|
|
);
|
|
|
|
|
}
|