This commit is contained in:
fengxiangdebaozi committed 2026-06-29 17:39:52 +08:00
commit 45ce14e15a
13 files changed
+1011 -60

No files matched your search

@@ -20,6 +20,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { PromptInputButton } from "@/components/ai-elements/prompt-input"; import { PromptInputButton } from "@/components/ai-elements/prompt-input";
import { AgentManageDialog } from "@/components/workspace/agents/agent-manage-dialog"; import { AgentManageDialog } from "@/components/workspace/agents/agent-manage-dialog";
import { Tooltip } from "@/components/workspace/tooltip";
import { useAgentSelector, useAgentsPaginated } from "@/core/agents"; import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
import type { Agent } from "@/core/agents"; import type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
@@ -86,6 +87,7 @@ export function AgentSelector({
}; };
const triggerLabel = selectedAgent ? desensitize(selectedAgent.name) : "选择 Agent"; const triggerLabel = selectedAgent ? desensitize(selectedAgent.name) : "选择 Agent";
const iconTooltipLabel = selectedAgent ? triggerLabel : "未选择";
const iconMode = triggerVariant === "icon"; const iconMode = triggerVariant === "icon";
return ( return (
@@ -93,38 +95,40 @@ export function AgentSelector({
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
{iconMode ? ( {iconMode ? (
<Button <Tooltip content={iconTooltipLabel}>
type="button" <Button
variant="ghost" type="button"
size="icon" variant="ghost"
disabled={disabled} size="icon"
aria-label={selectedAgent ? `已选择智能体:${triggerLabel}` : "选择智能体"} disabled={disabled}
className={cn( aria-label={selectedAgent ? `已选择智能体:${triggerLabel}` : "选择智能体"}
"relative size-9 rounded-full text-muted-foreground shadow-none hover:bg-muted/60 hover:text-foreground", className={cn(
selectedAgent && "text-amber-600 hover:text-amber-700", "relative size-9 rounded-full text-muted-foreground shadow-none hover:bg-muted/60 hover:text-foreground",
selectedAgent && iconActiveClassName, selectedAgent && "text-amber-600 hover:text-amber-700",
iconButtonClassName, selectedAgent && iconActiveClassName,
)} iconButtonClassName,
> )}
<BotIcon className="size-4" /> >
{selectedAgent && ( <BotIcon className="size-4" />
<span {selectedAgent && (
role="button" <span
tabIndex={0} role="button"
aria-label="清除已选 agent" tabIndex={0}
className="absolute -right-1 -top-1 inline-flex size-4 items-center justify-center rounded-full border bg-background text-foreground shadow-sm hover:bg-muted" aria-label="清除已选 agent"
onClick={handleClear} className="absolute -right-1 -top-1 inline-flex size-4 items-center justify-center rounded-full border bg-background text-foreground shadow-sm hover:bg-muted"
onKeyDown={(e) => { onClick={handleClear}
if (e.key === "Enter" || e.key === " ") { onKeyDown={(e) => {
e.preventDefault(); if (e.key === "Enter" || e.key === " ") {
onAgentChange(null); e.preventDefault();
} onAgentChange(null);
}} }
> }}
<XIcon className="size-2.5" /> >
</span> <XIcon className="size-2.5" />
)} </span>
</Button> )}
</Button>
</Tooltip>
) : ( ) : (
<PromptInputButton <PromptInputButton
type="button" type="button"
@@ -11,6 +11,7 @@ import {
GaugeIcon, GaugeIcon,
HistoryIcon, HistoryIcon,
MessageCircleQuestionIcon, MessageCircleQuestionIcon,
PlugIcon,
ScrollTextIcon, ScrollTextIcon,
Settings2Icon, Settings2Icon,
SettingsIcon, SettingsIcon,
@@ -218,6 +219,14 @@ export function WorkspaceNavMenu() {
<TagsIcon /> <TagsIcon />
{t.adminTags.title} {t.adminTags.title}
</DropdownMenuItem> </DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/plugins")}
>
<PlugIcon />
插件管理
</DropdownMenuItem>
)}
{isAdmin && ( {isAdmin && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/positions")} onClick={() => navigate("/page/workspace/admin/positions")}
@@ -0,0 +1,190 @@
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,
},
);
});
test("unmapped incoming url resets auto-selected agent back to default when there is no manual selection", () => {
assert.deepEqual(
resolveIncomingIframeSelection({
pageUrl: "https://another-unknown.example.com/article",
mappings: [
{
id: "m1",
domain: "foo.example.com",
agentId: "agent-1",
enabled: true,
},
],
availableAgentIds: ["agent-1", "agent-2"],
manualSelectedAgentId: null,
}),
{
selectedAgentId: null,
forceDefaultAssistant: true,
},
);
});
@@ -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,
};
}
@@ -8,12 +8,115 @@ export interface PromptPrefixSettings {
prompt_prefix: string; prompt_prefix: string;
} }
export interface PluginDomainAgentMappingDto {
id: string;
domain: string;
agent_id: string;
enabled: boolean;
}
export interface PluginManagementSettings {
domainAgentMappings: Array<{
id: string;
domain: string;
agentId: string;
enabled: boolean;
}>;
}
export interface PluginManagementUpdate {
domainAgentMappings?: Array<{
id: string;
domain: string;
agentId: string;
enabled: boolean;
}>;
}
export interface PromptPrefixUpdate { export interface PromptPrefixUpdate {
enabled?: boolean; enabled?: boolean;
switch_label?: string; switch_label?: string;
prompt_prefix?: string; prompt_prefix?: string;
} }
function fromPluginManagementDto(data: {
domain_agent_mappings: PluginDomainAgentMappingDto[];
}): PluginManagementSettings {
return {
domainAgentMappings: (data.domain_agent_mappings ?? []).map((item) => ({
id: item.id,
domain: item.domain,
agentId: item.agent_id,
enabled: item.enabled,
})),
};
}
function toPluginManagementDto(body: PluginManagementUpdate) {
return {
domain_agent_mappings: (body.domainAgentMappings ?? []).map((item) => ({
id: item.id,
domain: item.domain,
agent_id: item.agentId,
enabled: item.enabled,
})),
};
}
export async function loadPluginManagementSettings(): Promise<PluginManagementSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/plugin-management`,
);
if (!response.ok) {
throw new Error(
`Failed to load plugin management settings: ${response.status}`,
);
}
const data = (await response.json()) as {
domain_agent_mappings: PluginDomainAgentMappingDto[];
};
const mapped = fromPluginManagementDto(data);
writeYLog({
optType: OptType.view,
optDetail: "CM助手-全量-系统设置-插件管理查看",
url: "/api/system-settings/plugin-management",
returnParam: mapped,
});
return mapped;
}
export async function updatePluginManagementSettings(
body: PluginManagementUpdate,
): Promise<PluginManagementSettings> {
const payload = toPluginManagementDto(body);
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/plugin-management`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update plugin management settings" }));
throw new Error(detail.detail ?? "Failed to update plugin management settings");
}
const data = (await response.json()) as {
domain_agent_mappings: PluginDomainAgentMappingDto[];
};
const mapped = fromPluginManagementDto(data);
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-插件管理编辑",
url: "/api/system-settings/plugin-management",
reqParam: payload,
returnParam: mapped,
});
return mapped;
}
export async function loadPromptPrefixSettings(): Promise<PromptPrefixSettings> { export async function loadPromptPrefixSettings(): Promise<PromptPrefixSettings> {
const response = await apiFetch( const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/prompt-prefix`, `${getBackendBaseURL()}/api/system-settings/prompt-prefix`,
@@ -8,6 +8,7 @@ import {
loadCitationDisplaySettings, loadCitationDisplaySettings,
loadLeaderboardSettings, loadLeaderboardSettings,
loadModelConcurrencySettings, loadModelConcurrencySettings,
loadPluginManagementSettings,
loadPrivateSquareStatus, loadPrivateSquareStatus,
loadPromptPrefixSettings, loadPromptPrefixSettings,
loadPublishSquares, loadPublishSquares,
@@ -17,6 +18,7 @@ import {
updateCitationDisplaySettings, updateCitationDisplaySettings,
updateLeaderboardSettings, updateLeaderboardSettings,
updateModelConcurrencySettings, updateModelConcurrencySettings,
updatePluginManagementSettings,
updatePrivateSquareSettings, updatePrivateSquareSettings,
updatePromptPrefixSettings, updatePromptPrefixSettings,
updatePublishSquares, updatePublishSquares,
@@ -25,6 +27,7 @@ import {
type CitationDisplayUpdate, type CitationDisplayUpdate,
type LeaderboardSettingsUpdate, type LeaderboardSettingsUpdate,
type ModelConcurrencyUpdate, type ModelConcurrencyUpdate,
type PluginManagementUpdate,
type PrivateSquareUpdate, type PrivateSquareUpdate,
type PromptPrefixUpdate, type PromptPrefixUpdate,
type PublishSquaresUpdate, type PublishSquaresUpdate,
@@ -41,6 +44,7 @@ const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const; const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const; const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const; const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const;
const PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] as const;
export function usePromptPrefixSettings() { export function usePromptPrefixSettings() {
return useQuery({ return useQuery({
@@ -263,6 +267,27 @@ export function useUpdateModelConcurrencySettings() {
}); });
} }
export function usePluginManagementSettings() {
return useQuery({
queryKey: PLUGIN_MANAGEMENT_KEY,
queryFn: () => loadPluginManagementSettings(),
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
export function useUpdatePluginManagementSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: PluginManagementUpdate) =>
updatePluginManagementSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(PLUGIN_MANAGEMENT_KEY, data);
},
});
}
export function useLeaderboardSettings() { export function useLeaderboardSettings() {
return useQuery({ return useQuery({
queryKey: LEADERBOARD_KEY, queryKey: LEADERBOARD_KEY,
+163 -28
View File
@@ -26,18 +26,19 @@ import { Tooltip } from "@/components/workspace/tooltip";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { useAgentSelector } from "@/core/agents"; import { useAgentSelector } from "@/core/agents";
import { resolveIncomingIframeSelection } from "@/core/plugin-management/domain-agent";
import { getAccessToken } from "@/core/auth"; import { getAccessToken } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks"; import { useNotification } from "@/core/notification/hooks";
import { useRouteBase } from "@/core/route-base"; import { useRouteBase } from "@/core/route-base";
import { import {
getLastAgentId,
isThreadRagModeLocked, isThreadRagModeLocked,
saveLastAgentId, saveLastAgentId,
useLocalSettings, useLocalSettings,
useThreadSettings, useThreadSettings,
} from "@/core/settings"; } from "@/core/settings";
import { usePluginManagementSettings } from "@/core/system-settings/hooks";
import { useThreadStream } from "@/core/threads/hooks"; import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env"; import { env } from "@/env";
@@ -50,6 +51,22 @@ import { resolveIframeAssistantId } from "./iframe-chat-agent";
import { buildIframeModeButtons } from "./iframe-chat-mode-buttons"; import { buildIframeModeButtons } from "./iframe-chat-mode-buttons";
type BrainyChatStatus = "started" | "finished"; type BrainyChatStatus = "started" | "finished";
type ResolvedIframeModeButton =
| {
key: "agent";
label: string;
active: boolean;
disabled?: boolean;
}
| {
key: "writing" | "memory" | "references" | "contextCompression" | "pro";
label: string;
active: boolean;
disabled?: boolean;
icon: typeof FileTextIcon;
onClick: () => void;
activeClassName: string;
};
type BrainyAuthTokenMessage = { type BrainyAuthTokenMessage = {
source: "brainy-iframe"; source: "brainy-iframe";
type: "brainy:auth-token"; type: "brainy:auth-token";
@@ -66,6 +83,20 @@ type BrainyChatInterruptMessage = {
}; };
requestId?: string; requestId?: string;
}; };
type BrainyUrlContextMessage = {
source?: string;
type?: string;
payload?: {
url?: string;
};
};
type PendingIframeSubmit = {
message: PromptInputMessage;
externalContext?: Record<string, unknown>;
options?: { additionalKwargs?: Record<string, unknown> };
targetSelectedAgentId: string | null;
targetForceDefaultAssistant: boolean;
};
function postBrainyChatStatus(status: BrainyChatStatus, requestId: string) { function postBrainyChatStatus(status: BrainyChatStatus, requestId: string) {
window.parent?.postMessage( window.parent?.postMessage(
@@ -104,9 +135,22 @@ export default function IframeChatPage() {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const { tokenUsageEnabled } = useModels(); const { tokenUsageEnabled } = useModels();
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null); const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [manualSelectedAgentId, setManualSelectedAgentId] = useState<string | null>(null);
const [forceDefaultAssistant, setForceDefaultAssistant] = useState(true);
const [pendingIframeSubmit, setPendingIframeSubmit] =
useState<PendingIframeSubmit | null>(null);
const { agents: selectorAgents, isLoading: selectorLoading } = const { agents: selectorAgents, isLoading: selectorLoading } =
useAgentSelector(); useAgentSelector();
const { data: pluginManagementSettings } = usePluginManagementSettings();
const pendingRequestIdRef = useRef<string | null>(null); const pendingRequestIdRef = useRef<string | null>(null);
const handleSubmitRef = useRef<
| ((
message: PromptInputMessage,
externalContext?: Record<string, unknown>,
options?: { additionalKwargs?: Record<string, unknown> },
) => void)
| null
>(null);
const { selectedArtifact } = useArtifacts(); const { selectedArtifact } = useArtifacts();
useSpecificChatMode(); useSpecificChatMode();
@@ -139,23 +183,37 @@ export default function IframeChatPage() {
useEffect(() => { useEffect(() => {
if (isNewThread) { if (isNewThread) {
setHasStartedConversation(false); setHasStartedConversation(false);
setSelectedAgentId(getLastAgentId()); setSelectedAgentId(null);
setManualSelectedAgentId(null);
setForceDefaultAssistant(true);
setPendingIframeSubmit(null);
} }
}, [isNewThread, threadId]); }, [isNewThread, threadId]);
useEffect(() => { useEffect(() => {
if (selectorLoading || !selectedAgentId) { if (selectorLoading) {
return; return;
} }
if (!selectorAgents.some((agent) => agent.id === selectedAgentId)) { if (
selectedAgentId &&
!selectorAgents.some((agent) => agent.id === selectedAgentId)
) {
setSelectedAgentId(null); setSelectedAgentId(null);
setForceDefaultAssistant(true);
} }
}, [selectedAgentId, selectorAgents, selectorLoading]); if (
manualSelectedAgentId &&
!selectorAgents.some((agent) => agent.id === manualSelectedAgentId)
) {
setManualSelectedAgentId(null);
}
}, [manualSelectedAgentId, selectedAgentId, selectorAgents, selectorLoading]);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const activeAssistantId = resolveIframeAssistantId({ const activeAssistantId = resolveIframeAssistantId({
selectedAgentId, selectedAgentId,
threadMetadataAgentId: null, threadMetadataAgentId: null,
preferDefaultAssistant: forceDefaultAssistant,
}); });
const [thread, sendMessage, isUploading] = useThreadStream({ const [thread, sendMessage, isUploading] = useThreadStream({
@@ -209,14 +267,18 @@ export default function IframeChatPage() {
})(); })();
useEffect(() => { useEffect(() => {
const resolvedAgentId = resolveIframeAssistantId({ if (!threadMetadataAgentId || isNewThread) return;
selectedAgentId, if (manualSelectedAgentId) return;
threadMetadataAgentId, if (forceDefaultAssistant) return;
}); if (selectedAgentId) return;
if (resolvedAgentId && resolvedAgentId !== selectedAgentId) { setSelectedAgentId(threadMetadataAgentId);
setSelectedAgentId(resolvedAgentId); }, [
} forceDefaultAssistant,
}, [selectedAgentId, threadMetadataAgentId]); isNewThread,
manualSelectedAgentId,
selectedAgentId,
threadMetadataAgentId,
]);
const writingArtifactPath = useMemo(() => { const writingArtifactPath = useMemo(() => {
const isMarkdown = (path: string | null | undefined) => const isMarkdown = (path: string | null | undefined) =>
@@ -277,10 +339,43 @@ export default function IframeChatPage() {
); );
const handleAgentChange = useCallback((agentId: string | null) => { const handleAgentChange = useCallback((agentId: string | null) => {
setManualSelectedAgentId(agentId);
setSelectedAgentId(agentId); setSelectedAgentId(agentId);
setForceDefaultAssistant(!agentId);
saveLastAgentId(agentId); saveLastAgentId(agentId);
}, []); }, []);
useEffect(() => {
handleSubmitRef.current = handleSubmit;
}, [handleSubmit]);
useEffect(() => {
if (!pendingIframeSubmit) return;
const currentAssistantId = resolveIframeAssistantId({
selectedAgentId,
threadMetadataAgentId: null,
preferDefaultAssistant: forceDefaultAssistant,
});
const targetAssistantId = resolveIframeAssistantId({
selectedAgentId: pendingIframeSubmit.targetSelectedAgentId,
threadMetadataAgentId: null,
preferDefaultAssistant: pendingIframeSubmit.targetForceDefaultAssistant,
});
if (
currentAssistantId !== targetAssistantId ||
selectedAgentId !== pendingIframeSubmit.targetSelectedAgentId ||
forceDefaultAssistant !== pendingIframeSubmit.targetForceDefaultAssistant
) {
return;
}
handleSubmitRef.current?.(
pendingIframeSubmit.message,
pendingIframeSubmit.externalContext,
pendingIframeSubmit.options,
);
setPendingIframeSubmit(null);
}, [forceDefaultAssistant, pendingIframeSubmit, selectedAgentId]);
const ragModeLocked = isThreadRagModeLocked(threadId); const ragModeLocked = isThreadRagModeLocked(threadId);
const ragModeEnabled = const ragModeEnabled =
localSettings.display.ragModeEnabled || ragModeLocked; localSettings.display.ragModeEnabled || ragModeLocked;
@@ -341,9 +436,33 @@ export default function IframeChatPage() {
[thread], [thread],
); );
const applyIncomingUrlSelection = useCallback(
(pageUrl: string | null | undefined) => {
const nextSelection = resolveIncomingIframeSelection({
pageUrl,
mappings: pluginManagementSettings?.domainAgentMappings ?? [],
availableAgentIds: selectorLoading
? undefined
: selectorAgents.map((agent) => agent.id),
manualSelectedAgentId,
});
setSelectedAgentId(nextSelection.selectedAgentId);
setForceDefaultAssistant(nextSelection.forceDefaultAssistant);
return nextSelection;
},
[
manualSelectedAgentId,
pluginManagementSettings?.domainAgentMappings,
selectorAgents,
selectorLoading,
],
);
useEffect(() => { useEffect(() => {
const handleMessage = ( const handleMessage = (
event: MessageEvent<BrainyChatSubmitMessage | BrainyChatInterruptMessage>, event: MessageEvent<
BrainyChatSubmitMessage | BrainyChatInterruptMessage | BrainyUrlContextMessage
>,
) => { ) => {
const data = event.data; const data = event.data;
if (data?.source !== "brainy-extension") return; if (data?.source !== "brainy-extension") return;
@@ -351,12 +470,19 @@ export default function IframeChatPage() {
void handleInterrupt(data as BrainyChatInterruptMessage); void handleInterrupt(data as BrainyChatInterruptMessage);
return; return;
} }
if (data?.type === "brainy:context-url") {
applyIncomingUrlSelection(
(data as BrainyUrlContextMessage).payload?.url ?? null,
);
return;
}
if (data?.type !== "brainy:chat-submit") return; if (data?.type !== "brainy:chat-submit") return;
const submitMessage = data as BrainyChatSubmitMessage; const submitMessage = data as BrainyChatSubmitMessage;
const { submitText, files, iframeContext, additionalKwargs } = const { submitText, files, iframeContext, additionalKwargs } =
parseBrainyChatSubmitMessage(submitMessage); parseBrainyChatSubmitMessage(submitMessage);
if (!submitText && files.length === 0) return;
console.log("收到插件消息", { console.log("收到插件消息", {
text: submitMessage.payload?.text, text: submitMessage.payload?.text,
prompt: submitMessage.payload?.prompt, prompt: submitMessage.payload?.prompt,
@@ -368,26 +494,30 @@ export default function IframeChatPage() {
iframeContext, iframeContext,
}); });
if (!submitText && files.length === 0) return;
pendingRequestIdRef.current = submitMessage.requestId ?? null; pendingRequestIdRef.current = submitMessage.requestId ?? null;
setPendingIframeSubmit({
handleSubmit( message: { text: submitText, files },
{ text: submitText, files }, externalContext: iframeContext,
iframeContext, options: additionalKwargs ? { additionalKwargs } : undefined,
additionalKwargs ? { additionalKwargs } : undefined, targetSelectedAgentId: selectedAgentId,
); targetForceDefaultAssistant: forceDefaultAssistant,
});
}; };
window.addEventListener("message", handleMessage); window.addEventListener("message", handleMessage);
return () => { return () => {
window.removeEventListener("message", handleMessage); window.removeEventListener("message", handleMessage);
}; };
}, [handleInterrupt, handleSubmit]); }, [
applyIncomingUrlSelection,
forceDefaultAssistant,
handleInterrupt,
selectedAgentId,
]);
const isLandingState = isNewThread && !hasStartedConversation; const isLandingState = isNewThread && !hasStartedConversation;
const bottomBarHeightClass = "pb-24"; const bottomBarHeightClass = "pb-24";
const modeButtons = buildIframeModeButtons({ const modeButtons: ResolvedIframeModeButton[] = buildIframeModeButtons({
agentSelected: !!selectedAgentId, agentSelected: !!selectedAgentId,
writingModeEnabled: !!settings.context.writing_mode, writingModeEnabled: !!settings.context.writing_mode,
memoryInjectionEnabled: Boolean( memoryInjectionEnabled: Boolean(
@@ -397,10 +527,15 @@ export default function IframeChatPage() {
ragModeLocked, ragModeLocked,
contextCompressionEnabled, contextCompressionEnabled,
proModeEnabled, proModeEnabled,
}).map((button) => { }).map((button): ResolvedIframeModeButton => {
switch (button.key) { switch (button.key) {
case "agent": case "agent":
return button; return {
key: "agent",
label: button.label,
active: button.active,
disabled: button.disabled,
};
case "writing": case "writing":
return { return {
...button, ...button,
@@ -437,7 +572,7 @@ export default function IframeChatPage() {
activeClassName: "text-fuchsia-600 hover:text-fuchsia-700", activeClassName: "text-fuchsia-600 hover:text-fuchsia-700",
}; };
default: default:
return button; throw new Error(`Unknown iframe mode button: ${String(button.key)}`);
} }
}); });
@@ -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>
);
}
@@ -47,6 +47,7 @@ import AdminMemoryPage from './AdminMemoryPage';
import AdminUserHubPage from './AdminUserHubPage'; import AdminUserHubPage from './AdminUserHubPage';
import TagsPage from './TagsPage'; import TagsPage from './TagsPage';
import PositionsPage from './PositionsPage'; import PositionsPage from './PositionsPage';
import PluginManagementPage from './PluginManagementPage';
import RecommendedQuestionsPage from './RecommendedQuestionsPage'; import RecommendedQuestionsPage from './RecommendedQuestionsPage';
import AIWritingPage from '@/open-canvas/pages/AIWritingPage'; import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
import StudioNotebooksPage from './StudioNotebooksPage'; import StudioNotebooksPage from './StudioNotebooksPage';
@@ -554,6 +555,18 @@ export default function WorkspaceRoutes({
</WorkspaceLayout> </WorkspaceLayout>
} }
/> />
<Route
path="admin/plugins"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<PluginManagementPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route <Route
path="admin/positions" path="admin/positions"
element={ element={
@@ -33,3 +33,14 @@ test("falls back to lead-agent mode when no agent is selected or bound", () => {
undefined, undefined,
); );
}); });
test("can force lead-agent mode even when the thread metadata is bound to another agent", () => {
assert.equal(
resolveIframeAssistantId({
selectedAgentId: null,
threadMetadataAgentId: "roundtable-structure",
preferDefaultAssistant: true,
}),
undefined,
);
});
@@ -1,6 +1,7 @@
type ResolveIframeAssistantIdInput = { type ResolveIframeAssistantIdInput = {
selectedAgentId: string | null; selectedAgentId: string | null;
threadMetadataAgentId: string | null; threadMetadataAgentId: string | null;
preferDefaultAssistant?: boolean;
}; };
function normalizeAgentId(agentId: string | null | undefined): string | undefined { function normalizeAgentId(agentId: string | null | undefined): string | undefined {
@@ -11,7 +12,11 @@ function normalizeAgentId(agentId: string | null | undefined): string | undefine
export function resolveIframeAssistantId({ export function resolveIframeAssistantId({
selectedAgentId, selectedAgentId,
threadMetadataAgentId, threadMetadataAgentId,
preferDefaultAssistant = false,
}: ResolveIframeAssistantIdInput): string | undefined { }: ResolveIframeAssistantIdInput): string | undefined {
if (preferDefaultAssistant) {
return normalizeAgentId(selectedAgentId);
}
return ( return (
normalizeAgentId(selectedAgentId) ?? normalizeAgentId(selectedAgentId) ??
normalizeAgentId(threadMetadataAgentId) normalizeAgentId(threadMetadataAgentId)
@@ -4,6 +4,7 @@ from __future__ import annotations
import logging import logging
from typing import Literal from typing import Literal
from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -14,6 +15,8 @@ from deerflow.config import get_app_config
from deerflow.config.system_settings import ( from deerflow.config.system_settings import (
LeaderboardSettings, LeaderboardSettings,
ModelConcurrencySettings, ModelConcurrencySettings,
PluginDomainAgentMapping,
PluginManagementSettings,
PromptPrefixSettings, PromptPrefixSettings,
PublishSquare, PublishSquare,
PublishSquaresSettings, PublishSquaresSettings,
@@ -43,6 +46,21 @@ class PromptPrefixUpdateRequest(BaseModel):
prompt_prefix: str | None = None prompt_prefix: str | None = None
class PluginDomainAgentMappingItem(BaseModel):
id: str = Field(..., description="Stable row id")
domain: str = Field(..., description="Normalized host matched against pageContext.url")
agent_id: str = Field(..., description="Agent id selected for the host")
enabled: bool = Field(default=True, description="Whether the mapping is active")
class PluginManagementResponse(BaseModel):
domain_agent_mappings: list[PluginDomainAgentMappingItem] = Field(default_factory=list)
class PluginManagementUpdateRequest(BaseModel):
domain_agent_mappings: list[PluginDomainAgentMappingItem] | None = None
class PrivateSquareStatusResponse(BaseModel): class PrivateSquareStatusResponse(BaseModel):
"""Status of the private-square feature, readable by any logged-in user. """Status of the private-square feature, readable by any logged-in user.
@@ -91,6 +109,34 @@ def _to_response(settings: PromptPrefixSettings) -> PromptPrefixResponse:
) )
def _normalize_plugin_domain(value: str | None) -> str:
raw = (value or "").strip()
if not raw:
return ""
candidate = raw if "://" in raw else f"https://{raw}"
try:
parsed = urlparse(candidate)
except Exception:
return ""
return (parsed.netloc or parsed.path or "").strip().lower()
def _plugin_management_to_response(
settings: PluginManagementSettings,
) -> PluginManagementResponse:
return PluginManagementResponse(
domain_agent_mappings=[
PluginDomainAgentMappingItem(
id=item.id,
domain=item.domain,
agent_id=item.agent_id,
enabled=item.enabled,
)
for item in settings.domain_agent_mappings
]
)
async def _require_admin(request: Request) -> None: async def _require_admin(request: Request) -> None:
user = await get_optional_user_from_request(request) user = await get_optional_user_from_request(request)
if user is None: if user is None:
@@ -117,6 +163,50 @@ async def update_prompt_prefix(body: PromptPrefixUpdateRequest, request: Request
return _to_response(saved.prompt_prefix) return _to_response(saved.prompt_prefix)
@router.get("/plugin-management", response_model=PluginManagementResponse)
async def get_plugin_management() -> PluginManagementResponse:
"""Return the iframe plugin domain -> agent mappings."""
return _plugin_management_to_response(load_system_settings().plugin_management)
@router.put("/plugin-management", response_model=PluginManagementResponse)
async def update_plugin_management(
body: PluginManagementUpdateRequest, request: Request
) -> PluginManagementResponse:
"""Replace the iframe plugin domain -> agent mappings. Admin only."""
from uuid import uuid4
await _require_admin(request)
current = load_system_settings()
mappings: list[PluginDomainAgentMapping] = []
seen_domains: set[str] = set()
for item in body.domain_agent_mappings or []:
domain = _normalize_plugin_domain(item.domain)
agent_id = (item.agent_id or "").strip()
if not domain or not agent_id:
continue
if domain in seen_domains:
continue
seen_domains.add(domain)
mappings.append(
PluginDomainAgentMapping(
id=(item.id or "").strip() or uuid4().hex,
domain=domain,
agent_id=agent_id,
enabled=bool(item.enabled),
)
)
updated = current.plugin_management.model_copy(
update={"domain_agent_mappings": mappings}
)
saved = save_system_settings(
current.model_copy(update={"plugin_management": updated})
)
return _plugin_management_to_response(saved.plugin_management)
class SkillCompressionResponse(BaseModel): class SkillCompressionResponse(BaseModel):
enabled: bool = Field(..., description="Whether skill compression is active") enabled: bool = Field(..., description="Whether skill compression is active")
search_top_n: int = Field(..., description="Max skills returned by the search_skills tool") search_top_n: int = Field(..., description="Max skills returned by the search_skills tool")
@@ -206,6 +206,26 @@ class AgentModuleSettings(BaseModel):
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
class PluginDomainAgentMapping(BaseModel):
"""One configured host -> agent mapping for iframe chat auto-selection."""
id: str = Field(..., description="Stable row id for client-side editing")
domain: str = Field(..., description="Normalized host matched against pageContext.url")
agent_id: str = Field(..., description="Agent id selected when the host matches")
enabled: bool = Field(default=True, description="Whether this mapping is active")
model_config = ConfigDict(extra="ignore")
class PluginManagementSettings(BaseModel):
"""Admin-configurable iframe plugin mappings."""
domain_agent_mappings: list[PluginDomainAgentMapping] = Field(
default_factory=list,
description="Ordered host -> agent mappings used by the iframe chat entry page.",
)
model_config = ConfigDict(extra="ignore")
def get_module_tag_ids(settings: AgentModuleSettings, key: str) -> list[str]: def get_module_tag_ids(settings: AgentModuleSettings, key: str) -> list[str]:
"""Return the configured agent-tag ids for ``key`` (empty when unset).""" """Return the configured agent-tag ids for ``key`` (empty when unset)."""
return list(settings.modules.get(key, [])) return list(settings.modules.get(key, []))
@@ -280,6 +300,7 @@ class SystemSettings(BaseModel):
citation_display: CitationDisplaySettings = Field(default_factory=CitationDisplaySettings) citation_display: CitationDisplaySettings = Field(default_factory=CitationDisplaySettings)
leaderboard: LeaderboardSettings = Field(default_factory=LeaderboardSettings) leaderboard: LeaderboardSettings = Field(default_factory=LeaderboardSettings)
skill_compression: SkillCompressionSettings = Field(default_factory=SkillCompressionSettings) skill_compression: SkillCompressionSettings = Field(default_factory=SkillCompressionSettings)
plugin_management: PluginManagementSettings = Field(default_factory=PluginManagementSettings)
agent_modules: AgentModuleSettings = Field(default_factory=AgentModuleSettings) agent_modules: AgentModuleSettings = Field(default_factory=AgentModuleSettings)
concurrency_monitor: ConcurrencyMonitorSettings = Field(default_factory=ConcurrencyMonitorSettings) concurrency_monitor: ConcurrencyMonitorSettings = Field(default_factory=ConcurrencyMonitorSettings)
model_concurrency: ModelConcurrencySettings = Field(default_factory=ModelConcurrencySettings) model_concurrency: ModelConcurrencySettings = Field(default_factory=ModelConcurrencySettings)