From 9e452ca464f5eba5ee16535dc61228676ad32080 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Wed, 18 Mar 2026 15:19:14 +0800 Subject: [PATCH] Refactor homepage into prompt-first workspace --- src/app/AppProviders.tsx | 9 +- src/app/api/admin/homepage-video/route.ts | 117 +++++ src/app/api/homepage-video/route.ts | 40 ++ src/app/page.tsx | 461 +++++++++++------- .../openclaw/OpenClawAssistantPane.tsx | 369 ++++++++------ src/lib/home/heroVideoMedia.ts | 44 -- src/lib/home/homepageVideo.ts | 191 ++++++++ .../components/HomepageVideoSettingsPanel.tsx | 258 ++++++++++ .../builtin/user-center/routes/management.tsx | 70 +++ 9 files changed, 1205 insertions(+), 354 deletions(-) create mode 100644 src/app/api/admin/homepage-video/route.ts create mode 100644 src/app/api/homepage-video/route.ts delete mode 100644 src/lib/home/heroVideoMedia.ts create mode 100644 src/lib/home/homepageVideo.ts create mode 100644 src/modules/extensions/builtin/user-center/management/components/HomepageVideoSettingsPanel.tsx diff --git a/src/app/AppProviders.tsx b/src/app/AppProviders.tsx index eb5275c..2627072 100644 --- a/src/app/AppProviders.tsx +++ b/src/app/AppProviders.tsx @@ -25,9 +25,14 @@ export function AppProviders({ const isOpenClawWorkspace = pathname.startsWith("/xworkmate") || pathname.startsWith("/services/openclaw"); + const isHomepage = pathname === "/"; const reserveSpace = - !isOpenClawWorkspace && isOpen && !isMinimized && !isMobileViewport; + !isOpenClawWorkspace && + !isHomepage && + isOpen && + !isMinimized && + !isMobileViewport; useEffect(() => { setScope("global", assistantDefaults); @@ -76,7 +81,7 @@ export function AppProviders({
{children}
- {!isOpenClawWorkspace ? ( + {!isOpenClawWorkspace && !isHomepage ? ( null); + if (payload === null) { + return NextResponse.json( + { error: "invalid_response" }, + { status: 502 }, + ); + } + + return NextResponse.json(payload, { status: response.status }); +} + +export async function GET(request: NextRequest) { + const session = await getAccountSession(request); + const user = session.user; + + if (!user || !session.token) { + return NextResponse.json( + { error: "unauthenticated" }, + { status: 401 }, + ); + } + + if (!(await userHasRoleOrPermission(user, READ_ROLES, READ_PERMISSIONS))) { + return NextResponse.json( + { error: "forbidden" }, + { status: 403 }, + ); + } + + return proxyAccountRequest( + request, + `${ACCOUNT_API_BASE}/admin/homepage-video`, + "GET", + session.token, + ); +} + +export async function PUT(request: NextRequest) { + const session = await getAccountSession(request); + const user = session.user; + + if (!user || !session.token) { + return NextResponse.json( + { error: "unauthenticated" }, + { status: 401 }, + ); + } + + if ( + !( + (await userHasRole(user, WRITE_ROLES)) || + (await userHasPermission(user, ["admin.settings.write"])) + ) + ) { + return NextResponse.json( + { error: "forbidden" }, + { status: 403 }, + ); + } + + return proxyAccountRequest( + request, + `${ACCOUNT_API_BASE}/admin/homepage-video`, + "PUT", + session.token, + ); +} diff --git a/src/app/api/homepage-video/route.ts b/src/app/api/homepage-video/route.ts new file mode 100644 index 0000000..c12c973 --- /dev/null +++ b/src/app/api/homepage-video/route.ts @@ -0,0 +1,40 @@ +export const dynamic = "force-dynamic"; + +import { NextRequest, NextResponse } from "next/server"; + +import { getAccountServiceApiBaseUrl } from "@/server/serviceConfig"; + +const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl(); + +export async function GET(request: NextRequest) { + try { + const headers = new Headers({ + Accept: "application/json", + }); + + const requestHost = + request.headers.get("x-forwarded-host") ?? request.headers.get("host"); + if (requestHost?.trim()) { + headers.set("X-Forwarded-Host", requestHost.trim()); + } + + const response = await fetch(`${ACCOUNT_API_BASE}/homepage-video`, { + method: "GET", + headers, + cache: "no-store", + }); + + const payload = await response.json().catch(() => null); + if (payload === null) { + return NextResponse.json({ error: "invalid_response" }, { status: 502 }); + } + + return NextResponse.json(payload, { status: response.status }); + } catch (error) { + console.error("homepage video proxy failed", error); + return NextResponse.json( + { error: "account_service_unreachable" }, + { status: 502 }, + ); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 9a90a13..275073b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,17 +2,16 @@ export const dynamic = "error"; +import { useState } from "react"; import { AppWindow, ArrowRight, - BookOpen, + Bot, Command, Layers, Link, Lock, MousePointerClick, - Play, - PlusCircle, ShieldCheck, Sparkles, Terminal, @@ -20,14 +19,18 @@ import { } from "lucide-react"; import useSWR from "swr"; +import { OpenClawAssistantPane } from "@/components/openclaw/OpenClawAssistantPane"; +import type { IntegrationDefaults } from "@/lib/openclaw/types"; import Footer from "../components/Footer"; import UnifiedNavigation from "../components/UnifiedNavigation"; import { useLanguage } from "../i18n/LanguageProvider"; import { translations } from "../i18n/translations"; import { - heroVideoMedia, - type HeroVideoMedia, -} from "../lib/home/heroVideoMedia"; + DEFAULT_HOMEPAGE_VIDEO_SETTINGS, + resolveHomepageVideoPresentation, + type HomepageVideoPresentation, + type ResolvedHomepageVideoResponse, +} from "../lib/home/homepageVideo"; import { useMoltbotStore } from "../lib/moltbotStore"; import { useUserStore } from "../lib/userStore"; import { cn } from "../lib/utils"; @@ -38,6 +41,18 @@ const HOME_SECTION_LABEL_CLASS = "text-[0.68rem] font-semibold uppercase tracking-[0.26em] text-text-subtle"; const HOME_LIST_CARD_CLASS = "rounded-[1.5rem] border border-slate-900/10 bg-[#fcfbf8] transition duration-200"; +const EMPTY_ASSISTANT_DEFAULTS: IntegrationDefaults = { + openclawUrl: "", + openclawOrigin: "", + openclawTokenConfigured: false, + vaultUrl: "", + vaultNamespace: "", + vaultTokenConfigured: false, + vaultSecretPath: "", + vaultSecretKey: "", + apisixUrl: "", + apisixTokenConfigured: false, +}; const iconMap: Record = { "Global Acceleration Network": Link, @@ -71,6 +86,33 @@ const iconMap: Record = { const getIcon = (key: string, fallback: any) => iconMap[key] || fallback; +async function jsonFetcher( + input: RequestInfo, + init?: RequestInit, +): Promise { + const response = await fetch(input, { + ...init, + credentials: "include", + headers: { + Accept: "application/json", + ...(init?.headers instanceof Headers + ? Object.fromEntries(init.headers.entries()) + : init?.headers), + }, + cache: "no-store", + }); + + if (!response.ok) { + const payload = (await response.json().catch(() => ({}))) as { + error?: string; + message?: string; + }; + throw new Error(payload.error ?? payload.message ?? "请求失败"); + } + + return (await response.json()) as T; +} + export default function HomePage() { const { mode, isOpen } = useMoltbotStore(); @@ -92,6 +134,7 @@ export default function HomePage() {
+ @@ -110,7 +153,54 @@ export function HeroSection() { const { user } = useUserStore(); const { language } = useLanguage(); const isChinese = language === "zh"; - const t = translations[language].marketing.home; + const [promptSeed, setPromptSeed] = useState(""); + const [promptSeedKey, setPromptSeedKey] = useState(0); + const assistantDefaultsSWR = useSWR( + "/api/integrations/defaults", + jsonFetcher, + { + revalidateOnFocus: false, + }, + ); + + const heroCopy = isChinese + ? { + eyebrow: "AI Native Workspace", + title: "直接说出你的需求,剩下的交给 AI", + subtitle: "从想法到上线,AI 自动完成构建、部署与优化。", + description: + "从 xstream 到 xworkmate,再到 console.svc.plus,用一次对话串起构建、部署、排障和运营。", + status: user + ? `当前模式:${user.username} · 可直接发起任务` + : "当前模式:Guest · 可直接体验", + examplesTitle: "你可以这样开始", + examplesHint: "点击任一示例后,会直接填入右侧输入框。", + examples: [ + "帮我构建一个 SaaS 应用", + "分析这个报错并给出修复建议", + "生成一个 AI agent workflow", + "帮我设计一个控制台首页", + ], + } + : { + eyebrow: "AI Native Workspace", + title: "Describe what you need. Let AI handle the rest.", + subtitle: + "From idea to launch, AI can assemble, deploy, and optimize the work.", + description: + "From xstream to xworkmate to console.svc.plus, one conversation can carry the whole workflow.", + status: user + ? `Mode: ${user.username} · Ready to execute` + : "Mode: Guest · Ready to explore", + examplesTitle: "Try starting with", + examplesHint: "Click a prompt to fill the composer on the right.", + examples: [ + "Help me build a SaaS app", + "Analyze this error and suggest a fix", + "Generate an AI agent workflow", + "Design a console homepage", + ], + }; return (
@@ -122,64 +212,107 @@ export function HeroSection() {
-
-
- {t.hero.eyebrow ? ( -

{t.hero.eyebrow}

- ) : null} +
+
+

{heroCopy.eyebrow}

- {t.hero.title} + {heroCopy.title}

-

- {t.hero.subtitle} -

+
+

+ {heroCopy.subtitle} +

+

+ {heroCopy.description} +

+

+ {heroCopy.status} +

+
-
- {user ? ( -
-
- {t.signedIn.replace("{{username}}", user.username)} +
+
+
+
- ) : ( - - )} - - +
+

+ {heroCopy.examplesTitle} +

+

+ {heroCopy.examplesHint} +

+
+
+ +
+ {heroCopy.examples.map((example) => ( + + ))} +
- card.title)} - isChinese={isChinese} - media={heroVideoMedia} - /> +
+
+
+
+

+ {isChinese ? "X 助手" : "X Assistant"} +

+

+ {isChinese + ? "首页只保留一个主路径:先提问,再由助手拆解任务、调用能力并推进执行。" + : "The homepage keeps one primary path: ask first, then let the assistant plan and execute."} +

+
+ + {isChinese ? "对话即入口" : "Prompt-first"} + +
+
+ +
+ +
+
@@ -465,30 +598,21 @@ type LatestBlogPost = { date?: string; }; -function HeroVideoShell({ - items, - isChinese, - media, -}: { - items: string[]; - isChinese: boolean; - media: HeroVideoMedia; -}) { - const mediaTitle = isChinese ? media.title.zh : media.title.en; - const mediaDescription = isChinese - ? media.description.zh - : media.description.en; - const mediaStatusLabel = isChinese - ? media.statusLabel.zh - : media.statusLabel.en; - const hasVideo = Boolean(media.videoUrl); - const previewStyle = media.posterUrl - ? { - backgroundImage: `linear-gradient(180deg,rgba(15,23,42,0.18),rgba(15,23,42,0.52)), url(${media.posterUrl})`, - backgroundSize: "cover", - backgroundPosition: "center", - } - : undefined; +function ProductDemoSection() { + const { language } = useLanguage(); + const isChinese = language === "zh"; + const { data } = useSWR( + "/api/homepage-video", + jsonFetcher, + { + fallbackData: { + resolved: DEFAULT_HOMEPAGE_VIDEO_SETTINGS.defaultEntry, + }, + revalidateOnFocus: false, + }, + ); + const entry = data?.resolved ?? DEFAULT_HOMEPAGE_VIDEO_SETTINGS.defaultEntry; + const presentation = resolveHomepageVideoPresentation(entry); return (
@@ -500,111 +624,106 @@ function HeroVideoShell({

{isChinese - ? "这里预留为视频展示区,后续可以直接替换成产品介绍、工作流演示或 onboarding 视频。" - : "Reserved for a video showcase. You can later replace it with a product intro, workflow demo, or onboarding clip."} + ? "这里展示当前域名对应的产品演示链接。主站默认走 YouTube,中国站可切到 Bilibili,也可以继续按域名覆盖。" + : "This section resolves the product demo for the current host. The default can use YouTube while regional hosts override it."}

- {isChinese ? "16:9 占位" : "16:9 shell"} + {isChinese ? "按域名解析" : "Domain aware"}
-
- {hasVideo ? ( - - ) : ( - <> -
-
-
- - )} - -
-
- - - {mediaStatusLabel} - - - {media.durationLabel} - -
- -
- {!hasVideo ? ( - - ) : null} -
-

- {mediaTitle} -

-

- {mediaDescription} -

-
-
- -
-
-
-
-
- {media.chapters.map((chapter) => ( - - {isChinese ? chapter.zh : chapter.en} - - ))} -
-
-
-
- -
- {items.map((item) => ( - - {item} - - ))} + +
+ + {entry.domain?.trim() + ? `${isChinese ? "当前域名" : "Host"}: ${entry.domain}` + : isChinese + ? "默认主站配置" + : "Default site config"} + + + {isChinese ? "打开原始链接" : "Open source link"} +
); } + +function DemoVideoSurface({ + presentation, + isChinese, +}: { + presentation: HomepageVideoPresentation; + isChinese: boolean; +}) { + const fallbackStyle = + presentation.posterUrl && presentation.kind === "empty" + ? { + backgroundImage: `linear-gradient(180deg,rgba(15,23,42,0.16),rgba(15,23,42,0.42)), url(${presentation.posterUrl})`, + backgroundSize: "cover", + backgroundPosition: "center", + } + : undefined; + + return ( +
+ {presentation.kind === "embed" ? ( +