mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 18:18:37 +00:00
feat(ui): web_search chain editor + TTS settings form
Add dedicated UI forms for web_search and TTS builtin tool settings, replacing the raw JSON editor for these tools. - web-search-chain-form: drag-drop provider order (Exa/Tavily/Brave), DuckDuckGo locked as always-on fallback, color-coded rails, per-provider max_results config - tts-provider-form: primary provider selector + auto mode dropdown with inline descriptions (off/inbound/tagged/always) - Wire both into builtin-tool-settings-dialog dispatch - i18n strings for en/vi/zh
This commit is contained in:
1 parent
3a8470b8da
commit
c07aa5cf4b
6 files changed
+481
-2
No files matched your search
@@ -143,6 +143,38 @@
|
||||
"save": "Save",
|
||||
"saving": "Saving..."
|
||||
},
|
||||
"searchChain": {
|
||||
"title": "Web Search Provider Chain",
|
||||
"description": "Drag to reorder search providers. First enabled provider with a valid API key is tried first.",
|
||||
"maxResults": "Max results",
|
||||
"locked": "Always-on fallback",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"providers": {
|
||||
"exa": "Exa",
|
||||
"tavily": "Tavily",
|
||||
"brave": "Brave Search",
|
||||
"duckduckgo": "DuckDuckGo"
|
||||
}
|
||||
},
|
||||
"ttsForm": {
|
||||
"title": "TTS Settings",
|
||||
"description": "Configure text-to-speech provider and auto-apply mode.",
|
||||
"primary": "Primary provider",
|
||||
"auto": "Auto mode",
|
||||
"autoOff": "Off",
|
||||
"autoInbound": "Voice inbound",
|
||||
"autoTagged": "Tagged",
|
||||
"autoAlways": "Always",
|
||||
"autoOffDesc": "Only when agent explicitly uses tts tool",
|
||||
"autoInboundDesc": "Auto-apply when user sends voice/audio",
|
||||
"autoTaggedDesc": "Apply when text contains [[tts]] directive",
|
||||
"autoAlwaysDesc": "Always convert final reply to speech",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"saving": "Saving..."
|
||||
},
|
||||
"extractorChain": {
|
||||
"title": "Web Fetch — Extractor Chain",
|
||||
"description": "Ordered list of content extractors. Each URL is tried top-to-bottom until one returns quality content.",
|
||||
|
||||
@@ -143,6 +143,38 @@
|
||||
"save": "Lưu",
|
||||
"saving": "Đang lưu..."
|
||||
},
|
||||
"searchChain": {
|
||||
"title": "Chuỗi nhà cung cấp tìm kiếm web",
|
||||
"description": "Kéo để sắp xếp lại nhà cung cấp tìm kiếm. Nhà cung cấp đầu tiên được bật với API key hợp lệ sẽ được thử trước.",
|
||||
"maxResults": "Số kết quả tối đa",
|
||||
"locked": "Dự phòng luôn bật",
|
||||
"cancel": "Hủy",
|
||||
"save": "Lưu",
|
||||
"saving": "Đang lưu...",
|
||||
"providers": {
|
||||
"exa": "Exa",
|
||||
"tavily": "Tavily",
|
||||
"brave": "Brave Search",
|
||||
"duckduckgo": "DuckDuckGo"
|
||||
}
|
||||
},
|
||||
"ttsForm": {
|
||||
"title": "Cài đặt TTS",
|
||||
"description": "Cấu hình nhà cung cấp chuyển văn bản thành giọng nói và chế độ tự động áp dụng.",
|
||||
"primary": "Nhà cung cấp chính",
|
||||
"auto": "Chế độ tự động",
|
||||
"autoOff": "Tắt",
|
||||
"autoInbound": "Giọng nói đến",
|
||||
"autoTagged": "Có thẻ",
|
||||
"autoAlways": "Luôn luôn",
|
||||
"autoOffDesc": "Chỉ khi agent tường minh sử dụng công cụ tts",
|
||||
"autoInboundDesc": "Tự động áp dụng khi người dùng gửi giọng nói/âm thanh",
|
||||
"autoTaggedDesc": "Áp dụng khi văn bản chứa chỉ thị [[tts]]",
|
||||
"autoAlwaysDesc": "Luôn chuyển đổi câu trả lời cuối thành giọng nói",
|
||||
"cancel": "Hủy",
|
||||
"save": "Lưu",
|
||||
"saving": "Đang lưu..."
|
||||
},
|
||||
"extractorChain": {
|
||||
"title": "Web Fetch — Chuỗi bộ trích xuất",
|
||||
"description": "Danh sách bộ trích xuất nội dung theo thứ tự. Mỗi URL được thử từ trên xuống cho đến khi một bộ trả về nội dung chất lượng.",
|
||||
|
||||
@@ -57,6 +57,38 @@
|
||||
"saving": "保存中...",
|
||||
"title": "知识图谱设置"
|
||||
},
|
||||
"searchChain": {
|
||||
"title": "网页搜索 Provider 链",
|
||||
"description": "拖动以重新排序搜索 Provider。第一个已启用且具有有效 API Key 的 Provider 将被优先尝试。",
|
||||
"maxResults": "最大结果数",
|
||||
"locked": "始终启用的回退",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"saving": "保存中...",
|
||||
"providers": {
|
||||
"exa": "Exa",
|
||||
"tavily": "Tavily",
|
||||
"brave": "Brave Search",
|
||||
"duckduckgo": "DuckDuckGo"
|
||||
}
|
||||
},
|
||||
"ttsForm": {
|
||||
"title": "TTS 设置",
|
||||
"description": "配置文字转语音 Provider 和自动应用模式。",
|
||||
"primary": "主要 Provider",
|
||||
"auto": "自动模式",
|
||||
"autoOff": "关闭",
|
||||
"autoInbound": "语音入站",
|
||||
"autoTagged": "标记触发",
|
||||
"autoAlways": "始终",
|
||||
"autoOffDesc": "仅当 Agent 明确使用 tts 工具时",
|
||||
"autoInboundDesc": "当用户发送语音/音频时自动应用",
|
||||
"autoTaggedDesc": "当文本包含 [[tts]] 指令时应用",
|
||||
"autoAlwaysDesc": "始终将最终回复转换为语音",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"saving": "保存中..."
|
||||
},
|
||||
"extractorChain": {
|
||||
"title": "Web Fetch — 提取器链",
|
||||
"description": "有序内容提取器列表。每个 URL 从上到下依次尝试,直到某个提取器返回高质量内容。",
|
||||
|
||||
@@ -16,9 +16,13 @@ import { MEDIA_TOOLS } from "./media-provider-params-schema";
|
||||
import { MediaProviderChainForm } from "./media-provider-chain-form";
|
||||
import { KGSettingsForm } from "./kg-settings-form";
|
||||
import { WebFetchExtractorChainForm } from "./web-fetch-extractor-chain-form";
|
||||
import { WebSearchChainForm } from "./web-search-chain-form";
|
||||
import { TtsProviderForm } from "./tts-provider-form";
|
||||
|
||||
const KG_TOOL = "knowledge_graph_search";
|
||||
const WEB_FETCH_TOOL = "web_fetch";
|
||||
const WEB_SEARCH_TOOL = "web_search";
|
||||
const TTS_TOOL = "tts";
|
||||
|
||||
interface Props {
|
||||
tool: BuiltinToolData | null;
|
||||
@@ -55,7 +59,9 @@ export function BuiltinToolSettingsDialog({
|
||||
const isMedia = tool ? MEDIA_TOOLS.has(tool.name) : false;
|
||||
const isKG = tool?.name === KG_TOOL;
|
||||
const isWebFetch = tool?.name === WEB_FETCH_TOOL;
|
||||
const wide = isMedia || isKG || isWebFetch;
|
||||
const isWebSearch = tool?.name === WEB_SEARCH_TOOL;
|
||||
const isTts = tool?.name === TTS_TOOL;
|
||||
const wide = isMedia || isKG || isWebFetch || isWebSearch;
|
||||
|
||||
// Tenant-scope overlay: prefer the tenant override when present; fall back
|
||||
// to the global default so the form opens pre-populated with something
|
||||
@@ -77,7 +83,19 @@ export function BuiltinToolSettingsDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className={wide ? "sm:max-w-2xl" : "sm:max-w-md"}>
|
||||
{modeBadge}
|
||||
{isWebFetch && tool ? (
|
||||
{isWebSearch && tool ? (
|
||||
<WebSearchChainForm
|
||||
initialSettings={initialSettings}
|
||||
onSave={(settings) => onSave(tool.name, settings).then(() => onOpenChange(false))}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
/>
|
||||
) : isTts && tool ? (
|
||||
<TtsProviderForm
|
||||
initialSettings={initialSettings}
|
||||
onSave={(settings) => onSave(tool.name, settings).then(() => onOpenChange(false))}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
/>
|
||||
) : isWebFetch && tool ? (
|
||||
<WebFetchExtractorChainForm
|
||||
initialSettings={initialSettings}
|
||||
onSave={(settings) => onSave(tool.name, settings).then(() => onOpenChange(false))}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
type TtsProvider = "openai" | "elevenlabs" | "edge" | "minimax";
|
||||
type AutoMode = "off" | "inbound" | "tagged" | "always";
|
||||
|
||||
interface Props {
|
||||
initialSettings: Record<string, unknown>;
|
||||
onSave: (settings: Record<string, unknown>) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const TTS_PROVIDERS: TtsProvider[] = ["openai", "elevenlabs", "edge", "minimax"];
|
||||
const AUTO_MODES: AutoMode[] = ["off", "inbound", "tagged", "always"];
|
||||
|
||||
const AUTO_MODE_DESC_KEYS: Record<AutoMode, string> = {
|
||||
off: "builtin.ttsForm.autoOffDesc",
|
||||
inbound: "builtin.ttsForm.autoInboundDesc",
|
||||
tagged: "builtin.ttsForm.autoTaggedDesc",
|
||||
always: "builtin.ttsForm.autoAlwaysDesc",
|
||||
};
|
||||
|
||||
const AUTO_MODE_LABEL_KEYS: Record<AutoMode, string> = {
|
||||
off: "builtin.ttsForm.autoOff",
|
||||
inbound: "builtin.ttsForm.autoInbound",
|
||||
tagged: "builtin.ttsForm.autoTagged",
|
||||
always: "builtin.ttsForm.autoAlways",
|
||||
};
|
||||
|
||||
export function TtsProviderForm({ initialSettings, onSave, onCancel }: Props) {
|
||||
const { t } = useTranslation("tools");
|
||||
|
||||
const [primary, setPrimary] = useState<TtsProvider>(
|
||||
(initialSettings.primary as TtsProvider) ?? "openai",
|
||||
);
|
||||
const [auto, setAuto] = useState<AutoMode>(
|
||||
(initialSettings.auto as AutoMode) ?? "inbound",
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({ primary, auto });
|
||||
} catch {
|
||||
// toast shown by hook
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("builtin.ttsForm.title")}</DialogTitle>
|
||||
<DialogDescription>{t("builtin.ttsForm.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 my-4">
|
||||
{/* Primary provider */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm font-medium">{t("builtin.ttsForm.primary")}</Label>
|
||||
<Select value={primary} onValueChange={(v) => setPrimary(v as TtsProvider)}>
|
||||
<SelectTrigger className="text-base md:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TTS_PROVIDERS.map((p) => (
|
||||
<SelectItem key={p} value={p} className="text-base md:text-sm">
|
||||
{p.charAt(0).toUpperCase() + p.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Auto mode */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm font-medium">{t("builtin.ttsForm.auto")}</Label>
|
||||
<Select value={auto} onValueChange={(v) => setAuto(v as AutoMode)}>
|
||||
<SelectTrigger className="text-base md:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AUTO_MODES.map((mode) => (
|
||||
<SelectItem key={mode} value={mode} className="text-base md:text-sm">
|
||||
<div>
|
||||
<div className="font-medium">{t(AUTO_MODE_LABEL_KEYS[mode])}</div>
|
||||
<div className="text-xs text-muted-foreground">{t(AUTO_MODE_DESC_KEYS[mode])}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
{t("builtin.ttsForm.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{saving ? t("builtin.ttsForm.saving") : t("builtin.ttsForm.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { GripVertical, Lock, Loader2 } from "lucide-react";
|
||||
import { uniqueId } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||
|
||||
type ProviderKey = "exa" | "tavily" | "brave" | "duckduckgo";
|
||||
|
||||
interface ProviderEntry {
|
||||
id: string;
|
||||
name: ProviderKey;
|
||||
enabled: boolean;
|
||||
max_results?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initialSettings: Record<string, unknown>;
|
||||
onSave: (settings: Record<string, unknown>) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const SORTABLE_PROVIDERS: ProviderKey[] = ["exa", "tavily", "brave"];
|
||||
const LOCKED_PROVIDER: ProviderKey = "duckduckgo";
|
||||
const DEFAULT_ORDER: ProviderKey[] = ["exa", "tavily", "brave"];
|
||||
|
||||
const RAIL_COLOR: Record<ProviderKey, string> = {
|
||||
exa: "bg-blue-600",
|
||||
tavily: "bg-cyan-500",
|
||||
brave: "bg-orange-500",
|
||||
duckduckgo: "bg-slate-500",
|
||||
};
|
||||
|
||||
function parseInitialEntries(settings: Record<string, unknown>): ProviderEntry[] {
|
||||
const rawOrder = Array.isArray(settings.provider_order)
|
||||
? (settings.provider_order as string[]).filter((p): p is ProviderKey =>
|
||||
SORTABLE_PROVIDERS.includes(p as ProviderKey),
|
||||
)
|
||||
: DEFAULT_ORDER;
|
||||
|
||||
const sortable: ProviderEntry[] = rawOrder.map((name) => {
|
||||
const cfg = (settings[name] ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
id: uniqueId(),
|
||||
name,
|
||||
enabled: Boolean(cfg.enabled ?? true),
|
||||
max_results: cfg.max_results != null ? Number(cfg.max_results) : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return sortable;
|
||||
}
|
||||
|
||||
interface SortableCardProps {
|
||||
entry: ProviderEntry;
|
||||
index: number;
|
||||
onUpdate: (id: string, patch: Partial<ProviderEntry>) => void;
|
||||
}
|
||||
|
||||
function SortableProviderCard({ entry, index, onUpdate }: SortableCardProps) {
|
||||
const { t } = useTranslation("tools");
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: entry.id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
const displayName = t(`builtin.searchChain.providers.${entry.name}`, { defaultValue: entry.name });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`border rounded-lg bg-card flex overflow-hidden ${!entry.enabled ? "opacity-60" : ""}`}
|
||||
>
|
||||
<div className={`w-1 shrink-0 ${RAIL_COLOR[entry.name]}`} />
|
||||
<div className="flex-1 px-3 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-grab text-muted-foreground hover:text-foreground shrink-0"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground font-mono shrink-0">#{index + 1}</span>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={entry.enabled}
|
||||
onCheckedChange={(v) => onUpdate(entry.id, { enabled: v })}
|
||||
/>
|
||||
<span className="text-sm font-medium flex-1">{displayName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-2 pl-10">
|
||||
<Label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{t("builtin.searchChain.maxResults")}
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={entry.max_results ?? 5}
|
||||
onChange={(e) => onUpdate(entry.id, { max_results: Number(e.target.value) })}
|
||||
className="h-7 w-16 text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LockedDuckDuckGoCard({ settings }: { settings: Record<string, unknown> }) {
|
||||
const { t } = useTranslation("tools");
|
||||
const cfg = (settings[LOCKED_PROVIDER] ?? {}) as Record<string, unknown>;
|
||||
const enabled = Boolean(cfg.enabled ?? true);
|
||||
|
||||
return (
|
||||
<div className={`border rounded-lg bg-card flex overflow-hidden ${!enabled ? "opacity-60" : ""}`}>
|
||||
<div className={`w-1 shrink-0 ${RAIL_COLOR.duckduckgo}`} />
|
||||
<div className="flex-1 px-3 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="size-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs text-muted-foreground font-mono shrink-0">#4</span>
|
||||
<Switch size="sm" checked disabled />
|
||||
<span className="text-sm font-medium flex-1">
|
||||
{t("builtin.searchChain.providers.duckduckgo")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{t("builtin.searchChain.locked")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WebSearchChainForm({ initialSettings, onSave, onCancel }: Props) {
|
||||
const { t } = useTranslation("tools");
|
||||
const [entries, setEntries] = useState<ProviderEntry[]>(() =>
|
||||
parseInitialEntries(initialSettings),
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
setEntries((prev) => {
|
||||
const oldIndex = prev.findIndex((e) => e.id === active.id);
|
||||
const newIndex = prev.findIndex((e) => e.id === over.id);
|
||||
return arrayMove(prev, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleUpdate = useCallback((id: string, patch: Partial<ProviderEntry>) => {
|
||||
setEntries((prev) => prev.map((e) => (e.id === id ? { ...e, ...patch } : e)));
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const providerOrder = entries.map((e) => e.name);
|
||||
const settings: Record<string, unknown> = { provider_order: providerOrder };
|
||||
for (const entry of entries) {
|
||||
const cfg: Record<string, unknown> = { enabled: entry.enabled };
|
||||
if (entry.max_results != null) cfg.max_results = entry.max_results;
|
||||
settings[entry.name] = cfg;
|
||||
}
|
||||
settings[LOCKED_PROVIDER] = { enabled: true };
|
||||
await onSave(settings);
|
||||
} catch {
|
||||
// toast shown by hook
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("builtin.searchChain.title")}</DialogTitle>
|
||||
<DialogDescription>{t("builtin.searchChain.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2 max-h-[60vh] overflow-y-auto pr-1 my-4">
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={entries.map((e) => e.id)} strategy={verticalListSortingStrategy}>
|
||||
{entries.map((entry, index) => (
|
||||
<SortableProviderCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
index={index}
|
||||
onUpdate={handleUpdate}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<LockedDuckDuckGoCard settings={initialSettings} />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
{t("builtin.searchChain.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{saving ? t("builtin.searchChain.saving") : t("builtin.searchChain.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user