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

+163 -28
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,20 @@ type BrainyChatInterruptMessage = {
};
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) {
window.parent?.postMessage(
@@ -104,9 +135,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 +183,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 +267,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 +339,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;
@@ -341,9 +436,33 @@ export default function IframeChatPage() {
[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(() => {
const handleMessage = (
event: MessageEvent<BrainyChatSubmitMessage | BrainyChatInterruptMessage>,
event: MessageEvent<
BrainyChatSubmitMessage | BrainyChatInterruptMessage | BrainyUrlContextMessage
>,
) => {
const data = event.data;
if (data?.source !== "brainy-extension") return;
@@ -351,12 +470,19 @@ export default function IframeChatPage() {
void handleInterrupt(data as BrainyChatInterruptMessage);
return;
}
if (data?.type === "brainy:context-url") {
applyIncomingUrlSelection(
(data as BrainyUrlContextMessage).payload?.url ?? null,
);
return;
}
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;
console.log("收到插件消息", {
text: submitMessage.payload?.text,
prompt: submitMessage.payload?.prompt,
@@ -368,26 +494,30 @@ 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: selectedAgentId,
targetForceDefaultAssistant: forceDefaultAssistant,
});
};
window.addEventListener("message", handleMessage);
return () => {
window.removeEventListener("message", handleMessage);
};
}, [handleInterrupt, handleSubmit]);
}, [
applyIncomingUrlSelection,
forceDefaultAssistant,
handleInterrupt,
selectedAgentId,
]);
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 +527,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 +572,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)}`);
}
});
@@ -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 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';
@@ -554,6 +555,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)