feat(home): add gateway-driven hero entry
This commit is contained in:
parent
feddbc1b4d
commit
7e0eb91782
314
src/app/page.tsx
314
src/app/page.tsx
@ -2,7 +2,6 @@
|
||||
|
||||
export const dynamic = "error";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
AppWindow,
|
||||
ArrowRight,
|
||||
@ -16,20 +15,14 @@ import {
|
||||
Terminal,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
|
||||
import type { IntegrationDefaults } from "@/lib/openclaw/types";
|
||||
import { useUserStore } from "@/lib/userStore";
|
||||
import { XWorkmateAssistantShell } from "@/components/xworkmate/XWorkmateAssistantShell";
|
||||
import { GatewayHero } from "@/components/home/GatewayHero";
|
||||
import Footer from "../components/Footer";
|
||||
import UnifiedNavigation from "../components/UnifiedNavigation";
|
||||
import { useLanguage } from "../i18n/LanguageProvider";
|
||||
import { translations } from "../i18n/translations";
|
||||
import {
|
||||
DEFAULT_HOMEPAGE_VIDEO_SETTINGS,
|
||||
type ResolvedHomepageVideoResponse,
|
||||
} from "../lib/home/homepageVideo";
|
||||
import { useMoltbotStore } from "../lib/moltbotStore";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
@ -84,33 +77,6 @@ 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();
|
||||
|
||||
@ -146,287 +112,23 @@ export default function HomePage() {
|
||||
}
|
||||
|
||||
export function HeroSection() {
|
||||
const { language } = useLanguage();
|
||||
const isChinese = language === "zh";
|
||||
const router = useRouter();
|
||||
const user = useUserStore((state) => state.user);
|
||||
const [heroPrompt, setHeroPrompt] = useState("");
|
||||
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 homeStatsSWR = useSWR<HomeStatsResponse>(
|
||||
"/api/marketing/home-stats",
|
||||
async (url: string) => {
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
const response = await fetch(url, {
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load home stats: ${response.status}`);
|
||||
throw new Error(`Failed to load integrations defaults: ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as HomeStatsResponse;
|
||||
return (await response.json()) as IntegrationDefaults;
|
||||
},
|
||||
{
|
||||
refreshInterval: 60 * 60 * 1000,
|
||||
revalidateOnFocus: false,
|
||||
shouldRetryOnError: false,
|
||||
},
|
||||
);
|
||||
const entry =
|
||||
homepageVideoSWR.data?.resolved ??
|
||||
DEFAULT_HOMEPAGE_VIDEO_SETTINGS.defaultEntry;
|
||||
const stats = homeStatsSWR.data;
|
||||
const locale = isChinese ? "zh-CN" : "en-US";
|
||||
const compactFormatter = new Intl.NumberFormat(locale, {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
const displayName =
|
||||
user?.name?.trim() ||
|
||||
user?.username?.trim() ||
|
||||
user?.email?.split("@")[0] ||
|
||||
(isChinese ? "朋友" : "there");
|
||||
const dailyVisitsValue =
|
||||
typeof stats?.visits?.daily === "number"
|
||||
? compactFormatter.format(stats.visits.daily)
|
||||
: isChinese
|
||||
? "同步中"
|
||||
: "Syncing";
|
||||
const registeredUsersValue =
|
||||
typeof stats?.registeredUsers === "number"
|
||||
? compactFormatter.format(stats.registeredUsers)
|
||||
: isChinese
|
||||
? "同步中"
|
||||
: "Syncing";
|
||||
|
||||
const heroCopy = isChinese
|
||||
? {
|
||||
eyebrow: "AI Native Workspace",
|
||||
subtitle: "从想法到上线,AI 自动完成构建、部署与优化。",
|
||||
demoLabel: "动态欢迎",
|
||||
greeting: "早上好",
|
||||
todayStatus: "今日状态",
|
||||
statusItems: [
|
||||
{ label: "服务", value: "正常" },
|
||||
{ label: "今日访问", value: dailyVisitsValue },
|
||||
{ label: "注册用户", value: registeredUsersValue },
|
||||
],
|
||||
prompt: "有什么想问的?",
|
||||
quickLinksLabel: "快速入口",
|
||||
quickLinks: ["常用工具", "最近使用", "产品演示"],
|
||||
helperNote: "个性化、状态感知、即时信息",
|
||||
sourceLink: "打开原始链接",
|
||||
maximizeLabel: "最大化到 XWorkmate",
|
||||
}
|
||||
: {
|
||||
eyebrow: "AI Native Workspace",
|
||||
subtitle:
|
||||
"From idea to launch, AI can assemble, deploy, and optimize the work.",
|
||||
demoLabel: "Dynamic welcome",
|
||||
greeting: "Good morning",
|
||||
todayStatus: "Today",
|
||||
statusItems: [
|
||||
{ label: "Service", value: "Healthy" },
|
||||
{ label: "Daily visits", value: dailyVisitsValue },
|
||||
{ label: "Registered", value: registeredUsersValue },
|
||||
],
|
||||
prompt: "What would you like to ask?",
|
||||
quickLinksLabel: "Quick access",
|
||||
quickLinks: ["Tools", "Recent", "Demo"],
|
||||
helperNote: "Personal, status-aware, and instantly actionable.",
|
||||
sourceLink: "Open source link",
|
||||
maximizeLabel: "Open in XWorkmate",
|
||||
};
|
||||
|
||||
const openXWorkmate = () => {
|
||||
const query = heroPrompt.trim();
|
||||
router.push(
|
||||
query.length > 0
|
||||
? `/xworkmate?prompt=${encodeURIComponent(query)}`
|
||||
: "/xworkmate",
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden rounded-[1.25rem] border border-slate-900/8 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(248,250,252,0.98))] p-3.5 shadow-[var(--shadow-md)] sm:p-4 lg:p-5">
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0">
|
||||
<div className="absolute left-[5%] top-[3%] h-[16rem] w-[16rem] rounded-full bg-[radial-gradient(circle,rgba(51,102,255,0.15),transparent_60%)] blur-3xl" />
|
||||
<div className="absolute right-[8%] top-[5%] h-[14rem] w-[14rem] rounded-full bg-[radial-gradient(circle,rgba(76,139,245,0.12),transparent_65%)] blur-3xl" />
|
||||
<div className="absolute inset-x-0 top-0 h-[20rem] bg-[linear-gradient(180deg,rgba(255,255,255,0.85),rgba(255,255,255,0)_75%)]" />
|
||||
</div>
|
||||
|
||||
<div className="relative space-y-4">
|
||||
<div className="overflow-hidden rounded-[1.1rem] border border-slate-900/8 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(248,250,252,0.98))] shadow-[var(--shadow-md)] backdrop-blur-sm">
|
||||
<div className="border-b border-slate-900/10 px-5 py-3.5 sm:px-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className={HOME_SECTION_LABEL_CLASS}>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-primary animate-pulse" />
|
||||
{heroCopy.demoLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-4 sm:p-4.5">
|
||||
<div className="rounded-[1rem] border border-slate-900/8 bg-slate-50/85 p-4 shadow-[var(--shadow-soft)]">
|
||||
<div className="max-w-[40rem] space-y-5 font-[ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace] text-[15px] leading-8 text-slate-700">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-[1.05rem] text-slate-800">
|
||||
<span aria-hidden>{isChinese ? "☀️" : "☀"}</span>
|
||||
<span>
|
||||
{heroCopy.greeting}, {displayName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold text-slate-800">
|
||||
{heroCopy.todayStatus}:
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
{heroCopy.statusItems.map((item) => (
|
||||
<p key={item.label} className="text-slate-700">
|
||||
├ {item.label}:{" "}
|
||||
<span className="font-semibold text-slate-900">
|
||||
{item.value}
|
||||
</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[0.9rem] border border-slate-900/15 bg-white/86 p-3 shadow-[var(--shadow-sm)]">
|
||||
<textarea
|
||||
value={heroPrompt}
|
||||
onChange={(event) => setHeroPrompt(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
event.key === "Enter"
|
||||
) {
|
||||
event.preventDefault();
|
||||
openXWorkmate();
|
||||
}
|
||||
}}
|
||||
placeholder={heroCopy.prompt}
|
||||
className="min-h-[120px] w-full resize-none bg-transparent px-1 py-1 text-[1rem] leading-8 text-slate-700 outline-none placeholder:text-slate-500"
|
||||
/>
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 border-t border-slate-900/10 pt-3">
|
||||
<div className="text-xs text-slate-500">
|
||||
{isChinese
|
||||
? "输入一句话,进入完整工作台继续对话。"
|
||||
: "Start here, then continue in the full workspace."}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openXWorkmate}
|
||||
className="tactile-button tactile-button-primary px-4 py-2 text-sm"
|
||||
>
|
||||
{heroCopy.maximizeLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-slate-700">
|
||||
<span className="font-medium">
|
||||
{heroCopy.quickLinksLabel}:
|
||||
</span>
|
||||
{heroCopy.quickLinks.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item}
|
||||
onClick={() => setHeroPrompt(item)}
|
||||
className="rounded-[10px] border border-slate-900/10 bg-white/82 px-3 py-1 text-[13px] text-slate-700"
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<p className={HOME_SECTION_LABEL_CLASS}>{heroCopy.eyebrow}</p>
|
||||
<p className="max-w-3xl text-[1rem] leading-[1.75] text-text-muted sm:text-[1.05rem]">
|
||||
{heroCopy.subtitle}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2.5 text-xs text-slate-500">
|
||||
<a
|
||||
href={entry.videoUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="tactile-button tactile-button-subtle px-3.5 py-2 text-slate-700 hover:text-primary"
|
||||
>
|
||||
{heroCopy.sourceLink}
|
||||
</a>
|
||||
<div className="text-sm leading-6 text-text-subtle">
|
||||
{heroCopy.helperNote}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto w-full max-w-[1120px]">
|
||||
<div className="overflow-hidden rounded-[1.1rem] border border-slate-900/8 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(248,250,252,0.98))] shadow-[var(--shadow-md)] backdrop-blur-sm">
|
||||
<div className="border-b border-slate-900/10 px-5 py-3.5 sm:px-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className={HOME_SECTION_LABEL_CLASS}>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-primary animate-pulse" />
|
||||
{isChinese ? "X 助手" : "X Assistant"}
|
||||
</p>
|
||||
</div>
|
||||
<span className="hidden rounded-[14px] border border-slate-900/8 bg-white/88 px-3.5 py-1.5 text-xs font-semibold text-slate-600 sm:inline-flex shadow-sm">
|
||||
{isChinese ? "对话即入口" : "Prompt-first"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 sm:p-4.5">
|
||||
<XWorkmateAssistantShell
|
||||
mode="hero"
|
||||
isChinese={isChinese}
|
||||
prompt={heroPrompt}
|
||||
onPromptChange={setHeroPrompt}
|
||||
connected={Boolean(
|
||||
(
|
||||
assistantDefaultsSWR.data ?? EMPTY_ASSISTANT_DEFAULTS
|
||||
).openclawUrl.trim(),
|
||||
)}
|
||||
endpointLabel={
|
||||
(assistantDefaultsSWR.data ?? EMPTY_ASSISTANT_DEFAULTS)
|
||||
.openclawUrl
|
||||
}
|
||||
showConnectionStatus={false}
|
||||
secondaryActionLabel={heroCopy.maximizeLabel}
|
||||
onExpand={(nextPrompt) => {
|
||||
const query = nextPrompt?.trim();
|
||||
router.push(
|
||||
query
|
||||
? `/xworkmate?prompt=${encodeURIComponent(query)}`
|
||||
: "/xworkmate",
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
return <GatewayHero defaults={assistantDefaultsSWR.data ?? EMPTY_ASSISTANT_DEFAULTS} />;
|
||||
}
|
||||
|
||||
export function StatsSection() {
|
||||
|
||||
@ -15,7 +15,7 @@ export const metadata = {
|
||||
export default async function XWorkmatePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: Promise<{ prompt?: string }>;
|
||||
searchParams?: Promise<{ prompt?: string; sessionKey?: string }>;
|
||||
}) {
|
||||
const defaults = getConsoleIntegrationDefaults();
|
||||
const scopeKey = buildXWorkmateScopeKey(null, null);
|
||||
@ -24,6 +24,10 @@ export default async function XWorkmatePage({
|
||||
typeof resolvedSearchParams?.prompt === "string"
|
||||
? resolvedSearchParams.prompt
|
||||
: "";
|
||||
const initialSessionKey =
|
||||
typeof resolvedSearchParams?.sessionKey === "string"
|
||||
? resolvedSearchParams.sessionKey
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-var(--app-shell-nav-offset))] w-full">
|
||||
@ -31,6 +35,7 @@ export default async function XWorkmatePage({
|
||||
<XWorkmateWorkspacePage
|
||||
defaults={defaults}
|
||||
initialPrompt={initialPrompt}
|
||||
initialSessionKey={initialSessionKey}
|
||||
scopeKey={scopeKey}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
95
src/components/home/GatewayHero.test.tsx
Normal file
95
src/components/home/GatewayHero.test.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import type { IntegrationDefaults } from "@/lib/openclaw/types";
|
||||
import { GatewayHero } from "@/components/home/GatewayHero";
|
||||
|
||||
const pushMock = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: pushMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/i18n/LanguageProvider", () => ({
|
||||
useLanguage: () => ({
|
||||
language: "zh",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/userStore", () => ({
|
||||
useUserStore: (selector: (state: { user: { name: string } }) => unknown) =>
|
||||
selector({
|
||||
user: {
|
||||
name: "Guest",
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const sendPromptMock = vi.fn();
|
||||
|
||||
vi.mock("@/components/home/useGatewayHero", () => ({
|
||||
useGatewayHero: () => ({
|
||||
bootstrap: {
|
||||
activeSessionKey: "session-1",
|
||||
agents: [{ id: "agent-1", name: "Deep Research" }],
|
||||
sessions: [{ key: "session-1", derivedTitle: "排查首页入口" }],
|
||||
},
|
||||
bootstrapError: null,
|
||||
bootstrapLoading: false,
|
||||
gatewayConfigured: true,
|
||||
sendPrompt: sendPromptMock,
|
||||
sendState: {
|
||||
isSending: false,
|
||||
responseText: "这是首页首轮真实响应。",
|
||||
errorMessage: "",
|
||||
activeSessionKey: "session-1",
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const defaults: IntegrationDefaults = {
|
||||
openclawUrl: "wss://gateway.example.com",
|
||||
openclawOrigin: "",
|
||||
openclawTokenConfigured: true,
|
||||
vaultUrl: "",
|
||||
vaultNamespace: "",
|
||||
vaultTokenConfigured: false,
|
||||
vaultSecretPath: "",
|
||||
vaultSecretKey: "",
|
||||
apisixUrl: "",
|
||||
apisixTokenConfigured: false,
|
||||
};
|
||||
|
||||
describe("GatewayHero", () => {
|
||||
beforeEach(() => {
|
||||
pushMock.mockReset();
|
||||
sendPromptMock.mockReset();
|
||||
});
|
||||
|
||||
it("renders zh guest as 游客 and shows live gateway content", () => {
|
||||
render(<GatewayHero defaults={defaults} />);
|
||||
|
||||
expect(screen.getByText(/游客/)).toBeInTheDocument();
|
||||
expect(screen.getByText("在线可用")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Deep Research").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("这是首页首轮真实响应。")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends prompt and carries session key to workspace", async () => {
|
||||
sendPromptMock.mockResolvedValue("session-1");
|
||||
render(<GatewayHero defaults={defaults} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("有什么想问的?"), {
|
||||
target: { value: "检查今天状态" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /发送/ }));
|
||||
expect(sendPromptMock).toHaveBeenCalledWith("检查今天状态");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /继续到工作台/ }));
|
||||
expect(pushMock).toHaveBeenCalledWith(
|
||||
"/xworkmate?prompt=%E6%A3%80%E6%9F%A5%E4%BB%8A%E5%A4%A9%E7%8A%B6%E6%80%81&sessionKey=session-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
354
src/components/home/GatewayHero.tsx
Normal file
354
src/components/home/GatewayHero.tsx
Normal file
@ -0,0 +1,354 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowRight,
|
||||
Cloud,
|
||||
Send,
|
||||
Sparkles,
|
||||
SunMedium,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLanguage } from "@/i18n/LanguageProvider";
|
||||
import type { IntegrationDefaults } from "@/lib/openclaw/types";
|
||||
import { useUserStore } from "@/lib/userStore";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
buildHomeGatewayHeroViewModel,
|
||||
type HomeGatewayStatusNode,
|
||||
} from "@/components/home/gatewayHeroModel";
|
||||
import { useGatewayHero } from "@/components/home/useGatewayHero";
|
||||
|
||||
function toneClasses(tone: HomeGatewayStatusNode["tone"]): string {
|
||||
if (tone === "healthy") {
|
||||
return "border-lime-300/70 bg-lime-50/90 text-lime-950";
|
||||
}
|
||||
if (tone === "warning") {
|
||||
return "border-amber-300/70 bg-amber-50/95 text-amber-950";
|
||||
}
|
||||
return "border-slate-200 bg-white/90 text-slate-900";
|
||||
}
|
||||
|
||||
function resolveDisplayName(params: {
|
||||
isChinese: boolean;
|
||||
name?: string | null;
|
||||
username?: string | null;
|
||||
email?: string | null;
|
||||
}): string {
|
||||
const rawCandidates = [
|
||||
params.name?.trim(),
|
||||
params.username?.trim(),
|
||||
params.email?.split("@")[0]?.trim(),
|
||||
].filter(Boolean) as string[];
|
||||
const first = rawCandidates[0];
|
||||
|
||||
if (!first) {
|
||||
return params.isChinese ? "游客" : "Guest";
|
||||
}
|
||||
|
||||
if (first.toLowerCase() === "guest") {
|
||||
return params.isChinese ? "游客" : "Guest";
|
||||
}
|
||||
|
||||
return first;
|
||||
}
|
||||
|
||||
export function GatewayHero({
|
||||
defaults,
|
||||
}: {
|
||||
defaults: IntegrationDefaults;
|
||||
}) {
|
||||
const { language } = useLanguage();
|
||||
const isChinese = language === "zh";
|
||||
const router = useRouter();
|
||||
const user = useUserStore((state) => state.user);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const { bootstrap, bootstrapError, bootstrapLoading, gatewayConfigured, sendPrompt, sendState } =
|
||||
useGatewayHero(defaults);
|
||||
|
||||
const displayName = resolveDisplayName({
|
||||
isChinese,
|
||||
name: user?.name,
|
||||
username: user?.username,
|
||||
email: user?.email,
|
||||
});
|
||||
const model = buildHomeGatewayHeroViewModel({
|
||||
isChinese,
|
||||
displayName,
|
||||
bootstrap,
|
||||
bootstrapError,
|
||||
connected: gatewayConfigured && !bootstrapError,
|
||||
});
|
||||
const badgeClass =
|
||||
model.statusTone === "healthy"
|
||||
? "bg-lime-400 text-lime-950 shadow-[0_0_30px_rgba(163,230,53,0.55)]"
|
||||
: model.statusTone === "warning"
|
||||
? "bg-amber-300 text-amber-950"
|
||||
: "bg-slate-200 text-slate-700";
|
||||
const periodAccent =
|
||||
model.period === "morning"
|
||||
? "from-lime-100/90 via-white to-sky-50"
|
||||
: model.period === "afternoon"
|
||||
? "from-amber-50 via-white to-cyan-50"
|
||||
: model.period === "evening"
|
||||
? "from-emerald-50 via-white to-slate-100"
|
||||
: "from-slate-100 via-white to-indigo-50";
|
||||
const responseText = sendState.responseText.trim();
|
||||
const sessionKey =
|
||||
sendState.activeSessionKey || bootstrap?.activeSessionKey || "";
|
||||
|
||||
async function handleSend(): Promise<void> {
|
||||
if (!prompt.trim()) {
|
||||
return;
|
||||
}
|
||||
await sendPrompt(prompt);
|
||||
}
|
||||
|
||||
function openWorkspace(): void {
|
||||
const query = new URLSearchParams();
|
||||
if (prompt.trim()) {
|
||||
query.set("prompt", prompt.trim());
|
||||
}
|
||||
if (sessionKey) {
|
||||
query.set("sessionKey", sessionKey);
|
||||
}
|
||||
const suffix = query.toString();
|
||||
router.push(suffix ? `/xworkmate?${suffix}` : "/xworkmate");
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden rounded-[1.6rem] border border-lime-200/85 bg-white shadow-[0_24px_70px_rgba(143,214,38,0.12)]">
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 bg-gradient-to-br",
|
||||
periodAccent,
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 bg-[linear-gradient(rgba(163,230,53,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(163,230,53,0.08)_1px,transparent_1px)] bg-[size:32px_32px] opacity-40"
|
||||
/>
|
||||
<div className="relative p-5 sm:p-6 lg:p-8">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-3">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-lime-200 bg-white/85 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-600">
|
||||
<SunMedium className="h-3.5 w-3.5 text-lime-600" />
|
||||
{model.panelLabel}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-[1.05rem] font-semibold text-slate-800">
|
||||
{model.greeting}
|
||||
</p>
|
||||
<h1 className="text-[2rem] font-semibold tracking-[-0.05em] text-slate-950 sm:text-[2.35rem]">
|
||||
{model.headline}
|
||||
</h1>
|
||||
<p className="max-w-2xl text-[15px] leading-7 text-slate-600 sm:text-[16px]">
|
||||
{model.summary}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 self-start rounded-[1.2rem] border border-white/80 bg-white/85 px-4 py-3 shadow-[0_12px_30px_rgba(15,23,42,0.06)] backdrop-blur">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-12 w-12 items-center justify-center rounded-full text-sm font-bold",
|
||||
badgeClass,
|
||||
)}
|
||||
>
|
||||
{model.statusTone === "healthy" ? "✓" : model.statusTone === "warning" ? "!" : "·"}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[13px] font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
{isChinese ? "首屏状态" : "Hero Status"}
|
||||
</div>
|
||||
<div className="mt-1 text-[1.6rem] font-semibold tracking-[-0.05em] text-slate-950">
|
||||
{model.statusBadge}
|
||||
</div>
|
||||
<p className="mt-1 max-w-[18rem] text-sm leading-6 text-slate-600">
|
||||
{model.statusDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.15fr)_minmax(20rem,0.85fr)]">
|
||||
<div className="rounded-[1.35rem] border border-lime-200/80 bg-white/88 p-5 shadow-[0_18px_40px_rgba(15,23,42,0.05)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-3 w-3 rounded-full bg-lime-400 shadow-[0_0_18px_rgba(163,230,53,0.9)]" />
|
||||
<div className="h-px flex-1 bg-lime-200" />
|
||||
</div>
|
||||
<div className="mt-5 space-y-5">
|
||||
{model.statusNodes.map((node) => (
|
||||
<div key={node.key} className="flex items-center gap-4">
|
||||
<div className="relative flex w-[9.5rem] items-center gap-3 text-[1rem] font-semibold text-slate-800">
|
||||
<div className="h-3 w-3 rounded-full bg-lime-400 shadow-[0_0_12px_rgba(163,230,53,0.75)]" />
|
||||
<span>{node.label}</span>
|
||||
</div>
|
||||
<div className="h-10 w-px bg-slate-200" />
|
||||
<div className={cn("min-w-0 rounded-full border px-4 py-2 text-lg font-semibold", toneClasses(node.tone))}>
|
||||
{node.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-[1rem] border border-slate-200 bg-slate-50/90 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||
<Workflow className="h-4 w-4 text-slate-500" />
|
||||
{isChinese ? "最近会话" : "Recent Sessions"}
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{model.recentSessions.length > 0 ? (
|
||||
model.recentSessions.map((session) => (
|
||||
<div
|
||||
key={session.key}
|
||||
className="rounded-[0.9rem] border border-white bg-white/90 px-3 py-2 text-sm text-slate-700"
|
||||
>
|
||||
{session.derivedTitle ||
|
||||
session.displayName ||
|
||||
session.lastMessagePreview ||
|
||||
session.key}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm leading-6 text-slate-500">
|
||||
{isChinese
|
||||
? "当前还没有可展示的会话摘要。"
|
||||
: "No recent session summary is available yet."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[1rem] border border-slate-200 bg-slate-50/90 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||
<Cloud className="h-4 w-4 text-slate-500" />
|
||||
{isChinese ? "可用代理" : "Available Agents"}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{model.featuredAgents.length > 0 ? (
|
||||
model.featuredAgents.map((agent) => (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white bg-white/95 px-3 py-2 text-sm font-medium text-slate-700"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5 text-lime-600" />
|
||||
{agent.name}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm leading-6 text-slate-500">
|
||||
{isChinese
|
||||
? "未从 Gateway 拉到代理摘要,先使用默认助手。"
|
||||
: "No agent summary was returned yet, so the default assistant remains available."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[1.35rem] border border-sky-200/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(241,247,255,0.95))] p-4 shadow-[0_22px_45px_rgba(96,165,250,0.14)]">
|
||||
<div className="rounded-[1.1rem] border border-white/80 bg-white/95 p-4 shadow-[inset_0_0_0_1px_rgba(148,163,184,0.08)]">
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
isChinese ? "有什么想问的?" : "What would you like to ask?"
|
||||
}
|
||||
className="min-h-[150px] w-full resize-none bg-transparent text-[1.05rem] leading-8 text-slate-700 outline-none placeholder:text-slate-400"
|
||||
/>
|
||||
<div className="mt-4 flex items-center justify-between gap-3 border-t border-slate-100 pt-3">
|
||||
<div className="text-xs leading-5 text-slate-500">
|
||||
{gatewayConfigured
|
||||
? isChinese
|
||||
? "首页会直接通过 Gateway 发起首轮对话。"
|
||||
: "The homepage sends the first live turn through the gateway."
|
||||
: isChinese
|
||||
? "当前未配置 Gateway,可先跳到 XWorkmate。"
|
||||
: "Gateway is not configured yet. You can continue in XWorkmate."}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSend()}
|
||||
disabled={!prompt.trim() || sendState.isSending || !gatewayConfigured}
|
||||
className="tactile-button tactile-button-primary min-h-10 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
{sendState.isSending
|
||||
? isChinese
|
||||
? "发送中"
|
||||
: "Sending"
|
||||
: isChinese
|
||||
? "发送"
|
||||
: "Send"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-[1rem] border border-slate-200 bg-white/92 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-slate-700">
|
||||
{isChinese ? "快速入口" : "Quick Access"}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openWorkspace}
|
||||
className="inline-flex items-center gap-1 text-sm font-semibold text-slate-500 transition hover:text-slate-900"
|
||||
>
|
||||
{isChinese ? "继续到工作台" : "Continue in workspace"}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{model.quickPrompts.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item}
|
||||
onClick={() => setPrompt(item)}
|
||||
className="rounded-full border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 transition hover:border-lime-300 hover:bg-lime-50"
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-[1rem] border border-slate-200 bg-white/92 p-4">
|
||||
<div className="text-sm font-semibold text-slate-700">
|
||||
{isChinese ? "首轮演示结果" : "First Turn Demo"}
|
||||
</div>
|
||||
<div className="mt-3 min-h-[120px] rounded-[0.95rem] border border-dashed border-slate-200 bg-slate-50/75 p-4 text-sm leading-7 text-slate-600">
|
||||
{sendState.errorMessage ? (
|
||||
<p className="text-amber-700">{sendState.errorMessage}</p>
|
||||
) : responseText ? (
|
||||
<p>{responseText}</p>
|
||||
) : bootstrapLoading ? (
|
||||
<p>{isChinese ? "正在同步 Gateway 状态…" : "Syncing gateway state…"}</p>
|
||||
) : (
|
||||
<p>
|
||||
{isChinese
|
||||
? "发送一个 prompt,这里会显示首页首轮真实响应。"
|
||||
: "Send a prompt to show the first live response directly on the homepage."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
85
src/components/home/gatewayHeroModel.test.ts
Normal file
85
src/components/home/gatewayHeroModel.test.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import type { OpenClawBootstrapResponse } from "@/lib/openclaw/types";
|
||||
import {
|
||||
buildHomeGatewayHeroViewModel,
|
||||
buildQuickPrompts,
|
||||
} from "@/components/home/gatewayHeroModel";
|
||||
|
||||
const bootstrapFixture: OpenClawBootstrapResponse = {
|
||||
activeSessionKey: "main",
|
||||
mainSessionKey: "main",
|
||||
gatewayUrl: "wss://gateway.example.com",
|
||||
tokenSource: "env",
|
||||
connectedAt: "2026-03-18T08:00:00.000Z",
|
||||
agents: [
|
||||
{ id: "agent-1", name: "Deep Research" },
|
||||
{ id: "agent-2", name: "Code Builder" },
|
||||
],
|
||||
sessions: [
|
||||
{
|
||||
key: "main",
|
||||
derivedTitle: "继续昨天的发布检查",
|
||||
lastMessagePreview: "检查最近发布异常",
|
||||
},
|
||||
{
|
||||
key: "agent-2",
|
||||
derivedTitle: "修复首页入口",
|
||||
lastMessagePreview: "继续首页重构",
|
||||
},
|
||||
],
|
||||
messages: [],
|
||||
statusPayload: { connection: "ready" },
|
||||
healthPayload: { status: "ok" },
|
||||
};
|
||||
|
||||
describe("gatewayHeroModel", () => {
|
||||
it("builds morning view model with connected gateway data", () => {
|
||||
const model = buildHomeGatewayHeroViewModel({
|
||||
isChinese: true,
|
||||
displayName: "shenlan",
|
||||
bootstrap: bootstrapFixture,
|
||||
connected: true,
|
||||
now: new Date("2026-03-18T07:30:00+08:00"),
|
||||
});
|
||||
|
||||
expect(model.period).toBe("morning");
|
||||
expect(model.headline).toContain("shenlan");
|
||||
expect(model.statusBadge).toBe("在线可用");
|
||||
expect(model.statusNodes[0]?.value).toBe("实时在线");
|
||||
expect(model.statusNodes[1]?.value).toContain("继续昨天");
|
||||
expect(model.quickPrompts[0]).toContain("继续昨天");
|
||||
});
|
||||
|
||||
it("falls back to warning state when bootstrap fails", () => {
|
||||
const model = buildHomeGatewayHeroViewModel({
|
||||
isChinese: true,
|
||||
displayName: "游客",
|
||||
connected: false,
|
||||
bootstrapError: new Error("offline"),
|
||||
now: new Date("2026-03-18T23:30:00+08:00"),
|
||||
});
|
||||
|
||||
expect(model.period).toBe("night");
|
||||
expect(model.statusTone).toBe("warning");
|
||||
expect(model.statusBadge).toBe("连接异常");
|
||||
expect(model.quickPrompts).toContain("准备明天的任务");
|
||||
});
|
||||
|
||||
it("prioritizes sessions then agents then fallback prompts", () => {
|
||||
const prompts = buildQuickPrompts({
|
||||
isChinese: false,
|
||||
period: "afternoon",
|
||||
sessions: [
|
||||
{
|
||||
key: "s1",
|
||||
derivedTitle: "Resume release review",
|
||||
lastMessagePreview: "Look at alerts",
|
||||
},
|
||||
],
|
||||
agents: [{ id: "a1", name: "Research Agent" }],
|
||||
});
|
||||
|
||||
expect(prompts[0]).toBe("Resume release review");
|
||||
expect(prompts[1]).toBe("Research Agent");
|
||||
expect(prompts).toContain("Check gateway health");
|
||||
});
|
||||
});
|
||||
337
src/components/home/gatewayHeroModel.ts
Normal file
337
src/components/home/gatewayHeroModel.ts
Normal file
@ -0,0 +1,337 @@
|
||||
import type {
|
||||
GatewayAgentSummary,
|
||||
GatewaySessionSummary,
|
||||
OpenClawBootstrapResponse,
|
||||
} from "@/lib/openclaw/types";
|
||||
|
||||
export type DayPeriod = "morning" | "afternoon" | "evening" | "night";
|
||||
|
||||
export type HomeGatewayStatusNode = {
|
||||
key: "gateway" | "session" | "agent";
|
||||
label: string;
|
||||
value: string;
|
||||
tone: "healthy" | "warning" | "neutral";
|
||||
};
|
||||
|
||||
export type HomeGatewayHeroViewModel = {
|
||||
period: DayPeriod;
|
||||
greeting: string;
|
||||
headline: string;
|
||||
summary: string;
|
||||
panelLabel: string;
|
||||
statusBadge: string;
|
||||
statusTone: "healthy" | "warning" | "neutral";
|
||||
statusDescription: string;
|
||||
statusNodes: HomeGatewayStatusNode[];
|
||||
quickPrompts: string[];
|
||||
recentSessions: GatewaySessionSummary[];
|
||||
featuredAgents: GatewayAgentSummary[];
|
||||
};
|
||||
|
||||
function trimText(value?: string | null): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function compactText(value: string, maxLength = 36): string {
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
|
||||
}
|
||||
|
||||
function deriveDayPeriod(date: Date): DayPeriod {
|
||||
const hour = date.getHours();
|
||||
if (hour >= 5 && hour < 12) {
|
||||
return "morning";
|
||||
}
|
||||
if (hour >= 12 && hour < 18) {
|
||||
return "afternoon";
|
||||
}
|
||||
if (hour >= 18 && hour < 23) {
|
||||
return "evening";
|
||||
}
|
||||
return "night";
|
||||
}
|
||||
|
||||
function periodCopy(
|
||||
isChinese: boolean,
|
||||
period: DayPeriod,
|
||||
displayName: string,
|
||||
): Pick<
|
||||
HomeGatewayHeroViewModel,
|
||||
"greeting" | "headline" | "summary" | "panelLabel"
|
||||
> {
|
||||
if (isChinese) {
|
||||
switch (period) {
|
||||
case "morning":
|
||||
return {
|
||||
greeting: "早上好",
|
||||
headline: `晨间状态面板,${displayName}`,
|
||||
summary: "先看 Gateway 状态,再用一句话启动今天的第一轮协作。",
|
||||
panelLabel: "晨间启动",
|
||||
};
|
||||
case "afternoon":
|
||||
return {
|
||||
greeting: "下午好",
|
||||
headline: `午间工作台,${displayName}`,
|
||||
summary: "把当前连接、会话和代理能力收拢在一屏,适合快速接续进行中的工作。",
|
||||
panelLabel: "午间续航",
|
||||
};
|
||||
case "evening":
|
||||
return {
|
||||
greeting: "晚上好",
|
||||
headline: `晚间协作台,${displayName}`,
|
||||
summary: "适合回看最近会话、补发一个 prompt,或把工作转交给完整工作台继续推进。",
|
||||
panelLabel: "晚间协作",
|
||||
};
|
||||
case "night":
|
||||
default:
|
||||
return {
|
||||
greeting: "夜深了",
|
||||
headline: `夜间守望面板,${displayName}`,
|
||||
summary: "用更轻的首页入口确认 Gateway 是否在线,再决定是否进入完整工作台。",
|
||||
panelLabel: "夜间巡检",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
switch (period) {
|
||||
case "morning":
|
||||
return {
|
||||
greeting: "Good morning",
|
||||
headline: `Morning status board, ${displayName}`,
|
||||
summary:
|
||||
"Check the gateway first, then kick off the first task with one prompt.",
|
||||
panelLabel: "Morning Start",
|
||||
};
|
||||
case "afternoon":
|
||||
return {
|
||||
greeting: "Good afternoon",
|
||||
headline: `Midday workspace, ${displayName}`,
|
||||
summary:
|
||||
"Keep connection health, recent sessions, and agents in one place for a fast resume.",
|
||||
panelLabel: "Midday Flow",
|
||||
};
|
||||
case "evening":
|
||||
return {
|
||||
greeting: "Good evening",
|
||||
headline: `Evening workspace, ${displayName}`,
|
||||
summary:
|
||||
"Review recent sessions, send a fresh prompt, or continue in the full workspace.",
|
||||
panelLabel: "Evening Sync",
|
||||
};
|
||||
case "night":
|
||||
default:
|
||||
return {
|
||||
greeting: "Late night",
|
||||
headline: `Night watch panel, ${displayName}`,
|
||||
summary:
|
||||
"Use the lighter homepage entry to confirm gateway health before moving deeper into work.",
|
||||
panelLabel: "Night Watch",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackQuickPrompts(
|
||||
isChinese: boolean,
|
||||
period: DayPeriod,
|
||||
): string[] {
|
||||
const common = isChinese
|
||||
? ["查看最近会话", "检查 Gateway 状态", "继续当前任务"]
|
||||
: ["Show recent sessions", "Check gateway health", "Continue current task"];
|
||||
|
||||
if (period === "morning") {
|
||||
return isChinese
|
||||
? ["今天先做什么", "检查 Gateway 状态", "继续昨天的会话"]
|
||||
: ["What should I tackle first", "Check gateway health", "Resume yesterday's session"];
|
||||
}
|
||||
|
||||
if (period === "night") {
|
||||
return isChinese
|
||||
? ["总结今天进展", "检查是否仍在线", "准备明天的任务"]
|
||||
: ["Summarize today's work", "Check if the gateway is still online", "Prepare tomorrow's tasks"];
|
||||
}
|
||||
|
||||
return common;
|
||||
}
|
||||
|
||||
function deriveStatusTone(
|
||||
connected: boolean,
|
||||
bootstrapError?: Error | null,
|
||||
): "healthy" | "warning" | "neutral" {
|
||||
if (bootstrapError) {
|
||||
return "warning";
|
||||
}
|
||||
if (connected) {
|
||||
return "healthy";
|
||||
}
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function deriveStatusBadge(
|
||||
isChinese: boolean,
|
||||
tone: "healthy" | "warning" | "neutral",
|
||||
): string {
|
||||
if (isChinese) {
|
||||
if (tone === "healthy") {
|
||||
return "在线可用";
|
||||
}
|
||||
if (tone === "warning") {
|
||||
return "连接异常";
|
||||
}
|
||||
return "等待配置";
|
||||
}
|
||||
|
||||
if (tone === "healthy") {
|
||||
return "Gateway Ready";
|
||||
}
|
||||
if (tone === "warning") {
|
||||
return "Needs Attention";
|
||||
}
|
||||
return "Awaiting Config";
|
||||
}
|
||||
|
||||
function deriveStatusDescription(params: {
|
||||
isChinese: boolean;
|
||||
tone: "healthy" | "warning" | "neutral";
|
||||
sessions: GatewaySessionSummary[];
|
||||
agents: GatewayAgentSummary[];
|
||||
}): string {
|
||||
const sessionCount = params.sessions.length;
|
||||
const agentCount = params.agents.length;
|
||||
|
||||
if (params.isChinese) {
|
||||
if (params.tone === "healthy") {
|
||||
return `Gateway 已连接,最近 ${sessionCount} 个会话可续接,当前可用 ${agentCount} 个代理能力。`;
|
||||
}
|
||||
if (params.tone === "warning") {
|
||||
return "Gateway 当前没有成功完成首屏引导,可以直接跳转 XWorkmate 或稍后重试。";
|
||||
}
|
||||
return "尚未检测到可用 Gateway 配置,首页保留演示入口与降级跳转。";
|
||||
}
|
||||
|
||||
if (params.tone === "healthy") {
|
||||
return `Gateway connected, ${sessionCount} recent sessions are available, and ${agentCount} agents can be surfaced here.`;
|
||||
}
|
||||
if (params.tone === "warning") {
|
||||
return "The gateway bootstrap did not complete successfully. You can still continue in XWorkmate or retry here.";
|
||||
}
|
||||
return "No gateway configuration was detected yet. The homepage keeps a reduced entry and fallback navigation.";
|
||||
}
|
||||
|
||||
function buildGatewayNode(
|
||||
isChinese: boolean,
|
||||
connected: boolean,
|
||||
tone: "healthy" | "warning" | "neutral",
|
||||
): HomeGatewayStatusNode {
|
||||
return {
|
||||
key: "gateway",
|
||||
label: isChinese ? "Gateway" : "Gateway",
|
||||
value: connected
|
||||
? isChinese
|
||||
? "实时在线"
|
||||
: "Live"
|
||||
: tone === "warning"
|
||||
? isChinese
|
||||
? "需重试"
|
||||
: "Retry"
|
||||
: isChinese
|
||||
? "未配置"
|
||||
: "Not configured",
|
||||
tone,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSessionNode(
|
||||
isChinese: boolean,
|
||||
sessions: GatewaySessionSummary[],
|
||||
): HomeGatewayStatusNode {
|
||||
const latest = sessions[0];
|
||||
const label = isChinese ? "最近会话" : "Latest Session";
|
||||
const title = trimText(latest?.derivedTitle) || trimText(latest?.displayName);
|
||||
return {
|
||||
key: "session",
|
||||
label,
|
||||
value: title
|
||||
? compactText(title, 18)
|
||||
: isChinese
|
||||
? "等待新对话"
|
||||
: "Ready for a new run",
|
||||
tone: title ? "healthy" : "neutral",
|
||||
};
|
||||
}
|
||||
|
||||
function buildAgentNode(
|
||||
isChinese: boolean,
|
||||
agents: GatewayAgentSummary[],
|
||||
): HomeGatewayStatusNode {
|
||||
const first = agents[0];
|
||||
return {
|
||||
key: "agent",
|
||||
label: isChinese ? "可用代理" : "Active Agents",
|
||||
value: first
|
||||
? compactText(first.name, 18)
|
||||
: isChinese
|
||||
? "默认助手"
|
||||
: "Default assistant",
|
||||
tone: first ? "healthy" : "neutral",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildQuickPrompts(params: {
|
||||
isChinese: boolean;
|
||||
period: DayPeriod;
|
||||
sessions?: GatewaySessionSummary[];
|
||||
agents?: GatewayAgentSummary[];
|
||||
}): string[] {
|
||||
const sessionPrompts =
|
||||
params.sessions
|
||||
?.map((session) => trimText(session.derivedTitle) || trimText(session.lastMessagePreview))
|
||||
.filter(Boolean)
|
||||
.map((value) => compactText(value, 22)) ?? [];
|
||||
const agentPrompts =
|
||||
params.agents?.map((agent) => compactText(trimText(agent.name), 22)).filter(Boolean) ?? [];
|
||||
|
||||
return [...sessionPrompts, ...agentPrompts, ...fallbackQuickPrompts(params.isChinese, params.period)]
|
||||
.filter(Boolean)
|
||||
.slice(0, 6);
|
||||
}
|
||||
|
||||
export function buildHomeGatewayHeroViewModel(params: {
|
||||
isChinese: boolean;
|
||||
displayName: string;
|
||||
now?: Date;
|
||||
bootstrap?: OpenClawBootstrapResponse | null;
|
||||
bootstrapError?: Error | null;
|
||||
connected: boolean;
|
||||
}): HomeGatewayHeroViewModel {
|
||||
const now = params.now ?? new Date();
|
||||
const period = deriveDayPeriod(now);
|
||||
const timeCopy = periodCopy(params.isChinese, period, params.displayName);
|
||||
const sessions = params.bootstrap?.sessions.slice(0, 3) ?? [];
|
||||
const agents = params.bootstrap?.agents.slice(0, 3) ?? [];
|
||||
const tone = deriveStatusTone(params.connected, params.bootstrapError);
|
||||
|
||||
return {
|
||||
period,
|
||||
...timeCopy,
|
||||
statusBadge: deriveStatusBadge(params.isChinese, tone),
|
||||
statusTone: tone,
|
||||
statusDescription: deriveStatusDescription({
|
||||
isChinese: params.isChinese,
|
||||
tone,
|
||||
sessions,
|
||||
agents,
|
||||
}),
|
||||
statusNodes: [
|
||||
buildGatewayNode(params.isChinese, params.connected, tone),
|
||||
buildSessionNode(params.isChinese, sessions),
|
||||
buildAgentNode(params.isChinese, agents),
|
||||
],
|
||||
quickPrompts: buildQuickPrompts({
|
||||
isChinese: params.isChinese,
|
||||
period,
|
||||
sessions,
|
||||
agents,
|
||||
}),
|
||||
recentSessions: sessions,
|
||||
featuredAgents: agents,
|
||||
};
|
||||
}
|
||||
199
src/components/home/useGatewayHero.ts
Normal file
199
src/components/home/useGatewayHero.ts
Normal file
@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
|
||||
import type {
|
||||
IntegrationDefaults,
|
||||
OpenClawBootstrapResponse,
|
||||
OpenClawStreamEvent,
|
||||
} from "@/lib/openclaw/types";
|
||||
|
||||
type SendState = {
|
||||
isSending: boolean;
|
||||
responseText: string;
|
||||
errorMessage: string;
|
||||
activeSessionKey: string;
|
||||
};
|
||||
|
||||
const EMPTY_SEND_STATE: SendState = {
|
||||
isSending: false,
|
||||
responseText: "",
|
||||
errorMessage: "",
|
||||
activeSessionKey: "",
|
||||
};
|
||||
|
||||
async function bootstrapFetcher(defaults: IntegrationDefaults): Promise<OpenClawBootstrapResponse> {
|
||||
const response = await fetch("/api/openclaw/assistant", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: "bootstrap",
|
||||
gatewayUrl: defaults.openclawUrl,
|
||||
gatewayOrigin: defaults.openclawOrigin,
|
||||
}),
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
throw new Error(payload.error ?? payload.message ?? "Failed to bootstrap gateway.");
|
||||
}
|
||||
|
||||
return (await response.json()) as OpenClawBootstrapResponse;
|
||||
}
|
||||
|
||||
async function readNdjsonStream(
|
||||
response: Response,
|
||||
onEvent: (event: OpenClawStreamEvent) => void,
|
||||
): Promise<void> {
|
||||
if (!response.body) {
|
||||
throw new Error("Gateway response body is empty.");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
onEvent(JSON.parse(trimmed) as OpenClawStreamEvent);
|
||||
}
|
||||
}
|
||||
|
||||
const trailing = buffer.trim();
|
||||
if (trailing) {
|
||||
onEvent(JSON.parse(trailing) as OpenClawStreamEvent);
|
||||
}
|
||||
}
|
||||
|
||||
export function useGatewayHero(defaults: IntegrationDefaults) {
|
||||
const [sendState, setSendState] = useState<SendState>(EMPTY_SEND_STATE);
|
||||
const gatewayConfigured = Boolean(defaults.openclawUrl.trim());
|
||||
const bootstrapSWR = useSWR<OpenClawBootstrapResponse>(
|
||||
gatewayConfigured ? ["home-gateway-bootstrap", defaults.openclawUrl] : null,
|
||||
() => bootstrapFetcher(defaults),
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
shouldRetryOnError: false,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSendState(EMPTY_SEND_STATE);
|
||||
}, [defaults.openclawUrl]);
|
||||
|
||||
async function sendPrompt(prompt: string): Promise<string> {
|
||||
const trimmedPrompt = prompt.trim();
|
||||
if (!trimmedPrompt) {
|
||||
return "";
|
||||
}
|
||||
|
||||
setSendState((current) => ({
|
||||
...current,
|
||||
isSending: true,
|
||||
errorMessage: "",
|
||||
responseText: "",
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/openclaw/assistant", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: "send",
|
||||
gatewayUrl: defaults.openclawUrl,
|
||||
gatewayOrigin: defaults.openclawOrigin,
|
||||
message: trimmedPrompt,
|
||||
sessionKey:
|
||||
sendState.activeSessionKey ||
|
||||
bootstrapSWR.data?.activeSessionKey ||
|
||||
"main",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
throw new Error(payload.error ?? payload.message ?? "Failed to send prompt.");
|
||||
}
|
||||
|
||||
let nextSessionKey =
|
||||
sendState.activeSessionKey || bootstrapSWR.data?.activeSessionKey || "main";
|
||||
let finalText = "";
|
||||
|
||||
await readNdjsonStream(response, (event) => {
|
||||
if (event.type === "ack") {
|
||||
nextSessionKey = event.sessionKey;
|
||||
setSendState((current) => ({
|
||||
...current,
|
||||
activeSessionKey: event.sessionKey,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "delta") {
|
||||
finalText = event.text;
|
||||
setSendState((current) => ({
|
||||
...current,
|
||||
responseText: event.text,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "error") {
|
||||
throw new Error(event.message);
|
||||
}
|
||||
});
|
||||
|
||||
setSendState((current) => ({
|
||||
...current,
|
||||
isSending: false,
|
||||
activeSessionKey: nextSessionKey,
|
||||
responseText: finalText || current.responseText,
|
||||
}));
|
||||
void bootstrapSWR.mutate();
|
||||
return nextSessionKey;
|
||||
} catch (error) {
|
||||
setSendState((current) => ({
|
||||
...current,
|
||||
isSending: false,
|
||||
errorMessage:
|
||||
error instanceof Error ? error.message : "Failed to send prompt.",
|
||||
}));
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bootstrap: bootstrapSWR.data ?? null,
|
||||
bootstrapError: bootstrapSWR.error instanceof Error ? bootstrapSWR.error : null,
|
||||
bootstrapLoading: bootstrapSWR.isLoading,
|
||||
gatewayConfigured,
|
||||
sendPrompt,
|
||||
sendState,
|
||||
};
|
||||
}
|
||||
@ -556,12 +556,14 @@ export function XWorkmateWorkspacePage({
|
||||
scopeKey,
|
||||
requestHost,
|
||||
initialPrompt = "",
|
||||
initialSessionKey = "",
|
||||
}: {
|
||||
defaults: IntegrationDefaults;
|
||||
profile?: XWorkmateProfileResponse | null;
|
||||
scopeKey: string;
|
||||
requestHost?: string;
|
||||
initialPrompt?: string;
|
||||
initialSessionKey?: string;
|
||||
}) {
|
||||
const { language } = useLanguage();
|
||||
const isChinese = language === "zh";
|
||||
@ -573,6 +575,9 @@ export function XWorkmateWorkspacePage({
|
||||
|
||||
const setScope = useOpenClawConsoleStore((state) => state.setScope);
|
||||
const applyDefaults = useOpenClawConsoleStore((state) => state.applyDefaults);
|
||||
const setSelectedSessionKey = useOpenClawConsoleStore(
|
||||
(state) => state.setSelectedSessionKey,
|
||||
);
|
||||
const openclawUrl = useOpenClawConsoleStore((state) => state.openclawUrl);
|
||||
const vaultUrl = useOpenClawConsoleStore((state) => state.vaultUrl);
|
||||
const apisixUrl = useOpenClawConsoleStore((state) => state.apisixUrl);
|
||||
@ -586,6 +591,13 @@ export function XWorkmateWorkspacePage({
|
||||
setComposerValue(initialPrompt);
|
||||
}, [initialPrompt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialSessionKey.trim()) {
|
||||
return;
|
||||
}
|
||||
setSelectedSessionKey(initialSessionKey);
|
||||
}, [initialSessionKey, setSelectedSessionKey]);
|
||||
|
||||
const sections = useMemo(() => createSections(isChinese), [isChinese]);
|
||||
const activeDefinition =
|
||||
sections.find((section) => section.key === activeSection) ?? sections[0];
|
||||
|
||||
@ -1,26 +1,20 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test.describe('Marketing homepage experience', () => {
|
||||
test('renders localized markdown content and switches language dynamically', async ({ page }) => {
|
||||
test('renders gateway-driven hero and switches language dynamically', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
await expect(page.getByRole('heading', { level: 1, name: '云原生套件' })).toBeVisible()
|
||||
await expect(page.getByText('构建一体化的云原生工具集', { exact: false })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: '产品体验' })).toHaveAttribute('href', /\/demo\/?\?product=xcloudflow/)
|
||||
await expect(page.getByRole('heading', { level: 2, name: '产品矩阵' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { level: 2, name: '社区与动态' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { level: 2, name: '开源项目' })).toBeVisible()
|
||||
await expect(page.getByText(/状态面板|工作台|协作台|守望面板/)).toBeVisible()
|
||||
await expect(page.getByText('首屏状态')).toBeVisible()
|
||||
await expect(page.getByText('快速入口')).toBeVisible()
|
||||
await expect(page.getByText('平台统计')).toBeVisible()
|
||||
|
||||
const languageToggle = page.getByRole('combobox')
|
||||
await languageToggle.selectOption('en')
|
||||
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'Cloud-Native Suite' })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'Try the product' })).toHaveAttribute(
|
||||
'href',
|
||||
/\/demo\/?\?product=xcloudflow/
|
||||
)
|
||||
await expect(page.getByRole('heading', { level: 2, name: 'Product Overview' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { level: 2, name: 'Community Pulse' })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'View all updates' })).toBeVisible()
|
||||
await expect(page.getByText(/Morning status board|Midday workspace|Evening workspace|Night watch panel/)).toBeVisible()
|
||||
await expect(page.getByText('Hero Status')).toBeVisible()
|
||||
await expect(page.getByText('Quick Access')).toBeVisible()
|
||||
await expect(page.getByText('Platform pulse')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user