Merge branch 'gzy_iframedf' into 'master'

Gzy iframedf

See merge request knowledge-base-bj/deerflow-server!79
This commit is contained in:
g zy committed 2026-06-03 03:08:10 +00:00
commit f2dc03b51e
17 files changed
+1600 -529

No files matched your search

+5
View File
@@ -91,5 +91,10 @@ deploy
frontend-web/.offline-pack-build/ frontend-web/.offline-pack-build/
frontend-web/.offline-extract-test/ frontend-web/.offline-extract-test/
.codegraph
codegraph.mdc
docs/aidev-docs
docs/backups
# Frontend skills components must be tracked (excluded from the broad skills/ ignore above) # Frontend skills components must be tracked (excluded from the broad skills/ ignore above)
!frontend-web/src/components/workspace/skills/ !frontend-web/src/components/workspace/skills/
@@ -0,0 +1,139 @@
# Iframe Chat Page Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a standalone `/page/workspace/chats_iframe/*` chat page that reuses the existing thread system and history, while showing only `你好` and no input box before the first user message.
**Architecture:** Keep the existing workspace shell and chat runtime intact, then add a new route pair and a copied page component dedicated to the iframe path. The new page keeps the original chat behavior once a thread already has messages, but it owns its empty-state rendering and URL transition to `/page/workspace/chats_iframe/:thread_id`.
**Tech Stack:** React, TypeScript, React Router, Next navigation shim, existing LangGraph thread hooks
---
### Task 1: Add the iframe chat routes
**Files:**
- Modify: `frontend-web/src/pages/WorkspaceRoutes.tsx`
- [ ] **Step 1: Add `chats_iframe/new` and `chats_iframe/:thread_id` routes that render the new page inside the existing workspace layout and chat runtime**
```tsx
<Route
path="chats_iframe/new"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<IframeChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="chats_iframe/:thread_id"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<IframeChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
```
- [ ] **Step 2: Ensure the new route imports compile cleanly**
Run: `rg -n "IframeChatPage|chats_iframe" frontend-web/src/pages/WorkspaceRoutes.tsx`
Expected: `WorkspaceRoutes.tsx` contains one import for `IframeChatPage` and both `chats_iframe` route entries.
### Task 2: Add a standalone iframe chat page
**Files:**
- Create: `frontend-web/src/pages/IframeChatPage.tsx`
- Reference: `frontend-web/src/pages/ChatPage.tsx`
- [ ] **Step 1: Copy the current `ChatPage` implementation into `IframeChatPage` and preserve the existing chat runtime integration**
```tsx
export default function IframeChatPage() {
const routeBase = useRouteBase();
const router = useRouter();
const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
useThreadChat();
// keep the rest of the existing chat hooks and handlers aligned with ChatPage
}
```
- [ ] **Step 2: Change the thread creation URL replacement to stay on the iframe route family**
```tsx
onStart: (createdThreadId) => {
setThreadId(createdThreadId);
setIsNewThread(false);
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#${routeBase}/chats_iframe/${createdThreadId}`,
);
},
```
- [ ] **Step 3: Replace the default landing state with a simple `你好` view and do not render the input box while the iframe page is still a brand-new untouched chat**
```tsx
{isLandingState && mounted ? (
<div className="flex min-h-[240px] items-center justify-center">
<div className="text-2xl font-medium">你好</div>
</div>
) : null}
{!isLandingState &&
(mounted ? (
<div className="w-full -translate-y-4">
<InputBox
className="w-full"
isNewThread={false}
threadId={threadId}
autoFocus={false}
status={thread.error ? "error" : thread.isLoading ? "streaming" : "ready"}
context={settings.context}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || isUploading}
onContextChange={(context) => setSettings("context", context)}
promptPrefix={promptPrefixProp}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
</div>
) : (
<div aria-hidden="true" className="bg-background/5 h-32 w-full -translate-y-4 rounded-3xl border" />
))}
```
- [ ] **Step 4: Keep message rendering, thread title, export, token usage, todos, artifact triggers, and all post-first-message behavior identical to the original page**
Run: `rg -n "MessageList|ThreadTitle|ArtifactTrigger|TokenUsageIndicator|TodoList" frontend-web/src/pages/IframeChatPage.tsx`
Expected: The new page still renders the same core chat primitives used by `ChatPage`.
### Task 3: Verify the new page compiles
**Files:**
- Test: `frontend-web/src/pages/IframeChatPage.tsx`
- Test: `frontend-web/src/pages/WorkspaceRoutes.tsx`
- [ ] **Step 1: Run the frontend build**
Run: `cmd /c npm run build`
Expected: Build succeeds without TypeScript or bundling errors related to `IframeChatPage` or `chats_iframe`.
- [ ] **Step 2: Spot-check the changed routes in the built source**
Run: `rg -n "chats_iframe" frontend-web/src/pages`
Expected: Matches appear in `WorkspaceRoutes.tsx` and `IframeChatPage.tsx`.
- [ ] **Step 3: Commit after verification**
```bash
git add docs/superpowers/plans/2026-06-01-iframe-chat-page.md frontend-web/src/pages/WorkspaceRoutes.tsx frontend-web/src/pages/IframeChatPage.tsx
git commit -m "feat: add iframe chat page routes"
```
@@ -67,9 +67,14 @@ function logReferenceDebug(
}); });
} }
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ const ChatBox: React.FC<{
children: React.ReactNode;
threadId: string;
disableArtifacts?: boolean;
}> = ({
children, children,
threadId, threadId,
disableArtifacts = false,
}) => { }) => {
const { thread } = useThread(); const { thread } = useThread();
const pathname = usePathname(); const pathname = usePathname();
@@ -275,12 +280,16 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
} }
// Update artifacts from the current thread // Update artifacts from the current thread
const nextArtifacts = thread.values.artifacts ?? []; const nextArtifacts = disableArtifacts ? [] : (thread.values.artifacts ?? []);
const hadArtifacts = artifactsCountRef.current > 0; const hadArtifacts = artifactsCountRef.current > 0;
const hasNewArtifact = nextArtifacts.length > artifactsCountRef.current; const hasNewArtifact = nextArtifacts.length > artifactsCountRef.current;
artifactsCountRef.current = nextArtifacts.length; artifactsCountRef.current = nextArtifacts.length;
setArtifacts(nextArtifacts); setArtifacts(nextArtifacts);
if ((hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) && !selectedArtifact) { if (
!disableArtifacts &&
(hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) &&
!selectedArtifact
) {
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!); selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
setArtifactsOpen(true); setArtifactsOpen(true);
setSideTab("document"); setSideTab("document");
@@ -295,6 +304,7 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
// } // }
if ( if (
!disableArtifacts &&
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
autoSelectFirstArtifact autoSelectFirstArtifact
) { ) {
@@ -311,14 +321,18 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
selectedArtifact, selectedArtifact,
setArtifacts, setArtifacts,
thread.values.artifacts, thread.values.artifacts,
disableArtifacts,
]); ]);
const artifactPanelOpen = useMemo(() => { const artifactPanelOpen = useMemo(() => {
if (disableArtifacts) {
return false;
}
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") { if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
return artifactsOpen && artifacts?.length > 0; return artifactsOpen && artifacts?.length > 0;
} }
return artifactsOpen; return artifactsOpen;
}, [artifactsOpen, artifacts]); }, [artifactsOpen, artifacts, disableArtifacts]);
const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen); const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen);
const showTabs = !!selectedArtifact && hasCitationReferences; const showTabs = !!selectedArtifact && hasCitationReferences;
@@ -347,6 +361,10 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
} }
}, [sidePanelOpen]); }, [sidePanelOpen]);
if (disableArtifacts) {
return <>{children}</>;
}
return ( return (
<ResizablePanelGroup <ResizablePanelGroup
id={`${resizableIdBase}-panels`} id={`${resizableIdBase}-panels`}
@@ -1,5 +1,5 @@
import type { Message } from "@langchain/langgraph-sdk"; import type { Message } from '@langchain/langgraph-sdk';
import { ClockIcon, FileIcon, Loader2Icon } from "lucide-react"; import { ClockIcon, FileIcon, Loader2Icon } from 'lucide-react';
import { import {
memo, memo,
useCallback, useCallback,
@@ -8,31 +8,31 @@ import {
useState, useState,
type AnchorHTMLAttributes, type AnchorHTMLAttributes,
type ImgHTMLAttributes, type ImgHTMLAttributes,
} from "react"; } from 'react';
import rehypeKatex from "rehype-katex"; import rehypeKatex from 'rehype-katex';
import { Loader } from "@/components/ai-elements/loader"; import { Loader } from '@/components/ai-elements/loader';
import { import {
Message as AIElementMessage, Message as AIElementMessage,
MessageContent as AIElementMessageContent, MessageContent as AIElementMessageContent,
MessageResponse as AIElementMessageResponse, MessageResponse as AIElementMessageResponse,
MessageToolbar, MessageToolbar,
} from "@/components/ai-elements/message"; } from '@/components/ai-elements/message';
import { import {
Reasoning, Reasoning,
ReasoningContent, ReasoningContent,
ReasoningTrigger, ReasoningTrigger,
} from "@/components/ai-elements/reasoning"; } from '@/components/ai-elements/reasoning';
import { Task, TaskTrigger } from "@/components/ai-elements/task"; import { Task, TaskTrigger } from '@/components/ai-elements/task';
import { Badge } from "@/components/ui/badge"; import { Badge } from '@/components/ui/badge';
import { import {
UploadedFilePreviewDialog, UploadedFilePreviewDialog,
useAuthedObjectUrl, useAuthedObjectUrl,
} from "@/components/workspace/messages/uploaded-file-preview-dialog"; } from '@/components/workspace/messages/uploaded-file-preview-dialog';
import { resolveArtifactURL } from "@/core/artifacts/utils"; import { resolveArtifactURL } from '@/core/artifacts/utils';
import { useChatAskTimes } from "@/core/chat-timestamps"; import { useChatAskTimes } from '@/core/chat-timestamps';
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from '@/core/config';
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from '@/core/i18n/hooks';
import { import {
extractContentFromMessage, extractContentFromMessage,
extractReasoningContentFromMessage, extractReasoningContentFromMessage,
@@ -40,22 +40,63 @@ import {
stripArtifactTag, stripArtifactTag,
stripUploadedFilesTag, stripUploadedFilesTag,
type FileInMessage, type FileInMessage,
} from "@/core/messages/utils"; } from '@/core/messages/utils';
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { useRehypeSplitWordsIntoSpans } from '@/core/rehype';
import { import {
applyInlineCitations, applyInlineCitations,
type RuntimeDisplaySource, type RuntimeDisplaySource,
} from "@/core/skill-display/runtime"; } from '@/core/skill-display/runtime';
import { humanMessagePlugins } from "@/core/streamdown"; import { humanMessagePlugins } from '@/core/streamdown';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
import { CitationLink } from "../citations/citation-link"; import { CitationLink } from '../citations/citation-link';
import { CopyButton } from "../copy-button"; import { CopyButton } from '../copy-button';
import { SkillDisplayBlock } from "../skill-display-block"; import { SkillDisplayBlock } from '../skill-display-block';
import { ThreadContext } from "./context"; import { ThreadContext } from './context';
import { DownloadWordButton } from "./download-word-button"; import { DownloadWordButton } from './download-word-button';
import { MarkdownContent, type MarkdownContentProps } from "./markdown-content"; import { MarkdownContent, type MarkdownContentProps } from './markdown-content';
import { MessageTokenUsage } from "./message-token-usage"; import { MessageTokenUsage } from './message-token-usage';
type EmbeddedPageContext = {
title?: string;
url?: string;
content?: string;
};
function extractTagContent(source: string, tagName: string) {
const match = new RegExp(
`<${tagName}>\\s*([\\s\\S]*?)\\s*</${tagName}>`,
'i',
).exec(source);
return match?.[1]?.trim() ?? '';
}
function parseEmbeddedPageContext(source: string): {
content: string;
pageContext: EmbeddedPageContext | null;
} {
const match = /<page_context>\s*([\s\S]*?)\s*<\/page_context>/i.exec(source);
if (!match) {
return { content: source, pageContext: null };
}
const body = match[1] ?? '';
const pageContext = {
title: extractTagContent(body, 'title') || undefined,
url: extractTagContent(body, 'url') || undefined,
content: extractTagContent(body, 'content') || undefined,
};
const cleanedContent = source
.replace(match[0], '')
.replace(/\n{3,}/g, '\n\n')
.trim();
if (!pageContext.title && !pageContext.url && !pageContext.content) {
return { content: cleanedContent, pageContext: null };
}
return { content: cleanedContent, pageContext };
}
/** /**
* Resolve an in-conversation file path to a fetchable URL. * Resolve an in-conversation file path to a fetchable URL.
@@ -69,7 +110,7 @@ function useArtifactResolver(threadId: string): (path: string) => string {
return useCallback( return useCallback(
(path: string) => { (path: string) => {
if (publicShareCode) { if (publicShareCode) {
const normalized = path.startsWith("/") ? path : `/${path}`; const normalized = path.startsWith('/') ? path : `/${path}`;
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent( return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
publicShareCode, publicShareCode,
)}/files${normalized}`; )}/files${normalized}`;
@@ -82,6 +123,7 @@ function useArtifactResolver(threadId: string): (path: string) => string {
export function MessageListItem({ export function MessageListItem({
className, className,
fitRowToContent = false,
message, message,
isLoading, isLoading,
threadId, threadId,
@@ -90,6 +132,7 @@ export function MessageListItem({
extraRehypePlugins, extraRehypePlugins,
}: { }: {
className?: string; className?: string;
fitRowToContent?: boolean;
message: Message; message: Message;
isLoading?: boolean; isLoading?: boolean;
threadId: string; threadId: string;
@@ -97,18 +140,23 @@ export function MessageListItem({
displaySources?: RuntimeDisplaySource[]; displaySources?: RuntimeDisplaySource[];
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>; extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
}) { }) {
const isHuman = message.type === "human"; const isHuman = message.type === 'human';
const messageMarkdown = const messageMarkdown =
extractContentFromMessage(message) ?? extractContentFromMessage(message) ??
extractReasoningContentFromMessage(message) ?? extractReasoningContentFromMessage(message) ??
""; '';
return ( return (
<AIElementMessage <AIElementMessage
className={cn("group/conversation-message relative w-full", className)} className={cn(
from={isHuman ? "user" : "assistant"} 'group/conversation-message relative',
fitRowToContent ? 'max-w-full w-fit' : 'w-full',
isHuman && fitRowToContent && 'ml-auto',
className,
)}
from={isHuman ? 'user' : 'assistant'}
> >
<MessageContent <MessageContent
className={isHuman ? "w-fit" : "w-full"} className={isHuman ? 'w-fit' : 'w-full'}
message={message} message={message}
isLoading={isLoading} isLoading={isLoading}
threadId={threadId} threadId={threadId}
@@ -119,8 +167,8 @@ export function MessageListItem({
{!isLoading && ( {!isLoading && (
<MessageToolbar <MessageToolbar
className={cn( className={cn(
isHuman ? "-bottom-9 justify-end" : "-bottom-8", isHuman ? '-bottom-9 justify-end' : '-bottom-8',
"absolute right-0 left-0 z-20 opacity-0 transition-opacity delay-200 duration-300 group-hover/conversation-message:opacity-100", 'absolute right-0 left-0 z-20 opacity-0 transition-opacity delay-200 duration-300 group-hover/conversation-message:opacity-100',
)} )}
> >
<div className="flex gap-1"> <div className="flex gap-1">
@@ -145,7 +193,7 @@ function MessageImage({
src, src,
alt, alt,
threadId, threadId,
maxWidth = "90%", maxWidth = '90%',
...props ...props
}: React.ImgHTMLAttributes<HTMLImageElement> & { }: React.ImgHTMLAttributes<HTMLImageElement> & {
threadId: string; threadId: string;
@@ -154,13 +202,13 @@ function MessageImage({
const resolveArtifact = useArtifactResolver(threadId); const resolveArtifact = useArtifactResolver(threadId);
if (!src) return null; if (!src) return null;
const imgClassName = cn("overflow-hidden rounded-lg", `max-w-[${maxWidth}]`); const imgClassName = cn('overflow-hidden rounded-lg', `max-w-[${maxWidth}]`);
if (typeof src !== "string") { if (typeof src !== 'string') {
return <img className={imgClassName} src={src} alt={alt} {...props} />; return <img className={imgClassName} src={src} alt={alt} {...props} />;
} }
const url = src.startsWith("/mnt/") ? resolveArtifact(src) : src; const url = src.startsWith('/mnt/') ? resolveArtifact(src) : src;
return ( return (
<a href={url} target="_blank" rel="noopener noreferrer"> <a href={url} target="_blank" rel="noopener noreferrer">
@@ -188,11 +236,14 @@ function MessageContent_({
}) { }) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const rehypePlugins = useMemo( const rehypePlugins = useMemo(
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins), () =>
extraRehypePlugins?.length
? [...baseRehypePlugins, ...extraRehypePlugins]
: baseRehypePlugins,
[baseRehypePlugins, extraRehypePlugins], [baseRehypePlugins, extraRehypePlugins],
); );
const askTimes = useChatAskTimes(); const askTimes = useChatAskTimes();
const isHuman = message.type === "human"; const isHuman = message.type === 'human';
const resolveArtifact = useArtifactResolver(threadId); const resolveArtifact = useArtifactResolver(threadId);
const components = useMemo( const components = useMemo(
() => ({ () => ({
@@ -200,13 +251,16 @@ function MessageContent_({
<MessageImage {...props} threadId={threadId} maxWidth="90%" /> <MessageImage {...props} threadId={threadId} maxWidth="90%" />
), ),
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => { a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (href?.startsWith("#reference-")) { if (href?.startsWith('#reference-')) {
return <CitationLink href={href} {...props} />; return <CitationLink href={href} {...props} />;
} }
if (typeof props.children === "string" && /^citation:/i.test(props.children)) { if (
typeof props.children === 'string' &&
/^citation:/i.test(props.children)
) {
return <CitationLink href={href} {...props} />; return <CitationLink href={href} {...props} />;
} }
if (href?.startsWith("/mnt/")) { if (href?.startsWith('/mnt/')) {
const url = resolveArtifact(href); const url = resolveArtifact(href);
return ( return (
<a <a
@@ -234,7 +288,7 @@ function MessageContent_({
const files = useMemo(() => { const files = useMemo(() => {
const files = message.additional_kwargs?.files; const files = message.additional_kwargs?.files;
if (!Array.isArray(files) || files.length === 0) { if (!Array.isArray(files) || files.length === 0) {
if (rawContent.includes("<uploaded_files>")) { if (rawContent.includes('<uploaded_files>')) {
// If the content contains the <uploaded_files> tag, we return the parsed files from the content for backward compatibility. // If the content contains the <uploaded_files> tag, we return the parsed files from the content for backward compatibility.
return parseUploadedFiles(rawContent); return parseUploadedFiles(rawContent);
} }
@@ -245,10 +299,19 @@ function MessageContent_({
const contentToDisplay = useMemo(() => { const contentToDisplay = useMemo(() => {
if (isHuman) { if (isHuman) {
return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : ""; return rawContent
? stripArtifactTag(stripUploadedFilesTag(rawContent))
: '';
} }
return applyInlineCitations(rawContent ?? "", displaySources); return applyInlineCitations(rawContent ?? '', displaySources);
}, [rawContent, isHuman, displaySources]); }, [rawContent, isHuman, displaySources]);
const parsedHumanContent = useMemo(
() =>
isHuman
? parseEmbeddedPageContext(contentToDisplay)
: { content: contentToDisplay, pageContext: null },
[contentToDisplay, isHuman],
);
const filesList = const filesList =
files && files.length > 0 ? ( files && files.length > 0 ? (
@@ -256,7 +319,7 @@ function MessageContent_({
) : null; ) : null;
// Uploading state: mock AI message shown while files upload // Uploading state: mock AI message shown while files upload
if (message.additional_kwargs?.element === "task") { if (message.additional_kwargs?.element === 'task') {
return ( return (
<AIElementMessageContent className={className}> <AIElementMessageContent className={className}>
<Task defaultOpen={false}> <Task defaultOpen={false}>
@@ -289,7 +352,7 @@ function MessageContent_({
} }
if (isHuman) { if (isHuman) {
const messageResponse = contentToDisplay ? ( const messageResponse = parsedHumanContent.content ? (
<AIElementMessageResponse <AIElementMessageResponse
remarkPlugins={humanMessagePlugins.remarkPlugins} remarkPlugins={humanMessagePlugins.remarkPlugins}
rehypePlugins={humanMessagePlugins.rehypePlugins} rehypePlugins={humanMessagePlugins.rehypePlugins}
@@ -300,13 +363,23 @@ function MessageContent_({
</AIElementMessageResponse> </AIElementMessageResponse>
) : null; ) : null;
return ( return (
<div className={cn("ml-auto flex flex-col gap-2", className)}> <div
className={cn(
'ml-auto flex max-w-full min-w-0 flex-col items-end gap-2',
className,
)}
>
{filesList} {filesList}
{messageResponse && ( {messageResponse && (
<AIElementMessageContent className="w-fit"> <AIElementMessageContent className="min-w-0 max-w-full w-fit">
{messageResponse} {messageResponse}
</AIElementMessageContent> </AIElementMessageContent>
)} )}
{parsedHumanContent.pageContext ? (
<EmbeddedPageContextCard
pageContext={parsedHumanContent.pageContext}
/>
) : null}
<MessageTimeStamp time={messageTime} align="right" /> <MessageTimeStamp time={messageTime} align="right" />
</div> </div>
); );
@@ -321,7 +394,7 @@ function MessageContent_({
displaySources={displaySources} displaySources={displaySources}
isLoading={isLoading} isLoading={isLoading}
renderSkillDisplayBlocks={false} renderSkillDisplayBlocks={false}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]} rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
components={components} components={components}
/> />
</div> </div>
@@ -337,6 +410,27 @@ function MessageContent_({
); );
} }
function EmbeddedPageContextCard({
pageContext,
}: {
pageContext: EmbeddedPageContext;
}) {
const previewText = [pageContext.title, pageContext.url, pageContext.content]
.filter(Boolean)
.join(' | ');
return (
<div className="bg-muted/70 ml-auto flex w-fit min-w-0 max-w-[min(24rem,calc(100vw-2rem),100%)] flex-col gap-2 rounded-2xl border px-3 py-2 text-sm shadow-sm">
<div
className="text-muted-foreground line-clamp-2 min-w-0 [overflow-wrap:anywhere] break-words text-xs"
title={previewText}
>
{previewText}
</div>
</div>
);
}
/** /**
* Format a wall-clock message time as ``YYYY-MM-DD HH:mm`` for display, plus a * Format a wall-clock message time as ``YYYY-MM-DD HH:mm`` for display, plus a
* full localized string for the hover title. Returns ``null`` when no/invalid * full localized string for the hover title. Returns ``null`` when no/invalid
@@ -345,10 +439,10 @@ function MessageContent_({
function formatAskedTime( function formatAskedTime(
clientTs: unknown, clientTs: unknown,
): { label: string; full: string } | null { ): { label: string; full: string } | null {
if (typeof clientTs !== "string" || !clientTs) return null; if (typeof clientTs !== 'string' || !clientTs) return null;
const date = new Date(clientTs); const date = new Date(clientTs);
if (Number.isNaN(date.getTime())) return null; if (Number.isNaN(date.getTime())) return null;
const pad = (n: number) => String(n).padStart(2, "0"); const pad = (n: number) => String(n).padStart(2, '0');
const label = const label =
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
` ${pad(date.getHours())}:${pad(date.getMinutes())}`; ` ${pad(date.getHours())}:${pad(date.getMinutes())}`;
@@ -361,17 +455,17 @@ function formatAskedTime(
*/ */
function MessageTimeStamp({ function MessageTimeStamp({
time, time,
align = "right", align = 'right',
}: { }: {
time: { label: string; full: string } | null; time: { label: string; full: string } | null;
align?: "left" | "right"; align?: 'left' | 'right';
}) { }) {
if (!time) return null; if (!time) return null;
return ( return (
<div <div
className={cn( className={cn(
"text-muted-foreground/60 flex items-center gap-1 text-[11px] leading-none tabular-nums", 'text-muted-foreground/60 flex items-center gap-1 text-[11px] leading-none tabular-nums',
align === "right" ? "justify-end pr-0.5" : "justify-start pl-0.5", align === 'right' ? 'justify-end pr-0.5' : 'justify-start pl-0.5',
)} )}
title={time.full} title={time.full}
> >
@@ -385,39 +479,39 @@ function MessageTimeStamp({
* Get file extension and check helpers * Get file extension and check helpers
*/ */
const getFileExt = (filename: string) => const getFileExt = (filename: string) =>
filename.split(".").pop()?.toLowerCase() ?? ""; filename.split('.').pop()?.toLowerCase() ?? '';
const FILE_TYPE_MAP: Record<string, string> = { const FILE_TYPE_MAP: Record<string, string> = {
json: "JSON", json: 'JSON',
csv: "CSV", csv: 'CSV',
txt: "TXT", txt: 'TXT',
md: "Markdown", md: 'Markdown',
py: "Python", py: 'Python',
js: "JavaScript", js: 'JavaScript',
ts: "TypeScript", ts: 'TypeScript',
tsx: "TSX", tsx: 'TSX',
jsx: "JSX", jsx: 'JSX',
html: "HTML", html: 'HTML',
css: "CSS", css: 'CSS',
xml: "XML", xml: 'XML',
yaml: "YAML", yaml: 'YAML',
yml: "YAML", yml: 'YAML',
pdf: "PDF", pdf: 'PDF',
png: "PNG", png: 'PNG',
jpg: "JPG", jpg: 'JPG',
jpeg: "JPEG", jpeg: 'JPEG',
gif: "GIF", gif: 'GIF',
svg: "SVG", svg: 'SVG',
zip: "ZIP", zip: 'ZIP',
tar: "TAR", tar: 'TAR',
gz: "GZ", gz: 'GZ',
}; };
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"]; const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'];
function getFileTypeLabel(filename: string): string { function getFileTypeLabel(filename: string): string {
const ext = getFileExt(filename); const ext = getFileExt(filename);
return FILE_TYPE_MAP[ext] ?? (ext.toUpperCase() || "FILE"); return FILE_TYPE_MAP[ext] ?? (ext.toUpperCase() || 'FILE');
} }
function isImageFile(filename: string): boolean { function isImageFile(filename: string): boolean {
@@ -428,7 +522,7 @@ function isImageFile(filename: string): boolean {
* Format bytes to human-readable size string * Format bytes to human-readable size string
*/ */
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
if (bytes === 0) return "—"; if (bytes === 0) return '0 B';
const kb = bytes / 1024; const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(1)} KB`; if (kb < 1024) return `${kb.toFixed(1)} KB`;
return `${(kb / 1024).toFixed(1)} MB`; return `${(kb / 1024).toFixed(1)} MB`;
@@ -471,7 +565,7 @@ function RichFileCard({
const { t } = useI18n(); const { t } = useI18n();
const resolveArtifact = useArtifactResolver(threadId); const resolveArtifact = useArtifactResolver(threadId);
const [previewOpen, setPreviewOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false);
const isUploading = file.status === "uploading"; const isUploading = file.status === 'uploading';
const isImage = isImageFile(file.filename); const isImage = isImageFile(file.filename);
const fileUrl = file.path ? resolveArtifact(file.path) : null; const fileUrl = file.path ? resolveArtifact(file.path) : null;
// Thumbnails must be fetched with auth: the artifact route is gated and a // Thumbnails must be fetched with auth: the artifact route is gated and a
@@ -601,8 +695,8 @@ function AssistantDisplayContent({
displaySources: RuntimeDisplaySource[]; displaySources: RuntimeDisplaySource[];
isLoading: boolean; isLoading: boolean;
renderSkillDisplayBlocks: boolean; renderSkillDisplayBlocks: boolean;
rehypePlugins: MarkdownContentProps["rehypePlugins"]; rehypePlugins: MarkdownContentProps['rehypePlugins'];
components: MarkdownContentProps["components"]; components: MarkdownContentProps['components'];
}) { }) {
const { parts, placedKeys } = useMemo( const { parts, placedKeys } = useMemo(
() => () =>
@@ -618,14 +712,14 @@ function AssistantDisplayContent({
return []; return [];
} }
return groupDisplaySources(displaySources).filter( return groupDisplaySources(displaySources).filter(
(group) => group.mode !== "citation" && !placedKeys.has(group.key), (group) => group.mode !== 'citation' && !placedKeys.has(group.key),
); );
}, [displaySources, placedKeys, renderSkillDisplayBlocks]); }, [displaySources, placedKeys, renderSkillDisplayBlocks]);
return ( return (
<> <>
{parts.map((part, index) => {parts.map((part, index) =>
part.type === "markdown" ? ( part.type === 'markdown' ? (
<MarkdownContent <MarkdownContent
key={`markdown-${index}`} key={`markdown-${index}`}
content={part.content} content={part.content}
@@ -654,11 +748,11 @@ function AssistantDisplayContent({
} }
type DisplayPart = type DisplayPart =
| { type: "markdown"; content: string } | { type: 'markdown'; content: string }
| { | {
type: "display"; type: 'display';
key: string; key: string;
mode: RuntimeDisplaySource["mode"]; mode: RuntimeDisplaySource['mode'];
sources: RuntimeDisplaySource[]; sources: RuntimeDisplaySource[];
}; };
@@ -675,16 +769,16 @@ function splitSkillDisplayPlaceholders(
const start = match.index ?? 0; const start = match.index ?? 0;
const before = content.slice(cursor, start); const before = content.slice(cursor, start);
if (before.trim()) { if (before.trim()) {
parts.push({ type: "markdown", content: before }); parts.push({ type: 'markdown', content: before });
} }
const attrs = parseDisplayAttrs(match[1] ?? ""); const attrs = parseDisplayAttrs(match[1] ?? '');
const group = findDisplayGroup(displaySources, attrs.tool, attrs.mode); const group = findDisplayGroup(displaySources, attrs.tool, attrs.mode);
if (group) { if (group) {
placedKeys.add(group.key); placedKeys.add(group.key);
} }
if (group && renderSkillDisplayBlocks) { if (group && renderSkillDisplayBlocks) {
parts.push({ parts.push({
type: "display", type: 'display',
key: group.key, key: group.key,
mode: group.mode, mode: group.mode,
sources: group.sources, sources: group.sources,
@@ -694,26 +788,29 @@ function splitSkillDisplayPlaceholders(
} }
const rest = content.slice(cursor); const rest = content.slice(cursor);
if (rest.trim() || parts.length === 0) { if (rest.trim() || parts.length === 0) {
parts.push({ type: "markdown", content: rest }); parts.push({ type: 'markdown', content: rest });
} }
return { parts, placedKeys }; return { parts, placedKeys };
} }
function parseDisplayAttrs(raw: string): { tool?: string; mode?: RuntimeDisplaySource["mode"] } { function parseDisplayAttrs(raw: string): {
tool?: string;
mode?: RuntimeDisplaySource['mode'];
} {
const attrs: Record<string, string> = {}; const attrs: Record<string, string> = {};
for (const match of raw.matchAll(/([a-zA-Z][\w-]*)="([^"]*)"/g)) { for (const match of raw.matchAll(/([a-zA-Z][\w-]*)="([^"]*)"/g)) {
attrs[match[1]!] = match[2]!; attrs[match[1]!] = match[2]!;
} }
return { return {
tool: attrs.tool || attrs.skill || attrs.name, tool: attrs.tool || attrs.skill || attrs.name,
mode: attrs.mode as RuntimeDisplaySource["mode"] | undefined, mode: attrs.mode as RuntimeDisplaySource['mode'] | undefined,
}; };
} }
function findDisplayGroup( function findDisplayGroup(
sources: RuntimeDisplaySource[], sources: RuntimeDisplaySource[],
tool?: string, tool?: string,
mode?: RuntimeDisplaySource["mode"], mode?: RuntimeDisplaySource['mode'],
) { ) {
return groupDisplaySources(sources).find((group) => { return groupDisplaySources(sources).find((group) => {
if (mode && group.mode !== mode) { if (mode && group.mode !== mode) {
@@ -734,20 +831,18 @@ function groupDisplaySources(sources: RuntimeDisplaySource[]) {
{ {
key: string; key: string;
skillName: string; skillName: string;
mode: RuntimeDisplaySource["mode"]; mode: RuntimeDisplaySource['mode'];
sources: RuntimeDisplaySource[]; sources: RuntimeDisplaySource[];
} }
>(); >();
for (const source of sources) { for (const source of sources) {
const key = `${source.skillName}:${source.mode}`; const key = `${source.skillName}:${source.mode}`;
const group = const group = groups.get(key) ?? {
groups.get(key) ?? key,
{ skillName: source.skillName,
key, mode: source.mode,
skillName: source.skillName, sources: [],
mode: source.mode, };
sources: [],
};
group.sources.push(source); group.sources.push(source);
groups.set(key, group); groups.set(key, group);
} }
@@ -755,5 +850,5 @@ function groupDisplaySources(sources: RuntimeDisplaySource[]) {
} }
function normalizeName(name: string) { function normalizeName(name: string) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, ""); return name.toLowerCase().replace(/[^a-z0-9]+/g, '');
} }
@@ -50,22 +50,26 @@ import { SubtaskCard } from "./subtask-card";
export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 160; export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 160;
export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80; export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80;
export function MessageList({ export function MessageList({
className, className,
threadId, contentClassName,
thread, fitMessageRowsToContent = false,
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM, threadId,
tokenUsageEnabled = false, thread,
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
tokenUsageEnabled = false,
extraRehypePlugins, extraRehypePlugins,
onClarificationSubmit, onClarificationSubmit,
onSetupAgentApprove, onSetupAgentApprove,
onSetupAgentReject, onSetupAgentReject,
}: { }: {
className?: string; className?: string;
threadId: string; contentClassName?: string;
thread: BaseStream<AgentThreadState>; fitMessageRowsToContent?: boolean;
paddingBottom?: number; threadId: string;
tokenUsageEnabled?: boolean; thread: BaseStream<AgentThreadState>;
paddingBottom?: number;
tokenUsageEnabled?: boolean;
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>; extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onClarificationSubmit?: (answer: string) => void | Promise<void>; onClarificationSubmit?: (answer: string) => void | Promise<void>;
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>; onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
@@ -213,12 +217,17 @@ export function MessageList({
if (thread.isThreadLoading && messages.length === 0) { if (thread.isThreadLoading && messages.length === 0) {
return <MessageListSkeleton />; return <MessageListSkeleton />;
} }
return ( return (
<Conversation <Conversation
className={cn("flex size-full flex-col justify-center", className)} className={cn("flex size-full flex-col justify-center", className)}
> >
<ConversationContent className="mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12"> <ConversationContent
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}> className={cn(
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12",
contentClassName,
)}
>
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{groupMessages(messages, (group) => { {groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") { if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => { return group.messages.map((msg) => {
@@ -233,11 +242,12 @@ export function MessageList({
}) })
: []; : [];
return ( return (
<MessageListItem <MessageListItem
key={`${group.id}/${msg.id}`} key={`${group.id}/${msg.id}`}
message={msg} fitRowToContent={fitMessageRowsToContent}
isLoading={thread.isLoading} message={msg}
threadId={threadId} isLoading={thread.isLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled} tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources} displaySources={displaySources}
extraRehypePlugins={extraRehypePlugins} extraRehypePlugins={extraRehypePlugins}
@@ -1,9 +1,10 @@
import type { BaseStream } from "@langchain/langgraph-sdk"; import type { BaseStream } from "@langchain/langgraph-sdk";
import { useEffect } from "react"; import { useEffect } from "react";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import type { AgentThreadState } from "@/core/threads"; import type { AgentThreadState } from "@/core/threads";
import { desensitize } from "@/lib/desensitize"; import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { useThreadChat } from "./chats"; import { useThreadChat } from "./chats";
import { FlipDisplay } from "./flip-display"; import { FlipDisplay } from "./flip-display";
@@ -16,16 +17,18 @@ export function ThreadTitle({
threadId: string; threadId: string;
thread: BaseStream<AgentThreadState>; thread: BaseStream<AgentThreadState>;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { isNewThread } = useThreadChat(); const { isNewThread } = useThreadChat();
useEffect(() => { const displayTitle = sanitizeThreadTitleForDisplay(thread.values?.title);
let _title = t.pages.untitled;
useEffect(() => {
if (thread.values?.title) { let _title = t.pages.untitled;
_title = thread.values.title;
} else if (isNewThread) { if (thread.values?.title) {
_title = t.pages.newChat; _title = displayTitle;
} } else if (isNewThread) {
_title = t.pages.newChat;
}
if (thread.isThreadLoading) { if (thread.isThreadLoading) {
document.title = `Loading... - ${t.pages.appName}`; document.title = `Loading... - ${t.pages.appName}`;
} else { } else {
@@ -35,17 +38,18 @@ export function ThreadTitle({
isNewThread, isNewThread,
t.pages.newChat, t.pages.newChat,
t.pages.untitled, t.pages.untitled,
t.pages.appName, t.pages.appName,
thread.isThreadLoading, thread.isThreadLoading,
thread.values, thread.values,
]); displayTitle,
]);
if (!thread.values?.title) { if (!thread.values?.title) {
return null; return null;
} }
return ( return (
<FlipDisplay uniqueKey={threadId}> <FlipDisplay uniqueKey={threadId}>
{desensitize(thread.values.title ?? "Untitled")} {desensitize(displayTitle)}
</FlipDisplay> </FlipDisplay>
); );
} }
+19 -17
View File
@@ -2,26 +2,28 @@
import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client"; import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
import { getAuthorizationHeaderValue } from "../auth"; import { getAuthorizationHeaderValue } from "../auth";
import { getLangGraphBaseURL } from "../config"; import { getLangGraphBaseURL } from "../config";
import { sanitizeRunStreamOptions } from "./stream-mode"; import { withCsrfHeaders } from "./csrf";
import { sanitizeRunStreamOptions } from "./stream-mode";
function createCompatibleClient(isMock?: boolean): LangGraphClient { function createCompatibleClient(isMock?: boolean): LangGraphClient {
const client = new LangGraphClient({ const client = new LangGraphClient({
apiUrl: getLangGraphBaseURL(isMock), apiUrl: getLangGraphBaseURL(isMock),
onRequest(_url, init) { onRequest(_url, init) {
const headers = new Headers(init.headers ?? {}); const headers = withCsrfHeaders(init.headers);
const authHeader = getAuthorizationHeaderValue(); const authHeader = getAuthorizationHeaderValue();
if (authHeader && !headers.has("Authorization")) { if (authHeader && !headers.has("Authorization")) {
headers.set("Authorization", authHeader); headers.set("Authorization", authHeader);
} }
return { return {
...init, ...init,
headers, credentials: init.credentials ?? "include",
}; headers,
}, };
}); },
});
const originalRunStream = client.runs.stream.bind(client.runs); const originalRunStream = client.runs.stream.bind(client.runs);
client.runs.stream = ((threadId, assistantId, payload) => client.runs.stream = ((threadId, assistantId, payload) =>
+7 -2
View File
@@ -5,6 +5,7 @@ import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
import { getAuthorizationHeaderValue } from "../auth"; import { getAuthorizationHeaderValue } from "../auth";
import { getCanvasLangGraphBaseURL } from "../config"; import { getCanvasLangGraphBaseURL } from "../config";
import { withCsrfHeaders } from "./csrf";
import { sanitizeRunStreamOptions } from "./stream-mode"; import { sanitizeRunStreamOptions } from "./stream-mode";
/** /**
@@ -19,12 +20,16 @@ function createCanvasClient(): LangGraphClient {
const client = new LangGraphClient({ const client = new LangGraphClient({
apiUrl: getCanvasLangGraphBaseURL(), apiUrl: getCanvasLangGraphBaseURL(),
onRequest(_url, init) { onRequest(_url, init) {
const headers = new Headers(init.headers ?? {}); const headers = withCsrfHeaders(init.headers);
const authHeader = getAuthorizationHeaderValue(); const authHeader = getAuthorizationHeaderValue();
if (authHeader && !headers.has("Authorization")) { if (authHeader && !headers.has("Authorization")) {
headers.set("Authorization", authHeader); headers.set("Authorization", authHeader);
} }
return { ...init, headers }; return {
...init,
credentials: init.credentials ?? "include",
headers,
};
}, },
}); });
+42
View File
@@ -0,0 +1,42 @@
const CSRF_COOKIE_NAME = "csrf_token";
const CSRF_HEADER_NAME = "X-CSRF-Token";
function readCookie(name: string): string | undefined {
if (typeof document === "undefined") {
return undefined;
}
const cookies = document.cookie.split(";");
for (const cookie of cookies) {
const [rawName, ...rest] = cookie.trim().split("=");
if (rawName !== name) {
continue;
}
const rawValue = rest.join("=");
if (!rawValue) {
return undefined;
}
try {
return decodeURIComponent(rawValue);
} catch {
return rawValue;
}
}
return undefined;
}
export function getCsrfToken(): string | undefined {
const token = readCookie(CSRF_COOKIE_NAME)?.trim();
return token ? token : undefined;
}
export function withCsrfHeaders(headers?: HeadersInit): Headers {
const next = new Headers(headers ?? {});
const csrfToken = getCsrfToken();
if (csrfToken && !next.has(CSRF_HEADER_NAME)) {
next.set(CSRF_HEADER_NAME, csrfToken);
}
return next;
}
+17 -15
View File
@@ -1,18 +1,20 @@
import { getAuthorizationHeaderValue } from "@/core/auth"; import { getAuthorizationHeaderValue } from "@/core/auth";
import { withCsrfHeaders } from "./csrf";
function withAuthHeaders(headers?: HeadersInit): Headers {
const next = new Headers(headers ?? {}); function withAuthHeaders(headers?: HeadersInit): Headers {
const authHeader = getAuthorizationHeaderValue(); const next = withCsrfHeaders(headers);
if (authHeader && !next.has("Authorization")) { const authHeader = getAuthorizationHeaderValue();
next.set("Authorization", authHeader); if (authHeader && !next.has("Authorization")) {
} next.set("Authorization", authHeader);
return next; }
return next;
} }
export function apiFetch(input: RequestInfo | URL, init?: RequestInit) { export function apiFetch(input: RequestInfo | URL, init?: RequestInit) {
return fetch(input, { return fetch(input, {
...init, ...init,
headers: withAuthHeaders(init?.headers), credentials: init?.credentials ?? "include",
}); headers: withAuthHeaders(init?.headers),
} });
}
+6 -3
View File
@@ -350,9 +350,12 @@ export function useThreadStream({
// agent thread URL — otherwise fall back to the global new chat. // agent thread URL — otherwise fall back to the global new chat.
const hash = typeof window !== "undefined" ? window.location.hash : ""; const hash = typeof window !== "undefined" ? window.location.hash : "";
const agentMatch = hash.match(/#\/page\/workspace\/agents\/([^/]+)\//); const agentMatch = hash.match(/#\/page\/workspace\/agents\/([^/]+)\//);
const target = agentMatch const iframeMatch = hash.match(/#\/page\/workspace\/chats_iframe\//);
? `/page/workspace/agents/${agentMatch[1]}/chats/new` const target = agentMatch
: "/page/workspace/chats/new"; ? `/page/workspace/agents/${agentMatch[1]}/chats/new`
: iframeMatch
? "/page/workspace/chats_iframe/new"
: "/page/workspace/chats/new";
// First navigate to a safe URL so the post-reload page lands on a // First navigate to a safe URL so the post-reload page lands on a
// valid route. Then full-reload so TanStack Query cache, context // valid route. Then full-reload so TanStack Query cache, context
// providers and any other state captured under the previous user's // providers and any other state captured under the previous user's
+12
View File
@@ -65,3 +65,15 @@ export function textOfMessage(message: Message) {
export function titleOfThread(thread: AgentThread) { export function titleOfThread(thread: AgentThread) {
return thread.values?.title ?? "Untitled"; return thread.values?.title ?? "Untitled";
} }
export function sanitizeThreadTitleForDisplay(title: string | null | undefined) {
if (!title) return "Untitled";
const cleaned = title
.replace(/<\/?[^>\n]+>/g, " ")
.replace(/<[^>\n]*$/g, " ")
.replace(/\s+/g, " ")
.trim();
return cleaned || "Untitled";
}
+322
View File
@@ -0,0 +1,322 @@
"use client";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { useArtifacts } from "@/components/workspace/artifacts";
import {
ChatBox,
useSpecificChatMode,
useThreadChat,
} from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import {
MessageList,
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list";
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
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 { useThreadSettings } from "@/core/settings";
import { usePromptPrefixSettings } 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";
type BrainyChatStatus = "started" | "finished";
function postBrainyChatStatus(status: BrainyChatStatus, requestId: string) {
window.parent?.postMessage(
{
source: "brainy-iframe",
type: "brainy:chat-status",
payload: {
status,
requestId,
},
},
"*",
);
}
export default function IframeChatPage() {
const routeBase = useRouteBase();
const router = useRouter();
const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
useThreadChat();
const [settings] = useThreadSettings(threadId);
const [mounted, setMounted] = useState(false);
const { tokenUsageEnabled } = useModels();
const { data: promptPrefixSettings } = usePromptPrefixSettings();
const [promptPrefixOn, setPromptPrefixOn] = useState(false);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const pendingRequestIdRef = useRef<string | null>(null);
const { selectedArtifact } = useArtifacts();
useSpecificChatMode();
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (isNewThread) {
setHasStartedConversation(false);
setPromptPrefixOn(false);
setSelectedAgentId(null);
}
}, [isNewThread, threadId]);
const { showNotification } = useNotification();
const [thread, sendMessage, isUploading] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
context: settings.context,
isMock,
onStart: (createdThreadId) => {
setThreadId(createdThreadId);
setIsNewThread(false);
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#${routeBase}/chats_iframe/${createdThreadId}`,
);
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 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<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;
}
const shouldApplyPrefix =
isNewThread &&
!hasStartedConversation &&
promptPrefixOn &&
promptPrefixSettings?.enabled === true;
setHasStartedConversation(true);
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
const extraContext: Record<string, unknown> = {};
if (shouldApplyPrefix) {
extraContext.prompt_prefix_enabled = true;
}
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,
);
},
[
hasStartedConversation,
isNewThread,
promptPrefixOn,
promptPrefixSettings,
router,
selectedAgentId,
sendMessage,
settings.context.writing_mode,
threadId,
writingArtifactPath,
],
);
const handleClarificationSubmit = useCallback(
(answer: string) => {
setHasStartedConversation(true);
void sendMessage(threadId, { text: answer, files: [] });
},
[sendMessage, threadId],
);
useEffect(() => {
const handleMessage = (event: MessageEvent<BrainyChatSubmitMessage>) => {
const data = event.data;
if (data?.source !== "brainy-extension") return;
if (data?.type !== "brainy:chat-submit") return;
const { submitText, files, iframeContext } =
parseBrainyChatSubmitMessage(data);
console.log("收到插件消息", {
text: data.payload?.text,
prompt: data.payload?.prompt,
pageContext: data.payload?.pageContext,
screenshot: data.payload?.screenshot,
requestId: data.requestId,
filesCount: files.length,
});
if (!submitText && files.length === 0) return;
pendingRequestIdRef.current = data.requestId ?? null;
handleSubmit({ text: submitText, files }, iframeContext);
};
window.addEventListener("message", handleMessage);
return () => {
window.removeEventListener("message", handleMessage);
};
}, [handleSubmit]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined;
const isLandingState = isNewThread && !hasStartedConversation;
return (
<ThreadContext.Provider value={{ thread, isMock }}>
<ChatBox threadId={threadId} disableArtifacts>
<div className="relative flex size-full min-h-0 justify-between overflow-hidden">
<header
className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center border-b px-4",
isLandingState
? "border-transparent bg-transparent"
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
)}
>
<div className="flex w-full items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="flex items-center gap-2">
<TokenUsageIndicator
enabled={tokenUsageEnabled}
messages={thread.messages}
/>
<ExportTrigger threadId={threadId} />
</div>
</header>
<main className="flex min-h-0 max-w-full grow flex-col overflow-hidden">
<div className="flex size-full min-w-0">
{!isLandingState && (
<MessageList
className={cn("size-full", "pt-10")}
contentClassName="mx-0 max-w-full px-3 sm:px-4"
threadId={threadId}
thread={thread}
paddingBottom={messageListPaddingBottom}
tokenUsageEnabled={tokenUsageEnabled}
onClarificationSubmit={handleClarificationSubmit}
/>
)}
</div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div
className={cn(
"relative w-full",
isLandingState && "-translate-y-[calc(50vh-96px)]",
isLandingState
? "max-w-(--container-width-md)"
: "max-w-full",
)}
>
<div className="absolute -top-4 right-0 left-0 z-0">
<div className="absolute right-0 bottom-0 left-0">
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={
!thread.values.todos || thread.values.todos.length === 0
}
/>
</div>
</div>
{isLandingState && mounted && (
<div className="mb-6 flex min-h-40 flex-col items-center justify-center">
<div className="text-2xl font-medium">你好</div>
</div>
)}
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
</div>
</div>
</main>
</div>
</ChatBox>
</ThreadContext.Provider>
);
}
+55 -35
View File
@@ -1,29 +1,32 @@
"use client"; 'use client';
import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from 'react';
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from '@/contexts/ThemeContext';
import { loginByToken, loginByUsername } from "@/core/auth/api"; import { loginByToken, loginByUsername } from '@/core/auth/api';
import { setStoredAuth } from "@/core/auth"; import { setStoredAuth } from '@/core/auth';
import { import {
persistExternalLoginParams, persistExternalLoginParams,
resolveThemeFromQuery, resolveThemeFromQuery,
} from "@/core/auth/external-login"; } from '@/core/auth/external-login';
import { isWujieEnv, useWujieProps } from "@/core/embed/wujie"; import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
import { import {
GENERAL_QA_ROUTE, GENERAL_QA_ROUTE,
loginByYUserId, loginByYUserId,
persistToolsToken, persistToolsToken,
} from "@/core/auth/yuser-login"; } from '@/core/auth/yuser-login';
import { import {
isSidebarEnabledFromSearch, isSidebarEnabledFromSearch,
persistSidebarEnabled, persistSidebarEnabled,
} from "@/core/page-layout/sidebar-enabled"; } from '@/core/page-layout/sidebar-enabled';
const DEFAULT_USERNAME = "guest"; const DEFAULT_USERNAME = 'guest';
const IFRAME_LOGIN_SOURCE = 'iframe';
const DEFAULT_REDIRECT_PATH = '/page/workspace/chats/new';
const IFRAME_REDIRECT_PATH = '/page/workspace/chats_iframe/new';
type LoginStatus = "loading" | "success" | "error"; type LoginStatus = 'loading' | 'success' | 'error';
export default function LoginPage() { export default function LoginPage() {
const { username } = useParams<{ username?: string }>(); const { username } = useParams<{ username?: string }>();
@@ -34,25 +37,35 @@ export default function LoginPage() {
() => isSidebarEnabledFromSearch(searchParams.toString()), () => isSidebarEnabledFromSearch(searchParams.toString()),
[searchParams], [searchParams],
); );
const redirectPath = useMemo(
() =>
searchParams.get('from') === IFRAME_LOGIN_SOURCE
? IFRAME_REDIRECT_PATH
: DEFAULT_REDIRECT_PATH,
[searchParams],
);
const resolvedUsername = useMemo(() => { const resolvedUsername = useMemo(() => {
const raw = username?.trim(); const raw = username?.trim();
return raw && raw.length > 0 ? raw : DEFAULT_USERNAME; return raw && raw.length > 0 ? raw : DEFAULT_USERNAME;
}, [username]); }, [username]);
// 外部跳转参数:theme 直接调 setTheme;ticket/userId/env 持久化到 localStorage // 外部跳转参数:theme 直接调 setTheme;ticket/userId/env 持久化到 localStorage
const yUserId = searchParams.get("yUserId")?.trim() ?? ""; const yUserId = searchParams.get('yUserId')?.trim() ?? '';
const toolToken = searchParams.get("token")?.trim() ?? ""; const toolToken = searchParams.get('token')?.trim() ?? '';
// 用户名直登口令门:开启后端开关时,口令从 ?password= 透传 // 用户名直登口令门:开启后端开关时,口令从 ?password= 透传
const gatePassword = searchParams.get("password") ?? ""; const gatePassword = searchParams.get('password') ?? '';
// token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开) // token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开)
const authToken = searchParams.get("authToken")?.trim() ?? ""; const authToken = searchParams.get('authToken')?.trim() ?? '';
const externalParams = useMemo(() => ({ const externalParams = useMemo(
theme: searchParams.get("theme") ?? "", () => ({
ticket: searchParams.get("ticket") ?? "", theme: searchParams.get('theme') ?? '',
userId: searchParams.get("userId") ?? "", ticket: searchParams.get('ticket') ?? '',
env: searchParams.get("env") ?? "", userId: searchParams.get('userId') ?? '',
}), [searchParams]); env: searchParams.get('env') ?? '',
const [status, setStatus] = useState<LoginStatus>("loading"); }),
const [errorMessage, setErrorMessage] = useState<string>(""); [searchParams],
);
const [status, setStatus] = useState<LoginStatus>('loading');
const [errorMessage, setErrorMessage] = useState<string>('');
// 无界(wujie)父级通过 $wujie.props 注入的参数(ticket / userId / theme / env)。 // 无界(wujie)父级通过 $wujie.props 注入的参数(ticket / userId / theme / env)。
// hook 内部已把 ticket/userId/env 持久化到 localStorage(复用 external-login 存储)。 // hook 内部已把 ticket/userId/env 持久化到 localStorage(复用 external-login 存储)。
@@ -61,7 +74,7 @@ export default function LoginPage() {
// 调试:把无界注入的 props 打印出来(仅在无界子应用环境下,避免独立访问时刷日志)。 // 调试:把无界注入的 props 打印出来(仅在无界子应用环境下,避免独立访问时刷日志)。
useEffect(() => { useEffect(() => {
if (!isWujieEnv()) return; if (!isWujieEnv()) return;
console.log("[wujie] sub-app props:", wujieProps); console.log('[wujie] sub-app props:', wujieProps);
}, [wujieProps]); }, [wujieProps]);
// 主题切换:无界 props.theme 优先,其次 URL query theme。两者取值约定一致 // 主题切换:无界 props.theme 优先,其次 URL query theme。两者取值约定一致
@@ -85,8 +98,8 @@ export default function LoginPage() {
let cancelled = false; let cancelled = false;
async function runLogin() { async function runLogin() {
setStatus("loading"); setStatus('loading');
setErrorMessage(""); setErrorMessage('');
try { try {
let auth; let auth;
if (authToken) { if (authToken) {
@@ -106,15 +119,15 @@ export default function LoginPage() {
} }
setStoredAuth(auth); setStoredAuth(auth);
persistSidebarEnabled(sidebarEnabled); persistSidebarEnabled(sidebarEnabled);
setStatus("success"); setStatus('success');
router.replace(yUserId ? GENERAL_QA_ROUTE : "/page/workspace/chats/new"); router.replace(yUserId ? GENERAL_QA_ROUTE : redirectPath);
} catch (error) { } catch (error) {
if (cancelled) { if (cancelled) {
return; return;
} }
setStatus("error"); setStatus('error');
setErrorMessage( setErrorMessage(
error instanceof Error ? error.message : "Login request failed.", error instanceof Error ? error.message : 'Login request failed.',
); );
} }
} }
@@ -123,19 +136,26 @@ export default function LoginPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [resolvedUsername, router, sidebarEnabled, yUserId, toolToken, authToken, gatePassword]); }, [
resolvedUsername,
router,
sidebarEnabled,
yUserId,
toolToken,
authToken,
gatePassword,
]);
return ( return (
<div className="flex min-h-screen items-center justify-center bg-background px-6"> <div className="flex min-h-screen items-center justify-center bg-background px-6">
<div className="w-full max-w-lg rounded-xl border bg-card p-6 shadow-sm"> <div className="w-full max-w-lg rounded-xl border bg-card p-6 shadow-sm">
<h1 className="text-xl font-semibold">正在进入智能体模式</h1> <h1 className="text-xl font-semibold">正在进入智能体模式</h1>
{status === "error" && ( {status === 'error' && (
<p className="mt-4 text-sm text-red-600"> <p className="mt-4 text-sm text-red-600">
登录失败:{errorMessage || "未知错误"} 登录失败:{errorMessage || '未知错误'}
</p> </p>
)} )}
</div> </div>
</div> </div>
); );
} }
+370 -296
View File
@@ -1,45 +1,55 @@
import type { CSSProperties, ReactNode } from "react"; import type { CSSProperties, ReactNode } from 'react';
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from 'react-router-dom';
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from '@/core/auth';
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { PromptInputProvider } from "@/components/ai-elements/prompt-input"; import { PromptInputProvider } from '@/components/ai-elements/prompt-input';
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar"; import { WorkspaceSidebar } from '@/components/workspace/workspace-sidebar';
import { CommandPalette } from "@/components/workspace/command-palette"; import { CommandPalette } from '@/components/workspace/command-palette';
import { Toaster } from "sonner"; import { Toaster } from 'sonner';
import { SubtasksProvider } from "@/core/tasks/context"; import { SubtasksProvider } from '@/core/tasks/context';
import { ArtifactsProvider } from "@/components/workspace/artifacts"; import { ArtifactsProvider } from '@/components/workspace/artifacts';
import { RouteBaseProvider } from "@/core/route-base"; import { RouteBaseProvider } from '@/core/route-base';
import ChatsPage from "./ChatsPage"; import ChatsPage from './ChatsPage';
import ChatPage from "./ChatPage"; import ChatPage from './ChatPage';
import AgentsPage from "./AgentsPage"; import IframeChatPage from './IframeChatPage';
import AgentChatPage from "./AgentChatPage"; import AgentsPage from './AgentsPage';
import NewAgentPage from "./NewAgentPage"; import AgentChatPage from './AgentChatPage';
import AdminAgentsPage from "./AdminAgentsPage"; import NewAgentPage from './NewAgentPage';
import AdminScheduledTasksPage from "./AdminScheduledTasksPage"; import AdminAgentsPage from './AdminAgentsPage';
import AdminSkillsPage from "./AdminSkillsPage"; import AdminScheduledTasksPage from './AdminScheduledTasksPage';
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage"; import AdminSkillsPage from './AdminSkillsPage';
import ScheduledTasksPage from "./ScheduledTasksPage"; import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
import LlmMetricsPage from "./LlmMetricsPage"; import ScheduledTasksPage from './ScheduledTasksPage';
import MemoryPage from "./MemoryPage"; import LlmMetricsPage from './LlmMetricsPage';
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage"; import MemoryPage from './MemoryPage';
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage"; import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
import SkillEvolutionPage from "./SkillEvolutionPage"; import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
import SkillsPage from "./SkillsPage"; import SkillEvolutionPage from './SkillEvolutionPage';
import UsersPage from "./UsersPage"; import SkillsPage from './SkillsPage';
import AdminUserActivityPage from "./AdminUserActivityPage"; import UsersPage from './UsersPage';
import AdminMemoryPage from "./AdminMemoryPage"; import AdminUserActivityPage from './AdminUserActivityPage';
import AdminUserHubPage from "./AdminUserHubPage"; import AdminMemoryPage from './AdminMemoryPage';
import TagsPage from "./TagsPage"; import AdminUserHubPage from './AdminUserHubPage';
import RecommendedQuestionsPage from "./RecommendedQuestionsPage"; import TagsPage from './TagsPage';
import AIWritingPage from "@/open-canvas/pages/AIWritingPage"; import RecommendedQuestionsPage from './RecommendedQuestionsPage';
import StudioNotebooksPage from "./StudioNotebooksPage"; import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
import StudioNotebookDetailPage from "./StudioNotebookDetailPage"; import StudioNotebooksPage from './StudioNotebooksPage';
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage"; import StudioNotebookDetailPage from './StudioNotebookDetailPage';
import "@/roundtable-planning/styles/roundtable-planning.css"; import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
import '@/roundtable-planning/styles/roundtable-planning.css';
import { cn } from '@/lib/utils';
function isInIframe(): boolean {
try {
return window.self !== window.top;
} catch {
return true;
}
}
export function WorkspaceLayout({ export function WorkspaceLayout({
children, children,
@@ -47,6 +57,8 @@ export function WorkspaceLayout({
pageSidebarHidden = false, pageSidebarHidden = false,
headerHidden = false, headerHidden = false,
compactWorkspaceSidebar = false, compactWorkspaceSidebar = false,
wrapperOverflowHidden = false,
insetOverflowHidden = false,
pageSidebarDark = false, pageSidebarDark = false,
}: { }: {
children: ReactNode; children: ReactNode;
@@ -54,20 +66,24 @@ export function WorkspaceLayout({
pageSidebarHidden?: boolean; pageSidebarHidden?: boolean;
headerHidden?: boolean; headerHidden?: boolean;
compactWorkspaceSidebar?: boolean; compactWorkspaceSidebar?: boolean;
wrapperOverflowHidden?: boolean;
insetOverflowHidden?: boolean;
pageSidebarDark?: boolean; pageSidebarDark?: boolean;
}) { }) {
const pageSidebarWidth = pageSidebarHidden const pageSidebarWidth = pageSidebarHidden
? "0rem" ? '0rem'
: pageSidebarDark : pageSidebarDark
// dark page sidebar: ? // dark page sidebar:
// 展开: 视觉右边界 108px(外框 98 + 内层 translateX 10)+ 10px gap = 118px // 展开: 视觉右边界 108px(外框 98 + 内层 translateX 10)+ 10px gap = 118px
// 收起: sidebar 主体完全消失(连同其占据的宽度),workspace 内容铺到屏幕左边; // 收起: sidebar 主体完全消失(连同其占据的宽度),workspace 内容铺到屏幕左边;
// 左下角浮动按钮是 fixed,叠在主内容上不影响 layout // 左下角浮动按钮是 fixed,叠在主内容上不影响 layout
// 与 PageRoutes.tsx 的 main pl-[118px] / pl-0 保持一致 // 与 PageRoutes.tsx 的 main pl-[118px] / pl-0 保持一致
? (pageSidebarCollapsed ? "0rem" : "118px") pageSidebarCollapsed
? '0rem'
: '118px'
: pageSidebarCollapsed : pageSidebarCollapsed
? "4rem" ? '4rem'
: "16rem"; : '16rem';
return ( return (
<SidebarProvider <SidebarProvider
@@ -81,22 +97,26 @@ export function WorkspaceLayout({
// 故这里 left/top 归零即与原 fixed 视觉位置逐像素一致;高度则跟随容器而非视口。 // 故这里 left/top 归零即与原 fixed 视觉位置逐像素一致;高度则跟随容器而非视口。
// 作用域仅限本 SidebarProvider 实例(不动全局 ui/sidebar 原语),不影响 artifacts 等其它侧栏。 // 作用域仅限本 SidebarProvider 实例(不动全局 ui/sidebar 原语),不影响 artifacts 等其它侧栏。
className={[ className={[
"relative h-full w-full overflow-x-hidden", 'relative h-full w-full overflow-x-hidden',
"[&_[data-slot=sidebar-container]]:absolute", '[&_[data-slot=sidebar-container]]:absolute',
"[&_[data-slot=sidebar-container]]:left-0", '[&_[data-slot=sidebar-container]]:left-0',
"[&_[data-slot=sidebar-container]]:top-0", '[&_[data-slot=sidebar-container]]:top-0',
].join(" ")} ].join(' ')}
style={ style={
{ {
"--page-sidebar-width": pageSidebarWidth, '--page-sidebar-width': pageSidebarWidth,
"--header-height": headerHidden ? "0rem" : "4rem", '--header-height': headerHidden ? '0rem' : '4rem',
} as CSSProperties } as CSSProperties
} }
data-page-sidebar-collapsed={pageSidebarCollapsed} data-page-sidebar-collapsed={pageSidebarCollapsed}
defaultOpen defaultOpen
> >
<WorkspaceSidebar compactChrome={compactWorkspaceSidebar} /> <WorkspaceSidebar compactChrome={compactWorkspaceSidebar} />
<SidebarInset className="min-w-0">{children}</SidebarInset> <SidebarInset
className={cn('min-w-0', insetOverflowHidden && 'overflow-hidden')}
>
{children}
</SidebarInset>
<CommandPalette /> <CommandPalette />
<Toaster position="top-center" /> <Toaster position="top-center" />
</SidebarProvider> </SidebarProvider>
@@ -113,6 +133,30 @@ export function ChatRuntime({ children }: { children: ReactNode }) {
); );
} }
function NewChatEntryRoute({
layoutProps,
}: {
layoutProps: {
pageSidebarCollapsed: boolean;
pageSidebarHidden: boolean;
headerHidden: boolean;
compactWorkspaceSidebar: boolean;
pageSidebarDark: boolean;
};
}) {
if (isInIframe()) {
return <Navigate to="/page/workspace/chats_iframe/new" replace />;
}
return (
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<ChatPage />
</ChatRuntime>
</WorkspaceLayout>
);
}
export default function WorkspaceRoutes({ export default function WorkspaceRoutes({
pageSidebarCollapsed = false, pageSidebarCollapsed = false,
pageSidebarHidden = false, pageSidebarHidden = false,
@@ -136,263 +180,293 @@ export default function WorkspaceRoutes({
return ( return (
<RouteBaseProvider value="/page/workspace"> <RouteBaseProvider value="/page/workspace">
<Routes> <Routes>
<Route index element={<Navigate to="chats/new" replace />} /> <Route index element={<Navigate to="chats/new" replace />} />
<Route <Route
path="chats" path="chats"
element={ element={
<WorkspaceLayout {...layoutProps}>
<ChatsPage />
</WorkspaceLayout>
}
/>
<Route
path="chats/:thread_id"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<ChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="agents"
element={
<WorkspaceLayout {...layoutProps}>
<AgentsPage />
</WorkspaceLayout>
}
/>
<Route
path="agents/new"
element={
<WorkspaceLayout {...layoutProps}>
<NewAgentPage />
</WorkspaceLayout>
}
/>
<Route
path="agents/:agent_id/chats"
element={<Navigate to="/page/workspace/agents" replace />}
/>
<Route
path="agents/:agent_id/chats/:thread_id"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<AgentChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="skills"
element={
<WorkspaceLayout {...layoutProps}>
<SkillsPage />
</WorkspaceLayout>
}
/>
<Route
path="skills/evolution"
element={
<WorkspaceLayout {...layoutProps}>
<SkillEvolutionPage />
</WorkspaceLayout>
}
/>
<Route
path="memory"
element={
<WorkspaceLayout {...layoutProps}>
<MemoryPage />
</WorkspaceLayout>
}
/>
<Route
path="ai-writing"
element={
<WorkspaceLayout {...layoutProps}>
<AIWritingPage hideAdvancedFields />
</WorkspaceLayout>
}
/>
<Route
path="studio/notebooks"
element={
<WorkspaceLayout {...layoutProps}>
<StudioNotebooksPage />
</WorkspaceLayout>
}
/>
<Route
path="studio/notebooks/:notebookId"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<StudioNotebookDetailPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="roundtable"
element={<Navigate to="/page/workspace/roundtable/planning" replace />}
/>
<Route
path="roundtable/planning"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<RoundtablePlanningPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="scheduled"
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
/>
<Route
path="scheduled/tasks"
element={
<WorkspaceLayout {...layoutProps}>
<ScheduledTasksPage />
</WorkspaceLayout>
}
/>
<Route
path="scheduled/runs/:run_id"
element={
<WorkspaceLayout {...layoutProps}>
<ScheduledTaskRunDetailPage />
</WorkspaceLayout>
}
/>
{/* Unified admin user hub */}
<Route
path="admin"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps} pageSidebarHidden>
<AdminUserHubPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
{/* Legacy redirects */}
<Route
path="admin/users"
element={<Navigate to="/page/workspace/admin" replace />}
/>
<Route
path="admin/user-activity"
element={<Navigate to="/page/workspace/admin" replace />}
/>
<Route
path="admin/memory"
element={<Navigate to="/page/workspace/admin" replace />}
/>
<Route
path="admin/scheduled-tasks"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}> <WorkspaceLayout {...layoutProps}>
<AdminScheduledTasksPage /> <ChatsPage />
</WorkspaceLayout> </WorkspaceLayout>
) : ( }
<Navigate to="/page/workspace/chats/new" replace /> />
) <Route
} path="chats/new"
/> element={<NewChatEntryRoute layoutProps={layoutProps} />}
<Route />
path="admin/agents" <Route
element={ path="chats/:thread_id"
getStoredAuth()?.system_role === "admin" ? ( element={
<WorkspaceLayout {...layoutProps}> <WorkspaceLayout {...layoutProps}>
<AdminAgentsPage /> <ChatRuntime>
<ChatPage />
</ChatRuntime>
</WorkspaceLayout> </WorkspaceLayout>
) : ( }
<Navigate to="/page/workspace/chats/new" replace /> />
) <Route
} path="chats_iframe/new"
/> element={
<Route
path="admin/skills"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}> <WorkspaceLayout {...layoutProps}>
<AdminSkillsPage /> <ChatRuntime>
<IframeChatPage />
</ChatRuntime>
</WorkspaceLayout> </WorkspaceLayout>
) : ( }
<Navigate to="/page/workspace/chats/new" replace /> />
) <Route
} path="chats_iframe/:thread_id"
/> element={
<Route <WorkspaceLayout
path="admin/tags" {...layoutProps}
element={ wrapperOverflowHidden
getStoredAuth()?.system_role === "admin" ? ( insetOverflowHidden
>
<ChatRuntime>
<IframeChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="agents"
element={
<WorkspaceLayout {...layoutProps}> <WorkspaceLayout {...layoutProps}>
<TagsPage /> <AgentsPage />
</WorkspaceLayout> </WorkspaceLayout>
) : ( }
<Navigate to="/page/workspace/chats/new" replace /> />
) <Route
} path="agents/new"
/> element={
<Route
path="settings/recommended-questions"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}> <WorkspaceLayout {...layoutProps}>
<RecommendedQuestionsPage /> <NewAgentPage />
</WorkspaceLayout> </WorkspaceLayout>
) : ( }
<Navigate to="/page/workspace/chats/new" replace /> />
) <Route
} path="agents/:agent_id/chats"
/> element={<Navigate to="/page/workspace/agents" replace />}
<Route />
path="settings/prompt-prefix" <Route
element={ path="agents/:agent_id/chats/:thread_id"
getStoredAuth()?.system_role === "admin" ? ( element={
<WorkspaceLayout {...layoutProps}> <WorkspaceLayout {...layoutProps}>
<PromptPrefixSettingsPage /> <ChatRuntime>
<AgentChatPage />
</ChatRuntime>
</WorkspaceLayout> </WorkspaceLayout>
) : ( }
<Navigate to="/page/workspace/chats/new" replace /> />
) <Route
} path="skills"
/> element={
<Route
path="settings/private-square"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}> <WorkspaceLayout {...layoutProps}>
<PrivateSquareSettingsPage /> <SkillsPage />
</WorkspaceLayout> </WorkspaceLayout>
) : ( }
<Navigate to="/page/workspace/chats/new" replace /> />
) <Route
} path="skills/evolution"
/> element={
<Route
path="settings/llm-metrics"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}> <WorkspaceLayout {...layoutProps}>
<LlmMetricsPage /> <SkillEvolutionPage />
</WorkspaceLayout> </WorkspaceLayout>
) : ( }
<Navigate to="/page/workspace/chats/new" replace /> />
) <Route
} path="memory"
/> element={
</Routes> <WorkspaceLayout {...layoutProps}>
<MemoryPage />
</WorkspaceLayout>
}
/>
<Route
path="ai-writing"
element={
<WorkspaceLayout {...layoutProps}>
<AIWritingPage hideAdvancedFields />
</WorkspaceLayout>
}
/>
<Route
path="studio/notebooks"
element={
<WorkspaceLayout {...layoutProps}>
<StudioNotebooksPage />
</WorkspaceLayout>
}
/>
<Route
path="studio/notebooks/:notebookId"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<StudioNotebookDetailPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="roundtable"
element={
<Navigate to="/page/workspace/roundtable/planning" replace />
}
/>
<Route
path="roundtable/planning"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<RoundtablePlanningPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="scheduled"
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
/>
<Route
path="scheduled/tasks"
element={
<WorkspaceLayout {...layoutProps}>
<ScheduledTasksPage />
</WorkspaceLayout>
}
/>
<Route
path="scheduled/runs/:run_id"
element={
<WorkspaceLayout {...layoutProps}>
<ScheduledTaskRunDetailPage />
</WorkspaceLayout>
}
/>
{/* Unified admin user hub */}
<Route
path="admin"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps} pageSidebarHidden>
<AdminUserHubPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
{/* Legacy redirects */}
<Route
path="admin/users"
element={<Navigate to="/page/workspace/admin" replace />}
/>
<Route
path="admin/user-activity"
element={<Navigate to="/page/workspace/admin" replace />}
/>
<Route
path="admin/memory"
element={<Navigate to="/page/workspace/admin" replace />}
/>
<Route
path="admin/scheduled-tasks"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<AdminScheduledTasksPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/agents"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<AdminAgentsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/skills"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<AdminSkillsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/tags"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<TagsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/recommended-questions"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<RecommendedQuestionsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/prompt-prefix"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<PromptPrefixSettingsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/private-square"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<PrivateSquareSettingsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/llm-metrics"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<LlmMetricsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
</Routes>
</RouteBaseProvider> </RouteBaseProvider>
); );
} }
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseBrainyChatSubmitMessage } from "./iframe-chat-submit";
test("parses iframe text, page context, and screenshot into a prompt message", () => {
const result = parseBrainyChatSubmitMessage({
source: "brainy-extension",
type: "brainy:chat-submit",
requestId: "iframe-123",
payload: {
text: "提取并翻译",
prompt: "提取图片中的文字并翻译成简体中文",
pageContext: {
title: "UN News",
content: "Banning children from social media is not the answer.",
url: "https://news.un.org/example",
},
screenshot: {
dataUrl: "data:image/png;base64,aGVsbG8=",
fileName: "capture.png",
images: [
{
filename: "capture.png",
url: "C:\\Users\\Rocky\\AppData\\Local\\Temp\\capture.png",
size: 0,
},
],
},
},
});
assert.equal(result.submitText, [
"提取并翻译",
"<extension_prompt>\n提取图片中的文字并翻译成简体中文\n</extension_prompt>",
"<page_context>\n<title>\nUN News\n</title>\n\n<url>\nhttps://news.un.org/example\n</url>\n\n<content>\nBanning children from social media is not the answer.\n</content>\n</page_context>",
].join("\n\n"));
assert.deepEqual(result.iframeContext, { brainy_request_id: "iframe-123" });
assert.equal(result.files.length, 1);
assert.equal(result.files[0]?.type, "file");
assert.equal(result.files[0]?.filename, "capture.png");
assert.equal(result.files[0]?.mediaType, "image/png");
assert.ok(result.files[0]?.file instanceof File);
assert.equal(result.files[0]?.file?.name, "capture.png");
assert.equal(result.files[0]?.file?.type, "image/png");
assert.equal(result.files[0]?.url, "data:image/png;base64,aGVsbG8=");
});
test("keeps screenshot-only iframe submissions in the conversation flow", () => {
const result = parseBrainyChatSubmitMessage({
source: "brainy-extension",
type: "brainy:chat-submit",
payload: {
screenshot: {
dataUrl: "data:image/jpeg;base64,aGVsbG8=",
images: [
{
filename: "shot.jpg",
url: "C:\\Users\\Rocky\\AppData\\Local\\Temp\\shot.jpg",
size: 0,
},
],
},
},
});
assert.equal(result.submitText, "");
assert.equal(result.files.length, 1);
assert.equal(result.files[0]?.filename, "shot.jpg");
assert.equal(result.files[0]?.mediaType, "image/jpeg");
});
@@ -0,0 +1,247 @@
type BrainyPageContext = {
title?: string;
content?: string;
url?: string;
};
type BrainyScreenshotPath = {
image_url?: string;
};
type BrainyScreenshotImage = {
filename?: string;
url?: string;
size?: number;
};
type BrainyScreenshot = {
dataUrl?: string;
fileName?: string;
filePath?: BrainyScreenshotPath[];
images?: BrainyScreenshotImage[];
};
export type BrainyChatSubmitMessage = {
source?: string;
type?: string;
payload?: {
text?: string;
prompt?: string;
pageContext?: BrainyPageContext;
screenshot?: BrainyScreenshot;
};
requestId?: string;
};
export type IframePromptInputFilePart = {
type: "file";
url: string;
mediaType: string;
filename: string;
file?: File;
};
export type ParsedBrainyChatSubmitMessage = {
submitText: string;
files: IframePromptInputFilePart[];
iframeContext?: Record<string, unknown>;
};
function trimToUndefined(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function fileNameFromPath(path: string | undefined): string | undefined {
if (!path) return undefined;
const parts = path.split(/[\\/]/).filter(Boolean);
return parts.at(-1);
}
function isLikelyLocalPath(path: string): boolean {
return /^[a-zA-Z]:[\\/]/.test(path) || path.startsWith("\\\\");
}
function mimeTypeFromFilename(filename: string | undefined): string | undefined {
const lower = filename?.toLowerCase() ?? "";
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".gif")) return "image/gif";
return undefined;
}
function parseDataUrlMeta(dataUrl: string): { mimeType: string; payload: string } | null {
const match = /^data:([^;,]+)?(?:;base64)?,(.*)$/i.exec(dataUrl);
if (!match) return null;
return {
mimeType: match[1] || "application/octet-stream",
payload: match[2] || "",
};
}
function decodeBase64(base64: string): Uint8Array {
if (typeof Buffer !== "undefined") {
return Uint8Array.from(Buffer.from(base64, "base64"));
}
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function fileFromDataUrl(
dataUrl: string,
filename: string,
mediaType: string,
): File | undefined {
const meta = parseDataUrlMeta(dataUrl);
if (!meta) return undefined;
const bytes = decodeBase64(meta.payload);
const arrayBuffer = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
);
return new File([arrayBuffer], filename, {
type: mediaType || meta.mimeType,
});
}
function normalizePageContext(
pageContext: BrainyPageContext | undefined,
): BrainyPageContext | undefined {
if (!pageContext) return undefined;
const normalized = {
title: trimToUndefined(pageContext.title),
content: trimToUndefined(pageContext.content),
url: trimToUndefined(pageContext.url),
};
return normalized.title || normalized.content || normalized.url
? normalized
: undefined;
}
export function buildIframeMessageText({
text,
prompt,
pageContext,
}: {
text?: string;
prompt?: string;
pageContext?: BrainyPageContext;
}) {
const primaryText = trimToUndefined(text) || trimToUndefined(prompt) || "";
const sections: string[] = [];
if (primaryText) {
sections.push(primaryText);
}
const normalizedPrompt = trimToUndefined(prompt);
if (normalizedPrompt && normalizedPrompt !== primaryText) {
sections.push(
`<extension_prompt>\n${normalizedPrompt}\n</extension_prompt>`,
);
}
const normalizedPageContext = normalizePageContext(pageContext);
if (normalizedPageContext) {
const pageContextParts = [
normalizedPageContext.title
? `<title>\n${normalizedPageContext.title}\n</title>`
: null,
normalizedPageContext.url
? `<url>\n${normalizedPageContext.url}\n</url>`
: null,
normalizedPageContext.content
? `<content>\n${normalizedPageContext.content}\n</content>`
: null,
].filter(Boolean);
if (pageContextParts.length > 0) {
sections.push(
`<page_context>\n${pageContextParts.join("\n\n")}\n</page_context>`,
);
}
}
return sections.join("\n\n");
}
function buildScreenshotFileParts(
screenshot: BrainyScreenshot | undefined,
): IframePromptInputFilePart[] {
if (!screenshot) return [];
const fallbackPath =
screenshot.filePath?.map((item) => item.image_url).find(Boolean) ??
screenshot.images?.map((item) => item.url).find(Boolean);
const fallbackFilename =
trimToUndefined(screenshot.fileName) ??
screenshot.images?.map((item) => trimToUndefined(item.filename)).find(Boolean) ??
fileNameFromPath(fallbackPath) ??
"screenshot.png";
const dataUrl = trimToUndefined(screenshot.dataUrl);
if (dataUrl) {
const parsed = parseDataUrlMeta(dataUrl);
const mediaType =
parsed?.mimeType || mimeTypeFromFilename(fallbackFilename) || "image/png";
const file = fileFromDataUrl(dataUrl, fallbackFilename, mediaType);
return [
{
type: "file",
url: dataUrl,
mediaType,
filename: fallbackFilename,
...(file ? { file } : {}),
},
];
}
const remoteImage = screenshot.images?.find((item) => {
const url = trimToUndefined(item.url);
return !!url && !isLikelyLocalPath(url);
});
if (remoteImage?.url && remoteImage.filename) {
return [
{
type: "file",
url: remoteImage.url,
mediaType:
mimeTypeFromFilename(remoteImage.filename) || "application/octet-stream",
filename: remoteImage.filename,
},
];
}
return [];
}
export function parseBrainyChatSubmitMessage(
data: BrainyChatSubmitMessage,
): ParsedBrainyChatSubmitMessage {
const text = trimToUndefined(data.payload?.text);
const prompt = trimToUndefined(data.payload?.prompt);
const pageContext = normalizePageContext(data.payload?.pageContext);
const submitText = buildIframeMessageText({ text, prompt, pageContext });
const files = buildScreenshotFileParts(data.payload?.screenshot);
const requestId = trimToUndefined(data.requestId);
return {
submitText,
files,
...(requestId
? { iframeContext: { brainy_request_id: requestId } }
: {}),
};
}