代码提交

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

+4
View File
@@ -40,6 +40,10 @@ window.__CM_RUNTIME_CONFIG__ = {
// Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。
VITE_CANVAS_LANGGRAPH_BASE_URL: "",
// routeKey 短路由的用户名直登口令:例如 #/login/admin?routeKey=a。
// 填值时优先使用这里的 password;留空时使用 URL 中的 ?password=。
VITE_ROUTE_LOGIN_PASSWORD: "",
// 轻应用「新建笔记」系列接口地址。
VITE_NOTE_API_BASE_URL: "http://47.88.25.99:8000",
@@ -8,6 +8,17 @@
flex-shrink: 0;
}
/* `?hideHeader=1`: keep the menu rail, but let it occupy the Header's space. */
.sidebar-contnet--header-hidden {
top: 0;
height: 100vh;
}
.sidebar-contnet--header-hidden .item-nav-content {
height: calc(100vh - 20px);
min-height: calc(100vh - 20px);
}
.sidebar-contents {
width: 98px;
min-height: calc(100% - 20px);
@@ -618,6 +629,12 @@ html.lightyellow .light-topnav-item--active::after {
width: 236px;
}
/* `?hideHeader=1`: preserve the light sidebar while removing only Header. */
.light-sidebar--header-hidden {
top: 0;
height: 100vh;
}
/* 二级列:固定 96px,竖排图标卡片 + 底部设置 */
.light-sidebar__primary {
flex: 0 0 96px;
@@ -17,9 +17,15 @@ type PageSidebarDarkProps = {
/** 是否收起(true 时只渲染左下角浮动入口,sidebar 主体隐藏) */
collapsed: boolean;
onToggleCollapsed: () => void;
/** 全局 Header 隐藏时,侧边栏从视口顶部开始占满高度。 */
headerHidden?: boolean;
};
export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDarkProps) {
export function PageSidebarDark({
collapsed,
onToggleCollapsed,
headerHidden = false,
}: PageSidebarDarkProps) {
const isAdmin = getStoredAuth()?.system_role === "admin";
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
const inWujieEnv = isWujieEnv();
@@ -213,7 +219,11 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
}
return (
<aside className={`sidebar-contnet ${inWujieEnv ? "" : "sidebar-contnet--top-submenu"}`}>
<aside
className={`sidebar-contnet ${inWujieEnv ? "" : "sidebar-contnet--top-submenu"} ${
headerHidden ? "sidebar-contnet--header-hidden" : ""
}`}
>
<div className="sidebar-contents">
{sidebarMenuItemsToRender.map((item) => (
<div
+19 -5
View File
@@ -49,6 +49,8 @@ import "@/components/page-sdebar.css";
type PageSidebarProps = {
collapsed: boolean;
onToggleCollapsed: () => void;
/** Global Header is hidden but this sidebar remains available. */
headerHidden?: boolean;
};
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
@@ -79,23 +81,28 @@ const findFirstRoutePath = (items?: MenuItem[]): string | null => {
* - wujie 嵌入环境:顶栏被宿主隐藏,一级导航无处安放,故回落到原手风琴整树菜单
* (保证嵌入模式下所有层级仍可达,零回归)。
*/
export function PageSidebar({ collapsed, onToggleCollapsed }: PageSidebarProps) {
export function PageSidebar({
collapsed,
onToggleCollapsed,
headerHidden = false,
}: PageSidebarProps) {
if (isWujieEnv()) {
return (
<LegacyAccordionSidebar
collapsed={collapsed}
onToggleCollapsed={onToggleCollapsed}
headerHidden={headerHidden}
/>
);
}
return <SecondaryNavSidebar />;
return <SecondaryNavSidebar headerHidden={headerHidden} />;
}
/**
* 浅色二级导航侧边栏(图标卡片,红/蓝主题色经 --brand-* 变量自动切换)。
* 浅色模式不支持侧边栏收起,故无收起按钮、不接收 collapsed 相关 props。
*/
function SecondaryNavSidebar() {
function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
const isAdmin = getStoredAuth()?.system_role === "admin";
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
const { t } = useI18n();
@@ -150,7 +157,11 @@ function SecondaryNavSidebar() {
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<aside className={`light-sidebar ${expanded ? "light-sidebar--expanded" : ""}`}>
<aside
className={`light-sidebar ${expanded ? "light-sidebar--expanded" : ""} ${
headerHidden ? "light-sidebar--header-hidden" : ""
}`}
>
{/* 一级列:二级图标卡片 + 底部设置 */}
<div className="light-sidebar__primary">
<nav className="light-sidebar__list" aria-label="二级菜单">
@@ -287,6 +298,7 @@ function SecondaryNavSidebar() {
function LegacyAccordionSidebar({
collapsed,
onToggleCollapsed,
headerHidden,
}: PageSidebarProps) {
const { theme } = useTheme();
const isDark = theme === "dark";
@@ -438,7 +450,9 @@ function LegacyAccordionSidebar({
onOpenChange={setNotificationOpen}
/>
<aside
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
className={`fixed left-0 z-40 flex flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
headerHidden ? "top-0 h-screen" : "top-16 h-[calc(100vh-4rem)]"
} ${
isDark
? "border-r border-blue-800/30 bg-slate-900"
: "border-r border-red-100 bg-white"
@@ -184,7 +184,7 @@ export function WorkspaceNavChatList({
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
{/* <SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/roundtable/position")}
asChild
@@ -192,10 +192,10 @@ export function WorkspaceNavChatList({
>
<Link href="/page/workspace/roundtable/position">
<BriefcaseBusiness />
<span>岗位会商1</span>
<span>岗位会商</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem> */}
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/scheduled/tasks"}
+39 -3
View File
@@ -39,6 +39,8 @@ export interface EmbedSession {
theme: EmbedTheme;
/** 隐藏深链底部「快捷跳转」栏(TaskJumpBar:底部按钮 + 右侧悬浮菜单)。 */
hideJump: boolean;
/** Only hide the global Header while retaining PageSidebar and normal app behavior. */
hideHeader: boolean;
/**
* 仅去全局 chrome(Header 导航栏 + PageSidebar 侧边栏),**不**强制主题、**不**隔离鉴权。
* 区别于 `embed`:嵌入态会额外强制主题 + 走 sessionStorage 隔离登录身份;`hideChrome` 只想要
@@ -75,6 +77,11 @@ export function readHideJumpParam(search: string): FlagParam {
return readFlagParam(search, "hideJump");
}
/** Read the URL's `hideHeader` three-state flag (hide only the global Header). */
export function readHideHeaderParam(search: string): FlagParam {
return readFlagParam(search, "hideHeader");
}
/** 读 URL 里的 `hideChrome` 三态(仅去全局导航栏 + 侧边栏)。 */
export function readHideChromeParam(search: string): FlagParam {
return readFlagParam(search, "hideChrome");
@@ -110,6 +117,7 @@ export function readEmbedSession(): EmbedSession | null {
embed: p.embed === true,
theme: p.theme === "dark" ? "dark" : "light",
hideJump: p.hideJump === true,
hideHeader: p.hideHeader === true,
hideChrome: p.hideChrome === true,
isolate: p.isolate === true,
};
@@ -121,7 +129,7 @@ export function readEmbedSession(): EmbedSession | null {
/** 写会话;无任何开关置位时直接清除(保持 sessionStorage 干净)。隐私模式/quota 异常静默失败。 */
function writeEmbedSession(next: EmbedSession): void {
try {
if (!next.embed && !next.hideJump && !next.hideChrome && !next.isolate) {
if (!next.embed && !next.hideJump && !next.hideHeader && !next.hideChrome && !next.isolate) {
sessionStorage.removeItem(EMBED_SESSION_KEY);
return;
}
@@ -141,6 +149,16 @@ export function isJumpBarHidden(): boolean {
return readEmbedSession()?.hideJump === true;
}
/**
* Whether this tab should hide its global Header.
* `embed` and `hideChrome` also hide the Header, while `hideHeader` alone
* deliberately leaves PageSidebar visible.
*/
export function isHeaderHidden(): boolean {
const session = readEmbedSession();
return session?.embed === true || session?.hideChrome === true || session?.hideHeader === true;
}
/**
* 本标签页是否要去全局 chrome(Header 导航栏 + PageSidebar 侧边栏)。
* `embed` 已隐含去 chrome,故这里取「embed || hideChrome」之并——任一置位都隐藏。
@@ -172,7 +190,14 @@ export function isIsolatedSession(): boolean {
export function syncEmbedSessionFromSearch(search: string): void {
const cur =
readEmbedSession() ??
{ embed: false, theme: "light" as EmbedTheme, hideJump: false, hideChrome: false, isolate: false };
{
embed: false,
theme: "light" as EmbedTheme,
hideJump: false,
hideHeader: false,
hideChrome: false,
isolate: false,
};
const next: EmbedSession = { ...cur };
const embedParam = readEmbedParam(search);
@@ -187,6 +212,10 @@ export function syncEmbedSessionFromSearch(search: string): void {
if (hideJumpParam === "on") next.hideJump = true;
else if (hideJumpParam === "off") next.hideJump = false;
const hideHeaderParam = readHideHeaderParam(search);
if (hideHeaderParam === "on") next.hideHeader = true;
else if (hideHeaderParam === "off") next.hideHeader = false;
const hideChromeParam = readHideChromeParam(search);
if (hideChromeParam === "on") next.hideChrome = true;
else if (hideChromeParam === "off") next.hideChrome = false;
@@ -238,6 +267,13 @@ export function resolveForcedEmbedTheme(search: string): EmbedTheme | undefined
export function setIsolatedSession(on: boolean): void {
const cur =
readEmbedSession() ??
{ embed: false, theme: "light" as EmbedTheme, hideJump: false, hideChrome: false, isolate: false };
{
embed: false,
theme: "light" as EmbedTheme,
hideJump: false,
hideHeader: false,
hideChrome: false,
isolate: false,
};
writeEmbedSession({ ...cur, isolate: on });
}
@@ -2,8 +2,10 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
isExplicitBrainyIframeMode,
isChatsIframeWorkspacePath,
resolveForcedThemeForPath,
resolveStandardChatFallback,
} from "./chats-iframe-route.ts";
test("matches only workspace chats_iframe routes", () => {
@@ -31,3 +33,39 @@ test("forces light theme for landing and chats_iframe routes only", () => {
);
assert.equal(resolveForcedThemeForPath("/page/workspace/chats/new"), undefined);
});
test("requires an explicit chatMode=iframe opt-in for the Brainy page", () => {
assert.equal(isExplicitBrainyIframeMode(""), false);
assert.equal(isExplicitBrainyIframeMode("?from=iframe"), false);
assert.equal(isExplicitBrainyIframeMode("?chatMode=full"), false);
assert.equal(isExplicitBrainyIframeMode("?chatMode=iframe"), true);
assert.equal(isExplicitBrainyIframeMode("chatMode=IFRAME"), true);
});
test("redirects accidental chats_iframe routes to standard full chat", () => {
assert.equal(
resolveStandardChatFallback(
"/page/workspace/chats_iframe/new",
"?from=iframe&hideChrome=1",
),
"/page/workspace/chats/new?hideChrome=1&chatMode=full",
);
assert.equal(
resolveStandardChatFallback(
"/page/workspace/chats_iframe/thread-123",
"?hideChrome=1",
),
"/page/workspace/chats/thread-123?hideChrome=1&chatMode=full",
);
assert.equal(
resolveStandardChatFallback(
"/page/workspace/chats_iframe/new",
"?chatMode=iframe",
),
null,
);
assert.equal(
resolveStandardChatFallback("/page/workspace/chats/new", ""),
null,
);
});
@@ -5,6 +5,53 @@ export function isChatsIframeWorkspacePath(pathname: string): boolean {
);
}
/**
* The lightweight iframe page is a private transport for the Brainy
* postMessage protocol, not a general-purpose embedded chat page.
*
* Requiring an explicit mode prevents legacy `from=iframe` links, ordinary
* iframes and micro-frontend containers from accidentally landing on the
* protocol-only page.
*/
export function isExplicitBrainyIframeMode(search: string): boolean {
const normalizedSearch = search.startsWith("?") ? search.slice(1) : search;
return (
new URLSearchParams(normalizedSearch)
.get("chatMode")
?.trim()
.toLowerCase() === "iframe"
);
}
/**
* Convert an accidental/legacy `chats_iframe` URL to the equivalent standard
* chat URL. Existing thread ids and unrelated query parameters are retained.
*/
export function resolveStandardChatFallback(
pathname: string,
search: string,
): string | null {
if (
!isChatsIframeWorkspacePath(pathname) ||
isExplicitBrainyIframeMode(search)
) {
return null;
}
const routePrefix = "/page/workspace/chats_iframe/";
const routeSuffix = pathname.slice(routePrefix.length);
const targetPath =
routeSuffix && routeSuffix !== "new"
? `/page/workspace/chats/${routeSuffix}`
: "/page/workspace/chats/new";
const normalizedSearch = search.startsWith("?") ? search.slice(1) : search;
const params = new URLSearchParams(normalizedSearch);
params.delete("from");
params.set("chatMode", "full");
const targetSearch = params.toString();
return targetSearch ? `${targetPath}?${targetSearch}` : targetPath;
}
export function resolveForcedThemeForPath(
pathname: string,
): "light" | undefined {
+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 (
+7
View File
@@ -0,0 +1,7 @@
.git
.gitignore
*.tar
*.tar.gz
*.sha256
README*.md
DEPLOY*.md
+104
View File
@@ -0,0 +1,104 @@
# Nginx 1.28 一键部署包
该目录用于在安装了 Docker 的 Linux 服务器上一键部署 Nginx 1.28。默认公网端口为 `80`,映射关系为 `80:80`,容器名为 `nginx-1.28`。
## 文件说明
- `Dockerfile`:以官方 `nginx:1.28` 为基础镜像构建本地镜像 `nginx-oneclick:1.28`。
- `build-image.sh`:构建镜像并导出为 `nginx-oneclick-1.28.tar.gz`,用于离线分发。
- `deploy.sh`:加载镜像包(或自动构建),创建/启动容器并进行 HTTP 验证。
- `docker-compose.yml`:可选的 Compose 部署方式。
- `nginx-oneclick-1.28.tar.gz`:如果随包提供,则优先离线加载,不需要访问 Docker Hub。
## 一键部署
将整个目录复制到目标服务器后执行:
```bash
cd nginx-1.28-deployment
chmod +x build-image.sh deploy.sh
./deploy.sh
```
部署脚本的行为是幂等的:
1. 如果目录中有镜像压缩包,优先执行 `docker load`。
2. 没有镜像包但本机已有镜像时直接复用。
3. 两者都没有时,以 `Dockerfile` 自动构建并拉取 `nginx:1.28` 基础镜像。
4. 创建或启动 `nginx-1.28`,设置 `unless-stopped` 自动重启。
5. 访问 `http://服务器公网IP/` 做 HTTP 检查。
## 构建并导出离线镜像
在可访问 Docker Hub 的 Docker 主机执行:
```bash
./build-image.sh
```
生成的 `nginx-oneclick-1.28.tar.gz` 和 `.sha256` 文件可以与脚本一起复制到无外网服务器。脚本会自动加载该镜像包。
本次服务器上的离线镜像包已生成在:
```text
/opt/nginx-1.28-deployment/nginx-oneclick-1.28.tar.gz
```
对应 SHA-256:`d322ba812dcd3e89fb8c0afd14f27e79e73432f7bc5a5a52324a0ca67e784014`。
## 端口和容器名
默认配置:
```text
公网地址:http://服务器公网IP/
主机端口:80
容器端口:80
容器名:nginx-1.28
```
如 80 端口已被占用,可临时改用主机端口 8080:
```bash
NGINX_HOST_PORT=8080 ./deploy.sh
```
此时访问 `http://服务器公网IP:8080/`。如要更换容器名:
```bash
NGINX_CONTAINER_NAME=nginx-prod ./deploy.sh
```
## 已存在容器的处理
脚本不会默认删除或覆盖同名但配置不同的容器。确认要重建时显式执行:
```bash
FORCE_RECREATE=1 ./deploy.sh
```
该操作会删除并重新创建 `NGINX_CONTAINER_NAME` 指定的容器,不会删除镜像。
## Compose 部署(可选)
```bash
docker compose up -d --build
docker compose ps
curl -I http://127.0.0.1/
```
## 常用运维命令
```bash
docker ps --filter name=nginx-1.28
docker logs --tail 100 nginx-1.28
docker exec nginx-1.28 nginx -v
docker restart nginx-1.28
docker stop nginx-1.28
```
## 安全与公网访问检查
只映射 HTTP 端口时,服务器防火墙/云安全组需要允许 TCP `80`(或自定义的 `NGINX_HOST_PORT`)。部署脚本只负责 Docker 端口发布,不会修改云厂商安全组规则。
不要把 SSH 密码、私钥或生产配置写入该目录。若通过 SSH 远程部署,请使用密钥或交互式认证,并在密码已经暴露后及时轮换。
+6
View File
@@ -0,0 +1,6 @@
ARG NGINX_VERSION=1.28
FROM nginx:${NGINX_VERSION}
LABEL org.opencontainers.image.title="Nginx 1.28 one-click deployment" \
org.opencontainers.image.description="Reusable Nginx 1.28 deployment image" \
org.opencontainers.image.version="1.28"
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
IMAGE="${NGINX_IMAGE:-nginx-oneclick:1.28}"
NGINX_VERSION="${NGINX_VERSION:-1.28}"
ARCHIVE="${NGINX_IMAGE_ARCHIVE:-${SCRIPT_DIR}/nginx-oneclick-1.28.tar.gz}"
command -v docker >/dev/null 2>&1 || {
echo "错误:未找到 Docker,请先安装 Docker。" >&2
exit 1
}
echo "[1/2] 构建镜像 ${IMAGE}(基础镜像 nginx:${NGINX_VERSION})"
docker build \
--pull \
--build-arg "NGINX_VERSION=${NGINX_VERSION}" \
--tag "${IMAGE}" \
"${SCRIPT_DIR}"
echo "[2/2] 导出镜像 ${ARCHIVE}"
mkdir -p "$(dirname -- "${ARCHIVE}")"
tmp_archive="${ARCHIVE}.tmp"
trap 'rm -f -- "${tmp_archive}"' EXIT
docker save "${IMAGE}" | gzip -c >"${tmp_archive}"
mv -f -- "${tmp_archive}" "${ARCHIVE}"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "${ARCHIVE}" >"${ARCHIVE}.sha256"
fi
echo
echo "构建完成:"
echo " 镜像:${IMAGE}"
echo " 文件:${ARCHIVE}"
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
IMAGE="${NGINX_IMAGE:-nginx-oneclick:1.28}"
NGINX_VERSION="${NGINX_VERSION:-1.28}"
CONTAINER_NAME="${NGINX_CONTAINER_NAME:-nginx-1.28}"
HOST_PORT="${NGINX_HOST_PORT:-80}"
CONTAINER_PORT=80
FORCE_RECREATE="${FORCE_RECREATE:-0}"
ARCHIVE="${NGINX_IMAGE_ARCHIVE:-${SCRIPT_DIR}/nginx-oneclick-1.28.tar.gz}"
die() {
echo "错误:$*" >&2
exit 1
}
command -v docker >/dev/null 2>&1 || die "未找到 Docker,请先安装 Docker。"
[[ "${HOST_PORT}" =~ ^[0-9]+$ ]] || die "NGINX_HOST_PORT 必须是数字。"
(( HOST_PORT >= 1 && HOST_PORT <= 65535 )) || die "NGINX_HOST_PORT 必须在 1-65535 范围内。"
echo "部署参数:"
echo " 镜像:${IMAGE}"
echo " 容器:${CONTAINER_NAME}"
echo " 端口:${HOST_PORT}:${CONTAINER_PORT}"
if ! docker image inspect "${IMAGE}" >/dev/null 2>&1 && [[ -f "${ARCHIVE}" ]]; then
echo "[1/4] 从镜像包加载 ${ARCHIVE}"
case "${ARCHIVE}" in
*.tar.gz|*.tgz) gzip -cd -- "${ARCHIVE}" | docker load ;;
*) docker load --input "${ARCHIVE}" ;;
esac
fi
if ! docker image inspect "${IMAGE}" >/dev/null 2>&1; then
echo "[1/4] 未找到本地镜像,自动构建 ${IMAGE}"
docker build \
--pull \
--build-arg "NGINX_VERSION=${NGINX_VERSION}" \
--tag "${IMAGE}" \
"${SCRIPT_DIR}"
else
echo "[1/4] 已找到镜像 ${IMAGE}"
fi
existing_container=0
if docker container inspect "${CONTAINER_NAME}" >/dev/null 2>&1; then
existing_container=1
current_image="$(docker inspect -f '{{.Config.Image}}' "${CONTAINER_NAME}")"
current_host_port="$(docker inspect -f '{{with (index .HostConfig.PortBindings "80/tcp")}}{{(index . 0).HostPort}}{{end}}' "${CONTAINER_NAME}" 2>/dev/null || true)"
if [[ "${current_image}" != "${IMAGE}" || "${current_host_port}" != "${HOST_PORT}" ]]; then
if [[ "${FORCE_RECREATE}" != "1" ]]; then
die "已存在同名容器但镜像或端口不匹配。确认后可设置 FORCE_RECREATE=1 再执行。"
fi
echo "[2/4] FORCE_RECREATE=1,重建已有容器 ${CONTAINER_NAME}"
docker rm -f "${CONTAINER_NAME}" >/dev/null
existing_container=0
fi
fi
if (( existing_container == 0 )); then
echo "[2/4] 创建容器 ${CONTAINER_NAME}"
docker run -d \
--name "${CONTAINER_NAME}" \
--restart unless-stopped \
--publish "${HOST_PORT}:${CONTAINER_PORT}" \
--log-opt max-size=10m \
--log-opt max-file=3 \
"${IMAGE}" >/dev/null
else
running="$(docker inspect -f '{{.State.Running}}' "${CONTAINER_NAME}")"
if [[ "${running}" != "true" ]]; then
echo "[2/4] 启动已存在的容器 ${CONTAINER_NAME}"
docker start "${CONTAINER_NAME}" >/dev/null
else
echo "[2/4] 容器 ${CONTAINER_NAME} 已在运行"
fi
fi
echo "[3/4] 等待容器就绪"
for _ in {1..30}; do
running="$(docker inspect -f '{{.State.Running}}' "${CONTAINER_NAME}" 2>/dev/null || true)"
[[ "${running}" == "true" ]] && break
sleep 1
done
[[ "${running}" == "true" ]] || die "容器未能进入运行状态。"
echo "[4/4] 验证 HTTP 服务"
url="http://127.0.0.1:${HOST_PORT}/"
http_ok=0
if command -v curl >/dev/null 2>&1; then
for _ in {1..30}; do
if curl --fail --silent --max-time 2 "${url}" >/dev/null; then
http_ok=1
break
fi
sleep 1
done
elif command -v wget >/dev/null 2>&1; then
for _ in {1..30}; do
if wget -q -O /dev/null --timeout=2 "${url}"; then
http_ok=1
break
fi
sleep 1
done
else
echo "警告:系统没有 curl 或 wget,跳过 HTTP 验证。" >&2
http_ok=1
fi
(( http_ok == 1 )) || die "HTTP 验证失败:${url}"
nginx_version="$(docker exec "${CONTAINER_NAME}" nginx -v 2>&1 || true)"
echo
echo "部署成功。"
echo " 容器:${CONTAINER_NAME}"
echo " 端口:0.0.0.0:${HOST_PORT} -> ${CONTAINER_PORT}"
echo " 版本:${nginx_version}"
echo " 查看状态:docker ps --filter name=${CONTAINER_NAME}"
+11
View File
@@ -0,0 +1,11 @@
services:
nginx:
build:
context: .
args:
NGINX_VERSION: "${NGINX_VERSION:-1.28}"
image: "${NGINX_IMAGE:-nginx-oneclick:1.28}"
container_name: "${NGINX_CONTAINER_NAME:-nginx-1.28}"
restart: unless-stopped
ports:
- "${NGINX_HOST_PORT:-80}:80"
@@ -0,0 +1 @@
d322ba812dcd3e89fb8c0afd14f27e79e73432f7bc5a5a52324a0ca67e784014 nginx-oneclick-1.28.tar.gz
@@ -462,6 +462,7 @@ See the [LICENSE](../LICENSE) file in the project root.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines.
## Position roundtable durable history
The position-roundtable workflow stores database-owned recovery snapshots for