feat: 插件管理及域名和智能体对应

This commit is contained in:
Rocky Kwok committed 2026-06-29 14:31:40 +08:00
1 parent eb61b332a2
commit e9e4c181b9
9 files changed
+414 -27

No files matched your search

@@ -11,6 +11,7 @@ import {
GaugeIcon,
HistoryIcon,
MessageCircleQuestionIcon,
PlugIcon,
ScrollTextIcon,
Settings2Icon,
SettingsIcon,
@@ -218,6 +219,14 @@ export function WorkspaceNavMenu() {
<TagsIcon />
{t.adminTags.title}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/plugins")}
>
<PlugIcon />
插件管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/positions")}
@@ -8,12 +8,115 @@ export interface PromptPrefixSettings {
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 {
enabled?: boolean;
switch_label?: 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> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/prompt-prefix`,
@@ -8,6 +8,7 @@ import {
loadCitationDisplaySettings,
loadLeaderboardSettings,
loadModelConcurrencySettings,
loadPluginManagementSettings,
loadPrivateSquareStatus,
loadPromptPrefixSettings,
loadPublishSquares,
@@ -17,6 +18,7 @@ import {
updateCitationDisplaySettings,
updateLeaderboardSettings,
updateModelConcurrencySettings,
updatePluginManagementSettings,
updatePrivateSquareSettings,
updatePromptPrefixSettings,
updatePublishSquares,
@@ -25,6 +27,7 @@ import {
type CitationDisplayUpdate,
type LeaderboardSettingsUpdate,
type ModelConcurrencyUpdate,
type PluginManagementUpdate,
type PrivateSquareUpdate,
type PromptPrefixUpdate,
type PublishSquaresUpdate,
@@ -41,6 +44,7 @@ const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const;
const PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] as const;
export function usePromptPrefixSettings() {
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() {
return useQuery({
queryKey: LEADERBOARD_KEY,
+137 -27
View File
@@ -26,18 +26,19 @@ import { Tooltip } from "@/components/workspace/tooltip";
import { Button } from "@/components/ui/button";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useAgentSelector } from "@/core/agents";
import { resolveIncomingIframeSelection } from "@/core/plugin-management/domain-agent";
import { getAccessToken } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks";
import { useRouteBase } from "@/core/route-base";
import {
getLastAgentId,
isThreadRagModeLocked,
saveLastAgentId,
useLocalSettings,
useThreadSettings,
} from "@/core/settings";
import { usePluginManagementSettings } from "@/core/system-settings/hooks";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
@@ -50,6 +51,22 @@ import { resolveIframeAssistantId } from "./iframe-chat-agent";
import { buildIframeModeButtons } from "./iframe-chat-mode-buttons";
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 = {
source: "brainy-iframe";
type: "brainy:auth-token";
@@ -66,6 +83,13 @@ type BrainyChatInterruptMessage = {
};
requestId?: 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) {
window.parent?.postMessage(
@@ -104,9 +128,22 @@ export default function IframeChatPage() {
const [mounted, setMounted] = useState(false);
const { tokenUsageEnabled } = useModels();
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 } =
useAgentSelector();
const { data: pluginManagementSettings } = usePluginManagementSettings();
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();
useSpecificChatMode();
@@ -139,23 +176,37 @@ export default function IframeChatPage() {
useEffect(() => {
if (isNewThread) {
setHasStartedConversation(false);
setSelectedAgentId(getLastAgentId());
setSelectedAgentId(null);
setManualSelectedAgentId(null);
setForceDefaultAssistant(true);
setPendingIframeSubmit(null);
}
}, [isNewThread, threadId]);
useEffect(() => {
if (selectorLoading || !selectedAgentId) {
if (selectorLoading) {
return;
}
if (!selectorAgents.some((agent) => agent.id === selectedAgentId)) {
if (
selectedAgentId &&
!selectorAgents.some((agent) => agent.id === selectedAgentId)
) {
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 activeAssistantId = resolveIframeAssistantId({
selectedAgentId,
threadMetadataAgentId: null,
preferDefaultAssistant: forceDefaultAssistant,
});
const [thread, sendMessage, isUploading] = useThreadStream({
@@ -209,14 +260,18 @@ export default function IframeChatPage() {
})();
useEffect(() => {
const resolvedAgentId = resolveIframeAssistantId({
selectedAgentId,
threadMetadataAgentId,
});
if (resolvedAgentId && resolvedAgentId !== selectedAgentId) {
setSelectedAgentId(resolvedAgentId);
}
}, [selectedAgentId, threadMetadataAgentId]);
if (!threadMetadataAgentId || isNewThread) return;
if (manualSelectedAgentId) return;
if (forceDefaultAssistant) return;
if (selectedAgentId) return;
setSelectedAgentId(threadMetadataAgentId);
}, [
forceDefaultAssistant,
isNewThread,
manualSelectedAgentId,
selectedAgentId,
threadMetadataAgentId,
]);
const writingArtifactPath = useMemo(() => {
const isMarkdown = (path: string | null | undefined) =>
@@ -277,10 +332,43 @@ export default function IframeChatPage() {
);
const handleAgentChange = useCallback((agentId: string | null) => {
setManualSelectedAgentId(agentId);
setSelectedAgentId(agentId);
setForceDefaultAssistant(!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 ragModeEnabled =
localSettings.display.ragModeEnabled || ragModeLocked;
@@ -353,10 +441,22 @@ export default function IframeChatPage() {
}
if (data?.type !== "brainy:chat-submit") return;
const submitMessage = data as BrainyChatSubmitMessage;
const { submitText, files, iframeContext, additionalKwargs } =
parseBrainyChatSubmitMessage(submitMessage);
if (!submitText && files.length === 0) return;
const nextSelection = resolveIncomingIframeSelection({
pageUrl: submitMessage.payload?.pageContext?.url,
mappings: pluginManagementSettings?.domainAgentMappings ?? [],
availableAgentIds: selectorLoading
? undefined
: selectorAgents.map((agent) => agent.id),
manualSelectedAgentId,
});
setSelectedAgentId(nextSelection.selectedAgentId);
setForceDefaultAssistant(nextSelection.forceDefaultAssistant);
console.log("收到插件消息", {
text: submitMessage.payload?.text,
prompt: submitMessage.payload?.prompt,
@@ -368,26 +468,31 @@ export default function IframeChatPage() {
iframeContext,
});
if (!submitText && files.length === 0) return;
pendingRequestIdRef.current = submitMessage.requestId ?? null;
handleSubmit(
{ text: submitText, files },
iframeContext,
additionalKwargs ? { additionalKwargs } : undefined,
);
setPendingIframeSubmit({
message: { text: submitText, files },
externalContext: iframeContext,
options: additionalKwargs ? { additionalKwargs } : undefined,
targetSelectedAgentId: nextSelection.selectedAgentId,
targetForceDefaultAssistant: nextSelection.forceDefaultAssistant,
});
};
window.addEventListener("message", handleMessage);
return () => {
window.removeEventListener("message", handleMessage);
};
}, [handleInterrupt, handleSubmit]);
}, [
handleInterrupt,
manualSelectedAgentId,
pluginManagementSettings?.domainAgentMappings,
selectorAgents,
selectorLoading,
]);
const isLandingState = isNewThread && !hasStartedConversation;
const bottomBarHeightClass = "pb-24";
const modeButtons = buildIframeModeButtons({
const modeButtons: ResolvedIframeModeButton[] = buildIframeModeButtons({
agentSelected: !!selectedAgentId,
writingModeEnabled: !!settings.context.writing_mode,
memoryInjectionEnabled: Boolean(
@@ -397,10 +502,15 @@ export default function IframeChatPage() {
ragModeLocked,
contextCompressionEnabled,
proModeEnabled,
}).map((button) => {
}).map((button): ResolvedIframeModeButton => {
switch (button.key) {
case "agent":
return button;
return {
key: "agent",
label: button.label,
active: button.active,
disabled: button.disabled,
};
case "writing":
return {
...button,
@@ -437,7 +547,7 @@ export default function IframeChatPage() {
activeClassName: "text-fuchsia-600 hover:text-fuchsia-700",
};
default:
return button;
throw new Error(`Unknown iframe mode button: ${String(button.key)}`);
}
});
@@ -42,6 +42,7 @@ import AdminMemoryPage from './AdminMemoryPage';
import AdminUserHubPage from './AdminUserHubPage';
import TagsPage from './TagsPage';
import PositionsPage from './PositionsPage';
import PluginManagementPage from './PluginManagementPage';
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
import StudioNotebooksPage from './StudioNotebooksPage';
@@ -515,6 +516,18 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="admin/plugins"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<PluginManagementPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/positions"
element={
@@ -33,3 +33,14 @@ test("falls back to lead-agent mode when no agent is selected or bound", () => {
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 = {
selectedAgentId: string | null;
threadMetadataAgentId: string | null;
preferDefaultAssistant?: boolean;
};
function normalizeAgentId(agentId: string | null | undefined): string | undefined {
@@ -11,7 +12,11 @@ function normalizeAgentId(agentId: string | null | undefined): string | undefine
export function resolveIframeAssistantId({
selectedAgentId,
threadMetadataAgentId,
preferDefaultAssistant = false,
}: ResolveIframeAssistantIdInput): string | undefined {
if (preferDefaultAssistant) {
return normalizeAgentId(selectedAgentId);
}
return (
normalizeAgentId(selectedAgentId) ??
normalizeAgentId(threadMetadataAgentId)
@@ -4,6 +4,7 @@ from __future__ import annotations
import logging
from typing import Literal
from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
@@ -14,6 +15,8 @@ from deerflow.config import get_app_config
from deerflow.config.system_settings import (
LeaderboardSettings,
ModelConcurrencySettings,
PluginDomainAgentMapping,
PluginManagementSettings,
PromptPrefixSettings,
PublishSquare,
PublishSquaresSettings,
@@ -43,6 +46,21 @@ class PromptPrefixUpdateRequest(BaseModel):
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):
"""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:
user = await get_optional_user_from_request(request)
if user is None:
@@ -117,6 +163,50 @@ async def update_prompt_prefix(body: PromptPrefixUpdateRequest, request: Request
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):
enabled: bool = Field(..., description="Whether skill compression is active")
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")
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]:
"""Return the configured agent-tag ids for ``key`` (empty when unset)."""
return list(settings.modules.get(key, []))
@@ -280,6 +300,7 @@ class SystemSettings(BaseModel):
citation_display: CitationDisplaySettings = Field(default_factory=CitationDisplaySettings)
leaderboard: LeaderboardSettings = Field(default_factory=LeaderboardSettings)
skill_compression: SkillCompressionSettings = Field(default_factory=SkillCompressionSettings)
plugin_management: PluginManagementSettings = Field(default_factory=PluginManagementSettings)
agent_modules: AgentModuleSettings = Field(default_factory=AgentModuleSettings)
concurrency_monitor: ConcurrencyMonitorSettings = Field(default_factory=ConcurrencyMonitorSettings)
model_concurrency: ModelConcurrencySettings = Field(default_factory=ModelConcurrencySettings)