diff --git a/frontend-web/src/components/workspace/workspace-nav-menu.tsx b/frontend-web/src/components/workspace/workspace-nav-menu.tsx index 73a56ac4..5df42f84 100644 --- a/frontend-web/src/components/workspace/workspace-nav-menu.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-menu.tsx @@ -11,6 +11,7 @@ import { GaugeIcon, HistoryIcon, MessageCircleQuestionIcon, + PlugIcon, ScrollTextIcon, Settings2Icon, SettingsIcon, @@ -218,6 +219,14 @@ export function WorkspaceNavMenu() { {t.adminTags.title} + {isAdmin && ( + navigate("/page/workspace/admin/plugins")} + > + + 插件管理 + + )} {isAdmin && ( navigate("/page/workspace/admin/positions")} diff --git a/frontend-web/src/core/system-settings/api.ts b/frontend-web/src/core/system-settings/api.ts index 778b273a..dacfa808 100644 --- a/frontend-web/src/core/system-settings/api.ts +++ b/frontend-web/src/core/system-settings/api.ts @@ -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 { + 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 { + 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 { const response = await apiFetch( `${getBackendBaseURL()}/api/system-settings/prompt-prefix`, diff --git a/frontend-web/src/core/system-settings/hooks.ts b/frontend-web/src/core/system-settings/hooks.ts index 5ae171df..b21902f7 100644 --- a/frontend-web/src/core/system-settings/hooks.ts +++ b/frontend-web/src/core/system-settings/hooks.ts @@ -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, diff --git a/frontend-web/src/pages/IframeChatPage.tsx b/frontend-web/src/pages/IframeChatPage.tsx index ea526b4d..b847d3a0 100644 --- a/frontend-web/src/pages/IframeChatPage.tsx +++ b/frontend-web/src/pages/IframeChatPage.tsx @@ -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; + options?: { additionalKwargs?: Record }; + 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(null); + const [manualSelectedAgentId, setManualSelectedAgentId] = useState(null); + const [forceDefaultAssistant, setForceDefaultAssistant] = useState(true); + const [pendingIframeSubmit, setPendingIframeSubmit] = + useState(null); const { agents: selectorAgents, isLoading: selectorLoading } = useAgentSelector(); + const { data: pluginManagementSettings } = usePluginManagementSettings(); const pendingRequestIdRef = useRef(null); + const handleSubmitRef = useRef< + | (( + message: PromptInputMessage, + externalContext?: Record, + options?: { additionalKwargs?: Record }, + ) => 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)}`); } }); diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index bcc3bda5..1d58ac9f 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -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({ } /> + + + + ) : ( + + ) + } + /> { 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, + ); +}); diff --git a/frontend-web/src/pages/iframe-chat-agent.ts b/frontend-web/src/pages/iframe-chat-agent.ts index f10fdfa1..2ddb4b0b 100644 --- a/frontend-web/src/pages/iframe-chat-agent.ts +++ b/frontend-web/src/pages/iframe-chat-agent.ts @@ -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) diff --git a/offline-backend-20260512/backend/app/gateway/routers/system_settings.py b/offline-backend-20260512/backend/app/gateway/routers/system_settings.py index 079fc15f..baf5fceb 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/system_settings.py +++ b/offline-backend-20260512/backend/app/gateway/routers/system_settings.py @@ -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") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py index eac22b77..214f84c2 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py @@ -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)