diff --git a/frontend-web/src/core/plugin-management/domain-agent.test.mjs b/frontend-web/src/core/plugin-management/domain-agent.test.mjs new file mode 100644 index 00000000..eb82f447 --- /dev/null +++ b/frontend-web/src/core/plugin-management/domain-agent.test.mjs @@ -0,0 +1,168 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + normalizeDomainInput, + resolveIncomingIframeSelection, + resolveConfiguredIframeAgentId, + resolveDomainMappedAgentId, +} from "./domain-agent.ts"; + +test("normalizes full urls and bare hosts to comparable domains", () => { + assert.equal( + normalizeDomainInput(" https://Foo.Example.com/path?q=1 "), + "foo.example.com", + ); + assert.equal( + normalizeDomainInput("foo.example.com:8080/landing"), + "foo.example.com:8080", + ); +}); + +test("resolves the configured agent for a matching pageContext url", () => { + assert.equal( + resolveDomainMappedAgentId({ + pageUrl: "https://foo.example.com/article/123", + mappings: [ + { + id: "m1", + domain: "foo.example.com", + agentId: "agent-1", + enabled: true, + }, + ], + availableAgentIds: ["agent-1", "agent-2"], + }), + "agent-1", + ); +}); + +test("ignores disabled mappings and unavailable agents", () => { + assert.equal( + resolveDomainMappedAgentId({ + pageUrl: "https://foo.example.com/article/123", + mappings: [ + { + id: "m1", + domain: "foo.example.com", + agentId: "agent-1", + enabled: false, + }, + { + id: "m2", + domain: "bar.example.com", + agentId: "agent-2", + enabled: true, + }, + ], + availableAgentIds: ["agent-3"], + }), + undefined, + ); +}); + +test("does not auto-select after manual selection or once a conversation has started", () => { + assert.equal( + resolveConfiguredIframeAgentId({ + pageUrl: "https://foo.example.com", + mappings: [ + { + id: "m1", + domain: "foo.example.com", + agentId: "agent-1", + enabled: true, + }, + ], + availableAgentIds: ["agent-1"], + isNewThread: true, + hasStartedConversation: false, + manualSelectionLocked: true, + }), + undefined, + ); + + assert.equal( + resolveConfiguredIframeAgentId({ + pageUrl: "https://foo.example.com", + mappings: [ + { + id: "m1", + domain: "foo.example.com", + agentId: "agent-1", + enabled: true, + }, + ], + availableAgentIds: ["agent-1"], + isNewThread: true, + hasStartedConversation: true, + manualSelectionLocked: false, + }), + undefined, + ); +}); + +test("falls back to default assistant when url is unmapped and user has not manually selected an agent", () => { + assert.deepEqual( + resolveIncomingIframeSelection({ + pageUrl: "https://unknown.example.com", + mappings: [ + { + id: "m1", + domain: "foo.example.com", + agentId: "agent-1", + enabled: true, + }, + ], + availableAgentIds: ["agent-1", "agent-2"], + manualSelectedAgentId: null, + }), + { + selectedAgentId: null, + forceDefaultAssistant: true, + }, + ); +}); + +test("falls back to the user's manual selection when url is unmapped", () => { + assert.deepEqual( + resolveIncomingIframeSelection({ + pageUrl: "https://unknown.example.com", + mappings: [ + { + id: "m1", + domain: "foo.example.com", + agentId: "agent-1", + enabled: true, + }, + ], + availableAgentIds: ["agent-1", "agent-2"], + manualSelectedAgentId: "agent-2", + }), + { + selectedAgentId: "agent-2", + forceDefaultAssistant: false, + }, + ); +}); + +test("mapped domain overrides the user's manual selection for that incoming message", () => { + assert.deepEqual( + resolveIncomingIframeSelection({ + pageUrl: "https://foo.example.com/article", + mappings: [ + { + id: "m1", + domain: "foo.example.com", + agentId: "agent-1", + enabled: true, + }, + ], + availableAgentIds: ["agent-1", "agent-2"], + manualSelectedAgentId: "agent-2", + }), + { + selectedAgentId: "agent-1", + forceDefaultAssistant: false, + }, + ); +}); diff --git a/frontend-web/src/core/plugin-management/domain-agent.ts b/frontend-web/src/core/plugin-management/domain-agent.ts new file mode 100644 index 00000000..59da44fc --- /dev/null +++ b/frontend-web/src/core/plugin-management/domain-agent.ts @@ -0,0 +1,135 @@ +export type DomainAgentMapping = { + id: string; + domain: string; + agentId: string; + enabled: boolean; +}; + +function trimToUndefined(value: string | null | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function normalizeAgentId(agentId: string | null | undefined): string | undefined { + const trimmed = agentId?.trim(); + return trimmed ? trimmed : undefined; +} + +export function normalizeDomainInput( + value: string | null | undefined, +): string | undefined { + const trimmed = trimToUndefined(value); + if (!trimmed) return undefined; + + const candidate = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(trimmed) + ? trimmed + : `https://${trimmed}`; + + try { + const parsed = new URL(candidate); + const host = parsed.host.trim().toLowerCase(); + return host || undefined; + } catch { + return undefined; + } +} + +export function resolveDomainMappedAgentId({ + pageUrl, + mappings, + availableAgentIds, +}: { + pageUrl: string | null | undefined; + mappings: DomainAgentMapping[]; + availableAgentIds?: readonly string[] | null; +}): string | undefined { + const host = normalizeDomainInput(pageUrl); + if (!host) return undefined; + + const allowedAgents = + availableAgentIds && availableAgentIds.length > 0 + ? new Set(availableAgentIds) + : null; + + for (const mapping of mappings) { + if (!mapping.enabled) continue; + if (normalizeDomainInput(mapping.domain) !== host) continue; + if (allowedAgents && !allowedAgents.has(mapping.agentId)) continue; + return mapping.agentId; + } + + return undefined; +} + +export function resolveConfiguredIframeAgentId({ + pageUrl, + mappings, + availableAgentIds, + isNewThread, + hasStartedConversation, + manualSelectionLocked, +}: { + pageUrl: string | null | undefined; + mappings: DomainAgentMapping[]; + availableAgentIds?: readonly string[] | null; + isNewThread: boolean; + hasStartedConversation: boolean; + manualSelectionLocked: boolean; +}): string | undefined { + if (!isNewThread || hasStartedConversation || manualSelectionLocked) { + return undefined; + } + + return resolveDomainMappedAgentId({ + pageUrl, + mappings, + availableAgentIds, + }); +} + +export function resolveIncomingIframeSelection({ + pageUrl, + mappings, + availableAgentIds, + manualSelectedAgentId, +}: { + pageUrl: string | null | undefined; + mappings: DomainAgentMapping[]; + availableAgentIds?: readonly string[] | null; + manualSelectedAgentId: string | null; +}): { + selectedAgentId: string | null; + forceDefaultAssistant: boolean; +} { + const mappedAgentId = resolveDomainMappedAgentId({ + pageUrl, + mappings, + availableAgentIds, + }); + if (mappedAgentId) { + return { + selectedAgentId: mappedAgentId, + forceDefaultAssistant: false, + }; + } + + const allowedAgents = + availableAgentIds && availableAgentIds.length > 0 + ? new Set(availableAgentIds) + : null; + const normalizedManualAgentId = normalizeAgentId(manualSelectedAgentId); + if ( + normalizedManualAgentId && + (!allowedAgents || allowedAgents.has(normalizedManualAgentId)) + ) { + return { + selectedAgentId: normalizedManualAgentId, + forceDefaultAssistant: false, + }; + } + + return { + selectedAgentId: null, + forceDefaultAssistant: true, + }; +} diff --git a/frontend-web/src/pages/PluginManagementPage.tsx b/frontend-web/src/pages/PluginManagementPage.tsx new file mode 100644 index 00000000..0faf29b9 --- /dev/null +++ b/frontend-web/src/pages/PluginManagementPage.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { Loader2Icon, PlusIcon, Trash2Icon } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { useAgentSelector } from "@/core/agents"; +import { + type DomainAgentMapping, + normalizeDomainInput, +} from "@/core/plugin-management/domain-agent"; +import type { PluginManagementSettings } from "@/core/system-settings/api"; +import { + usePluginManagementSettings, + useUpdatePluginManagementSettings, +} from "@/core/system-settings/hooks"; + +function makeRow(): DomainAgentMapping { + return { + id: + typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, + domain: "", + agentId: "", + enabled: true, + }; +} + +function normalizeRows( + rows: DomainAgentMapping[], +): PluginManagementSettings["domainAgentMappings"] { + const seen = new Set(); + const next: PluginManagementSettings["domainAgentMappings"] = []; + for (const row of rows) { + const domain = normalizeDomainInput(row.domain); + const agentId = row.agentId.trim(); + if (!domain || !agentId) continue; + if (seen.has(domain)) continue; + seen.add(domain); + next.push({ + id: row.id || makeRow().id, + domain, + agentId, + enabled: row.enabled, + }); + } + return next; +} + +export default function PluginManagementPage() { + const { data, isLoading, error } = usePluginManagementSettings(); + const update = useUpdatePluginManagementSettings(); + const { agents, isLoading: agentsLoading } = useAgentSelector(); + const [rows, setRows] = useState([]); + const initializedRef = useRef(false); + + useEffect(() => { + if (!data || initializedRef.current) return; + setRows( + data.domainAgentMappings.length > 0 + ? data.domainAgentMappings + : [makeRow()], + ); + initializedRef.current = true; + }, [data]); + + const agentOptions = useMemo( + () => + [...agents] + .sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id, "zh-CN")) + .map((agent) => ({ + id: agent.id, + label: agent.name || agent.id, + })), + [agents], + ); + + const updateRow = (id: string, patch: Partial) => { + setRows((current) => + current.map((row) => (row.id === id ? { ...row, ...patch } : row)), + ); + }; + + const removeRow = (id: string) => { + setRows((current) => { + const next = current.filter((row) => row.id !== id); + return next.length > 0 ? next : [makeRow()]; + }); + }; + + const handleSave = async () => { + const domainAgentMappings = normalizeRows(rows); + try { + const saved = await update.mutateAsync({ domainAgentMappings }); + setRows( + saved.domainAgentMappings.length > 0 + ? saved.domainAgentMappings + : [makeRow()], + ); + toast.success("已保存"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "保存失败"); + } + }; + + return ( +
+
+

插件管理

+

+ 管理插件传入页面地址的域名与默认智能体映射。命中后,插件ZNCM模式会按配置默认选择对应智能体。 +

+
+ + {isLoading ? ( +
+ +
+ ) : error ? ( +
+ 加载失败:{error.message} +
+ ) : ( +
+
+
+
域名和智能体配置
+
+ 输入域名或完整 URL,保存时会自动规范化为 host。只按域名精确匹配。 +
+
+ +
+ +
+
域名
+
智能体
+
启用
+
操作
+
+ +
+ {rows.map((row) => ( +
+ updateRow(row.id, { domain: e.target.value })} + placeholder="如:https://example.com/path 或 example.com" + /> + +
+ + updateRow(row.id, { enabled: checked }) + } + /> +
+
+ +
+
+ ))} +
+ +
+ +
+
+ )} +
+ ); +}