feat: launch xworkmate workspace

This commit is contained in:
Haitao Pan 2026-03-12 16:18:41 +08:00
parent 01181d4385
commit 161350c608
13 changed files with 2991 additions and 1050 deletions

View File

@ -8,12 +8,12 @@ Cloud Neutral Toolkit 的开放云控制面板 (Open Cloud Control Panel).
## 部署要求 (Deployment Requirements)
| 维度 | 要求 / 规格 | 说明 |
|---|---|---|
| Node.js | `>=18.17 <25` | 推荐使用 `.nvmrc` |
| 包管理 | Yarn (推荐) 或 npm | Yarn 推荐配合 Corepack |
| Git | 必需 | 用于拉取仓库 |
| 部署 (可选) | Vercel / 自建 | 部署方式见 `docs/usage/deployment.md` |
| 维度 | 要求 / 规格 | 说明 |
| ----------- | ------------------ | ------------------------------------- |
| Node.js | `>=18.17 <25` | 推荐使用 `.nvmrc` |
| 包管理 | Yarn (推荐) 或 npm | Yarn 推荐配合 Corepack |
| Git | 必需 | 用于拉取仓库 |
| 部署 (可选) | Vercel / 自建 | 部署方式见 `docs/usage/deployment.md` |
## 快速开始 (Quickstart)
@ -41,7 +41,7 @@ cp .env.example .env
## 主要入口 (Key Routes)
- `/services`:服务导航页,保留现有控制台布局。
- `/services/openclaw`:原生 Next.js 的 OpenClaw 助手工作区
- `/xworkmate`:原生 Next.js 的 XWorkmate 在线工作区,底层通过 OpenClaw gateway 接入
- `/panel/api`:融合设置与集成页,用于配置和探测 OpenClaw Gateway、Vault Server、APISIX AI Gateway。
## AI 助手与集成能力 (Assistant & Integrations)
@ -49,7 +49,7 @@ cp .env.example .env
当前主页 AI 辅助功能已经基于本仓库原生实现,核心行为如下:
- 侧栏助手模式保留现有交互方式,但底层改为对接 OpenClaw gateway。
- 最大化助手页面统一收敛到 `/services/openclaw`,不再继续使用旧的 control UI 套壳。
- 最大化助手页面统一收敛到 `/xworkmate`,旧的 `/services/openclaw` 只保留兼容跳转,不再继续使用旧的 control UI 套壳。
- 页面截图通过 assistant chat 附件模式发送,而不是单独的浏览器控制壳。
- `/panel/api` 提供 OpenClaw、Vault、APISIX 三类集成的默认值预填与连通性探测。
- 网关地址与令牌从服务端环境变量读取,前端组件不硬编码敏感配置。
@ -58,32 +58,34 @@ cp .env.example .env
以下变量用于主页 AI 助手和集成页的服务端默认值预填:
| 变量 | 用途 |
|---|---|
| 变量 | 用途 |
| ----------------------------- | ------------------------------------ |
| `OPENCLAW_GATEWAY_REMOTE_URL` | OpenClaw gateway 远端 WebSocket 地址 |
| `OPENCLAW_GATEWAY_TOKEN` | OpenClaw gateway 访问令牌 |
| `VAULT_SERVER_URL` | Vault 服务地址 |
| `VAULT_NAMESPACE` | Vault namespace可选 |
| `VAULT_TOKEN` | Vault 探测令牌 |
| `APISIX_AI_GATEWAY_URL` | APISIX AI Gateway 地址 |
| `AI_GATEWAY_ACCESS_TOKEN` | APISIX AI Gateway 探测令牌 |
| `OPENCLAW_GATEWAY_TOKEN` | OpenClaw gateway 访问令牌 |
| `VAULT_SERVER_URL` | Vault 服务地址 |
| `VAULT_NAMESPACE` | Vault namespace可选 |
| `VAULT_TOKEN` | Vault 探测令牌 |
| `APISIX_AI_GATEWAY_URL` | APISIX AI Gateway 地址 |
| `AI_GATEWAY_ACCESS_TOKEN` | APISIX AI Gateway 探测令牌 |
更多说明见 `docs/getting-started/installation.md``.env.example`
## 核心特性 & 技术栈 (Features & Tech Stack)
核心特性:
* 统一控制面:汇聚 Cloud Neutral Toolkit 各微服务入口
* 原生 AI 助手工作区OpenClaw gateway 驱动的聊天、截图附件与会话体验
* 融合集成设置:在 `/panel/api` 统一管理 OpenClaw、Vault、APISIX AI Gateway
* 文档与内容系统Contentlayer 驱动的 docs/content pipeline
* 可扩展集成OIDC、Cloudflare Web Analytics 等
- 统一控制面:汇聚 Cloud Neutral Toolkit 各微服务入口
- 原生 AI 助手工作区OpenClaw gateway 驱动的聊天、截图附件与会话体验
- 融合集成设置:在 `/panel/api` 统一管理 OpenClaw、Vault、APISIX AI Gateway
- 文档与内容系统Contentlayer 驱动的 docs/content pipeline
- 可扩展集成OIDC、Cloudflare Web Analytics 等
技术栈:
* Next.js + TypeScript
* Tailwind CSS + Radix UI
* Zustand
* Contentlayer
- Next.js + TypeScript
- Tailwind CSS + Radix UI
- Zustand
- Contentlayer
## 开发命令 (Useful Commands)
@ -97,14 +99,17 @@ yarn typecheck
## 说明文档 (Docs)
入口:
* EN: `docs/README.md`
* ZH: `docs/zh/README.md`
- EN: `docs/README.md`
- ZH: `docs/zh/README.md`
常用链接:
* OIDC: `docs/integrations/oidc-auth.md`
* Cloudflare Web Analytics: `docs/integrations/cloudflare-web-analytics.md`
* Assistant / Integrations env setup: `docs/getting-started/installation.md`
* Chinese installation guide: `docs/zh/getting-started/installation.md`
- OIDC: `docs/integrations/oidc-auth.md`
- Cloudflare Web Analytics: `docs/integrations/cloudflare-web-analytics.md`
- Assistant / Integrations env setup: `docs/getting-started/installation.md`
- Chinese installation guide: `docs/zh/getting-started/installation.md`
其他:
* Agent rules: `AGENTS.md`
- Agent rules: `AGENTS.md`

View File

@ -1,43 +1,47 @@
'use client'
"use client";
import { useEffect, type ReactNode } from 'react'
import { usePathname } from 'next/navigation'
import { ThemeProvider } from '../components/theme'
import { LanguageProvider } from '../i18n/LanguageProvider'
import { AskAIDialog } from '../components/AskAIDialog'
import { useMoltbotStore } from '../lib/moltbotStore'
import { cn } from '../lib/utils'
import type { IntegrationDefaults } from '@/lib/openclaw/types'
import { useOpenClawConsoleStore } from '@/state/openclawConsoleStore'
import { useEffect, type ReactNode } from "react";
import { usePathname } from "next/navigation";
import { ThemeProvider } from "../components/theme";
import { LanguageProvider } from "../i18n/LanguageProvider";
import { AskAIDialog } from "../components/AskAIDialog";
import { useMoltbotStore } from "../lib/moltbotStore";
import { cn } from "../lib/utils";
import type { IntegrationDefaults } from "@/lib/openclaw/types";
import { useOpenClawConsoleStore } from "@/state/openclawConsoleStore";
export function AppProviders({
children,
assistantDefaults,
}: {
children: ReactNode
assistantDefaults: IntegrationDefaults
children: ReactNode;
assistantDefaults: IntegrationDefaults;
}) {
const { isOpen, isMinimized, close, toggleOpen } = useMoltbotStore()
const applyDefaults = useOpenClawConsoleStore((state) => state.applyDefaults)
const pathname = usePathname()
const isOpenClawWorkspace = pathname.startsWith('/services/openclaw')
const { isOpen, isMinimized, close, toggleOpen } = useMoltbotStore();
const applyDefaults = useOpenClawConsoleStore((state) => state.applyDefaults);
const pathname = usePathname();
const isOpenClawWorkspace =
pathname.startsWith("/xworkmate") ||
pathname.startsWith("/services/openclaw");
// Always reserve space if open and not minimized, since we only have "Float/Sidebar" mode now
// and user wants it to NEVER cover the homepage.
const reserveSpace = !isOpenClawWorkspace && isOpen && !isMinimized
const reserveSpace = !isOpenClawWorkspace && isOpen && !isMinimized;
useEffect(() => {
applyDefaults(assistantDefaults)
}, [applyDefaults, assistantDefaults])
applyDefaults(assistantDefaults);
}, [applyDefaults, assistantDefaults]);
return (
<ThemeProvider>
<LanguageProvider>
<div className="flex flex-col min-h-screen">
<div className={cn(
"flex-1 flex flex-col relative w-full overflow-hidden transition-[padding] duration-300 ease-in-out",
reserveSpace ? "pr-[400px]" : ""
)}>
<div
className={cn(
"flex-1 flex flex-col relative w-full overflow-hidden transition-[padding] duration-300 ease-in-out",
reserveSpace ? "pr-[400px]" : "",
)}
>
<div className="flex-1 flex flex-col w-full relative">
{children}
</div>
@ -53,5 +57,5 @@ export function AppProviders({
</div>
</LanguageProvider>
</ThemeProvider>
)
);
}

View File

@ -1,23 +1,5 @@
import { Suspense } from 'react'
import { OpenClawWorkspacePage } from '@/components/openclaw/OpenClawWorkspacePage'
import { getConsoleIntegrationDefaults } from '@/server/consoleIntegrations'
export const metadata = {
title: 'OpenClaw Assistant',
description: 'OpenClaw gateway assistant workspace',
}
import { redirect } from "next/navigation";
export default function OpenClawPage() {
const defaults = getConsoleIntegrationDefaults()
return (
<div className="h-[calc(100vh-var(--app-shell-nav-offset))] w-full p-4">
<Suspense
fallback={<div className="flex h-full items-center justify-center">Loading assistant...</div>}
>
<OpenClawWorkspacePage defaults={defaults} />
</Suspense>
</div>
)
redirect("/xworkmate");
}

View File

@ -42,17 +42,19 @@ const ServiceCard = ({
const cardContent = (
<div
className={`group flex h-full flex-col justify-between rounded-xl p-5 transition ${isMaterial
? "border border-surface-border bg-surface hover:-translate-y-[1px] hover:border-primary/50 hover:bg-background-muted"
: "border border-white/10 bg-white/5 hover:-translate-y-[1px] hover:border-indigo-400/50 hover:bg-slate-900/60"
}`}
className={`group flex h-full flex-col justify-between rounded-xl p-5 transition ${
isMaterial
? "border border-surface-border bg-surface hover:-translate-y-[1px] hover:border-primary/50 hover:bg-background-muted"
: "border border-white/10 bg-white/5 hover:-translate-y-[1px] hover:border-indigo-400/50 hover:bg-slate-900/60"
}`}
>
<div className="flex items-start gap-3">
<div
className={`flex h-10 w-10 items-center justify-center rounded-full ${isMaterial
? "bg-primary/15 text-primary"
: "bg-indigo-500/15 text-indigo-200"
}`}
className={`flex h-10 w-10 items-center justify-center rounded-full ${
isMaterial
? "bg-primary/15 text-primary"
: "bg-indigo-500/15 text-indigo-200"
}`}
>
<service.icon className="h-5 w-5" aria-hidden />
</div>
@ -70,10 +72,11 @@ const ServiceCard = ({
</div>
</div>
<span
className={`mt-4 inline-flex items-center gap-1 text-xs font-semibold transition ${isMaterial
? "text-primary group-hover:text-primary-hover"
: "text-indigo-200 group-hover:text-white"
}`}
className={`mt-4 inline-flex items-center gap-1 text-xs font-semibold transition ${
isMaterial
? "text-primary group-hover:text-primary-hover"
: "text-indigo-200 group-hover:text-white"
}`}
>
{isChinese ? "打开" : "Open"}
<ArrowRight className="h-4 w-4" aria-hidden />
@ -118,17 +121,19 @@ const PlaceholderCard = ({
return (
<div
className={`flex h-full flex-col justify-between rounded-xl border border-dashed p-5 ${isMaterial
? "border-surface-border-strong bg-surface text-text-muted"
: "border-white/15 bg-white/5 text-slate-300"
}`}
className={`flex h-full flex-col justify-between rounded-xl border border-dashed p-5 ${
isMaterial
? "border-surface-border-strong bg-surface text-text-muted"
: "border-white/15 bg-white/5 text-slate-300"
}`}
>
<div className="space-y-2">
<div
className={`flex h-10 w-10 items-center justify-center rounded-full border border-dashed text-sm ${isMaterial
? "border-surface-border-strong text-text-subtle"
: "border-white/20 text-slate-400"
}`}
className={`flex h-10 w-10 items-center justify-center rounded-full border border-dashed text-sm ${
isMaterial
? "border-surface-border-strong text-text-subtle"
: "border-white/20 text-slate-400"
}`}
>
<Box className="h-4 w-4" aria-hidden />
</div>
@ -295,11 +300,11 @@ export default function ServicesPage() {
},
{
key: "moltbot",
name: isChinese ? "OpenClaw 助手" : "OpenClaw Assistant",
name: "XWorkmate",
description: isChinese
? "OpenClaw gateway 驱动的原生 AI 助手工作区。"
: "Native AI assistant workspace powered by OpenClaw gateway.",
href: "/services/openclaw",
? "在线版 XWorkmate 工作区,底层由 OpenClaw gateway 驱动。"
: "Online XWorkmate workspace powered by the OpenClaw gateway.",
href: "/xworkmate",
icon: ClawdbotLogo,
},
];
@ -343,9 +348,7 @@ export default function ServicesPage() {
{isChinese ? "更多服务" : "More services"}
</p>
<h1 className="text-3xl font-semibold text-white sm:text-4xl">
{isChinese
? "扩展服务与工具箱"
: "Extended Services & Toolbox"}
{isChinese ? "扩展服务与工具箱" : "Extended Services & Toolbox"}
</h1>
<p className="max-w-2xl text-sm text-slate-300">
{isChinese

View File

@ -0,0 +1,27 @@
import { Suspense } from "react";
import { XWorkmateWorkspacePage } from "@/components/xworkmate/XWorkmateWorkspacePage";
import { getConsoleIntegrationDefaults } from "@/server/consoleIntegrations";
export const metadata = {
title: "XWorkmate",
description: "Online XWorkmate workspace powered by OpenClaw gateway",
};
export default function XWorkmatePage() {
const defaults = getConsoleIntegrationDefaults();
return (
<div className="h-[calc(100vh-var(--app-shell-nav-offset))] w-full p-4">
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
Loading XWorkmate...
</div>
}
>
<XWorkmateWorkspacePage defaults={defaults} />
</Suspense>
</div>
);
}

View File

@ -1,24 +1,24 @@
'use client'
"use client";
import { Maximize2, X } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { Maximize2, X } from "lucide-react";
import { useRouter } from "next/navigation";
import { OpenClawAssistantPane } from '@/components/openclaw/OpenClawAssistantPane'
import type { IntegrationDefaults } from '@/lib/openclaw/types'
import { cn } from '@/lib/utils'
import { OpenClawAssistantPane } from "@/components/openclaw/OpenClawAssistantPane";
import type { IntegrationDefaults } from "@/lib/openclaw/types";
import { cn } from "@/lib/utils";
export type InitialQuestionPayload = {
key: number
text: string
}
key: number;
text: string;
};
type AskAIDialogProps = {
open: boolean
defaults?: IntegrationDefaults
onMinimize: () => void
onEnd: () => void
initialQuestion?: InitialQuestionPayload
}
open: boolean;
defaults?: IntegrationDefaults;
onMinimize: () => void;
onEnd: () => void;
initialQuestion?: InitialQuestionPayload;
};
export function AskAIDialog({
open,
@ -27,44 +27,46 @@ export function AskAIDialog({
onEnd,
initialQuestion,
}: AskAIDialogProps) {
const router = useRouter()
const router = useRouter();
const resolvedDefaults: IntegrationDefaults = defaults ?? {
openclawUrl: '',
openclawUrl: "",
openclawTokenConfigured: false,
vaultUrl: '',
vaultNamespace: '',
vaultUrl: "",
vaultNamespace: "",
vaultTokenConfigured: false,
apisixUrl: '',
apisixUrl: "",
apisixTokenConfigured: false,
}
};
function handleMaximize(): void {
onEnd()
onEnd();
const query = initialQuestion?.text?.trim()
? `?q=${encodeURIComponent(initialQuestion.text.trim())}`
: ''
router.push(`/services/openclaw${query}`)
: "";
router.push(`/xworkmate${query}`);
}
return (
<div
className={cn(
'fixed bottom-0 right-0 z-[40] border-l border-[color:var(--color-surface-border)] bg-[var(--color-background)]/95 shadow-xl backdrop-blur',
"fixed bottom-0 right-0 z-[40] border-l border-[color:var(--color-surface-border)] bg-[var(--color-background)]/95 shadow-xl backdrop-blur",
)}
style={{
width: '400px',
top: 'var(--app-shell-nav-offset, 64px)',
height: 'calc(100vh - var(--app-shell-nav-offset, 64px))',
display: open ? 'block' : 'none',
width: "400px",
top: "var(--app-shell-nav-offset, 64px)",
height: "calc(100vh - var(--app-shell-nav-offset, 64px))",
display: open ? "block" : "none",
}}
>
<div className="flex h-full min-h-0 flex-col">
<div className="flex items-center justify-between gap-3 border-b border-[color:var(--color-surface-border)] px-4 py-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-text-subtle)]">
OpenClaw
XWorkmate
</p>
<h2 className="text-sm font-semibold text-[var(--color-heading)]">AI Assistant</h2>
<h2 className="text-sm font-semibold text-[var(--color-heading)]">
AI Assistant
</h2>
</div>
<div className="flex items-center gap-1 text-[var(--color-text-subtle)]">
@ -97,5 +99,5 @@ export function AskAIDialog({
</div>
</div>
</div>
)
);
}

View File

@ -1,162 +1,202 @@
'use client';
"use client";
import { useState, useRef, useEffect } from 'react';
import { ArrowRight, X, QrCode } from 'lucide-react';
import { cn } from '../lib/utils';
import Link from 'next/link';
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { ArrowRight, QrCode, X } from "lucide-react";
import { cn } from "../lib/utils";
interface GuideStep {
text: string;
link?: { url: string; label: string };
code?: string;
image?: string;
text: string;
link?: { url: string; label: string };
code?: string;
image?: string;
}
interface HeroCardProps {
icon: any;
icon: any;
title: string;
description: string;
guide?: {
title: string;
description: string;
guide?: {
title: string;
steps: GuideStep[];
dismiss: string;
};
steps: GuideStep[];
dismiss: string;
};
}
export function HeroCard({ icon: Icon, title, description, guide }: HeroCardProps) {
const [showGuide, setShowGuide] = useState(false);
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
export function HeroCard({
icon: Icon,
title,
description,
guide,
}: HeroCardProps) {
const [showGuide, setShowGuide] = useState(false);
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const primaryLink = guide?.steps.find((step) =>
step.link?.url.startsWith("/"),
)?.link;
const handleMouseEnter = () => {
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
if (guide) setShowGuide(true);
const handleMouseEnter = () => {
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
if (guide) {
setShowGuide(true);
}
};
const handleMouseLeave = () => {
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
}
closeTimeoutRef.current = setTimeout(() => {
setShowGuide(false);
}, 300);
};
useEffect(() => {
return () => {
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
}
};
}, []);
const handleMouseLeave = () => {
if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = setTimeout(() => {
setShowGuide(false);
}, 300); // 300ms grace period to move mouse to sidebar
};
const cardContent = (
<div
className={cn(
"group relative flex items-start gap-4 rounded-2xl border border-surface-border bg-surface p-6 transition-all duration-300",
primaryLink ? "cursor-pointer" : "",
showGuide
? "border-primary/50 shadow-lg"
: "hover:border-primary/50 hover:bg-surface-hover",
)}
>
<div className="mt-1 rounded-full border border-surface-border bg-surface-muted p-2 group-hover:border-primary/50 group-hover:text-primary">
<Icon className="h-5 w-5" />
</div>
<div className="flex w-full items-start justify-between gap-4">
<div className="space-y-1">
<h3 className="font-semibold text-heading">{title}</h3>
<p className="text-sm text-text-muted">{description}</p>
</div>
{primaryLink ? (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">
<ArrowRight className="h-3.5 w-3.5" />
</span>
) : null}
</div>
</div>
);
// Clean up timeout on unmount
useEffect(() => {
return () => {
if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current);
};
}, []);
return (
<>
<div onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
{primaryLink ? (
<Link href={primaryLink.url} className="block">
{cardContent}
</Link>
) : (
cardContent
)}
</div>
return (
<>
<div
className={cn(
"group relative flex items-start gap-4 rounded-2xl border border-surface-border bg-surface p-6 transition-all duration-300",
showGuide ? "border-primary/50 shadow-lg" : "hover:border-primary/50 hover:bg-surface-hover"
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="mt-1 rounded-full border border-surface-border bg-surface-muted p-2 group-hover:border-primary/50 group-hover:text-primary">
<Icon className="h-5 w-5" />
</div>
<div className="space-y-1 w-full">
<h3 className="font-semibold text-heading">{title}</h3>
<p className="text-sm text-text-muted">{description}</p>
</div>
{guide ? (
<div
className={cn(
"fixed top-0 right-0 z-[100] h-full w-[400px] transform border-l border-surface-border bg-surface shadow-2xl transition-transform duration-300 ease-in-out",
showGuide ? "translate-x-0" : "translate-x-full",
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="flex h-full flex-col overflow-y-auto p-8">
<div className="mb-8 flex items-center justify-between">
<h4 className="flex items-center gap-3 text-xl font-bold text-heading">
<span className="relative flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-primary" />
</span>
{guide.title}
</h4>
<button
onClick={() => setShowGuide(false)}
className="rounded-full p-2 text-text-muted transition-colors hover:bg-surface-muted hover:text-text"
>
<X className="h-5 w-5" />
<span className="sr-only">{guide.dismiss}</span>
</button>
</div>
{/* Right Sidebar Guide Drawer */}
{guide && (
<div
<div className="flex-1 space-y-8">
{guide.steps.map((step, idx) => (
<div key={idx} className="group/step relative pl-8">
{idx !== guide.steps.length - 1 ? (
<div className="absolute left-[11px] top-8 bottom-[-2rem] w-[2px] bg-surface-border transition-colors group-hover/step:bg-primary/20" />
) : null}
<span
className={cn(
"fixed top-0 right-0 h-full w-[400px] z-[100] bg-surface border-l border-surface-border shadow-2xl transition-transform duration-300 ease-in-out transform",
showGuide ? "translate-x-0" : "translate-x-full"
"absolute left-0 top-0 flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold ring-4 ring-surface transition-all duration-300",
"bg-surface-muted text-text-muted group-hover/step:scale-110 group-hover/step:bg-primary group-hover/step:text-white",
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="h-full flex flex-col p-8 overflow-y-auto">
<div className="flex items-center justify-between mb-8">
<h4 className="text-xl font-bold text-heading flex items-center gap-3">
<span className="relative flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-primary"></span>
</span>
{guide.title}
</h4>
<button
onClick={() => setShowGuide(false)}
className="rounded-full p-2 hover:bg-surface-muted text-text-muted hover:text-text transition-colors"
>
<X className="h-5 w-5" />
<span className="sr-only">{guide.dismiss}</span>
</button>
>
{idx + 1}
</span>
<div className="space-y-3">
<p className="text-base leading-relaxed text-text">
{step.text}
</p>
{step.link ? (
<Link
href={step.link.url}
className="inline-flex items-center gap-2 rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary transition-colors hover:bg-primary/20"
>
{step.link.label}
<ArrowRight className="h-4 w-4" />
</Link>
) : null}
{idx === 2 ? (
<div className="group/qr mt-4 cursor-crosshair rounded-xl border border-dashed border-surface-border bg-surface-muted/30 p-4 transition-all hover:border-primary/30 hover:bg-primary/5">
<div className="flex items-start gap-4">
<div className="rounded-lg bg-white p-2 shadow-sm">
<QrCode className="h-12 w-12 text-black" />
</div>
<div className="space-y-1">
<p className="text-sm font-semibold text-heading">
VLESS Protocol Ready
</p>
<p className="text-xs leading-relaxed text-text-muted">
Scan the QR code in the control panel to connect
automatically.
</p>
</div>
</div>
<div className="space-y-8 flex-1">
{guide.steps.map((step, idx) => (
<div key={idx} className="relative pl-8 group/step">
{/* Timeline line */}
{idx !== guide.steps.length - 1 && (
<div className="absolute left-[11px] top-8 bottom-[-2rem] w-[2px] bg-surface-border group-hover/step:bg-primary/20 transition-colors" />
)}
<span className={cn(
"absolute left-0 top-0 flex items-center justify-center h-6 w-6 rounded-full text-xs font-bold ring-4 ring-surface transition-all duration-300",
"bg-surface-muted text-text-muted group-hover/step:bg-primary group-hover/step:text-white group-hover/step:scale-110"
)}>
{idx + 1}
</span>
<div className="space-y-3">
<p className="text-base text-text leading-relaxed">{step.text}</p>
{step.link && (
<Link
href={step.link.url}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary text-sm font-medium hover:bg-primary/20 transition-colors"
>
{step.link.label}
<ArrowRight className="h-4 w-4" />
</Link>
)}
{/* Special handling for VLESS QR code hint */}
{idx === 2 && (
<div className="mt-4 p-4 rounded-xl border border-dashed border-surface-border bg-surface-muted/30 hover:border-primary/30 hover:bg-primary/5 transition-all cursor-crosshair group/qr">
<div className="flex items-start gap-4">
<div className="p-2 bg-white rounded-lg shadow-sm">
<QrCode className="h-12 w-12 text-black" />
</div>
<div className="space-y-1">
<p className="font-semibold text-heading text-sm">VLESS Protocol Ready</p>
<p className="text-xs text-text-muted leading-relaxed">
{/* English fallback if not found in props (though guide steps usually text) */}
Scan the QR code in the control panel to connect automatically.
</p>
</div>
</div>
</div>
)}
</div>
</div>
))}
</div>
<div className="mt-auto pt-6 border-t border-surface-border">
<button
onClick={() => setShowGuide(false)}
className="w-full py-3 rounded-xl border border-surface-border text-sm font-medium text-text-muted hover:bg-surface-muted hover:text-text transition-all"
>
{guide.dismiss}
</button>
</div>
</div>
</div>
) : null}
</div>
</div>
)}
</>
);
))}
</div>
<div className="mt-auto border-t border-surface-border pt-6">
<button
onClick={() => setShowGuide(false)}
className="w-full rounded-xl border border-surface-border py-3 text-sm font-medium text-text-muted transition-all hover:bg-surface-muted hover:text-text"
>
{guide.dismiss}
</button>
</div>
</div>
</div>
) : null}
</>
);
}

View File

@ -40,13 +40,13 @@ export default function Navbar() {
const pathname = usePathname();
const isHiddenRoute = pathname
? [
"/login",
"/register",
"/xstream",
"/xcloudflow",
"/xscopehub",
"/blogs",
].some((prefix) => pathname.startsWith(prefix))
"/login",
"/register",
"/xstream",
"/xcloudflow",
"/xscopehub",
"/blogs",
].some((prefix) => pathname.startsWith(prefix))
: false;
const [menuOpen, setMenuOpen] = useState(false);
const [selectedChannels, setSelectedChannels] = useState<ReleaseChannel[]>([
@ -130,42 +130,42 @@ export default function Navbar() {
const accountChildren: NavSubItem[] = user
? [
{
key: "userCenter",
label: accountCopy.userCenter,
href: "/panel",
togglePath: "/panel",
},
...(user?.isAdmin || user?.isOperator
? [
{
key: "management",
label: accountCopy.management,
href: "/panel/management",
togglePath: "/panel/management",
} satisfies NavSubItem,
]
: []),
{
key: "logout",
label: accountCopy.logout,
href: "/logout",
},
]
{
key: "userCenter",
label: accountCopy.userCenter,
href: "/panel",
togglePath: "/panel",
},
...(user?.isAdmin || user?.isOperator
? [
{
key: "management",
label: accountCopy.management,
href: "/panel/management",
togglePath: "/panel/management",
} satisfies NavSubItem,
]
: []),
{
key: "logout",
label: accountCopy.logout,
href: "/logout",
},
]
: [
{
key: "register",
label: nav.account.register,
href: "/register",
togglePath: "/register",
},
{
key: "login",
label: nav.account.login,
href: "/login",
togglePath: "/login",
},
];
{
key: "register",
label: nav.account.register,
href: "/register",
togglePath: "/register",
},
{
key: "login",
label: nav.account.login,
href: "/login",
togglePath: "/login",
},
];
const accountLabel = nav.account.title;
@ -186,7 +186,7 @@ export default function Navbar() {
openSource: isChinese ? "开源项目" : "Open source",
about: isChinese ? "关于" : "About",
moreServices: isChinese ? "更多服务" : "More services",
chat: translations[language].chat,
chat: "XWorkmate",
homepage: translations[language].homepage,
overview: isChinese ? "概览" : "Overview",
instances: isChinese ? "实例管理" : "Instances",
@ -274,8 +274,8 @@ export default function Navbar() {
key: "chat",
label: labels.chat,
icon: MessageSquare,
href: "/services/openclaw",
active: pathname?.startsWith("/services/openclaw"),
href: "/xworkmate",
active: pathname?.startsWith("/xworkmate"),
},
{
key: "overview",
@ -314,14 +314,14 @@ export default function Navbar() {
},
...(user?.isAdmin || user?.isOperator
? [
{
key: "instances",
label: labels.instances,
icon: Server,
href: "/panel/management",
active: pathname === "/panel/management",
},
]
{
key: "instances",
label: labels.instances,
icon: Server,
href: "/panel/management",
active: pathname === "/panel/management",
},
]
: []),
{
key: "about",
@ -346,28 +346,28 @@ export default function Navbar() {
},
...(!user
? [
{
key: "login",
label: nav.account.login,
icon: ({ className }: { className?: string }) => (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
/>
</svg>
),
href: "/login",
active: pathname?.startsWith("/login"),
},
]
{
key: "login",
label: nav.account.login,
icon: ({ className }: { className?: string }) => (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
/>
</svg>
),
href: "/login",
active: pathname?.startsWith("/login"),
},
]
: []),
];
@ -416,10 +416,11 @@ export default function Navbar() {
<Link
key={tab.key}
href={tab.href}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium whitespace-nowrap transition-colors ${tab.active
? "bg-primary/10 text-primary border border-primary/20"
: "text-text-muted hover:text-text hover:bg-surface-muted border border-transparent"
}`}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium whitespace-nowrap transition-colors ${
tab.active
? "bg-primary/10 text-primary border border-primary/20"
: "text-text-muted hover:text-text hover:bg-surface-muted border border-transparent"
}`}
>
<tab.icon className="w-4 h-4" />
{tab.label}
@ -655,11 +656,12 @@ export default function Navbar() {
<div className="flex-1 p-4">
<div className="space-y-1">
<Link
href="/services/openclaw"
className={`flex items-center px-4 py-3 rounded-xl text-sm font-medium transition-colors ${pathname?.startsWith("/services/openclaw")
? "bg-primary/10 text-primary"
: "text-text hover:bg-surface-muted"
}`}
href="/xworkmate"
className={`flex items-center px-4 py-3 rounded-xl text-sm font-medium transition-colors ${
pathname?.startsWith("/xworkmate")
? "bg-primary/10 text-primary"
: "text-text hover:bg-surface-muted"
}`}
onClick={() => setMenuOpen(false)}
>
<MessageSquare className="mr-3 h-5 w-5" />
@ -667,10 +669,11 @@ export default function Navbar() {
</Link>
<Link
href="/"
className={`flex items-center px-4 py-3 rounded-xl text-sm font-medium transition-colors ${pathname === "/"
? "bg-primary/10 text-primary"
: "text-text hover:bg-surface-muted"
}`}
className={`flex items-center px-4 py-3 rounded-xl text-sm font-medium transition-colors ${
pathname === "/"
? "bg-primary/10 text-primary"
: "text-text hover:bg-surface-muted"
}`}
onClick={() => setMenuOpen(false)}
>
<Image
@ -685,10 +688,11 @@ export default function Navbar() {
</Link>
<Link
href="/panel"
className={`flex items-center px-4 py-3 rounded-xl text-sm font-medium transition-colors ${pathname === "/panel"
? "bg-primary/10 text-primary"
: "text-text hover:bg-surface-muted"
}`}
className={`flex items-center px-4 py-3 rounded-xl text-sm font-medium transition-colors ${
pathname === "/panel"
? "bg-primary/10 text-primary"
: "text-text hover:bg-surface-muted"
}`}
onClick={() => setMenuOpen(false)}
>
<BarChart2 className="mr-3 h-5 w-5 opacity-70" />
@ -696,10 +700,11 @@ export default function Navbar() {
</Link>
<Link
href="/docs"
className={`flex items-center px-4 py-3 rounded-xl text-sm font-medium transition-colors ${pathname?.startsWith("/docs")
? "bg-primary/10 text-primary"
: "text-text hover:bg-surface-muted"
}`}
className={`flex items-center px-4 py-3 rounded-xl text-sm font-medium transition-colors ${
pathname?.startsWith("/docs")
? "bg-primary/10 text-primary"
: "text-text hover:bg-surface-muted"
}`}
onClick={() => setMenuOpen(false)}
>
<svg
@ -719,10 +724,11 @@ export default function Navbar() {
</Link>
<Link
href="/about"
className={`flex items-center px-4 py-3 rounded-xl text-sm font-medium transition-colors ${pathname === "/about"
? "bg-primary/10 text-primary"
: "text-text hover:bg-surface-muted"
}`}
className={`flex items-center px-4 py-3 rounded-xl text-sm font-medium transition-colors ${
pathname === "/about"
? "bg-primary/10 text-primary"
: "text-text hover:bg-surface-muted"
}`}
onClick={() => setMenuOpen(false)}
>
<svg
@ -742,10 +748,11 @@ export default function Navbar() {
</Link>
<Link
href="/services"
className={`flex items-center px-4 py-3 rounded-xl text-sm font-medium transition-colors ${pathname === "/services"
? "bg-primary/10 text-primary"
: "text-text hover:bg-surface-muted"
}`}
className={`flex items-center px-4 py-3 rounded-xl text-sm font-medium transition-colors ${
pathname === "/services"
? "bg-primary/10 text-primary"
: "text-text hover:bg-surface-muted"
}`}
onClick={() => setMenuOpen(false)}
>
<LinkIcon className="mr-3 h-5 w-5 opacity-70" />
@ -754,10 +761,11 @@ export default function Navbar() {
{(user?.isAdmin || user?.isOperator) && (
<Link
href="/panel/management"
className={`flex items-center px-4 py-3 rounded-xl text-sm font-medium transition-colors ${pathname === "/panel/management"
? "bg-primary/10 text-primary"
: "text-text hover:bg-surface-muted"
}`}
className={`flex items-center px-4 py-3 rounded-xl text-sm font-medium transition-colors ${
pathname === "/panel/management"
? "bg-primary/10 text-primary"
: "text-text hover:bg-surface-muted"
}`}
onClick={() => setMenuOpen(false)}
>
<Server className="mr-3 h-5 w-5 opacity-70" />

File diff suppressed because it is too large Load Diff

View File

@ -1,113 +0,0 @@
'use client'
import { useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { Maximize2, PanelLeft, PanelRight, X } from 'lucide-react'
import Footer from '@components/Footer'
import { HeroSection, NextStepsSection, ShortcutsSection, StatsSection } from '@/app/page'
import { cn } from '@/lib/utils'
import type { IntegrationDefaults } from '@/lib/openclaw/types'
import { OpenClawAssistantPane } from './OpenClawAssistantPane'
type ChatLayoutMode = 'left' | 'right' | 'full'
type OpenClawWorkspacePageProps = {
defaults: IntegrationDefaults
}
export function OpenClawWorkspacePage({ defaults }: OpenClawWorkspacePageProps) {
const router = useRouter()
const searchParams = useSearchParams()
const [layout, setLayout] = useState<ChatLayoutMode>('full')
const initialQuestion = searchParams.get('q') ?? undefined
const homeContent = (
<main className="space-y-12 py-10">
<HeroSection />
<NextStepsSection />
<StatsSection />
<ShortcutsSection />
<Footer />
</main>
)
const assistantPane = (
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-[var(--radius-2xl)] border border-[color:var(--color-surface-border)] bg-[var(--color-surface-elevated)] shadow-[var(--shadow-md)]">
<div className="flex items-center justify-between gap-3 border-b border-[color:var(--color-surface-border)] px-5 py-4">
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-text-subtle)]">
OpenClaw
</p>
<h1 className="text-lg font-semibold text-[var(--color-heading)]">AI Assistant Workspace</h1>
</div>
<div className="flex items-center gap-1 text-[var(--color-text-subtle)]">
<button
type="button"
onClick={() => setLayout('left')}
className={cn(
'rounded-xl p-2 transition hover:bg-[var(--color-surface-muted)]',
layout === 'left' ? 'bg-[var(--color-primary-muted)] text-[var(--color-primary)]' : '',
)}
title="Sidebar Left"
>
<PanelLeft className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setLayout('right')}
className={cn(
'rounded-xl p-2 transition hover:bg-[var(--color-surface-muted)]',
layout === 'right' ? 'bg-[var(--color-primary-muted)] text-[var(--color-primary)]' : '',
)}
title="Sidebar Right"
>
<PanelRight className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setLayout('full')}
className={cn(
'rounded-xl p-2 transition hover:bg-[var(--color-surface-muted)]',
layout === 'full' ? 'bg-[var(--color-primary-muted)] text-[var(--color-primary)]' : '',
)}
title="Fullscreen"
>
<Maximize2 className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => router.push('/')}
className="rounded-xl p-2 transition hover:bg-[var(--color-danger-muted)]/40 hover:text-[var(--color-danger-foreground)]"
title="Close"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="min-h-0 flex-1">
<OpenClawAssistantPane
defaults={defaults}
initialQuestion={initialQuestion}
initialQuestionKey={initialQuestion ? 1 : undefined}
variant="page"
/>
</div>
</div>
)
if (layout === 'full') {
return <div className="h-full min-h-0">{assistantPane}</div>
}
return (
<div className="flex h-full min-h-0 gap-4 overflow-hidden">
{layout === 'left' ? <div className="w-[460px] shrink-0">{assistantPane}</div> : null}
<div className="min-w-0 flex-1 overflow-y-auto">{homeContent}</div>
{layout === 'right' ? <div className="w-[460px] shrink-0">{assistantPane}</div> : null}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -73,10 +73,10 @@ export const createNavConfig = (
},
{
key: "chat",
label: (lang) => (lang === "zh" ? "AI 助手" : "AI Assistant"),
href: "/services/openclaw",
label: "XWorkmate",
href: "/xworkmate",
icon: MessageSquare,
active: (pathname) => pathname?.startsWith("/services/openclaw"),
active: (pathname) => pathname?.startsWith("/xworkmate"),
showOn: "both",
},
{
@ -92,7 +92,9 @@ export const createNavConfig = (
label: isChinese ? "控制台" : "Console",
href: "/panel",
icon: LayoutDashboard,
active: (pathname) => pathname.startsWith("/panel") && !pathname.startsWith("/panel/management"),
active: (pathname) =>
pathname.startsWith("/panel") &&
!pathname.startsWith("/panel/management"),
showOn: "both",
},
{
@ -100,7 +102,7 @@ export const createNavConfig = (
label: isChinese ? "更多服务" : "More Services",
href: "/services",
icon: Plus,
active: (pathname) => pathname.startsWith("/services") && !pathname.startsWith("/services/openclaw"),
active: (pathname) => pathname.startsWith("/services"),
showOn: "both",
},
{
@ -144,46 +146,46 @@ export const createNavConfig = (
const accountNav: NavItem[] = isLoggedIn
? [
{
key: "userCenter",
label: isChinese ? "用户中心" : "User Center",
href: "/panel",
icon: BarChart2,
showOn: "both",
},
...(isAdmin || isOperator
? [
{
key: "management",
label: isChinese ? "管理" : "Management",
href: "/panel/management",
icon: Settings,
showOn: "both" as const,
},
]
: []),
{
key: "logout",
label: isChinese ? "退出登录" : "Logout",
href: "/logout",
showOn: "both",
badge: isChinese ? "退出" : "Logout",
},
]
{
key: "userCenter",
label: isChinese ? "用户中心" : "User Center",
href: "/panel",
icon: BarChart2,
showOn: "both",
},
...(isAdmin || isOperator
? [
{
key: "management",
label: isChinese ? "管理" : "Management",
href: "/panel/management",
icon: Settings,
showOn: "both" as const,
},
]
: []),
{
key: "logout",
label: isChinese ? "退出登录" : "Logout",
href: "/logout",
showOn: "both",
badge: isChinese ? "退出" : "Logout",
},
]
: [
{
key: "register",
label: isChinese ? "注册" : "Register",
href: "/register",
showOn: "both",
},
{
key: "login",
label: isChinese ? "登录" : "Login",
href: "/login",
showOn: "both",
},
];
{
key: "register",
label: isChinese ? "注册" : "Register",
href: "/register",
showOn: "both",
},
{
key: "login",
label: isChinese ? "登录" : "Login",
href: "/login",
showOn: "both",
},
];
return { mainNav, secondaryNav, accountNav };
};

View File

@ -1,47 +1,51 @@
'use client'
"use client";
import { useEffect, useMemo, useState } from 'react'
import { ArrowRight, CheckCircle2, Link2, Loader2, ShieldCheck, Workflow } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useEffect, useMemo, useState } from "react";
import {
ArrowRight,
CheckCircle2,
Link2,
Loader2,
ShieldCheck,
Workflow,
} from "lucide-react";
import { useRouter } from "next/navigation";
import type { IntegrationDefaults } from '@/lib/openclaw/types'
import { useOpenClawConsoleStore } from '@/state/openclawConsoleStore'
import type { IntegrationDefaults } from "@/lib/openclaw/types";
import { useOpenClawConsoleStore } from "@/state/openclawConsoleStore";
import Card from './Card'
import Card from "./Card";
type ProbeTarget = 'openclaw' | 'vault' | 'apisix'
type ProbeTarget = "openclaw" | "vault" | "apisix";
type ProbeState = {
ok: boolean
status?: number
tokenSource?: string
body?: string
error?: string
}
ok: boolean;
status?: number;
tokenSource?: string;
body?: string;
error?: string;
};
type IntegrationsConsoleProps = {
defaults?: IntegrationDefaults
}
defaults?: IntegrationDefaults;
onOpenAssistant?: () => void;
};
function StatusBadge({
title,
ok,
}: {
title: string
ok?: boolean
}) {
function StatusBadge({ title, ok }: { title: string; ok?: boolean }) {
return (
<span
className={`inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-semibold ${
ok
? 'bg-emerald-500/10 text-emerald-600'
: 'bg-[var(--color-surface-muted)] text-[var(--color-text-subtle)]'
? "bg-emerald-500/10 text-emerald-600"
: "bg-[var(--color-surface-muted)] text-[var(--color-text-subtle)]"
}`}
>
<span className={`h-2 w-2 rounded-full ${ok ? 'bg-emerald-500' : 'bg-[var(--color-text-subtle)]/50'}`} />
<span
className={`h-2 w-2 rounded-full ${ok ? "bg-emerald-500" : "bg-[var(--color-text-subtle)]/50"}`}
/>
{title}
</span>
)
);
}
function Field({
@ -49,118 +53,143 @@ function Field({
hint,
children,
}: {
label: string
hint?: string
children: React.ReactNode
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<label className="flex flex-col gap-2 text-sm">
<div className="space-y-1">
<span className="font-medium text-[var(--color-text)]">{label}</span>
{hint ? <p className="text-xs text-[var(--color-text-subtle)]">{hint}</p> : null}
{hint ? (
<p className="text-xs text-[var(--color-text-subtle)]">{hint}</p>
) : null}
</div>
{children}
</label>
)
);
}
function inputClassName(type: 'input' | 'textarea' = 'input'): string {
function inputClassName(type: "input" | "textarea" = "input"): string {
return [
'w-full rounded-[var(--radius-xl)] border border-[color:var(--color-surface-border)] bg-[var(--color-surface)] px-4 py-3 text-sm text-[var(--color-text)] outline-none transition',
'focus:border-[color:var(--color-primary)] focus:ring-2 focus:ring-[color:var(--color-primary-muted)]',
type === 'textarea' ? 'min-h-[120px] resize-y' : '',
"w-full rounded-[var(--radius-xl)] border border-[color:var(--color-surface-border)] bg-[var(--color-surface)] px-4 py-3 text-sm text-[var(--color-text)] outline-none transition",
"focus:border-[color:var(--color-primary)] focus:ring-2 focus:ring-[color:var(--color-primary-muted)]",
type === "textarea" ? "min-h-[120px] resize-y" : "",
]
.filter(Boolean)
.join(' ')
.join(" ");
}
const EMPTY_DEFAULTS: IntegrationDefaults = {
openclawUrl: '',
openclawUrl: "",
openclawTokenConfigured: false,
vaultUrl: '',
vaultNamespace: '',
vaultUrl: "",
vaultNamespace: "",
vaultTokenConfigured: false,
apisixUrl: '',
apisixUrl: "",
apisixTokenConfigured: false,
}
};
export function IntegrationsConsole({ defaults }: IntegrationsConsoleProps) {
const router = useRouter()
const [loadingTarget, setLoadingTarget] = useState<ProbeTarget | null>(null)
const [resolvedDefaults, setResolvedDefaults] = useState<IntegrationDefaults>(defaults ?? EMPTY_DEFAULTS)
const [probeResults, setProbeResults] = useState<Record<ProbeTarget, ProbeState>>({
export function IntegrationsConsole({
defaults,
onOpenAssistant,
}: IntegrationsConsoleProps) {
const router = useRouter();
const [loadingTarget, setLoadingTarget] = useState<ProbeTarget | null>(null);
const [resolvedDefaults, setResolvedDefaults] = useState<IntegrationDefaults>(
defaults ?? EMPTY_DEFAULTS,
);
const [probeResults, setProbeResults] = useState<
Record<ProbeTarget, ProbeState>
>({
openclaw: { ok: false },
vault: { ok: false },
apisix: { ok: false },
})
});
const applyDefaults = useOpenClawConsoleStore((state) => state.applyDefaults)
const openclawUrl = useOpenClawConsoleStore((state) => state.openclawUrl)
const openclawToken = useOpenClawConsoleStore((state) => state.openclawToken)
const vaultUrl = useOpenClawConsoleStore((state) => state.vaultUrl)
const vaultNamespace = useOpenClawConsoleStore((state) => state.vaultNamespace)
const vaultToken = useOpenClawConsoleStore((state) => state.vaultToken)
const apisixUrl = useOpenClawConsoleStore((state) => state.apisixUrl)
const apisixToken = useOpenClawConsoleStore((state) => state.apisixToken)
const setOpenclawUrl = useOpenClawConsoleStore((state) => state.setOpenclawUrl)
const setOpenclawToken = useOpenClawConsoleStore((state) => state.setOpenclawToken)
const setVaultUrl = useOpenClawConsoleStore((state) => state.setVaultUrl)
const setVaultNamespace = useOpenClawConsoleStore((state) => state.setVaultNamespace)
const setVaultToken = useOpenClawConsoleStore((state) => state.setVaultToken)
const setApisixUrl = useOpenClawConsoleStore((state) => state.setApisixUrl)
const setApisixToken = useOpenClawConsoleStore((state) => state.setApisixToken)
const applyDefaults = useOpenClawConsoleStore((state) => state.applyDefaults);
const openclawUrl = useOpenClawConsoleStore((state) => state.openclawUrl);
const openclawToken = useOpenClawConsoleStore((state) => state.openclawToken);
const vaultUrl = useOpenClawConsoleStore((state) => state.vaultUrl);
const vaultNamespace = useOpenClawConsoleStore(
(state) => state.vaultNamespace,
);
const vaultToken = useOpenClawConsoleStore((state) => state.vaultToken);
const apisixUrl = useOpenClawConsoleStore((state) => state.apisixUrl);
const apisixToken = useOpenClawConsoleStore((state) => state.apisixToken);
const setOpenclawUrl = useOpenClawConsoleStore(
(state) => state.setOpenclawUrl,
);
const setOpenclawToken = useOpenClawConsoleStore(
(state) => state.setOpenclawToken,
);
const setVaultUrl = useOpenClawConsoleStore((state) => state.setVaultUrl);
const setVaultNamespace = useOpenClawConsoleStore(
(state) => state.setVaultNamespace,
);
const setVaultToken = useOpenClawConsoleStore((state) => state.setVaultToken);
const setApisixUrl = useOpenClawConsoleStore((state) => state.setApisixUrl);
const setApisixToken = useOpenClawConsoleStore(
(state) => state.setApisixToken,
);
useEffect(() => {
applyDefaults(resolvedDefaults)
}, [applyDefaults, resolvedDefaults])
applyDefaults(resolvedDefaults);
}, [applyDefaults, resolvedDefaults]);
useEffect(() => {
if (defaults) {
setResolvedDefaults(defaults)
return
setResolvedDefaults(defaults);
return;
}
let cancelled = false
let cancelled = false;
void (async () => {
try {
const response = await fetch('/api/integrations/defaults', { cache: 'no-store' })
const response = await fetch("/api/integrations/defaults", {
cache: "no-store",
});
if (!response.ok) {
return
return;
}
const payload = (await response.json()) as IntegrationDefaults
const payload = (await response.json()) as IntegrationDefaults;
if (!cancelled) {
setResolvedDefaults(payload)
setResolvedDefaults(payload);
}
} catch {
// Ignore; the form still works with manual input only.
}
})()
})();
return () => {
cancelled = true
}
}, [defaults])
cancelled = true;
};
}, [defaults]);
const summary = useMemo(
() => [
{
key: 'openclaw',
label: 'OpenClaw Gateway',
key: "openclaw",
label: "OpenClaw Gateway",
configured: Boolean(openclawUrl.trim()),
tokenConfigured: resolvedDefaults.openclawTokenConfigured || Boolean(openclawToken.trim()),
tokenConfigured:
resolvedDefaults.openclawTokenConfigured ||
Boolean(openclawToken.trim()),
},
{
key: 'vault',
label: 'Vault Server',
key: "vault",
label: "Vault Server",
configured: Boolean(vaultUrl.trim()),
tokenConfigured: resolvedDefaults.vaultTokenConfigured || Boolean(vaultToken.trim()),
tokenConfigured:
resolvedDefaults.vaultTokenConfigured || Boolean(vaultToken.trim()),
},
{
key: 'apisix',
label: 'APISIX AI Gateway',
key: "apisix",
label: "APISIX AI Gateway",
configured: Boolean(apisixUrl.trim()),
tokenConfigured: resolvedDefaults.apisixTokenConfigured || Boolean(apisixToken.trim()),
tokenConfigured:
resolvedDefaults.apisixTokenConfigured || Boolean(apisixToken.trim()),
},
],
[
@ -174,16 +203,16 @@ export function IntegrationsConsole({ defaults }: IntegrationsConsoleProps) {
vaultToken,
vaultUrl,
],
)
);
async function probe(target: ProbeTarget): Promise<void> {
setLoadingTarget(target)
setLoadingTarget(target);
try {
const response = await fetch('/api/integrations/probe', {
method: 'POST',
const response = await fetch("/api/integrations/probe", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({
target,
@ -194,9 +223,9 @@ export function IntegrationsConsole({ defaults }: IntegrationsConsoleProps) {
apisixUrl,
apisixToken,
}),
})
});
const payload = (await response.json()) as ProbeState
const payload = (await response.json()) as ProbeState;
setProbeResults((current) => ({
...current,
[target]: {
@ -206,17 +235,17 @@ export function IntegrationsConsole({ defaults }: IntegrationsConsoleProps) {
body: payload.body,
error: payload.error,
},
}))
}));
} catch (error) {
setProbeResults((current) => ({
...current,
[target]: {
ok: false,
error: error instanceof Error ? error.message : 'Probe failed.',
error: error instanceof Error ? error.message : "Probe failed.",
},
}))
}));
} finally {
setLoadingTarget(null)
setLoadingTarget(null);
}
}
@ -228,18 +257,28 @@ export function IntegrationsConsole({ defaults }: IntegrationsConsoleProps) {
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-text-subtle)]">
Assistant Integrations
</p>
<h1 className="text-2xl font-semibold text-[var(--color-heading)]">OpenClaw / Vault / APISIX</h1>
<h1 className="text-2xl font-semibold text-[var(--color-heading)]">
OpenClaw / Vault / APISIX
</h1>
<p className="max-w-3xl text-sm text-[var(--color-text-subtle)]">
console.svc.plus AI token
console.svc.plus AI
token
</p>
</div>
<button
type="button"
onClick={() => router.push('/services/openclaw')}
onClick={() => {
if (onOpenAssistant) {
onOpenAssistant();
return;
}
router.push("/xworkmate");
}}
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-primary)] px-4 py-2 text-sm font-semibold text-[var(--color-primary-foreground)]"
>
XWorkmate
<ArrowRight className="h-4 w-4" />
</button>
</div>
@ -252,13 +291,18 @@ export function IntegrationsConsole({ defaults }: IntegrationsConsoleProps) {
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-[var(--color-text)]">{item.label}</p>
<p className="text-sm font-semibold text-[var(--color-text)]">
{item.label}
</p>
<p className="mt-1 text-xs text-[var(--color-text-subtle)]">
{item.configured ? 'address ready' : 'missing address'} ·{' '}
{item.tokenConfigured ? 'token ready' : 'token pending'}
{item.configured ? "address ready" : "missing address"} ·{" "}
{item.tokenConfigured ? "token ready" : "token pending"}
</p>
</div>
<StatusBadge title={item.configured ? 'configured' : 'draft'} ok={item.configured} />
<StatusBadge
title={item.configured ? "configured" : "draft"}
ok={item.configured}
/>
</div>
</div>
))}
@ -271,9 +315,11 @@ export function IntegrationsConsole({ defaults }: IntegrationsConsoleProps) {
<Link2 className="h-5 w-5" />
</div>
<div className="space-y-1">
<h2 className="text-lg font-semibold text-[var(--color-heading)]">OpenClaw Gateway</h2>
<h2 className="text-lg font-semibold text-[var(--color-heading)]">
OpenClaw Gateway
</h2>
<p className="text-sm text-[var(--color-text-subtle)]">
`/services/openclaw`
`/xworkmate`
</p>
</div>
</div>
@ -288,13 +334,20 @@ export function IntegrationsConsole({ defaults }: IntegrationsConsoleProps) {
/>
</Field>
<Field label="Gateway Token" hint="优先使用当前会话值,留空时回退到服务端环境变量。">
<Field
label="Gateway Token"
hint="优先使用当前会话值,留空时回退到服务端环境变量。"
>
<input
type="password"
value={openclawToken}
onChange={(event) => setOpenclawToken(event.target.value)}
className={inputClassName()}
placeholder={resolvedDefaults.openclawTokenConfigured ? 'server env configured' : 'paste shared token'}
placeholder={
resolvedDefaults.openclawTokenConfigured
? "server env configured"
: "paste shared token"
}
/>
</Field>
</div>
@ -303,19 +356,27 @@ export function IntegrationsConsole({ defaults }: IntegrationsConsoleProps) {
<button
type="button"
onClick={() => {
void probe('openclaw')
void probe("openclaw");
}}
className="inline-flex items-center gap-2 rounded-full border border-[color:var(--color-surface-border)] px-4 py-2 text-sm font-medium text-[var(--color-text)] transition hover:border-[color:var(--color-primary-border)] hover:bg-[var(--color-surface-muted)]"
>
{loadingTarget === 'openclaw' ? <Loader2 className="h-4 w-4 animate-spin" /> : <CheckCircle2 className="h-4 w-4" />}
{loadingTarget === "openclaw" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
OpenClaw
</button>
<StatusBadge
title={probeResults.openclaw.ok ? 'gateway reachable' : 'not checked'}
title={
probeResults.openclaw.ok ? "gateway reachable" : "not checked"
}
ok={probeResults.openclaw.ok}
/>
<span className="text-xs text-[var(--color-text-subtle)]">
token source: {probeResults.openclaw.tokenSource || (resolvedDefaults.openclawTokenConfigured ? 'env' : 'session')}
token source:{" "}
{probeResults.openclaw.tokenSource ||
(resolvedDefaults.openclawTokenConfigured ? "env" : "session")}
</span>
</div>
@ -333,8 +394,12 @@ export function IntegrationsConsole({ defaults }: IntegrationsConsoleProps) {
<ShieldCheck className="h-5 w-5" />
</div>
<div className="space-y-1">
<h2 className="text-lg font-semibold text-[var(--color-heading)]">Vault Server</h2>
<p className="text-sm text-[var(--color-text-subtle)]"></p>
<h2 className="text-lg font-semibold text-[var(--color-heading)]">
Vault Server
</h2>
<p className="text-sm text-[var(--color-text-subtle)]">
</p>
</div>
</div>
@ -363,7 +428,11 @@ export function IntegrationsConsole({ defaults }: IntegrationsConsoleProps) {
value={vaultToken}
onChange={(event) => setVaultToken(event.target.value)}
className={inputClassName()}
placeholder={resolvedDefaults.vaultTokenConfigured ? 'server env configured' : 'paste vault token'}
placeholder={
resolvedDefaults.vaultTokenConfigured
? "server env configured"
: "paste vault token"
}
/>
</Field>
@ -371,14 +440,21 @@ export function IntegrationsConsole({ defaults }: IntegrationsConsoleProps) {
<button
type="button"
onClick={() => {
void probe('vault')
void probe("vault");
}}
className="inline-flex items-center gap-2 rounded-full border border-[color:var(--color-surface-border)] px-4 py-2 text-sm font-medium text-[var(--color-text)] transition hover:border-[color:var(--color-primary-border)] hover:bg-[var(--color-surface-muted)]"
>
{loadingTarget === 'vault' ? <Loader2 className="h-4 w-4 animate-spin" /> : <CheckCircle2 className="h-4 w-4" />}
{loadingTarget === "vault" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
Vault
</button>
<StatusBadge title={probeResults.vault.ok ? 'vault reachable' : 'not checked'} ok={probeResults.vault.ok} />
<StatusBadge
title={probeResults.vault.ok ? "vault reachable" : "not checked"}
ok={probeResults.vault.ok}
/>
</div>
{probeResults.vault.error ? (
@ -394,12 +470,19 @@ export function IntegrationsConsole({ defaults }: IntegrationsConsoleProps) {
<Workflow className="h-5 w-5" />
</div>
<div className="space-y-1">
<h2 className="text-lg font-semibold text-[var(--color-heading)]">APISIX AI Gateway</h2>
<p className="text-sm text-[var(--color-text-subtle)]"></p>
<h2 className="text-lg font-semibold text-[var(--color-heading)]">
APISIX AI Gateway
</h2>
<p className="text-sm text-[var(--color-text-subtle)]">
</p>
</div>
</div>
<Field label="Gateway URL" hint="建议填写 OpenAI-compatible `/v1` 前缀所在地址。">
<Field
label="Gateway URL"
hint="建议填写 OpenAI-compatible `/v1` 前缀所在地址。"
>
<input
type="text"
value={apisixUrl}
@ -414,27 +497,39 @@ export function IntegrationsConsole({ defaults }: IntegrationsConsoleProps) {
value={apisixToken}
onChange={(event) => setApisixToken(event.target.value)}
className={inputClassName()}
placeholder={resolvedDefaults.apisixTokenConfigured ? 'server env configured' : 'paste ai gateway token'}
placeholder={
resolvedDefaults.apisixTokenConfigured
? "server env configured"
: "paste ai gateway token"
}
/>
</Field>
<div className="rounded-[var(--radius-xl)] border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)]/40 px-4 py-3 text-sm text-[var(--color-text-subtle)]">
console.svc.plus AI APISIX profile/file-driven
console.svc.plus AI
APISIX profile/file-driven
</div>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => {
void probe('apisix')
void probe("apisix");
}}
className="inline-flex items-center gap-2 rounded-full border border-[color:var(--color-surface-border)] px-4 py-2 text-sm font-medium text-[var(--color-text)] transition hover:border-[color:var(--color-primary-border)] hover:bg-[var(--color-surface-muted)]"
>
{loadingTarget === 'apisix' ? <Loader2 className="h-4 w-4 animate-spin" /> : <CheckCircle2 className="h-4 w-4" />}
{loadingTarget === "apisix" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
APISIX
</button>
<StatusBadge
title={probeResults.apisix.ok ? 'gateway reachable' : 'not checked'}
title={
probeResults.apisix.ok ? "gateway reachable" : "not checked"
}
ok={probeResults.apisix.ok}
/>
</div>
@ -447,5 +542,5 @@ export function IntegrationsConsole({ defaults }: IntegrationsConsoleProps) {
</Card>
</div>
</div>
)
);
}