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
|
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.
|
||||||
|
|||||||
17
README.md
17
README.md
@ -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,服务端 |
|
||||||
|
|
||||||
|
以下变量用于旧助手和集成页的服务端默认值预填:
|
||||||
|
|
||||||
| 变量 | 用途 |
|
| 变量 | 用途 |
|
||||||
| ----------------------------- | ------------------------------------ |
|
| ----------------------------- | ------------------------------------ |
|
||||||
|
|||||||
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",
|
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,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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
@ -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}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user