fix: iframe页面子智能体不跳出,增加按钮

This commit is contained in:
Rocky Kwok committed 2026-06-23 10:56:51 +08:00
1 parent 1cbc0e2dd6
commit 1c2e524b28
7 files changed
+460 -105

No files matched your search

@@ -32,6 +32,10 @@ interface AgentSelectorProps {
disabled?: boolean;
/** 紧凑模式(CM 参考图):触发器改为灰底胶囊 + 主色机器人图标。 */
compact?: boolean;
/** 图标模式:用于 iframe 等底栏场景,仅显示机器人图标和可选清空按钮。 */
triggerVariant?: "default" | "icon";
iconButtonClassName?: string;
iconActiveClassName?: string;
}
/**
@@ -48,6 +52,9 @@ export function AgentSelector({
onAgentChange,
disabled,
compact = false,
triggerVariant = "default",
iconButtonClassName,
iconActiveClassName,
}: AgentSelectorProps) {
const [open, setOpen] = useState(false);
const [manageOpen, setManageOpen] = useState(false);
@@ -79,48 +86,84 @@ export function AgentSelector({
};
const triggerLabel = selectedAgent ? desensitize(selectedAgent.name) : "选择 Agent";
const iconMode = triggerVariant === "icon";
return (
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<PromptInputButton
type="button"
disabled={disabled}
className={cn(
"gap-1.5! rounded-full border px-3! shadow-none transition-colors",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
compact
? "border-transparent bg-gray-50/60 text-gray-600 hover:bg-gray-100/80 hover:text-gray-800 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700"
: selectedAgent
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
)}
>
<BotIcon
className={cn("size-3", compact && "text-[var(--primary)]")}
/>
<span className="max-w-[140px] truncate text-xs font-normal">
{triggerLabel}
</span>
{selectedAgent && (
<span
role="button"
tabIndex={0}
aria-label="清除已选 agent"
className="ml-0.5 inline-flex items-center justify-center rounded-full p-0.5 hover:bg-[#ffd9a7]"
onClick={handleClear}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onAgentChange(null);
}
}}
>
<XIcon className="size-3" />
{iconMode ? (
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled}
aria-label={selectedAgent ? `已选择智能体:${triggerLabel}` : "选择智能体"}
className={cn(
"relative size-9 rounded-full text-muted-foreground shadow-none hover:bg-muted/60 hover:text-foreground",
selectedAgent && "text-amber-600 hover:text-amber-700",
selectedAgent && iconActiveClassName,
iconButtonClassName,
)}
>
<BotIcon className="size-4" />
{selectedAgent && (
<span
role="button"
tabIndex={0}
aria-label="清除已选 agent"
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"
onClick={handleClear}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onAgentChange(null);
}
}}
>
<XIcon className="size-2.5" />
</span>
)}
</Button>
) : (
<PromptInputButton
type="button"
disabled={disabled}
className={cn(
"gap-1.5! rounded-full border px-3! shadow-none transition-colors",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
compact
? "border-transparent bg-gray-50/60 text-gray-600 hover:bg-gray-100/80 hover:text-gray-800 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700"
: selectedAgent
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
)}
>
<BotIcon
className={cn("size-3", compact && "text-[var(--primary)]")}
/>
<span className="max-w-[140px] truncate text-xs font-normal">
{triggerLabel}
</span>
)}
</PromptInputButton>
{selectedAgent && (
<span
role="button"
tabIndex={0}
aria-label="清除已选 agent"
className="ml-0.5 inline-flex items-center justify-center rounded-full p-0.5 hover:bg-[#ffd9a7]"
onClick={handleClear}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onAgentChange(null);
}
}}
>
<XIcon className="size-3" />
</span>
)}
</PromptInputButton>
)}
</DialogTrigger>
<DialogContent className="overflow-hidden p-0 sm:max-w-md">
<DialogTitle className="sr-only">选择 Agent</DialogTitle>
+116 -69
View File
@@ -5,8 +5,8 @@ import {
BrainCircuitIcon,
FileTextIcon,
GraduationCapIcon,
ShrinkIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
@@ -46,6 +46,8 @@ import {
type BrainyChatSubmitMessage,
parseBrainyChatSubmitMessage,
} from "./iframe-chat-submit";
import { resolveIframeAssistantId } from "./iframe-chat-agent";
import { buildIframeModeButtons } from "./iframe-chat-mode-buttons";
type BrainyChatStatus = "started" | "finished";
type BrainyAuthTokenMessage = {
@@ -93,7 +95,6 @@ function postBrainyAuthToken(accessToken: string) {
export default function IframeChatPage() {
const routeBase = useRouteBase();
const router = useRouter();
const { t } = useI18n();
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
@@ -152,11 +153,19 @@ export default function IframeChatPage() {
}, [selectedAgentId, selectorAgents, selectorLoading]);
const { showNotification } = useNotification();
const activeAssistantId = resolveIframeAssistantId({
selectedAgentId,
threadMetadataAgentId: null,
});
const [thread, sendMessage, isUploading] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
context: settings.context,
context: {
...settings.context,
...(activeAssistantId ? { agent_id: activeAssistantId } : {}),
},
isMock,
assistantId: activeAssistantId,
onStart: (createdThreadId) => {
setThreadId(createdThreadId);
setIsNewThread(false);
@@ -193,6 +202,22 @@ export default function IframeChatPage() {
},
});
const threadMetadataAgentId = (() => {
const metadata = (thread as { metadata?: Record<string, unknown> | null })
.metadata;
return typeof metadata?.agent_id === "string" ? metadata.agent_id : null;
})();
useEffect(() => {
const resolvedAgentId = resolveIframeAssistantId({
selectedAgentId,
threadMetadataAgentId,
});
if (resolvedAgentId && resolvedAgentId !== selectedAgentId) {
setSelectedAgentId(resolvedAgentId);
}
}, [selectedAgentId, threadMetadataAgentId]);
const writingArtifactPath = useMemo(() => {
const isMarkdown = (path: string | null | undefined) =>
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
@@ -210,31 +235,15 @@ export default function IframeChatPage() {
externalContext?: Record<string, unknown>,
options?: { additionalKwargs?: Record<string, unknown> },
) => {
if (
selectedAgentId &&
isNewThread &&
!hasStartedConversation &&
message.text &&
(!message.files || message.files.length === 0)
) {
try {
sessionStorage.setItem(
"pendingAgentMessage",
JSON.stringify({ agentId: selectedAgentId, text: message.text }),
);
} catch {
// sessionStorage may be unavailable in private mode; fall through.
}
router.push(`/page/workspace/agents/${selectedAgentId}/chats/new`);
return;
}
setHasStartedConversation(true);
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
const extraContext: Record<string, unknown> = {};
if (activeAssistantId) {
extraContext.agent_id = activeAssistantId;
}
if (settings.context.writing_mode && writingArtifactPath) {
extraContext.writing_artifact_path = writingArtifactPath;
}
@@ -250,10 +259,8 @@ export default function IframeChatPage() {
);
},
[
activeAssistantId,
hasStartedConversation,
isNewThread,
router,
selectedAgentId,
sendMessage,
settings.context.writing_mode,
threadId,
@@ -277,6 +284,8 @@ export default function IframeChatPage() {
const ragModeLocked = isThreadRagModeLocked(threadId);
const ragModeEnabled =
localSettings.display.ragModeEnabled || ragModeLocked;
const contextCompressionEnabled =
localSettings.display.contextCompressionEnabled ?? false;
const proModeEnabled = settings.context.mode === "pro";
const toggleWritingMode = useCallback(() => {
@@ -301,6 +310,12 @@ export default function IframeChatPage() {
});
}, [ragModeEnabled, ragModeLocked, setLocalSettings]);
const toggleContextCompression = useCallback(() => {
setLocalSettings("display", {
contextCompressionEnabled: !contextCompressionEnabled,
});
}, [contextCompressionEnabled, setLocalSettings]);
const toggleProMode = useCallback(() => {
setSettings("context", {
mode: proModeEnabled ? "flash" : "pro",
@@ -333,28 +348,29 @@ export default function IframeChatPage() {
const data = event.data;
if (data?.source !== "brainy-extension") return;
if (data?.type === "brainy:chat-interrupt") {
void handleInterrupt(data);
void handleInterrupt(data as BrainyChatInterruptMessage);
return;
}
if (data?.type !== "brainy:chat-submit") return;
const submitMessage = data as BrainyChatSubmitMessage;
const { submitText, files, iframeContext, additionalKwargs } =
parseBrainyChatSubmitMessage(data);
parseBrainyChatSubmitMessage(submitMessage);
console.log("收到插件消息", {
text: data.payload?.text,
prompt: data.payload?.prompt,
pageContext: data.payload?.pageContext,
screenshot: data.payload?.screenshot,
model: data.payload?.model,
requestId: data.requestId,
text: submitMessage.payload?.text,
prompt: submitMessage.payload?.prompt,
pageContext: submitMessage.payload?.pageContext,
screenshot: submitMessage.payload?.screenshot,
model: submitMessage.payload?.model,
requestId: submitMessage.requestId,
filesCount: files.length,
iframeContext,
});
if (!submitText && files.length === 0) return;
pendingRequestIdRef.current = data.requestId ?? null;
pendingRequestIdRef.current = submitMessage.requestId ?? null;
handleSubmit(
{ text: submitText, files },
@@ -371,41 +387,59 @@ export default function IframeChatPage() {
const isLandingState = isNewThread && !hasStartedConversation;
const bottomBarHeightClass = "pb-24";
const modeButtons = [
{
key: "writing",
label: "写作",
icon: FileTextIcon,
active: !!settings.context.writing_mode,
onClick: toggleWritingMode,
activeClassName: "text-amber-600 hover:text-amber-700",
},
{
key: "memory",
label: "记忆",
icon: BrainCircuitIcon,
active: settings.context.memory_injection_enabled ?? true,
onClick: toggleMemoryInjection,
activeClassName: "text-blue-600 hover:text-blue-700",
},
{
key: "references",
label: "参考文献",
icon: BookMarkedIcon,
active: ragModeEnabled,
onClick: toggleRagMode,
disabled: ragModeLocked,
activeClassName: "text-emerald-600 hover:text-emerald-700",
},
{
key: "pro",
label: "专业问答",
icon: GraduationCapIcon,
active: proModeEnabled,
onClick: toggleProMode,
activeClassName: "text-violet-600 hover:text-violet-700",
},
] as const;
const modeButtons = buildIframeModeButtons({
agentSelected: !!selectedAgentId,
writingModeEnabled: !!settings.context.writing_mode,
memoryInjectionEnabled: Boolean(
settings.context.memory_injection_enabled ?? true,
),
ragModeEnabled,
ragModeLocked,
contextCompressionEnabled,
proModeEnabled,
}).map((button) => {
switch (button.key) {
case "agent":
return button;
case "writing":
return {
...button,
icon: FileTextIcon,
onClick: toggleWritingMode,
activeClassName: "text-amber-600 hover:text-amber-700",
};
case "memory":
return {
...button,
icon: BrainCircuitIcon,
onClick: toggleMemoryInjection,
activeClassName: "text-blue-600 hover:text-blue-700",
};
case "references":
return {
...button,
icon: BookMarkedIcon,
onClick: toggleRagMode,
activeClassName: "text-emerald-600 hover:text-emerald-700",
};
case "contextCompression":
return {
...button,
icon: ShrinkIcon,
onClick: toggleContextCompression,
activeClassName: "text-violet-600 hover:text-violet-700",
};
case "pro":
return {
...button,
icon: GraduationCapIcon,
onClick: toggleProMode,
activeClassName: "text-fuchsia-600 hover:text-fuchsia-700",
};
default:
return button;
}
});
return (
<ThreadContext.Provider value={{ thread, isMock }}>
@@ -496,6 +530,19 @@ export default function IframeChatPage() {
<TooltipProvider delayDuration={250}>
<div className="mx-auto flex max-w-(--container-width-lg) flex-wrap items-center justify-center gap-0 px-0 py-1">
{modeButtons.map((button) => {
if (button.key === "agent") {
return (
<AgentSelector
key={button.key}
selectedAgentId={selectedAgentId}
onAgentChange={handleAgentChange}
disabled={isUploading}
triggerVariant="icon"
iconButtonClassName="size-9 rounded-full text-muted-foreground shadow-none hover:bg-muted/60 hover:text-foreground"
iconActiveClassName="text-amber-600 hover:text-amber-700"
/>
);
}
const Icon = button.icon;
return (
<Tooltip key={button.key} content={button.label}>
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import test from "node:test";
// @ts-ignore Node's test runner resolves the local .ts module directly.
import { resolveIframeAssistantId } from "./iframe-chat-agent.ts";
test("prefers the currently selected iframe agent", () => {
assert.equal(
resolveIframeAssistantId({
selectedAgentId: "roundtable-structure",
threadMetadataAgentId: "lead_agent",
}),
"roundtable-structure",
);
});
test("reuses the thread-bound agent when reopening an iframe thread", () => {
assert.equal(
resolveIframeAssistantId({
selectedAgentId: null,
threadMetadataAgentId: "roundtable-structure",
}),
"roundtable-structure",
);
});
test("falls back to lead-agent mode when no agent is selected or bound", () => {
assert.equal(
resolveIframeAssistantId({
selectedAgentId: null,
threadMetadataAgentId: null,
}),
undefined,
);
});
@@ -0,0 +1,19 @@
type ResolveIframeAssistantIdInput = {
selectedAgentId: string | null;
threadMetadataAgentId: string | null;
};
function normalizeAgentId(agentId: string | null | undefined): string | undefined {
const trimmed = agentId?.trim();
return trimmed ? trimmed : undefined;
}
export function resolveIframeAssistantId({
selectedAgentId,
threadMetadataAgentId,
}: ResolveIframeAssistantIdInput): string | undefined {
return (
normalizeAgentId(selectedAgentId) ??
normalizeAgentId(threadMetadataAgentId)
);
}
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import test from "node:test";
// @ts-ignore Node's test runner resolves the local .ts module directly.
import { buildIframeModeButtons } from "./iframe-chat-mode-buttons.ts";
test("includes the context compression toggle in the iframe chat toolbar", () => {
const buttons = buildIframeModeButtons({
agentSelected: false,
writingModeEnabled: false,
memoryInjectionEnabled: true,
ragModeEnabled: false,
ragModeLocked: false,
contextCompressionEnabled: false,
proModeEnabled: false,
});
assert.deepEqual(
buttons.map((button) => button.key),
[
"writing",
"memory",
"references",
"contextCompression",
"pro",
"agent",
],
);
});
test("returns only concrete toolbar button configs", () => {
const buttons = buildIframeModeButtons({
agentSelected: true,
writingModeEnabled: false,
memoryInjectionEnabled: true,
ragModeEnabled: false,
ragModeLocked: false,
contextCompressionEnabled: false,
proModeEnabled: false,
});
assert.equal(buttons.every((button) => typeof button.key === "string"), true);
});
@@ -0,0 +1,66 @@
export type IframeModeButtonState = {
key:
| "agent"
| "writing"
| "memory"
| "references"
| "contextCompression"
| "pro";
label: string;
active: boolean;
disabled?: boolean;
};
type BuildIframeModeButtonsInput = {
agentSelected: boolean;
writingModeEnabled: boolean;
memoryInjectionEnabled: boolean;
ragModeEnabled: boolean;
ragModeLocked: boolean;
contextCompressionEnabled: boolean;
proModeEnabled: boolean;
};
export function buildIframeModeButtons({
agentSelected,
writingModeEnabled,
memoryInjectionEnabled,
ragModeEnabled,
ragModeLocked,
contextCompressionEnabled,
proModeEnabled,
}: BuildIframeModeButtonsInput): IframeModeButtonState[] {
return [
{
key: "writing",
label: "写作",
active: writingModeEnabled,
},
{
key: "memory",
label: "记忆",
active: memoryInjectionEnabled,
},
{
key: "references",
label: "参考文献",
active: ragModeEnabled,
disabled: ragModeLocked,
},
{
key: "contextCompression",
label: "上下文压缩",
active: contextCompressionEnabled,
},
{
key: "pro",
label: "专业问答",
active: proModeEnabled,
},
{
key: "agent",
label: "智能体",
active: agentSelected,
},
];
}