Compare commits

...

7 Commits

Author SHA1 Message Date
Haitao Pan
ce4e8bf7ae feat(home): align release hero with main 2026-03-18 15:37:23 +08:00
Haitao Pan
74b13bde71 Avoid resetting paired X assistant on pairing required 2026-03-18 13:47:44 +08:00
Haitao Pan
8d11e30d76 Merge main into release/v0.2 2026-03-18 13:45:09 +08:00
Haitao Pan
11562f441e Fix footer contrast in light theme 2026-03-17 19:42:12 +08:00
Haitao Pan
67a119631d merge: bring stripe pricing console into release v0.2 2026-03-17 19:29:51 +08:00
Haitao Pan
f1bdacbc24 fix(auth): align console MFA proxy with accounts contract 2026-03-17 16:22:51 +08:00
Haitao Pan
c80fbd1cb1 Add tenant-aware XWorkmate console flows 2026-03-17 13:25:26 +08:00
6 changed files with 646 additions and 266 deletions

View File

@ -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<ErrorPayload>(
{ 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<ErrorPayload>(
{ error: "unauthenticated" },
{ status: 401 },
);
}
if (!(await userHasRoleOrPermission(user, READ_ROLES, READ_PERMISSIONS))) {
return NextResponse.json<ErrorPayload>(
{ 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<ErrorPayload>(
{ error: "unauthenticated" },
{ status: 401 },
);
}
if (
!(
(await userHasRole(user, WRITE_ROLES)) ||
(await userHasPermission(user, ["admin.settings.write"]))
)
) {
return NextResponse.json<ErrorPayload>(
{ error: "forbidden" },
{ status: 403 },
);
}
return proxyAccountRequest(
request,
`${ACCOUNT_API_BASE}/admin/homepage-video`,
"PUT",
session.token,
);
}

View File

@ -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 },
);
}
}

View File

@ -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<string, any> = {
"Global Acceleration Network": Link,
@ -71,6 +85,33 @@ const iconMap: Record<string, any> = {
const getIcon = (key: string, fallback: any) => iconMap[key] || fallback;
async function jsonFetcher<T>(
input: RequestInfo,
init?: RequestInit,
): Promise<T> {
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() {
<div className="relative mx-auto max-w-6xl px-4 pb-16 sm:px-6 sm:pb-20">
<main className="relative space-y-6 pt-6 sm:space-y-8 sm:pt-10">
<HeroSection />
<NextStepsSection />
<StatsSection />
<ShortcutsSection />
</main>
@ -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<IntegrationDefaults>(
"/api/integrations/defaults",
jsonFetcher,
{
revalidateOnFocus: false,
},
);
const homepageVideoSWR = useSWR<ResolvedHomepageVideoResponse>(
"/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 (
<section className="relative overflow-hidden rounded-[2.75rem] border border-slate-900/10 bg-[linear-gradient(180deg,#ffffff,#faf7f2)] p-6 shadow-[0_24px_56px_rgba(15,23,42,0.05)] sm:p-8 lg:p-10">
@ -122,133 +221,139 @@ export function HeroSection() {
</div>
<div className="relative grid gap-8 lg:grid-cols-[0.96fr_1.04fr] lg:gap-12">
<div className="flex flex-col justify-between gap-8">
<div className="flex flex-col gap-6 pt-2">
<div className="overflow-hidden rounded-[2rem] border border-slate-900/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(243,246,251,0.96))] shadow-[0_24px_60px_rgba(15,23,42,0.08)]">
<div className="border-b border-slate-900/10 px-5 py-4 sm:px-6">
<div className="flex items-start justify-between gap-4">
<div>
<p className={HOME_SECTION_LABEL_CLASS}>{heroCopy.demoLabel}</p>
<p className="mt-2 max-w-md text-sm leading-6 text-text-muted">
{heroCopy.demoHint}
</p>
</div>
<span className="hidden rounded-full border border-slate-900/10 bg-white/90 px-3 py-1 text-xs font-semibold text-slate-600 sm:inline-flex">
{entry.domain?.trim()
? `${isChinese ? "当前域名" : "Host"}: ${entry.domain}`
: isChinese
? "默认主站配置"
: "Default site config"}
</span>
</div>
</div>
<div className="space-y-4 p-4 sm:p-5">
<DemoVideoSurface presentation={presentation} isChinese={isChinese} />
<div className="flex flex-wrap gap-2 text-xs text-slate-500">
<a
href={entry.videoUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center rounded-full border border-slate-900/10 bg-white px-3 py-1.5 font-semibold text-slate-600 transition hover:border-slate-300 hover:text-slate-900"
>
{isChinese ? "打开原始链接" : "Open source link"}
</a>
</div>
</div>
</div>
<div className="space-y-5">
{t.hero.eyebrow ? (
<p className={HOME_SECTION_LABEL_CLASS}>{t.hero.eyebrow}</p>
) : null}
<div className="space-y-3">
<p className={HOME_SECTION_LABEL_CLASS}>{heroCopy.eyebrow}</p>
<h1
className={cn(
"max-w-[11ch] leading-[0.88] text-heading",
"max-w-[10ch] leading-[0.94] text-heading",
isChinese
? "text-[2.85rem] font-semibold tracking-[-0.08em] sm:text-[3.4rem] lg:text-[4.5rem]"
: "editorial-display text-[3.05rem] tracking-[-0.06em] sm:text-[3.6rem] lg:text-[4.8rem]",
? "text-[3.05rem] font-semibold tracking-[-0.055em] sm:text-[3.6rem] lg:text-[4.2rem]"
: "editorial-display text-[3rem] tracking-[-0.05em] sm:text-[3.5rem] lg:text-[4.3rem]",
)}
>
{t.hero.title}
{heroCopy.title}
</h1>
<p className="max-w-xl text-[1rem] leading-8 text-text-muted sm:text-[1.05rem]">
{t.hero.subtitle}
<p className="max-w-xl text-[1rem] leading-8 text-text-muted sm:text-[1.08rem]">
{heroCopy.subtitle}
</p>
</div>
<div className="flex flex-wrap gap-3">
{user ? (
<div className="inline-flex items-center gap-2 rounded-full border border-success/25 bg-success/10 px-4 py-2 text-sm font-semibold text-success">
<div className="h-2 w-2 rounded-full bg-success animate-pulse" />
{t.signedIn.replace("{{username}}", user.username)}
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-slate-950 text-white">
<Bot className="h-4 w-4" />
</div>
<div>
<h2 className="text-sm font-semibold text-slate-900">
{heroCopy.startTitle}
</h2>
<p className="text-xs text-slate-500">{heroCopy.startHint}</p>
</div>
) : (
<button
type="button"
className="inline-flex items-center gap-2 rounded-full bg-slate-950 px-6 py-3 text-sm font-semibold text-white transition hover:bg-primary"
>
<PlusCircle className="h-4 w-4" />
{t.heroButtons.create}
</button>
)}
<button
type="button"
className="inline-flex items-center gap-2 rounded-full border border-slate-900/10 bg-white px-6 py-3 text-sm font-semibold text-slate-800 transition hover:bg-slate-50"
>
<Play className="h-4 w-4" />
{t.heroButtons.playground}
</button>
<button
type="button"
className="inline-flex items-center gap-2 rounded-full border border-slate-900/10 bg-[#f8f4ec] px-6 py-3 text-sm font-semibold text-slate-800 transition hover:bg-[#f2ebdd]"
>
<BookOpen className="h-4 w-4" />
{t.heroButtons.tutorials}
</button>
</div>
<div className="space-y-3 border-t border-slate-900/10 pt-5">
<p className={HOME_SECTION_LABEL_CLASS}>{t.trustedBy}</p>
<div className="flex flex-wrap gap-2">
<LogoPill label="Next.js" />
<LogoPill label="Go" />
<LogoPill label="Vercel" />
<LogoPill label="Cloud Run" />
<LogoPill label="PostgreSQL" />
<div className="grid gap-3">
{t.nextSteps.items.map((item, index: number) => {
const example = heroCopy.examples[index] ?? item.title;
return (
<button
key={item.title}
type="button"
onClick={() => {
setPromptSeed(example);
setPromptSeedKey((current) => current + 1);
}}
className="group flex items-center justify-between gap-4 rounded-[1.25rem] border border-slate-200/90 bg-white/88 px-4 py-3.5 text-left shadow-[0_10px_26px_rgba(15,23,42,0.04)] transition duration-200 hover:-translate-y-[1px] hover:border-slate-300 hover:bg-[#fbfaf7]"
>
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<span className="inline-flex rounded-full border border-slate-900/10 bg-[#f8f4ec] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-600">
{item.status}
</span>
<p className="truncate text-sm font-semibold text-slate-900">
{item.title}
</p>
</div>
<p className="text-xs leading-5 text-slate-500">
{heroCopy.itemHint}
</p>
</div>
<ArrowRight className="h-4 w-4 shrink-0 text-slate-400 transition group-hover:text-slate-700" />
</button>
);
})}
</div>
</div>
</div>
</div>
<div className="lg:pl-4">
<HeroVideoShell
items={t.heroCards.map((card) => card.title)}
isChinese={isChinese}
media={heroVideoMedia}
<div className="overflow-hidden rounded-[2rem] border border-slate-900/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.94),rgba(246,248,251,0.98))] shadow-[0_24px_60px_rgba(15,23,42,0.08)]">
<div className="border-b border-slate-900/10 px-5 py-4 sm:px-6">
<div className="flex items-start justify-between gap-4">
<div>
<p className={HOME_SECTION_LABEL_CLASS}>
{isChinese ? "X 助手" : "X Assistant"}
</p>
<p className="mt-2 max-w-md text-sm leading-6 text-text-muted">
{isChinese
? "首页只保留一个主路径:先提问,再由助手拆解任务、调用能力并推进执行。"
: "The homepage keeps one primary path: ask first, then let the assistant plan and execute."}
</p>
</div>
<span className="hidden rounded-full border border-slate-900/10 bg-white/90 px-3 py-1 text-xs font-semibold text-slate-600 sm:inline-flex">
{isChinese ? "对话即入口" : "Prompt-first"}
</span>
</div>
</div>
<div className="p-4 sm:p-5">
<OpenClawAssistantPane
defaults={assistantDefaultsSWR.data ?? EMPTY_ASSISTANT_DEFAULTS}
initialQuestion={promptSeed}
initialQuestionKey={promptSeedKey}
autoSubmitInitialQuestion={false}
variant="page"
/>
</div>
</div>
</section>
);
}
export function NextStepsSection() {
const { language } = useLanguage();
const t = translations[language].marketing.home;
return (
<section className={cn(HOME_SECTION_CLASS, "space-y-4 p-5 lg:p-7")}>
<header className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className={HOME_SECTION_LABEL_CLASS}>{t.nextSteps.title}</p>
<p className="mt-2 text-sm leading-6 text-text-muted">
{language === "zh"
? "保留原有 onboarding 内容,但改成更轻、更整齐的起步列表。"
: "Keep the same onboarding content, in a lighter and calmer starting list."}
</p>
</div>
<span className="inline-flex w-fit items-center rounded-full border border-slate-900/10 bg-[#f8f4ec] px-3 py-1 text-xs font-semibold text-slate-700">
{t.nextSteps.badge}
</span>
</header>
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
{t.nextSteps.items.map((item, index: number) => {
const Icon = getIcon(item.title, Users);
return (
<div
key={index}
className={cn(
HOME_LIST_CARD_CLASS,
"flex h-full flex-col justify-between gap-6 p-4 hover:-translate-y-[1px] hover:bg-white",
)}
>
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-slate-900/[0.04] text-primary">
<Icon className="h-5 w-5" aria-hidden />
</div>
<span className="rounded-full border border-slate-900/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
{item.status}
</span>
</div>
<p className="text-base font-semibold leading-7 text-slate-900">
{item.title}
</p>
</div>
<button className="inline-flex items-center gap-1 text-sm font-semibold text-primary transition hover:text-primary-hover">
{t.nextSteps.learnMore}
<ArrowRight className="h-4 w-4" aria-hidden />
</button>
</div>
);
})}
</div>
</section>
);
@ -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
const fallbackStyle =
presentation.posterUrl && presentation.kind === "empty"
? {
backgroundImage: `linear-gradient(180deg,rgba(15,23,42,0.18),rgba(15,23,42,0.52)), url(${media.posterUrl})`,
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 (
<div className="overflow-hidden rounded-[2rem] border border-slate-900/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.88),rgba(244,247,252,0.96))] shadow-[0_24px_60px_rgba(15,23,42,0.08)]">
<div className="border-b border-slate-900/10 px-5 py-4 sm:px-6">
<div className="flex items-start justify-between gap-4">
<div>
<p className={HOME_SECTION_LABEL_CLASS}>
{isChinese ? "产品演示" : "Product demo"}
</p>
<p className="mt-2 max-w-md text-sm leading-6 text-text-muted">
{isChinese
? "这里预留为视频展示区,后续可以直接替换成产品介绍、工作流演示或 onboarding 视频。"
: "Reserved for a video showcase. You can later replace it with a product intro, workflow demo, or onboarding clip."}
</p>
</div>
<span className="hidden rounded-full border border-slate-900/10 bg-white/90 px-3 py-1 text-xs font-semibold text-slate-600 sm:inline-flex">
{isChinese ? "16:9 占位" : "16:9 shell"}
</span>
</div>
</div>
<div className="space-y-4 p-4 sm:p-5">
<div
className={cn(
"group relative aspect-video overflow-hidden rounded-[1.6rem] border border-slate-900/10",
!hasVideo &&
"bg-[radial-gradient(circle_at_top_left,rgba(51,102,255,0.16),transparent_34%),linear-gradient(135deg,#0f172a,#172033_52%,#1f2d4d)]",
"group relative aspect-video overflow-hidden rounded-[1.6rem] border border-slate-900/10 bg-[radial-gradient(circle_at_top_left,rgba(51,102,255,0.16),transparent_34%),linear-gradient(135deg,#0f172a,#172033_52%,#1f2d4d)]",
presentation.kind !== "empty" && "bg-slate-950",
)}
style={previewStyle}
style={fallbackStyle}
>
{hasVideo ? (
{presentation.kind === "embed" ? (
<iframe
src={presentation.src}
title={isChinese ? "产品演示视频" : "Product demo video"}
className="h-full w-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
/>
) : null}
{presentation.kind === "direct" ? (
<video
className="h-full w-full object-cover"
controls
playsInline
preload="metadata"
poster={media.posterUrl || undefined}
poster={presentation.posterUrl || undefined}
>
<source src={media.videoUrl} />
<source src={presentation.src} />
</video>
) : (
<>
<div
aria-hidden
className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(15,23,42,0.18))]"
/>
<div
aria-hidden
className="absolute left-5 top-5 h-20 w-20 rounded-full bg-[radial-gradient(circle,rgba(255,255,255,0.18),transparent_70%)] blur-2xl"
/>
<div
aria-hidden
className="absolute right-[-1.5rem] top-[-1.5rem] h-28 w-28 rounded-full border border-white/10"
/>
</>
)}
<div className="absolute inset-0 flex flex-col justify-between p-5 sm:p-6">
<div className="flex items-center justify-between gap-3">
<span className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.2em] text-white/80 backdrop-blur">
<span className="h-2 w-2 rounded-full bg-emerald-400" />
{mediaStatusLabel}
</span>
<span className="rounded-full border border-white/10 bg-black/10 px-3 py-1 text-xs font-medium text-white/70">
{media.durationLabel}
</span>
</div>
<div className="space-y-4">
{!hasVideo ? (
<button
type="button"
className="inline-flex h-16 w-16 items-center justify-center rounded-full border border-white/15 bg-white/12 text-white shadow-[0_14px_35px_rgba(15,23,42,0.25)] backdrop-blur transition group-hover:scale-[1.02]"
>
<Play className="ml-1 h-7 w-7" fill="currentColor" />
</button>
) : null}
{presentation.kind === "empty" ? (
<div className="absolute inset-0 flex flex-col justify-between p-5 sm:p-6">
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-white/15 bg-white/10 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.2em] text-white/80 backdrop-blur">
<span className="h-2 w-2 rounded-full bg-amber-300" />
{isChinese ? "待接入视频" : "Awaiting media"}
</div>
<div className="max-w-lg space-y-2">
<p className="text-xl font-semibold tracking-[-0.03em] text-white sm:text-2xl">
{mediaTitle}
{isChinese
? "这里会展示当前域名的视频演示"
: "This area shows the domain-specific product demo"}
</p>
<p className="text-sm leading-6 text-white/72 sm:text-[0.95rem]">
{mediaDescription}
{isChinese
? "管理页可为不同域名配置不同链接。若链接不是可嵌入或直链格式,这里会保留为占位状态。"
: "The admin page can assign a different link per host. Unsupported links remain in placeholder mode until a valid embed or direct video URL is provided."}
</p>
</div>
</div>
<div className="space-y-3">
<div className="h-1.5 overflow-hidden rounded-full bg-white/12">
<div className="h-full w-[28%] rounded-full bg-white/75" />
</div>
<div className="grid grid-cols-3 gap-2 text-[11px] font-medium text-white/60 sm:text-xs">
{media.chapters.map((chapter) => (
<span
key={chapter.en}
className="rounded-full border border-white/10 bg-white/8 px-3 py-2 text-center"
>
{isChinese ? chapter.zh : chapter.en}
</span>
))}
</div>
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
{items.map((item) => (
<span
key={item}
className="inline-flex items-center rounded-full border border-slate-900/10 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600"
>
{item}
</span>
))}
</div>
</div>
) : null}
</div>
);
}
function LogoPill({ label }: { label: string }) {
return (
<span className="inline-flex items-center gap-2 rounded-full border border-slate-900/10 bg-white px-3.5 py-1.5 text-xs font-semibold text-slate-700">
<div className="h-2 w-2 rounded-full bg-primary" />
{label}
</span>
);
}

View File

@ -49,6 +49,7 @@ type OpenClawAssistantPaneProps = {
defaults: IntegrationDefaults;
initialQuestion?: string;
initialQuestionKey?: number;
autoSubmitInitialQuestion?: boolean;
variant?: "page" | "sidebar";
showConversation?: boolean;
emptyConversationHint?: string;
@ -214,6 +215,7 @@ export function OpenClawAssistantPane({
defaults,
initialQuestion,
initialQuestionKey,
autoSubmitInitialQuestion = true,
variant = "page",
showConversation = true,
emptyConversationHint,
@ -778,8 +780,16 @@ export function OpenClawAssistantPane({
lastInitialQuestionKeyRef.current = resolvedKey;
setComposerValue(initialQuestion);
if (autoSubmitInitialQuestion) {
void sendMessage(initialQuestion);
}, [connectionState, initialQuestion, initialQuestionKey, sendMessage]);
}
}, [
autoSubmitInitialQuestion,
connectionState,
initialQuestion,
initialQuestionKey,
sendMessage,
]);
function handleTextareaKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === "Enter" && !event.shiftKey) {

View File

@ -0,0 +1,191 @@
export type HomepageVideoEntry = {
domain?: string;
videoUrl: string;
posterUrl?: string;
};
export type HomepageVideoSettingsResponse = {
defaultEntry: HomepageVideoEntry;
overrides: HomepageVideoEntry[];
};
export type ResolvedHomepageVideoResponse = {
resolved: HomepageVideoEntry;
};
export type HomepageVideoPresentation =
| {
kind: "embed";
provider: "youtube" | "bilibili";
src: string;
posterUrl?: string;
videoUrl: string;
}
| {
kind: "direct";
provider: "direct";
src: string;
posterUrl?: string;
videoUrl: string;
}
| {
kind: "empty";
provider: "none";
src: "";
posterUrl?: string;
videoUrl: string;
};
export const DEFAULT_HOMEPAGE_VIDEO_SETTINGS: HomepageVideoSettingsResponse = {
defaultEntry: {
videoUrl: "https://www.youtube.com/watch?v=UW6DY6HQnmo",
posterUrl: "",
},
overrides: [
{
domain: "cn-www.svc.plus",
videoUrl:
"https://www.bilibili.com/video/BV12DwazxEkL/?spm_id_from=333.1387.homepage.video_card.click&vd_source=e14d146f9a815c7d11e1a1fc23565ffd",
posterUrl: "",
},
],
};
function trimValue(value?: string): string {
return value?.trim() ?? "";
}
export function normalizeHomepageVideoEntry(
entry?: HomepageVideoEntry,
): HomepageVideoEntry {
return {
domain: trimValue(entry?.domain),
videoUrl: trimValue(entry?.videoUrl),
posterUrl: trimValue(entry?.posterUrl),
};
}
function tryParseUrl(raw: string): URL | null {
const trimmed = trimValue(raw);
if (!trimmed) {
return null;
}
try {
return new URL(trimmed);
} catch {
return null;
}
}
function resolveYoutubeEmbed(url: URL): string | undefined {
const host = url.hostname.toLowerCase();
if (host === "youtu.be") {
const videoId = url.pathname.replace(/^\/+/, "").split("/")[0];
return videoId ? `https://www.youtube.com/embed/${videoId}` : undefined;
}
if (host.endsWith("youtube.com")) {
if (url.pathname === "/watch") {
const videoId = trimValue(url.searchParams.get("v") ?? "");
return videoId ? `https://www.youtube.com/embed/${videoId}` : undefined;
}
const segments = url.pathname.split("/").filter(Boolean);
const videoId = segments.at(-1);
if (segments[0] === "embed" && videoId) {
return `https://www.youtube.com/embed/${videoId}`;
}
if ((segments[0] === "shorts" || segments[0] === "live") && videoId) {
return `https://www.youtube.com/embed/${videoId}`;
}
}
return undefined;
}
function resolveBilibiliEmbed(url: URL): string | undefined {
const host = url.hostname.toLowerCase();
if (!host.endsWith("bilibili.com")) {
return undefined;
}
const match = url.pathname.match(/\/video\/(BV[0-9A-Za-z]+)/i);
const bvid = match?.[1];
if (!bvid) {
return undefined;
}
const page = trimValue(url.searchParams.get("p") ?? "1") || "1";
return `https://player.bilibili.com/player.html?bvid=${encodeURIComponent(bvid)}&page=${encodeURIComponent(page)}`;
}
function isDirectVideoUrl(url: URL): boolean {
return /\.(mp4|webm|ogg|mov|m4v)(\?|#|$)/i.test(url.pathname);
}
export function resolveHomepageVideoPresentation(
entry?: HomepageVideoEntry,
): HomepageVideoPresentation {
const normalized = normalizeHomepageVideoEntry(entry);
if (!normalized.videoUrl) {
return {
kind: "empty",
provider: "none",
src: "",
posterUrl: normalized.posterUrl,
videoUrl: "",
};
}
const parsed = tryParseUrl(normalized.videoUrl);
if (!parsed) {
return {
kind: "empty",
provider: "none",
src: "",
posterUrl: normalized.posterUrl,
videoUrl: "",
};
}
const youtubeEmbed = resolveYoutubeEmbed(parsed);
if (youtubeEmbed) {
return {
kind: "embed",
provider: "youtube",
src: youtubeEmbed,
posterUrl: normalized.posterUrl,
videoUrl: normalized.videoUrl,
};
}
const bilibiliEmbed = resolveBilibiliEmbed(parsed);
if (bilibiliEmbed) {
return {
kind: "embed",
provider: "bilibili",
src: bilibiliEmbed,
posterUrl: normalized.posterUrl,
videoUrl: normalized.videoUrl,
};
}
if (isDirectVideoUrl(parsed)) {
return {
kind: "direct",
provider: "direct",
src: normalized.videoUrl,
posterUrl: normalized.posterUrl,
videoUrl: normalized.videoUrl,
};
}
return {
kind: "empty",
provider: "none",
src: "",
posterUrl: normalized.posterUrl,
videoUrl: normalized.videoUrl,
};
}

View File

@ -311,7 +311,7 @@ export class OpenClawGatewayClient {
allowSharedTokenFallback &&
Boolean(sharedGatewayToken) &&
Boolean(storedDeviceToken) &&
(detailCode === 'AUTH_DEVICE_TOKEN_MISMATCH' || detailCode === 'PAIRING_REQUIRED')
detailCode === 'AUTH_DEVICE_TOKEN_MISMATCH'
if (shouldRetryWithSharedToken) {
await clearOpenClawDeviceToken({