Merge branch 'gzy_iframedf' into 'master'
Gzy iframedf See merge request knowledge-base-bj/deerflow-server!79
This commit is contained in:
commit
f2dc03b51e
17 files changed
+1600
-529
No files matched your search
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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),
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 } }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user