fix: 智能体对应
This commit is contained in:
1 parent
e9e4c181b9
commit
be4b180d92
3 files changed
+513
No files matched your search
@@ -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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<string>();
|
||||||
|
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<DomainAgentMapping[]>([]);
|
||||||
|
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<DomainAgentMapping>) => {
|
||||||
|
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 (
|
||||||
|
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-6 py-10">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">插件管理</h1>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
管理插件传入页面地址的域名与默认智能体映射。命中后,插件ZNCM模式会按配置默认选择对应智能体。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-destructive rounded-xl border border-dashed py-16 text-center text-sm">
|
||||||
|
加载失败:{error.message}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">域名和智能体配置</div>
|
||||||
|
<div className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
输入域名或完整 URL,保存时会自动规范化为 host。只按域名精确匹配。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setRows((current) => [...current, makeRow()])}
|
||||||
|
>
|
||||||
|
<PlusIcon className="mr-1.5 size-4" />
|
||||||
|
新增配置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_120px_72px] gap-3 text-sm font-medium">
|
||||||
|
<div>域名</div>
|
||||||
|
<div>智能体</div>
|
||||||
|
<div>启用</div>
|
||||||
|
<div>操作</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.id}
|
||||||
|
className="grid grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_120px_72px] gap-3"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={row.domain}
|
||||||
|
onChange={(e) => updateRow(row.id, { domain: e.target.value })}
|
||||||
|
placeholder="如:https://example.com/path 或 example.com"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={row.agentId}
|
||||||
|
onChange={(e) => updateRow(row.id, { agentId: e.target.value })}
|
||||||
|
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={agentsLoading}
|
||||||
|
>
|
||||||
|
<option value="">请选择智能体</option>
|
||||||
|
{agentOptions.map((agent) => (
|
||||||
|
<option key={agent.id} value={agent.id}>
|
||||||
|
{agent.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="flex h-9 items-center">
|
||||||
|
<Switch
|
||||||
|
checked={row.enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateRow(row.id, { enabled: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-9 items-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => removeRow(row.id)}
|
||||||
|
aria-label="删除配置"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleSave} disabled={update.isPending}>
|
||||||
|
{update.isPending && (
|
||||||
|
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user