refactor: rebuild xworkmate bridge workspace

This commit is contained in:
Haitao Pan 2026-05-29 11:33:39 +08:00
parent 76d3d2884f
commit 017c33d8f4
8 changed files with 815 additions and 1112 deletions

View File

@ -25,6 +25,11 @@ SERVER_SERVICE_URL=https://api.svc.plus
NEXT_PUBLIC_SERVER_SERVICE_URL=https://api.svc.plus NEXT_PUBLIC_SERVER_SERVICE_URL=https://api.svc.plus
SERVER_SERVICE_INTERNAL_URL= SERVER_SERVICE_INTERNAL_URL=
# XWorkmate bridge runtime
# Read server-side by /api/xworkmate/bridge. Do not expose the token as NEXT_PUBLIC_*.
BRIDGE_SERVER_URL=https://xworkmate-bridge.svc.plus
BRIDGE_AUTH_TOKEN=
# OpenClaw assistant integrations # OpenClaw assistant integrations
# Use environment variables to prefill the assistant and integrations page. # Use environment variables to prefill the assistant and integrations page.
# Values are read server-side and are not hardcoded into the UI. # Values are read server-side and are not hardcoded into the UI.

View File

@ -41,22 +41,29 @@ cp .env.example .env
## 主要入口 (Key Routes) ## 主要入口 (Key Routes)
- `/services`:服务导航页,保留现有控制台布局。 - `/services`:服务导航页,保留现有控制台布局。
- `/xworkmate`:原生 Next.js 的 XWorkmate 在线工作区,底层通过 OpenClaw gateway 接入。 - `/xworkmate`:原生 Next.js 的 XWorkmate 在线工作区,底层通过 `xworkmate-bridge``/acp/rpc` 接入。
- `/panel/api`:融合设置与集成页,用于配置和探测 OpenClaw Gateway、Vault Server、APISIX AI Gateway。 - `/panel/api`:融合设置与集成页,用于配置和探测 OpenClaw Gateway、Vault Server、APISIX AI Gateway。
## AI 助手与集成能力 (Assistant & Integrations) ## AI 助手与集成能力 (Assistant & Integrations)
当前主页 AI 辅助功能已经基于本仓库原生实现,核心行为如下: 当前主页 AI 辅助功能已经基于本仓库原生实现,核心行为如下:
- 侧栏助手模式保留现有交互方式,但底层改为对接 OpenClaw gateway - 侧栏助手模式保留现有交互方式,但 `/xworkmate` 主工作区直接对接 `xworkmate-bridge`
- 最大化助手页面统一收敛到 `/xworkmate`,旧的 `/services/openclaw` 只保留兼容跳转,不再继续使用旧的 control UI 套壳。 - 最大化助手页面统一收敛到 `/xworkmate`,旧的 `/services/openclaw` 只保留兼容跳转,不再继续使用旧的 control UI 套壳。
- 页面截图通过 assistant chat 附件模式发送,而不是单独的浏览器控制壳。 - 页面截图通过 assistant chat 附件模式发送,而不是单独的浏览器控制壳。
- `/panel/api` 提供 OpenClaw、Vault、APISIX 三类集成的默认值预填与连通性探测 - `/panel/api` 仍保留旧集成配置入口;`/xworkmate` 主路径不依赖它
- 网关地址与令牌从服务端环境变量读取,前端组件不硬编码敏感配置。 - bridge 地址与令牌从服务端环境变量读取,前端组件不硬编码敏感配置。
## 环境变量 (Environment Variables) ## 环境变量 (Environment Variables)
以下变量用于主页 AI 助手和集成页的服务端默认值预填: 以下变量用于 `/xworkmate` 主工作区的服务端 bridge 代理:
| 变量 | 用途 |
| ------------------- | ------------------------------------- |
| `BRIDGE_SERVER_URL` | XWorkmate bridge 服务根地址 |
| `BRIDGE_AUTH_TOKEN` | XWorkmate bridge bearer token服务端 |
以下变量用于旧助手和集成页的服务端默认值预填:
| 变量 | 用途 | | 变量 | 用途 |
| ----------------------------- | ------------------------------------ | | ----------------------------- | ------------------------------------ |

View File

@ -0,0 +1,76 @@
import type { NextRequest } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const DEFAULT_BRIDGE_SERVER_URL = "https://xworkmate-bridge.svc.plus";
function bridgeServerUrl(): string {
return (
process.env.BRIDGE_SERVER_URL?.trim().replace(/\/+$/, "") ||
DEFAULT_BRIDGE_SERVER_URL
);
}
function bridgeAuthToken(): string {
return process.env.BRIDGE_AUTH_TOKEN?.trim() ?? "";
}
function jsonError(message: string, status = 500): Response {
return Response.json({ error: { message } }, { status });
}
function bridgeHeaders(): HeadersInit {
const token = bridgeAuthToken();
return {
Accept: "application/json",
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
export async function GET(request: NextRequest): Promise<Response> {
const action = request.nextUrl.searchParams.get("action") ?? "ping";
if (action !== "ping") {
return jsonError("Unsupported xworkmate bridge action.", 400);
}
const response = await fetch(`${bridgeServerUrl()}/api/ping`, {
cache: "no-store",
headers: bridgeHeaders(),
});
const body = await response.text();
return new Response(body, {
status: response.status,
headers: {
"Content-Type":
response.headers.get("content-type") ?? "application/json",
},
});
}
export async function POST(request: NextRequest): Promise<Response> {
let payload: unknown;
try {
payload = await request.json();
} catch {
return jsonError("Invalid JSON body.", 400);
}
const response = await fetch(`${bridgeServerUrl()}/acp/rpc`, {
method: "POST",
cache: "no-store",
headers: bridgeHeaders(),
body: JSON.stringify(payload),
});
const body = await response.text();
return new Response(body, {
status: response.status,
headers: {
"Content-Type":
response.headers.get("content-type") ?? "application/json",
},
});
}

View File

@ -214,8 +214,8 @@ export default function ServicesPage() {
key: "xworkmate", key: "xworkmate",
name: "XWorkmate", name: "XWorkmate",
description: isChinese description: isChinese
? "在线版 XWorkmate 工作区,底层由 OpenClaw gateway 驱动。" ? "在线版 XWorkmate 工作区,底层由 xworkmate-bridge 驱动。"
: "Online XWorkmate workspace powered by the OpenClaw gateway.", : "Online XWorkmate workspace powered by xworkmate-bridge.",
href: "/xworkmate", href: "/xworkmate",
icon: Command, icon: Command,
}, },

View File

@ -4,19 +4,17 @@ import { Suspense } from "react";
import { XWorkmateLoading } from "@/app/xworkmate/XWorkmateLoading"; import { XWorkmateLoading } from "@/app/xworkmate/XWorkmateLoading";
import { XWorkmateWorkspaceRoute } from "@/components/xworkmate/XWorkmateWorkspaceRoute"; import { XWorkmateWorkspaceRoute } from "@/components/xworkmate/XWorkmateWorkspaceRoute";
import { getConsoleIntegrationDefaults } from "@/server/consoleIntegrations";
export const metadata = { export const metadata = {
title: "XWorkmate", title: "XWorkmate",
description: "Online XWorkmate workspace powered by OpenClaw gateway", description: "Online XWorkmate workspace powered by xworkmate-bridge",
}; };
export default function XWorkmatePage() { export default function XWorkmatePage() {
const defaults = getConsoleIntegrationDefaults();
return ( return (
<div className="h-[calc(100vh-var(--app-shell-nav-offset))] w-full"> <div className="h-[calc(100vh-var(--app-shell-nav-offset))] w-full">
<Suspense fallback={<XWorkmateLoading />}> <Suspense fallback={<XWorkmateLoading />}>
<XWorkmateWorkspaceRoute defaults={defaults} /> <XWorkmateWorkspaceRoute />
</Suspense> </Suspense>
</div> </div>
); );

View File

@ -1,120 +1,63 @@
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { IntegrationDefaults } from "@/lib/openclaw/types";
import { XWorkmateWorkspacePage } from "@/components/xworkmate/XWorkmateWorkspacePage"; import { XWorkmateWorkspacePage } from "@/components/xworkmate/XWorkmateWorkspacePage";
const pushMock = vi.fn();
const assistantPaneMock = vi.fn();
const mockStore = {
setScope: vi.fn(),
applyDefaults: vi.fn(),
setSelectedSessionKey: vi.fn(),
selectedSessionKey: "",
openclawUrl: "",
vaultUrl: "",
apisixUrl: "",
};
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: pushMock,
}),
}));
vi.mock("@/i18n/LanguageProvider", () => ({
useLanguage: () => ({
language: "zh",
}),
}));
vi.mock("@/state/openclawConsoleStore", () => ({
useOpenClawConsoleStore: (selector: (state: typeof mockStore) => unknown) =>
selector(mockStore),
}));
vi.mock("@/components/openclaw/OpenClawAssistantPane", () => ({
OpenClawAssistantPane: (props: { integrationsHref?: string }) => {
assistantPaneMock(props);
return (
<div data-testid="assistant-pane">
assistant-pane:{props.integrationsHref ?? "missing"}
</div>
);
},
}));
const emptyDefaults: IntegrationDefaults = {
openclawUrl: "",
openclawOrigin: "",
openclawTokenConfigured: false,
vaultUrl: "",
vaultNamespace: "",
vaultTokenConfigured: false,
vaultSecretPath: "",
vaultSecretKey: "",
apisixUrl: "",
apisixTokenConfigured: false,
};
describe("XWorkmateWorkspacePage", () => { describe("XWorkmateWorkspacePage", () => {
beforeEach(() => { beforeEach(() => {
pushMock.mockReset(); vi.restoreAllMocks();
assistantPaneMock.mockReset(); vi.stubGlobal(
mockStore.setScope.mockReset(); "fetch",
mockStore.applyDefaults.mockReset(); vi.fn(async (input: RequestInfo | URL) => {
mockStore.setSelectedSessionKey.mockReset(); const url = String(input);
mockStore.selectedSessionKey = ""; if (url.includes("action=ping")) {
mockStore.openclawUrl = ""; return Response.json({
mockStore.vaultUrl = ""; status: "ok",
mockStore.apisixUrl = ""; version: "test-version",
}); });
}
it("renders the desktop-style AI Gateway empty state and routes to xworkmate integrations", () => { return Response.json({
render( jsonrpc: "2.0",
<XWorkmateWorkspacePage id: "test",
defaults={emptyDefaults} result: {
profile={null} success: true,
scopeKey="test-scope" output: "bridge task ok",
/>, artifacts: [{ name: "result.pdf" }],
); remoteWorkingDirectory: "/tmp/xworkmate",
},
expect(screen.getByText("先配置 AI Gateway")).toBeInTheDocument();
expect(
screen.getByText(
/请先在 Settings -> AI Gateway 中配置地址、API Key 和默认模型/,
),
).toBeInTheDocument();
fireEvent.click(
screen.getAllByRole("button", { name: "配置 AI Gateway" })[0],
);
expect(pushMock).toHaveBeenCalledWith("/xworkmate/integrations");
}); });
it("renders the assistant pane when a gateway target is available", () => {
const connectedDefaults: IntegrationDefaults = {
...emptyDefaults,
openclawUrl: "wss://gateway.example.com",
openclawTokenConfigured: true,
};
mockStore.openclawUrl = "wss://gateway.example.com";
render(
<XWorkmateWorkspacePage
defaults={connectedDefaults}
profile={null}
scopeKey="test-scope"
/>,
);
expect(screen.getByTestId("assistant-pane")).toBeInTheDocument();
expect(assistantPaneMock).toHaveBeenCalledWith(
expect.objectContaining({
integrationsHref: "/xworkmate/integrations",
}), }),
); );
}); });
it("renders the bridge workspace shell from the screenshot flow", async () => {
render(<XWorkmateWorkspacePage />);
expect(screen.getByText("XWorkmate")).toBeInTheDocument();
expect(screen.getByPlaceholderText("搜索任务")).toBeInTheDocument();
expect(screen.getByText("开始对话或运行任务")).toBeInTheDocument();
expect(
screen.getByText("已连接 · xworkmate-bridge.svc.plus"),
).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText(/Bridge: connected/)).toBeInTheDocument();
});
});
it("submits a prompt through the bridge proxy and renders the result", async () => {
render(<XWorkmateWorkspacePage />);
fireEvent.change(screen.getByPlaceholderText(/输入需求/), {
target: { value: "请只回复 ok" },
});
fireEvent.click(screen.getByRole("button", { name: "提交" }));
await waitFor(() => {
expect(screen.getAllByText("bridge task ok").length).toBeGreaterThan(0);
expect(screen.getByText("/tmp/xworkmate")).toBeInTheDocument();
expect(screen.getByText("result.pdf")).toBeInTheDocument();
});
});
}); });

File diff suppressed because it is too large Load Diff

View File

@ -1,100 +1,17 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import type { ReactNode } from "react";
import type { IntegrationDefaults } from "@/lib/openclaw/types";
import { useUserStore } from "@/lib/userStore";
import { normalizeXWorkmateHost } from "@/lib/xworkmate/host";
import {
buildXWorkmateScopeKey,
toXWorkmateIntegrationDefaults,
type XWorkmateProfileResponse,
} from "@/lib/xworkmate/types";
import { XWorkmateWorkspacePage } from "@/components/xworkmate/XWorkmateWorkspacePage"; import { XWorkmateWorkspacePage } from "@/components/xworkmate/XWorkmateWorkspacePage";
type XWorkmateWorkspaceRouteProps = { export function XWorkmateWorkspaceRoute(): ReactNode {
defaults: IntegrationDefaults;
};
async function fetchProfile(): Promise<XWorkmateProfileResponse | null> {
const response = await fetch("/api/xworkmate/profile", {
credentials: "include",
cache: "no-store",
headers: {
Accept: "application/json",
},
});
if (response.status === 401) {
return null;
}
if (!response.ok) {
throw new Error(`xworkmate_profile_failed:${response.status}`);
}
return (await response.json()) as XWorkmateProfileResponse;
}
export function XWorkmateWorkspaceRoute({
defaults,
}: XWorkmateWorkspaceRouteProps): React.ReactNode {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const sessionUser = useUserStore((state) => state.user);
const [profile, setProfile] = useState<XWorkmateProfileResponse | null>(null);
const requestHost = useMemo(() => {
if (typeof window === "undefined") {
return "";
}
return normalizeXWorkmateHost(window.location.host);
}, []);
useEffect(() => {
let cancelled = false;
async function loadProfile() {
try {
const nextProfile = await fetchProfile();
if (!cancelled) {
setProfile(nextProfile);
}
} catch (error) {
console.error("Failed to load xworkmate profile", error);
if (!cancelled) {
setProfile(null);
}
}
}
void loadProfile();
return () => {
cancelled = true;
};
}, []);
const resolvedDefaults = profile
? toXWorkmateIntegrationDefaults(profile)
: defaults;
const scopeKey = buildXWorkmateScopeKey(
profile,
sessionUser?.id ?? sessionUser?.uuid ?? null,
requestHost,
);
const initialPrompt = searchParams.get("prompt") ?? "";
const initialSessionKey = searchParams.get("sessionKey") ?? "";
return ( return (
<XWorkmateWorkspacePage <XWorkmateWorkspacePage
defaults={resolvedDefaults} initialPrompt={searchParams.get("prompt") ?? ""}
profile={profile} initialSessionKey={searchParams.get("sessionKey") ?? ""}
initialPrompt={initialPrompt}
initialSessionKey={initialSessionKey}
requestHost={requestHost}
scopeKey={scopeKey}
/> />
); );
} }