diff --git a/src/app/api/admin/homepage-video/route.ts b/src/app/api/admin/homepage-video/route.ts new file mode 100644 index 0000000..e1848b0 --- /dev/null +++ b/src/app/api/admin/homepage-video/route.ts @@ -0,0 +1,117 @@ +export const dynamic = "force-dynamic"; + +import { NextRequest, NextResponse } from "next/server"; + +import { getAccountServiceApiBaseUrl } from "@server/serviceConfig"; +import { + getAccountSession, + userHasPermission, + userHasRole, + userHasRoleOrPermission, +} from "@server/account/session"; +import type { AccountUserRole } from "@server/account/session"; + +const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl(); + +const READ_ROLES: AccountUserRole[] = ["admin", "operator"]; +const WRITE_ROLES: AccountUserRole[] = ["admin"]; +const READ_PERMISSIONS = ["admin.settings.read"]; + +type ErrorPayload = { + error: string; +}; + +async function proxyAccountRequest( + request: NextRequest, + endpoint: string, + method: string, + token: string, +) { + const headers = new Headers({ + Authorization: `Bearer ${token}`, + Accept: "application/json", + }); + + let body: string | undefined; + if (method !== "GET" && method !== "HEAD") { + body = await request.text(); + headers.set( + "Content-Type", + request.headers.get("content-type") ?? "application/json", + ); + } + + const response = await fetch(endpoint, { + method, + headers, + body, + 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 }); +} + +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 b8a503c..d2fe8ea 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,16 +19,19 @@ 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"; const HOME_SECTION_CLASS = @@ -38,6 +40,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 +85,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,7 +133,6 @@ export default function HomePage() {
-
@@ -107,10 +147,69 @@ export default function HomePage() { } export function HeroSection() { - const { user } = useUserStore(); const { language } = useLanguage(); const isChinese = language === "zh"; + const [promptSeed, setPromptSeed] = useState(""); + const [promptSeedKey, setPromptSeedKey] = useState(0); const t = translations[language].marketing.home; + const assistantDefaultsSWR = useSWR( + "/api/integrations/defaults", + jsonFetcher, + { + revalidateOnFocus: false, + }, + ); + const homepageVideoSWR = useSWR( + "/api/homepage-video", + jsonFetcher, + { + fallbackData: { + resolved: DEFAULT_HOMEPAGE_VIDEO_SETTINGS.defaultEntry, + }, + revalidateOnFocus: false, + }, + ); + const entry = + homepageVideoSWR.data?.resolved ?? DEFAULT_HOMEPAGE_VIDEO_SETTINGS.defaultEntry; + const presentation = resolveHomepageVideoPresentation(entry); + + const heroCopy = isChinese + ? { + eyebrow: "AI Native Workspace", + title: "直接说出你的需求,剩下的交给 AI", + subtitle: "从想法到上线,AI 自动完成构建、部署与优化。", + demoLabel: "产品演示", + demoHint: + "这里展示当前域名对应的产品演示链接。主站默认走 YouTube,中国站可切到 Bilibili,也可以继续按域名覆盖。", + startTitle: t.nextSteps.title, + startHint: "保留原有 onboarding 内容,但改成更轻、更整齐的起步列表。", + itemHint: "点击后填入右侧输入框,不会自动发送。", + 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.", + demoLabel: "Product demo", + demoHint: + "This section resolves the product demo for the current host. The default can use YouTube while regional hosts override it.", + startTitle: t.nextSteps.title, + startHint: + "Keep the same onboarding content, in a lighter and calmer starting list.", + itemHint: "Click to fill the composer on the right. It will not auto-submit.", + 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,133 +221,139 @@ export function HeroSection() {
-
-
- {t.hero.eyebrow ? ( -

{t.hero.eyebrow}

- ) : null} -

- {t.hero.title} -

-

- {t.hero.subtitle} -

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

{heroCopy.demoLabel}

+

+ {heroCopy.demoHint} +

+
+ + {entry.domain?.trim() + ? `${isChinese ? "当前域名" : "Host"}: ${entry.domain}` + : isChinese + ? "默认主站配置" + : "Default site config"} +
- ) : ( - - )} - - +
+ +
-
-

{t.trustedBy}

-
- - - - - +
+
+

{heroCopy.eyebrow}

+

+ {heroCopy.title} +

+

+ {heroCopy.subtitle} +

+
+ +
+
+
+ +
+
+

+ {heroCopy.startTitle} +

+

{heroCopy.startHint}

+
+
+ +
+ {t.nextSteps.items.map((item, index: number) => { + const example = heroCopy.examples[index] ?? item.title; + + return ( + + ); + })} +
- card.title)} - isChinese={isChinese} - media={heroVideoMedia} - /> -
-
- - ); -} - -export function NextStepsSection() { - const { language } = useLanguage(); - const t = translations[language].marketing.home; - - return ( -
-
-
-

{t.nextSteps.title}

-

- {language === "zh" - ? "保留原有 onboarding 内容,但改成更轻、更整齐的起步列表。" - : "Keep the same onboarding content, in a lighter and calmer starting list."} -

-
- - {t.nextSteps.badge} - -
- -
- {t.nextSteps.items.map((item, index: number) => { - const Icon = getIcon(item.title, Users); - return ( -
-
-
-
- -
- - {item.status} - +
+
+
+
+

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

+

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

-

- {item.title} -

+ + {isChinese ? "对话即入口" : "Prompt-first"} +
- -
- ); - })} + +
+ +
+
+
); @@ -476,155 +581,72 @@ type LatestBlogPost = { date?: string; }; -function HeroVideoShell({ - items, +function DemoVideoSurface({ + presentation, isChinese, - media, }: { - items: string[]; + presentation: HomepageVideoPresentation; 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; + 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 ( -
-
-
-
-

- {isChinese ? "产品演示" : "Product demo"} -

-

- {isChinese - ? "这里预留为视频展示区,后续可以直接替换成产品介绍、工作流演示或 onboarding 视频。" - : "Reserved for a video showcase. You can later replace it with a product intro, workflow demo, or onboarding clip."} -

-
- - {isChinese ? "16:9 占位" : "16:9 shell"} - -
-
+
+ {presentation.kind === "embed" ? ( +