fix(console): tighten hero and persist pairing state

This commit is contained in:
Haitao Pan 2026-03-18 16:09:02 +08:00
parent 55d96d2ecb
commit 68bf1e2c1e
2 changed files with 243 additions and 65 deletions

View File

@ -169,26 +169,20 @@ export function HeroSection() {
const presentation = resolveHomepageVideoPresentation(entry);
const heroCopy = isChinese
? {
? {
eyebrow: "AI Native Workspace",
title: "直接说出你的需求,剩下的交给 AI",
subtitle: "从想法到上线AI 自动完成构建、部署与优化。",
demoLabel: "产品演示",
demoHint:
"这里展示当前域名对应的产品演示链接。主站默认走 YouTube中国站可切到 Bilibili也可以继续按域名覆盖。",
}
: {
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.",
};
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">
<section className="relative overflow-hidden rounded-[1.75rem] border border-slate-900/10 bg-[linear-gradient(180deg,#ffffff,#faf7f2)] p-3 shadow-[0_18px_40px_rgba(15,23,42,0.05)] sm:p-4 lg:p-5">
<div aria-hidden className="pointer-events-none absolute inset-0">
<div className="absolute left-[8%] top-[8%] h-[16rem] w-[16rem] rounded-full bg-[radial-gradient(circle,rgba(37,78,219,0.1),transparent_64%)] blur-3xl" />
<div className="absolute left-[30%] top-[12%] h-[14rem] w-[14rem] rounded-full bg-[radial-gradient(circle,rgba(245,211,170,0.42),transparent_66%)] blur-3xl" />
@ -196,35 +190,25 @@ export function HeroSection() {
<div className="absolute inset-x-0 top-0 h-[18rem] bg-[linear-gradient(180deg,rgba(255,255,255,0.78),rgba(255,255,255,0)_72%)]" />
</div>
<div className="relative grid gap-8 lg:grid-cols-[0.96fr_1.04fr] lg:gap-12">
<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="relative grid gap-4 lg:grid-cols-[0.98fr_1.02fr] lg:gap-5">
<div className="flex flex-col gap-3 pt-1">
<div className="overflow-hidden rounded-[1.35rem] border border-slate-900/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(243,246,251,0.96))] shadow-[0_18px_44px_rgba(15,23,42,0.07)]">
<div className="border-b border-slate-900/10 px-4 py-3 sm:px-4.5">
<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">
<div className="space-y-3 p-3 sm:p-3.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"
className="inline-flex items-center rounded-full border border-slate-900/10 bg-white px-2.5 py-1 font-semibold text-slate-600 transition hover:border-slate-300 hover:text-slate-900"
>
{isChinese ? "打开原始链接" : "Open source link"}
</a>
@ -232,47 +216,32 @@ export function HeroSection() {
</div>
</div>
<div className="space-y-5">
<div className="space-y-3">
<div className="space-y-3">
<div className="space-y-2">
<p className={HOME_SECTION_LABEL_CLASS}>{heroCopy.eyebrow}</p>
<h1
className={cn(
"max-w-[10ch] leading-[0.94] text-heading",
isChinese
? "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]",
)}
>
{heroCopy.title}
</h1>
<p className="max-w-xl text-[1rem] leading-8 text-text-muted sm:text-[1.08rem]">
<p className="max-w-xl text-[0.95rem] leading-7 text-text-muted sm:text-[1rem]">
{heroCopy.subtitle}
</p>
</div>
</div>
</div>
<div className="lg:pl-4">
<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="lg:pl-1">
<div className="overflow-hidden rounded-[1.35rem] border border-slate-900/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.94),rgba(246,248,251,0.98))] shadow-[0_18px_44px_rgba(15,23,42,0.07)]">
<div className="border-b border-slate-900/10 px-4 py-3 sm:px-4.5">
<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">
<span className="hidden rounded-full border border-slate-900/10 bg-white/90 px-2.5 py-0.5 text-xs font-semibold text-slate-600 sm:inline-flex">
{isChinese ? "对话即入口" : "Prompt-first"}
</span>
</div>
</div>
<div className="p-4 sm:p-5">
<div className="p-3 sm:p-3.5">
<OpenClawAssistantPane
defaults={assistantDefaultsSWR.data ?? EMPTY_ASSISTANT_DEFAULTS}
autoSubmitInitialQuestion={false}

View File

@ -43,6 +43,7 @@ import {
type OpenClawStreamEvent,
type ThinkingLevel,
} from "@/lib/openclaw/types";
import { useUserStore } from "@/lib/userStore";
import { useOpenClawConsoleStore } from "@/state/openclawConsoleStore";
type OpenClawAssistantPaneProps = {
@ -75,6 +76,24 @@ type ConnectGatewayOptions = {
force?: boolean;
};
type PersistedPairingRequiredState = {
signature: string;
errorMessage: string;
scope: string;
savedAtMs: number;
ttlMs: number;
};
type PersistedPairingRequiredLookup = {
state: PersistedPairingRequiredState | null;
expired: boolean;
};
const PAIRING_REQUIRED_SESSION_STORAGE_KEY =
"openclaw:pairing-required-state";
const PAIRING_REQUIRED_STATE_TTL_MS = 1000 * 60 * 60 * 12;
const PAIRING_REQUIRED_GUEST_TTL_MS = 1000 * 60 * 60;
export type OpenClawAssistantViewState = {
connectionState: ConnectionState;
healthBadge: string;
@ -154,6 +173,99 @@ function buildPairingRequiredSignature(
return `${deviceId}::${requestId}::${reason}`;
}
function buildPairingPersistenceScope(params: {
openclawUrl: string;
openclawOrigin: string;
userId: string;
}): string {
return `${params.userId.trim()}::${params.openclawUrl.trim()}::${params.openclawOrigin.trim()}`;
}
function inspectPersistedPairingRequiredState(
scope: string,
): PersistedPairingRequiredLookup {
if (typeof window === "undefined") {
return { state: null, expired: false };
}
try {
const raw = window.sessionStorage.getItem(
PAIRING_REQUIRED_SESSION_STORAGE_KEY,
);
if (!raw) {
return { state: null, expired: false };
}
const parsed = JSON.parse(raw) as PersistedPairingRequiredState;
if (
!parsed ||
typeof parsed.signature !== "string" ||
typeof parsed.errorMessage !== "string" ||
typeof parsed.scope !== "string" ||
typeof parsed.savedAtMs !== "number"
) {
window.sessionStorage.removeItem(PAIRING_REQUIRED_SESSION_STORAGE_KEY);
return { state: null, expired: false };
}
const ttlMs =
typeof parsed.ttlMs === "number" && Number.isFinite(parsed.ttlMs)
? parsed.ttlMs
: PAIRING_REQUIRED_STATE_TTL_MS;
const isExpired = Date.now() - parsed.savedAtMs > ttlMs;
if (parsed.scope !== scope) {
window.sessionStorage.removeItem(PAIRING_REQUIRED_SESSION_STORAGE_KEY);
return { state: null, expired: false };
}
if (isExpired) {
window.sessionStorage.removeItem(PAIRING_REQUIRED_SESSION_STORAGE_KEY);
return { state: null, expired: true };
}
return {
state: {
...parsed,
ttlMs,
},
expired: false,
};
} catch {
return { state: null, expired: false };
}
}
function persistPairingRequiredState(
state: PersistedPairingRequiredState,
): void {
if (typeof window === "undefined") {
return;
}
try {
window.sessionStorage.setItem(
PAIRING_REQUIRED_SESSION_STORAGE_KEY,
JSON.stringify(state),
);
} catch {
// Ignore unavailable storage.
}
}
function clearPersistedPairingRequiredState(): void {
if (typeof window === "undefined") {
return;
}
try {
window.sessionStorage.removeItem(PAIRING_REQUIRED_SESSION_STORAGE_KEY);
} catch {
// Ignore unavailable storage.
}
}
function renderMarkdown(value: string): string {
return DOMPurify.sanitize(marked.parse(value) as string);
}
@ -321,6 +433,7 @@ export function OpenClawAssistantPane({
const [errorMessage, setErrorMessage] = useState("");
const [isSending, setIsSending] = useState(false);
const [isCapturing, setIsCapturing] = useState(false);
const [guestSessionExpired, setGuestSessionExpired] = useState(false);
const defaultsLoaded = useOpenClawConsoleStore(
(state) => state.defaultsLoaded,
@ -360,9 +473,20 @@ export function OpenClawAssistantPane({
const setSelectedSessionKey = useOpenClawConsoleStore(
(state) => state.setSelectedSessionKey,
);
const sessionUser = useUserStore((state) => state.user);
const compact = variant === "sidebar";
const minimalPage = variant === "page";
const pairingPersistenceUserId =
sessionUser?.uuid?.trim() || sessionUser?.id?.trim() || "anonymous";
const pairingPersistenceTtlMs = sessionUser?.isGuest
? PAIRING_REQUIRED_GUEST_TTL_MS
: PAIRING_REQUIRED_STATE_TTL_MS;
const pairingPersistenceScope = buildPairingPersistenceScope({
openclawUrl,
openclawOrigin,
userId: pairingPersistenceUserId,
});
const locale = isChinese ? "zh-CN" : "en-US";
const compactConnected = compact && connectionState === "ready";
const showMinimalAgentSelect =
@ -445,6 +569,13 @@ export function OpenClawAssistantPane({
"当前没有可用的 OpenClaw 地址。先到融合设置填写 gateway / vault / APISIX再回来启动 XWorkmate。",
"No OpenClaw endpoint is available yet. Configure gateway, vault, and APISIX first, then return to XWorkmate.",
),
guestSessionExpired: pickCopy(
isChinese,
"演示模式已超过 1 小时。请注册或登录后继续使用助手。",
"Demo mode has exceeded 1 hour. Register or sign in to continue using the assistant.",
),
login: pickCopy(isChinese, "登录", "Sign in"),
register: pickCopy(isChinese, "注册", "Register"),
openIntegrations: pickCopy(
isChinese,
"打开接口集成",
@ -572,24 +703,32 @@ export function OpenClawAssistantPane({
const presentAssistantError = useCallback(
(payload: AssistantApiErrorPayload, fallback: string) => {
const signature = buildPairingRequiredSignature(payload);
const formattedMessage = formatAssistantApiError({
payload,
isChinese,
fallback,
});
if (signature) {
if (lastPairingRequiredSignatureRef.current === signature) {
return;
}
lastPairingRequiredSignatureRef.current = signature;
setGuestSessionExpired(false);
persistPairingRequiredState({
signature,
errorMessage: formattedMessage,
scope: pairingPersistenceScope,
savedAtMs: Date.now(),
ttlMs: pairingPersistenceTtlMs,
});
} else {
lastPairingRequiredSignatureRef.current = null;
}
setErrorMessage(
formatAssistantApiError({
payload,
isChinese,
fallback,
}),
);
setErrorMessage(formattedMessage);
},
[isChinese],
[isChinese, pairingPersistenceScope, pairingPersistenceTtlMs],
);
const connectGateway = useCallback(
@ -606,6 +745,7 @@ export function OpenClawAssistantPane({
if (!options?.force && lastConnectPairingSignatureRef.current) {
setConnectionState("error");
setErrorMessage((current) => current);
return;
}
@ -652,6 +792,7 @@ export function OpenClawAssistantPane({
const data = payload as OpenClawBootstrapResponse;
lastConnectPairingSignatureRef.current = null;
clearPersistedPairingRequiredState();
setConnectionState("ready");
setAgents(data.agents);
@ -902,7 +1043,29 @@ export function OpenClawAssistantPane({
);
useEffect(() => {
if (!defaultsLoaded || bootstrappedRef.current) {
const persisted = inspectPersistedPairingRequiredState(
pairingPersistenceScope,
);
if (persisted.state) {
lastPairingRequiredSignatureRef.current = persisted.state.signature;
lastConnectPairingSignatureRef.current = persisted.state.signature;
setGuestSessionExpired(false);
setConnectionState("error");
setErrorMessage(persisted.state.errorMessage);
return;
}
if (sessionUser?.isGuest && persisted.expired) {
lastPairingRequiredSignatureRef.current = null;
lastConnectPairingSignatureRef.current = "guest-session-expired";
setGuestSessionExpired(true);
setConnectionState("error");
setErrorMessage(copy.guestSessionExpired);
}
}, [copy.guestSessionExpired, pairingPersistenceScope, sessionUser?.isGuest]);
useEffect(() => {
if (!defaultsLoaded || bootstrappedRef.current || guestSessionExpired) {
return;
}
@ -912,14 +1075,17 @@ export function OpenClawAssistantPane({
bootstrappedRef.current = true;
void connectGateway();
}, [connectGateway, defaultsLoaded, openclawUrl]);
}, [connectGateway, defaultsLoaded, guestSessionExpired, openclawUrl]);
useEffect(() => {
lastConnectPairingSignatureRef.current = null;
lastPairingRequiredSignatureRef.current = null;
setGuestSessionExpired(false);
}, [
openclawOrigin,
openclawToken,
openclawUrl,
pairingPersistenceUserId,
vaultNamespace,
vaultSecretKey,
vaultSecretPath,
@ -1124,7 +1290,12 @@ export function OpenClawAssistantPane({
</div>
) : null}
<div className="flex min-h-0 flex-1 flex-col">
<div
className={cn(
"flex min-h-0 flex-1 flex-col",
compact && "grid grid-rows-[minmax(0,1fr)_auto]",
)}
>
{!compact && !minimalPage ? (
<div className="border-b border-[color:var(--color-surface-border)] px-3 py-2.5">
<div className="flex flex-wrap gap-2">
@ -1153,7 +1324,12 @@ export function OpenClawAssistantPane({
</div>
) : null}
<div className="flex-1 overflow-y-auto px-3 py-3">
<div
className={cn(
"flex-1 overflow-y-auto px-3 py-3",
compact && "min-h-0 overscroll-contain",
)}
>
{!showConversation ? (
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-[var(--radius-xl)] border border-dashed border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)]/40 px-5 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-[var(--radius-lg)] bg-[var(--color-primary-muted)] text-[var(--color-primary)]">
@ -1284,7 +1460,12 @@ export function OpenClawAssistantPane({
)}
</div>
<div className="border-t border-[color:var(--color-surface-border)] px-3 py-3">
<div
className={cn(
"border-t border-[color:var(--color-surface-border)] px-3 py-3",
compact && "shrink-0",
)}
>
<div className="flex flex-wrap items-center gap-2">
{modeOptions.map((option) => (
<button
@ -1350,15 +1531,40 @@ export function OpenClawAssistantPane({
) : null}
{errorMessage ? (
<div className="mt-2.5 whitespace-pre-wrap rounded-[var(--radius-lg)] border border-[color:var(--color-danger-border)] bg-[var(--color-danger-muted)]/40 px-3 py-2 text-sm text-[var(--color-danger-foreground)]">
{errorMessage}
<div className="mt-2.5 space-y-2">
<div className="whitespace-pre-wrap rounded-[var(--radius-lg)] border border-[color:var(--color-danger-border)] bg-[var(--color-danger-muted)]/40 px-3 py-2 text-sm text-[var(--color-danger-foreground)]">
{errorMessage}
</div>
{guestSessionExpired ? (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => router.push("/login")}
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-primary)] px-3 py-1.5 text-xs font-semibold text-[var(--color-primary-foreground)] transition hover:opacity-90"
>
{copy.login}
</button>
<button
type="button"
onClick={() => router.push("/register")}
className="inline-flex items-center gap-2 rounded-full border border-[color:var(--color-surface-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs font-semibold text-[var(--color-text)] transition hover:border-[color:var(--color-primary-border)] hover:bg-[var(--color-surface-muted)]"
>
{copy.register}
</button>
</div>
) : null}
</div>
) : null}
<div className="mt-2.5 flex min-h-[248px] flex-1 flex-col rounded-[var(--radius-xl)] border border-[color:var(--color-surface-border)] bg-[var(--color-surface)] p-2.5 shadow-[var(--shadow-sm)]">
<div
className={cn(
"mt-2.5 flex flex-1 flex-col rounded-[var(--radius-xl)] border border-[color:var(--color-surface-border)] bg-[var(--color-surface)] p-2.5 shadow-[var(--shadow-sm)]",
compact ? "min-h-[184px]" : "min-h-[248px]",
)}
>
<textarea
ref={textareaRef}
rows={compact ? 3 : 4}
rows={compact ? 2 : 4}
value={composerValue}
placeholder={copy.placeholder}
onChange={(event) => setComposerValue(event.target.value)}
@ -1370,7 +1576,10 @@ export function OpenClawAssistantPane({
void addFiles(clipboardFiles);
}
}}
className="min-h-[148px] w-full flex-1 resize-none bg-transparent text-sm leading-6 text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-subtle)]/70"
className={cn(
"w-full flex-1 resize-none bg-transparent text-sm leading-6 text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-subtle)]/70",
compact ? "min-h-[92px]" : "min-h-[148px]",
)}
/>
<div className="mt-2.5 flex flex-wrap items-center gap-2">