feat: iframe page
This commit is contained in:
1 parent
c767566cb6
commit
0922c8299f
9 files changed
+542
-9
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,
|
children,
|
||||||
threadId,
|
threadId,
|
||||||
|
disableArtifacts = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { thread } = useThread();
|
const { thread } = useThread();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -271,12 +276,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");
|
||||||
@@ -291,6 +300,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
|
||||||
) {
|
) {
|
||||||
@@ -307,14 +317,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;
|
||||||
|
|
||||||
@@ -343,6 +357,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`}
|
||||||
|
|||||||
@@ -5,19 +5,21 @@ 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 { withCsrfHeaders } from "./csrf";
|
||||||
import { sanitizeRunStreamOptions } from "./stream-mode";
|
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,
|
||||||
|
credentials: init.credentials ?? "include",
|
||||||
headers,
|
headers,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,7 +1,8 @@
|
|||||||
import { getAuthorizationHeaderValue } from "@/core/auth";
|
import { getAuthorizationHeaderValue } from "@/core/auth";
|
||||||
|
import { withCsrfHeaders } from "./csrf";
|
||||||
|
|
||||||
function withAuthHeaders(headers?: HeadersInit): Headers {
|
function withAuthHeaders(headers?: HeadersInit): Headers {
|
||||||
const next = new Headers(headers ?? {});
|
const next = withCsrfHeaders(headers);
|
||||||
const authHeader = getAuthorizationHeaderValue();
|
const authHeader = getAuthorizationHeaderValue();
|
||||||
if (authHeader && !next.has("Authorization")) {
|
if (authHeader && !next.has("Authorization")) {
|
||||||
next.set("Authorization", authHeader);
|
next.set("Authorization", authHeader);
|
||||||
@@ -12,6 +13,7 @@ function withAuthHeaders(headers?: HeadersInit): Headers {
|
|||||||
export function apiFetch(input: RequestInfo | URL, init?: RequestInit) {
|
export function apiFetch(input: RequestInfo | URL, init?: RequestInit) {
|
||||||
return fetch(input, {
|
return fetch(input, {
|
||||||
...init,
|
...init,
|
||||||
|
credentials: init?.credentials ?? "include",
|
||||||
headers: withAuthHeaders(init?.headers),
|
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 iframeMatch = hash.match(/#\/page\/workspace\/chats_iframe\//);
|
||||||
const target = agentMatch
|
const target = agentMatch
|
||||||
? `/page/workspace/agents/${agentMatch[1]}/chats/new`
|
? `/page/workspace/agents/${agentMatch[1]}/chats/new`
|
||||||
: "/page/workspace/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
|
||||||
|
|||||||
@@ -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 ChatsPage from "./ChatsPage";
|
||||||
import ChatPage from "./ChatPage";
|
import ChatPage from "./ChatPage";
|
||||||
|
import IframeChatPage from "./IframeChatPage";
|
||||||
import AgentsPage from "./AgentsPage";
|
import AgentsPage from "./AgentsPage";
|
||||||
import AgentChatPage from "./AgentChatPage";
|
import AgentChatPage from "./AgentChatPage";
|
||||||
import NewAgentPage from "./NewAgentPage";
|
import NewAgentPage from "./NewAgentPage";
|
||||||
@@ -132,6 +133,26 @@ export default function WorkspaceRoutes({
|
|||||||
</WorkspaceLayout>
|
</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
|
<Route
|
||||||
path="agents"
|
path="agents"
|
||||||
element={
|
element={
|
||||||
|
|||||||
Reference in new issue
Block a user