feat: iframe page
This commit is contained in:
1 parent
c767566cb6
commit
0922c8299f
9 files changed
+574
-41
No files matched your search
@@ -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"
|
||||
```
|
||||
@@ -63,9 +63,14 @@ function logReferenceDebug(
|
||||
});
|
||||
}
|
||||
|
||||
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
const ChatBox: React.FC<{
|
||||
children: React.ReactNode;
|
||||
threadId: string;
|
||||
disableArtifacts?: boolean;
|
||||
}> = ({
|
||||
children,
|
||||
threadId,
|
||||
disableArtifacts = false,
|
||||
}) => {
|
||||
const { thread } = useThread();
|
||||
const pathname = usePathname();
|
||||
@@ -271,12 +276,16 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
}
|
||||
|
||||
// Update artifacts from the current thread
|
||||
const nextArtifacts = thread.values.artifacts ?? [];
|
||||
const nextArtifacts = disableArtifacts ? [] : (thread.values.artifacts ?? []);
|
||||
const hadArtifacts = artifactsCountRef.current > 0;
|
||||
const hasNewArtifact = nextArtifacts.length > artifactsCountRef.current;
|
||||
artifactsCountRef.current = nextArtifacts.length;
|
||||
setArtifacts(nextArtifacts);
|
||||
if ((hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) && !selectedArtifact) {
|
||||
if (
|
||||
!disableArtifacts &&
|
||||
(hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) &&
|
||||
!selectedArtifact
|
||||
) {
|
||||
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
@@ -291,6 +300,7 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
// }
|
||||
|
||||
if (
|
||||
!disableArtifacts &&
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
|
||||
autoSelectFirstArtifact
|
||||
) {
|
||||
@@ -307,14 +317,18 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
selectedArtifact,
|
||||
setArtifacts,
|
||||
thread.values.artifacts,
|
||||
disableArtifacts,
|
||||
]);
|
||||
|
||||
const artifactPanelOpen = useMemo(() => {
|
||||
if (disableArtifacts) {
|
||||
return false;
|
||||
}
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
|
||||
return artifactsOpen && artifacts?.length > 0;
|
||||
}
|
||||
return artifactsOpen;
|
||||
}, [artifactsOpen, artifacts]);
|
||||
}, [artifactsOpen, artifacts, disableArtifacts]);
|
||||
const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen);
|
||||
const showTabs = !!selectedArtifact && hasCitationReferences;
|
||||
|
||||
@@ -343,6 +357,10 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
}
|
||||
}, [sidePanelOpen]);
|
||||
|
||||
if (disableArtifacts) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
id={`${resizableIdBase}-panels`}
|
||||
|
||||
@@ -2,26 +2,28 @@
|
||||
|
||||
import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
|
||||
|
||||
import { getAuthorizationHeaderValue } from "../auth";
|
||||
import { getLangGraphBaseURL } from "../config";
|
||||
|
||||
import { sanitizeRunStreamOptions } from "./stream-mode";
|
||||
import { getAuthorizationHeaderValue } from "../auth";
|
||||
import { getLangGraphBaseURL } from "../config";
|
||||
|
||||
import { withCsrfHeaders } from "./csrf";
|
||||
import { sanitizeRunStreamOptions } from "./stream-mode";
|
||||
|
||||
function createCompatibleClient(isMock?: boolean): LangGraphClient {
|
||||
const client = new LangGraphClient({
|
||||
apiUrl: getLangGraphBaseURL(isMock),
|
||||
onRequest(_url, init) {
|
||||
const headers = new Headers(init.headers ?? {});
|
||||
const authHeader = getAuthorizationHeaderValue();
|
||||
if (authHeader && !headers.has("Authorization")) {
|
||||
headers.set("Authorization", authHeader);
|
||||
}
|
||||
return {
|
||||
...init,
|
||||
headers,
|
||||
};
|
||||
},
|
||||
});
|
||||
apiUrl: getLangGraphBaseURL(isMock),
|
||||
onRequest(_url, init) {
|
||||
const headers = withCsrfHeaders(init.headers);
|
||||
const authHeader = getAuthorizationHeaderValue();
|
||||
if (authHeader && !headers.has("Authorization")) {
|
||||
headers.set("Authorization", authHeader);
|
||||
}
|
||||
return {
|
||||
...init,
|
||||
credentials: init.credentials ?? "include",
|
||||
headers,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const originalRunStream = client.runs.stream.bind(client.runs);
|
||||
client.runs.stream = ((threadId, assistantId, payload) =>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
|
||||
import { getAuthorizationHeaderValue } from "../auth";
|
||||
import { getCanvasLangGraphBaseURL } from "../config";
|
||||
|
||||
import { withCsrfHeaders } from "./csrf";
|
||||
import { sanitizeRunStreamOptions } from "./stream-mode";
|
||||
|
||||
/**
|
||||
@@ -19,12 +20,16 @@ function createCanvasClient(): LangGraphClient {
|
||||
const client = new LangGraphClient({
|
||||
apiUrl: getCanvasLangGraphBaseURL(),
|
||||
onRequest(_url, init) {
|
||||
const headers = new Headers(init.headers ?? {});
|
||||
const headers = withCsrfHeaders(init.headers);
|
||||
const authHeader = getAuthorizationHeaderValue();
|
||||
if (authHeader && !headers.has("Authorization")) {
|
||||
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";
|
||||
|
||||
function withAuthHeaders(headers?: HeadersInit): Headers {
|
||||
const next = new Headers(headers ?? {});
|
||||
const authHeader = getAuthorizationHeaderValue();
|
||||
if (authHeader && !next.has("Authorization")) {
|
||||
next.set("Authorization", authHeader);
|
||||
}
|
||||
return next;
|
||||
import { getAuthorizationHeaderValue } from "@/core/auth";
|
||||
import { withCsrfHeaders } from "./csrf";
|
||||
|
||||
function withAuthHeaders(headers?: HeadersInit): Headers {
|
||||
const next = withCsrfHeaders(headers);
|
||||
const authHeader = getAuthorizationHeaderValue();
|
||||
if (authHeader && !next.has("Authorization")) {
|
||||
next.set("Authorization", authHeader);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function apiFetch(input: RequestInfo | URL, init?: RequestInit) {
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers: withAuthHeaders(init?.headers),
|
||||
});
|
||||
}
|
||||
export function apiFetch(input: RequestInfo | URL, init?: RequestInit) {
|
||||
return fetch(input, {
|
||||
...init,
|
||||
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.
|
||||
const hash = typeof window !== "undefined" ? window.location.hash : "";
|
||||
const agentMatch = hash.match(/#\/page\/workspace\/agents\/([^/]+)\//);
|
||||
const target = agentMatch
|
||||
? `/page/workspace/agents/${agentMatch[1]}/chats/new`
|
||||
: "/page/workspace/chats/new";
|
||||
const iframeMatch = hash.match(/#\/page\/workspace\/chats_iframe\//);
|
||||
const target = agentMatch
|
||||
? `/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
|
||||
// valid route. Then full-reload so TanStack Query cache, context
|
||||
// providers and any other state captured under the previous user's
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { usePromptPrefixSettings } from "@/core/system-settings/hooks";
|
||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
ChatBox,
|
||||
useSpecificChatMode,
|
||||
useThreadChat,
|
||||
} from "@/components/workspace/chats";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useThreadSettings } from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type BrainyChatSubmitMessage = {
|
||||
source?: string;
|
||||
type?: string;
|
||||
payload?: {
|
||||
text?: string;
|
||||
};
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
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, setSettings] = 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 { 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}`,
|
||||
);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
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) => {
|
||||
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;
|
||||
}
|
||||
void sendMessage(
|
||||
threadId,
|
||||
message,
|
||||
Object.keys(extraContext).length > 0 ? extraContext : undefined,
|
||||
);
|
||||
},
|
||||
[
|
||||
sendMessage,
|
||||
threadId,
|
||||
isNewThread,
|
||||
hasStartedConversation,
|
||||
promptPrefixOn,
|
||||
promptPrefixSettings,
|
||||
selectedAgentId,
|
||||
router,
|
||||
settings.context.writing_mode,
|
||||
writingArtifactPath,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
(answer: string) => {
|
||||
setHasStartedConversation(true);
|
||||
void sendMessage(threadId, { text: answer, files: [] });
|
||||
},
|
||||
[sendMessage, threadId],
|
||||
);
|
||||
const handleStop = useCallback(async () => {
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
|
||||
const handleCreateDemoThread = useCallback(() => {
|
||||
handleSubmit({ text: "hello", files: [] });
|
||||
}, [handleSubmit]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent<BrainyChatSubmitMessage>) => {
|
||||
const data = event.data;
|
||||
if (data?.source !== "brainy-extension") return;
|
||||
if (data?.type !== "brainy:chat-submit") return;
|
||||
|
||||
const text = data.payload?.text?.trim() ?? "";
|
||||
if (!text) return;
|
||||
|
||||
handleSubmit({ text, files: [] });
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
};
|
||||
}, [handleSubmit]);
|
||||
|
||||
const promptPrefixProp = useMemo(() => {
|
||||
if (!promptPrefixSettings?.enabled) return undefined;
|
||||
if (!isNewThread || hasStartedConversation) return undefined;
|
||||
return {
|
||||
label: promptPrefixSettings.switch_label || "前缀",
|
||||
on: promptPrefixOn,
|
||||
onToggle: () => setPromptPrefixOn((v) => !v),
|
||||
};
|
||||
}, [promptPrefixSettings, isNewThread, hasStartedConversation, promptPrefixOn]);
|
||||
|
||||
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">
|
||||
<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">
|
||||
<div className="flex size-full justify-center">
|
||||
{!isLandingState && (
|
||||
<MessageList
|
||||
className={cn("size-full", "pt-10")}
|
||||
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-(--container-width-lg)",
|
||||
)}
|
||||
>
|
||||
<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 gap-4">
|
||||
<div className="text-2xl font-medium">你好</div>
|
||||
<Button onClick={handleCreateDemoThread}>
|
||||
建立线程demo
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { RouteBaseProvider } from "@/core/route-base";
|
||||
|
||||
import ChatsPage from "./ChatsPage";
|
||||
import ChatPage from "./ChatPage";
|
||||
import IframeChatPage from "./IframeChatPage";
|
||||
import AgentsPage from "./AgentsPage";
|
||||
import AgentChatPage from "./AgentChatPage";
|
||||
import NewAgentPage from "./NewAgentPage";
|
||||
@@ -132,6 +133,26 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="agents"
|
||||
element={
|
||||
|
||||
Reference in new issue
Block a user