feat: launch xworkmate workspace
This commit is contained in:
parent
01181d4385
commit
161350c608
69
README.md
69
README.md
@ -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`
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
27
src/app/xworkmate/page.tsx
Normal file
27
src/app/xworkmate/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
@ -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>
|
||||
)
|
||||
}
|
||||
1750
src/components/xworkmate/XWorkmateWorkspacePage.tsx
Normal file
1750
src/components/xworkmate/XWorkmateWorkspacePage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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 };
|
||||
};
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user