代码提交

This commit is contained in:
董宏凯 committed 2026-07-27 19:16:12 +08:00
1 parent e70bb4aa6f
commit 5bf3955b26
20 files changed
+636 -61

No files matched your search

+108 -8
View File
@@ -1,13 +1,16 @@
"use client";
import {
ArrowRightIcon,
BookMarkedIcon,
BotIcon,
BrainCircuitIcon,
FileTextIcon,
GraduationCapIcon,
ShrinkIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { useArtifacts } from "@/components/workspace/artifacts";
@@ -25,7 +28,7 @@ import { TodoList } from "@/components/workspace/todo-list";
import { Tooltip } from "@/components/workspace/tooltip";
import { Button } from "@/components/ui/button";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useAgentSelector } from "@/core/agents";
import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
import { resolveIncomingIframeSelection } from "@/core/plugin-management/domain-agent";
import { getAccessToken } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
@@ -42,6 +45,7 @@ import { usePluginManagementSettings } from "@/core/system-settings/hooks";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import {
type BrainyChatSubmitMessage,
@@ -126,6 +130,8 @@ function postBrainyAuthToken(accessToken: string) {
export default function IframeChatPage() {
const routeBase = useRouteBase();
const location = useLocation();
const navigate = useNavigate();
const { t } = useI18n();
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
@@ -141,6 +147,34 @@ export default function IframeChatPage() {
useState<PendingIframeSubmit | null>(null);
const { agents: selectorAgents, isLoading: selectorLoading } =
useAgentSelector();
// The protocol page must still be escapable when no curated selector list
// has been configured. Include the user's own and public agents in this
// page's native fallback picker. A native select also remains interactive
// in iframe/micro-frontend environments where a portal dialog can fail.
const { agents: mineAgents } = useAgentsPaginated({
scope: "mine",
page: 1,
pageSize: 100,
});
const { agents: squareAgents } = useAgentsPaginated({
scope: "square",
page: 1,
pageSize: 100,
});
const fallbackAgents = useMemo(() => {
const uniqueAgents = new Map<
string,
(typeof selectorAgents)[number]
>();
for (const agent of [
...selectorAgents,
...mineAgents,
...squareAgents,
]) {
uniqueAgents.set(agent.id, agent);
}
return [...uniqueAgents.values()];
}, [mineAgents, selectorAgents, squareAgents]);
const { data: pluginManagementSettings } = usePluginManagementSettings();
const pendingRequestIdRef = useRef<string | null>(null);
const handleSubmitRef = useRef<
@@ -345,6 +379,30 @@ export default function IframeChatPage() {
saveLastAgentId(agentId);
}, []);
/**
* Manual fallback for accidental protocol-page visits.
*
* A real Brainy integration drives this page with postMessage. A human who
* reaches it directly should not be trapped here: move them to the standard
* chat workspace, optionally opening the selected agent.
*/
const enterStandardChat = useCallback(
(agentId?: string) => {
const params = new URLSearchParams(location.search);
params.delete("from");
params.set("chatMode", "full");
params.delete("agent");
if (agentId) {
params.set("agent", agentId);
saveLastAgentId(agentId);
}
navigate(`${routeBase}/chats/new?${params.toString()}`, {
replace: true,
});
},
[location.search, navigate, routeBase],
);
useEffect(() => {
handleSubmitRef.current = handleSubmit;
}, [handleSubmit]);
@@ -643,15 +701,57 @@ export default function IframeChatPage() {
</div>
</div>
{isLandingState && mounted && (
<div className="mb-6 flex min-h-40 flex-col items-center justify-center">
<div className="mb-6 flex min-h-40 flex-col items-center justify-center px-4 text-center">
<div className="text-2xl font-medium">你好</div>
<div className="mt-4">
<AgentSelector
selectedAgentId={selectedAgentId}
onAgentChange={handleAgentChange}
disabled={isUploading}
/>
<div className="mt-2 text-sm text-muted-foreground">
选择智能体进入问答,或直接使用通用问答
</div>
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
<label className="relative inline-flex h-9 min-w-40 items-center rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3 text-[#7e6a57] shadow-sm transition-colors hover:bg-white">
<BotIcon className="mr-2 size-3.5 shrink-0" />
<select
aria-label="选择智能体并进入问答"
defaultValue=""
disabled={isUploading || selectorLoading}
onChange={(event) => {
const agentId = event.target.value;
if (agentId) enterStandardChat(agentId);
}}
className="min-w-0 flex-1 cursor-pointer appearance-none bg-transparent pr-5 text-xs outline-none disabled:cursor-wait"
>
<option value="" disabled>
{selectorLoading ? "正在加载智能体…" : "选择 Agent"}
</option>
{fallbackAgents.map((agent) => (
<option key={agent.id} value={agent.id}>
{desensitize(agent.name)}
</option>
))}
</select>
<span
aria-hidden
className="pointer-events-none absolute right-3 text-[10px]"
>
▾
</span>
</label>
<Button
type="button"
variant="outline"
size="sm"
disabled={isUploading}
onClick={() => enterStandardChat()}
className="rounded-full"
>
直接进入通用问答
<ArrowRightIcon className="ml-1 size-3.5" />
</Button>
</div>
{!selectorLoading && fallbackAgents.length === 0 && (
<div className="mt-3 text-xs text-muted-foreground">
暂无可选智能体,仍可直接进入通用问答
</div>
)}
</div>
)}
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
+40 -14
View File
@@ -7,6 +7,11 @@ import { useEffect, useMemo, useState } from 'react';
import { useTheme } from '@/contexts/ThemeContext';
import { loginByToken, loginByUsername } from '@/core/auth/api';
import { setStoredAuth, setStoredUserName } from '@/core/auth';
import {
getRouteKeyLoginPassword,
resolveRouteKey,
ROUTE_KEY_PARAM,
} from '@/core/auth/route-key-login';
import {
persistExternalLoginParams,
readExternalLoginParams,
@@ -67,6 +72,7 @@ function summarizeStoredUserInfo(): Record<string, unknown> | null {
const DEFAULT_USERNAME = 'guest';
const IFRAME_LOGIN_SOURCE = 'iframe';
const IFRAME_CHAT_MODE = 'iframe';
const DEFAULT_REDIRECT_PATH = '/page/workspace/chats/new';
const IFRAME_REDIRECT_PATH = '/page/workspace/chats_iframe/new';
const FULL_CHAT_MODE = 'full';
@@ -90,29 +96,35 @@ export default function LoginPage() {
() => readEmbedParam(searchParams.toString()) === 'on',
[searchParams],
);
const requestedChatMode =
searchParams.get('chatMode')?.trim().toLowerCase() ?? '';
const brainyIframeRequested = requestedChatMode === IFRAME_CHAT_MODE;
// Wujie is an application container, not the Brainy iframe protocol. It
// must therefore land on the regular full ChatPage even when an old portal
// URL still carries `from=iframe`. Plain embeds can request the same route
// explicitly with `chatMode=full`.
// must therefore land on the regular full ChatPage. Legacy `from=iframe`
// links now do the same; only the explicit `chatMode=iframe` protocol opt-in
// is allowed to open IframeChatPage.
const fullChatRequested = useMemo(
() =>
isWujieEnv() ||
searchParams.get('chatMode')?.trim().toLowerCase() === FULL_CHAT_MODE,
[searchParams],
!brainyIframeRequested &&
(isWujieEnv() ||
requestedChatMode === FULL_CHAT_MODE ||
searchParams.get('from') === IFRAME_LOGIN_SOURCE),
[brainyIframeRequested, requestedChatMode, searchParams],
);
const redirectPath = useMemo(
() => {
// Keep the mode on the destination URL so PageRoutes can retain the
// full chat workspace sidebar while hideChrome hides only the global
// application chrome.
if (brainyIframeRequested) {
return `${IFRAME_REDIRECT_PATH}?chatMode=${IFRAME_CHAT_MODE}`;
}
if (fullChatRequested) {
return `${DEFAULT_REDIRECT_PATH}?chatMode=${FULL_CHAT_MODE}`;
}
return searchParams.get('from') === IFRAME_LOGIN_SOURCE
? IFRAME_REDIRECT_PATH
: DEFAULT_REDIRECT_PATH;
return DEFAULT_REDIRECT_PATH;
},
[fullChatRequested, searchParams],
[brainyIframeRequested, fullChatRequested],
);
const resolvedUsername = useMemo(() => {
const raw = username?.trim();
@@ -127,6 +139,14 @@ export default function LoginPage() {
const gatePassword = searchParams.get('password') ?? '';
// token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开)
const authToken = searchParams.get('authToken')?.trim() ?? '';
const routeKey = searchParams.get(ROUTE_KEY_PARAM)?.trim() ?? '';
const routeKeyTarget = useMemo(() => resolveRouteKey(routeKey), [routeKey]);
const configuredRouteLoginPassword = useMemo(
() => (routeKeyTarget ? getRouteKeyLoginPassword() : ''),
[routeKeyTarget],
);
const effectiveGatePassword = configuredRouteLoginPassword || gatePassword;
const usesConfiguredRouteLoginPassword = Boolean(configuredRouteLoginPassword);
// 任务深链:外部系统带 ?taskId=&goPath= 时,登录后拉任务详情并直送对应问答/会商页
const taskId = searchParams.get('taskId')?.trim() ?? '';
const goPath = searchParams.get('goPath')?.trim() ?? '';
@@ -142,6 +162,7 @@ export default function LoginPage() {
// (token/authToken/ticket 等)一律**不透传**,避免泄漏到应用页地址栏。
const redirectTo = useMemo(() => {
const raw =
routeKeyTarget ||
searchParams.get('redirect')?.trim() ||
searchParams.get('route')?.trim() ||
'';
@@ -162,7 +183,7 @@ export default function LoginPage() {
// 登录流程自身消费的参数,不透传到目标页(尤以鉴权类为甚)。
const LOGIN_OWNED = new Set([
'redirect', 'route', 'ticket', 'userId', 'env', 'theme', 'yUserId',
'token', 'access_token', 'password', 'authToken', 'goPath', 'from',
'token', 'access_token', 'password', 'authToken', 'goPath', 'from', ROUTE_KEY_PARAM,
]);
new URLSearchParams(searchParams.toString()).forEach((value, key) => {
if (LOGIN_OWNED.has(key)) return;
@@ -171,7 +192,7 @@ export default function LoginPage() {
});
const qs = target.toString();
return qs ? `${pathOnly}?${qs}` : pathOnly;
}, [fullChatRequested, searchParams]);
}, [fullChatRequested, routeKeyTarget, searchParams]);
const externalParams = useMemo(
() => ({
theme: searchParams.get('theme') ?? '',
@@ -250,7 +271,9 @@ export default function LoginPage() {
access_token: maskLoginToken(access_token),
toolToken: maskLoginToken(toolToken),
authToken: maskLoginToken(authToken),
gatePassword: gatePassword ? maskLoginToken(gatePassword) : '',
routeKey,
configuredRouteLoginPassword: usesConfiguredRouteLoginPassword,
gatePassword: effectiveGatePassword ? maskLoginToken(effectiveGatePassword) : '',
onlineTicket: onlineTicket ? maskLoginToken(onlineTicket) : '',
taskId,
goPath,
@@ -295,7 +318,7 @@ export default function LoginPage() {
persistToolsToken(toolToken);
}
auth = await loginByUsername(deerflowUsername, gatePassword);
auth = await loginByUsername(deerflowUsername, effectiveGatePassword);
loginUserName = deerflowUsername;
}
if (cancelled) {
@@ -397,6 +420,9 @@ export default function LoginPage() {
access_token,
onlineTicket,
authToken,
effectiveGatePassword,
usesConfiguredRouteLoginPassword,
routeKey,
gatePassword,
taskId,
goPath,
+13 -3
View File
@@ -9,8 +9,10 @@ import { isWujieEnv } from "@/core/embed/wujie";
import {
isEmbedActive,
isChromeHidden,
isHeaderHidden,
isHideChromeActive,
readEmbedParam,
readHideHeaderParam,
readHideChromeParam,
} from "@/core/embed/embed-session";
import { useTheme } from "@/contexts/ThemeContext";
@@ -69,6 +71,12 @@ function PageLayout() {
() => isHideChromeActive() || readHideChromeParam(location.search) === "on",
[location.pathname, location.search],
);
// `?hideHeader=1` only removes the global top navigation. Unlike
// `hideChrome=1`, PageSidebar remains available in the full layout.
const headerHidden = useMemo(
() => isHeaderHidden() || readHideHeaderParam(location.search) === "on",
[location.pathname, location.search],
);
// `chatMode=full` is the embed-friendly standard chat view: hide the global
// Header/PageSidebar when `hideChrome=1`, but keep WorkspaceSidebar so users
// can still pick an agent and access their common agents/history.
@@ -101,18 +109,20 @@ function PageLayout() {
return (
<RoundtableJobNotifier>
<div className="h-full flex flex-col bg-muted/20">
{pageSidebarAllowed && <Header collapsed={collapsed} />}
{pageSidebarAllowed && !headerHidden && <Header collapsed={collapsed} />}
<div className="flex flex-1 overflow-hidden">
{pageSidebarAllowed && (
isDark ? (
<PageSidebarDark
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
headerHidden={headerHidden}
/>
) : (
<PageSidebar
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
headerHidden={headerHidden}
/>
)
)}
@@ -139,12 +149,12 @@ function PageLayout() {
pageSidebarCollapsed={collapsed}
pageSidebarHidden={!pageSidebarAllowed}
workspaceSidebarHidden={hideChromeActive && !fullChatMode}
headerHidden={!pageSidebarAllowed}
headerHidden={!pageSidebarAllowed || headerHidden}
// Full-chat embedding has no global PageSidebar, but it
// still needs the full GeneralQaSidebar (common agents and
// conversation history) inside the workspace.
compactWorkspaceSidebar={pageSidebarAllowed || fullChatMode}
compactActionsHidden={hideChromeActive}
compactActionsHidden={hideChromeActive || headerHidden}
pageSidebarDark={isDark}
forceWorkspaceSidebarCollapsed={
forceCollapsedSidebar && !fullChatMode
+11 -23
View File
@@ -2,7 +2,7 @@ import type { CSSProperties, ReactNode } from 'react';
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
import { getStoredAuth } from '@/core/auth';
import { isWujieEnv } from '@/core/embed/wujie';
import { resolveStandardChatFallback } from '@/core/page-layout/chats-iframe-route';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { PromptInputProvider } from '@/components/ai-elements/prompt-input';
@@ -258,10 +258,10 @@ function NewChatEntryRoute({
/**
* `chats_iframe` is reserved for the Brainy postMessage protocol. Some legacy
* Wujie links still target it (or carry `from=iframe` during login), but Wujie
* needs the normal full chat workspace instead. Redirect it before mounting
* IframeChatPage; `chatMode=iframe` remains an explicit opt-in escape hatch
* for a Wujie integration that really implements that protocol.
* Legacy links may still target it (or carry `from=iframe` during login), but
* ordinary iframe/Wujie embedding needs the normal full chat workspace.
* `chatMode=iframe` is the only explicit opt-in for integrations that really
* implement the Brainy protocol.
*/
function IframeChatEntryRoute({
layoutProps,
@@ -280,25 +280,13 @@ function IframeChatEntryRoute({
hasThreadParam: boolean;
}) {
const location = useLocation();
const { thread_id } = useParams<{ thread_id: string }>();
const requestedMode = new URLSearchParams(location.search)
.get('chatMode')
?.trim()
.toLowerCase();
const standardChatTarget = resolveStandardChatFallback(
location.pathname,
location.search,
);
if (isWujieEnv() && requestedMode !== 'iframe') {
const targetParams = new URLSearchParams(location.search);
targetParams.set('chatMode', 'full');
const targetPath = hasThreadParam && thread_id
? `/page/workspace/chats/${thread_id}`
: '/page/workspace/chats/new';
const targetSearch = targetParams.toString();
return (
<Navigate
to={targetSearch ? `${targetPath}?${targetSearch}` : targetPath}
replace
/>
);
if (standardChatTarget) {
return <Navigate to={standardChatTarget} replace />;
}
return (