refactor: rebuild xworkmate bridge workspace
This commit is contained in:
parent
76d3d2884f
commit
017c33d8f4
@ -25,6 +25,11 @@ SERVER_SERVICE_URL=https://api.svc.plus
|
||||
NEXT_PUBLIC_SERVER_SERVICE_URL=https://api.svc.plus
|
||||
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
|
||||
# Use environment variables to prefill the assistant and integrations page.
|
||||
# Values are read server-side and are not hardcoded into the UI.
|
||||
|
||||
17
README.md
17
README.md
@ -41,22 +41,29 @@ cp .env.example .env
|
||||
## 主要入口 (Key Routes)
|
||||
|
||||
- `/services`:服务导航页,保留现有控制台布局。
|
||||
- `/xworkmate`:原生 Next.js 的 XWorkmate 在线工作区,底层通过 OpenClaw gateway 接入。
|
||||
- `/xworkmate`:原生 Next.js 的 XWorkmate 在线工作区,底层通过 `xworkmate-bridge` 的 `/acp/rpc` 接入。
|
||||
- `/panel/api`:融合设置与集成页,用于配置和探测 OpenClaw Gateway、Vault Server、APISIX AI Gateway。
|
||||
|
||||
## AI 助手与集成能力 (Assistant & Integrations)
|
||||
|
||||
当前主页 AI 辅助功能已经基于本仓库原生实现,核心行为如下:
|
||||
|
||||
- 侧栏助手模式保留现有交互方式,但底层改为对接 OpenClaw gateway。
|
||||
- 侧栏助手模式保留现有交互方式,但 `/xworkmate` 主工作区直接对接 `xworkmate-bridge`。
|
||||
- 最大化助手页面统一收敛到 `/xworkmate`,旧的 `/services/openclaw` 只保留兼容跳转,不再继续使用旧的 control UI 套壳。
|
||||
- 页面截图通过 assistant chat 附件模式发送,而不是单独的浏览器控制壳。
|
||||
- `/panel/api` 提供 OpenClaw、Vault、APISIX 三类集成的默认值预填与连通性探测。
|
||||
- 网关地址与令牌从服务端环境变量读取,前端组件不硬编码敏感配置。
|
||||
- `/panel/api` 仍保留旧集成配置入口;`/xworkmate` 主路径不依赖它。
|
||||
- bridge 地址与令牌从服务端环境变量读取,前端组件不硬编码敏感配置。
|
||||
|
||||
## 环境变量 (Environment Variables)
|
||||
|
||||
以下变量用于主页 AI 助手和集成页的服务端默认值预填:
|
||||
以下变量用于 `/xworkmate` 主工作区的服务端 bridge 代理:
|
||||
|
||||
| 变量 | 用途 |
|
||||
| ------------------- | ------------------------------------- |
|
||||
| `BRIDGE_SERVER_URL` | XWorkmate bridge 服务根地址 |
|
||||
| `BRIDGE_AUTH_TOKEN` | XWorkmate bridge bearer token,服务端 |
|
||||
|
||||
以下变量用于旧助手和集成页的服务端默认值预填:
|
||||
|
||||
| 变量 | 用途 |
|
||||
| ----------------------------- | ------------------------------------ |
|
||||
|
||||
76
src/app/api/xworkmate/bridge/route.ts
Normal file
76
src/app/api/xworkmate/bridge/route.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -214,8 +214,8 @@ export default function ServicesPage() {
|
||||
key: "xworkmate",
|
||||
name: "XWorkmate",
|
||||
description: isChinese
|
||||
? "在线版 XWorkmate 工作区,底层由 OpenClaw gateway 驱动。"
|
||||
: "Online XWorkmate workspace powered by the OpenClaw gateway.",
|
||||
? "在线版 XWorkmate 工作区,底层由 xworkmate-bridge 驱动。"
|
||||
: "Online XWorkmate workspace powered by xworkmate-bridge.",
|
||||
href: "/xworkmate",
|
||||
icon: Command,
|
||||
},
|
||||
|
||||
@ -4,19 +4,17 @@ import { Suspense } from "react";
|
||||
|
||||
import { XWorkmateLoading } from "@/app/xworkmate/XWorkmateLoading";
|
||||
import { XWorkmateWorkspaceRoute } from "@/components/xworkmate/XWorkmateWorkspaceRoute";
|
||||
import { getConsoleIntegrationDefaults } from "@/server/consoleIntegrations";
|
||||
|
||||
export const metadata = {
|
||||
title: "XWorkmate",
|
||||
description: "Online XWorkmate workspace powered by OpenClaw gateway",
|
||||
description: "Online XWorkmate workspace powered by xworkmate-bridge",
|
||||
};
|
||||
|
||||
export default function XWorkmatePage() {
|
||||
const defaults = getConsoleIntegrationDefaults();
|
||||
return (
|
||||
<div className="h-[calc(100vh-var(--app-shell-nav-offset))] w-full">
|
||||
<Suspense fallback={<XWorkmateLoading />}>
|
||||
<XWorkmateWorkspaceRoute defaults={defaults} />
|
||||
<XWorkmateWorkspaceRoute />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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 type { IntegrationDefaults } from "@/lib/openclaw/types";
|
||||
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", () => {
|
||||
beforeEach(() => {
|
||||
pushMock.mockReset();
|
||||
assistantPaneMock.mockReset();
|
||||
mockStore.setScope.mockReset();
|
||||
mockStore.applyDefaults.mockReset();
|
||||
mockStore.setSelectedSessionKey.mockReset();
|
||||
mockStore.selectedSessionKey = "";
|
||||
mockStore.openclawUrl = "";
|
||||
mockStore.vaultUrl = "";
|
||||
mockStore.apisixUrl = "";
|
||||
vi.restoreAllMocks();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("action=ping")) {
|
||||
return Response.json({
|
||||
status: "ok",
|
||||
version: "test-version",
|
||||
});
|
||||
}
|
||||
|
||||
it("renders the desktop-style AI Gateway empty state and routes to xworkmate integrations", () => {
|
||||
render(
|
||||
<XWorkmateWorkspacePage
|
||||
defaults={emptyDefaults}
|
||||
profile={null}
|
||||
scopeKey="test-scope"
|
||||
/>,
|
||||
);
|
||||
|
||||
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");
|
||||
return Response.json({
|
||||
jsonrpc: "2.0",
|
||||
id: "test",
|
||||
result: {
|
||||
success: true,
|
||||
output: "bridge task ok",
|
||||
artifacts: [{ name: "result.pdf" }],
|
||||
remoteWorkingDirectory: "/tmp/xworkmate",
|
||||
},
|
||||
});
|
||||
|
||||
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
@ -1,100 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
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";
|
||||
|
||||
type XWorkmateWorkspaceRouteProps = {
|
||||
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 {
|
||||
export function XWorkmateWorkspaceRoute(): ReactNode {
|
||||
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 (
|
||||
<XWorkmateWorkspacePage
|
||||
defaults={resolvedDefaults}
|
||||
profile={profile}
|
||||
initialPrompt={initialPrompt}
|
||||
initialSessionKey={initialSessionKey}
|
||||
requestHost={requestHost}
|
||||
scopeKey={scopeKey}
|
||||
initialPrompt={searchParams.get("prompt") ?? ""}
|
||||
initialSessionKey={searchParams.get("sessionKey") ?? ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user