"use client"; import { BookMarkedIcon, BrainCircuitIcon, FileTextIcon, GraduationCapIcon, ShrinkIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { useArtifacts } from "@/components/workspace/artifacts"; import { AgentSelector } from "@/components/workspace/agents/agent-selector"; import { ChatBox, useSpecificChatMode, useThreadChat, } from "@/components/workspace/chats"; import { MessageList, } from "@/components/workspace/messages"; import { ThreadContext } from "@/components/workspace/messages/context"; import { TodoList } from "@/components/workspace/todo-list"; 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 { buildChatsIframeThreadPath } from "@/core/page-layout/chats-iframe-route"; import { useRouteBase } from "@/core/route-base"; import { 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"; import { cn } from "@/lib/utils"; 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 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"; payload: { accessToken: string; }; }; type BrainyChatInterruptMessage = { source?: string; type?: string; payload?: { reason?: string; requestId?: string; }; requestId?: string; }; type BrainyUrlContextMessage = { source?: string; type?: string; payload?: { url?: string; }; }; type PendingIframeSubmit = { message: PromptInputMessage; externalContext?: Record; options?: { additionalKwargs?: Record }; targetSelectedAgentId: string | null; targetForceDefaultAssistant: boolean; }; function postBrainyChatStatus(status: BrainyChatStatus, requestId: string) { window.parent?.postMessage( { source: "brainy-iframe", type: "brainy:chat-status", payload: { status, requestId, }, }, "*", ); } function postBrainyAuthToken(accessToken: string) { const message: BrainyAuthTokenMessage = { source: "brainy-iframe", type: "brainy:auth-token", payload: { accessToken, }, }; window.parent?.postMessage(message, "*"); } export default function IframeChatPage() { const routeBase = useRouteBase(); const { t } = useI18n(); const [hasStartedConversation, setHasStartedConversation] = useState(false); const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } = useThreadChat(); const [settings, setSettings] = useThreadSettings(threadId); const [localSettings, setLocalSettings] = useLocalSettings(); 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(); useEffect(() => { setMounted(true); }, []); useEffect(() => { let lastToken: string | undefined; const emitAccessToken = () => { const accessToken = getAccessToken(); if (!accessToken || accessToken === lastToken) { return; } lastToken = accessToken; postBrainyAuthToken(accessToken); }; emitAccessToken(); window.addEventListener("focus", emitAccessToken); window.addEventListener("storage", emitAccessToken); return () => { window.removeEventListener("focus", emitAccessToken); window.removeEventListener("storage", emitAccessToken); }; }, []); useEffect(() => { if (isNewThread) { setHasStartedConversation(false); setSelectedAgentId(null); setManualSelectedAgentId(null); setForceDefaultAssistant(true); setPendingIframeSubmit(null); } }, [isNewThread, threadId]); useEffect(() => { if (selectorLoading) { return; } if ( selectedAgentId && !selectorAgents.some((agent) => agent.id === selectedAgentId) ) { setSelectedAgentId(null); setForceDefaultAssistant(true); } 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({ threadId: isNewThread ? undefined : threadId, context: { ...settings.context, ...(activeAssistantId ? { agent_id: activeAssistantId } : {}), }, isMock, assistantId: activeAssistantId, onStart: (createdThreadId) => { setThreadId(createdThreadId); setIsNewThread(false); history.replaceState( null, "", `${window.location.pathname}${window.location.search}#${buildChatsIframeThreadPath( routeBase, createdThreadId, location.search, )}`, ); if (pendingRequestIdRef.current) { postBrainyChatStatus("started", pendingRequestIdRef.current); } }, onFinish: (state) => { if (pendingRequestIdRef.current) { postBrainyChatStatus("finished", pendingRequestIdRef.current); pendingRequestIdRef.current = null; } if (document.hidden || !document.hasFocus()) { let body = "Conversation finished"; const lastMessage = state.messages.at(-1); if (lastMessage) { const textContent = textOfMessage(lastMessage); if (textContent) { body = textContent.length > 200 ? `${textContent.substring(0, 200)}...` : textContent; } } showNotification(state.title, { body }); } }, }); const threadMetadataAgentId = (() => { const metadata = (thread as { metadata?: Record | null }) .metadata; return typeof metadata?.agent_id === "string" ? metadata.agent_id : null; })(); useEffect(() => { 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) => !!path && /\.(md|markdown)(?:$|[?#])/i.test(path); if (isMarkdown(selectedArtifact)) { return selectedArtifact ?? undefined; } return [...(thread.values.artifacts ?? [])] .reverse() .find((path) => isMarkdown(path)); }, [selectedArtifact, thread.values.artifacts]); const handleSubmit = useCallback( ( message: PromptInputMessage, externalContext?: Record, options?: { additionalKwargs?: Record }, ) => { setHasStartedConversation(true); window.dispatchEvent( new CustomEvent("workspace:sandbox-resume-autoscroll"), ); const extraContext: Record = {}; if (activeAssistantId) { extraContext.agent_id = activeAssistantId; } if (settings.context.writing_mode && writingArtifactPath) { extraContext.writing_artifact_path = writingArtifactPath; } if (externalContext) { Object.assign(extraContext, externalContext); } void sendMessage( threadId, message, Object.keys(extraContext).length > 0 ? extraContext : undefined, options, ); }, [ activeAssistantId, hasStartedConversation, sendMessage, settings.context.writing_mode, threadId, writingArtifactPath, ], ); const handleClarificationSubmit = useCallback( (answer: string) => { setHasStartedConversation(true); void sendMessage(threadId, { text: answer, files: [] }); }, [sendMessage, threadId], ); 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; const contextCompressionEnabled = localSettings.display.contextCompressionEnabled ?? false; const proModeEnabled = settings.context.mode === "pro"; const toggleWritingMode = useCallback(() => { setSettings("context", { writing_mode: !settings.context.writing_mode, }); }, [setSettings, settings.context.writing_mode]); const toggleMemoryInjection = useCallback(() => { setSettings("context", { memory_injection_enabled: !(settings.context.memory_injection_enabled ?? true), }); }, [setSettings, settings.context.memory_injection_enabled]); const toggleRagMode = useCallback(() => { if (ragModeLocked) { return; } setLocalSettings("display", { ragModeEnabled: !ragModeEnabled, }); }, [ragModeEnabled, ragModeLocked, setLocalSettings]); const toggleContextCompression = useCallback(() => { setLocalSettings("display", { contextCompressionEnabled: !contextCompressionEnabled, }); }, [contextCompressionEnabled, setLocalSettings]); const toggleProMode = useCallback(() => { setSettings("context", { mode: proModeEnabled ? "flash" : "pro", reasoning_effort: proModeEnabled ? "minimal" : "medium", }); }, [proModeEnabled, setSettings]); const handleInterrupt = useCallback( async (message: BrainyChatInterruptMessage) => { const targetRequestId = message.payload?.requestId?.trim(); if (targetRequestId) { pendingRequestIdRef.current = targetRequestId; } console.log("收到插件中断消息", { reason: message.payload?.reason, requestId: message.requestId, targetRequestId, }); await thread.stop(); }, [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 | BrainyUrlContextMessage >, ) => { const data = event.data; if (data?.source !== "brainy-extension") return; if (data?.type === "brainy:chat-interrupt") { 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, pageContext: submitMessage.payload?.pageContext, screenshot: submitMessage.payload?.screenshot, model: submitMessage.payload?.model, requestId: submitMessage.requestId, filesCount: files.length, iframeContext, }); pendingRequestIdRef.current = submitMessage.requestId ?? null; setPendingIframeSubmit({ message: { text: submitText, files }, externalContext: iframeContext, options: additionalKwargs ? { additionalKwargs } : undefined, targetSelectedAgentId: selectedAgentId, targetForceDefaultAssistant: forceDefaultAssistant, }); }; window.addEventListener("message", handleMessage); return () => { window.removeEventListener("message", handleMessage); }; }, [ applyIncomingUrlSelection, forceDefaultAssistant, handleInterrupt, selectedAgentId, ]); const isLandingState = isNewThread && !hasStartedConversation; const bottomBarHeightClass = "pb-24"; const modeButtons: ResolvedIframeModeButton[] = buildIframeModeButtons({ agentSelected: !!selectedAgentId, writingModeEnabled: !!settings.context.writing_mode, memoryInjectionEnabled: Boolean( settings.context.memory_injection_enabled ?? true, ), ragModeEnabled, ragModeLocked, contextCompressionEnabled, proModeEnabled, }).map((button): ResolvedIframeModeButton => { switch (button.key) { case "agent": return { key: "agent", label: button.label, active: button.active, disabled: button.disabled, }; 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: throw new Error(`Unknown iframe mode button: ${String(button.key)}`); } }); return (
{/*
*/}
{!isLandingState && ( )}
{isLandingState && mounted && (
你好
)} {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
{t.common.notAvailableInDemoMode}
)}
{modeButtons.map((button) => { if (button.key === "agent") { return ( ); } const Icon = button.icon; return ( ); })}
); }