From 017c33d8f4c7f042a9997f2ea59d5c767dca11a1 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Fri, 29 May 2026 11:33:39 +0800 Subject: [PATCH] refactor: rebuild xworkmate bridge workspace --- .env.example | 5 + README.md | 17 +- src/app/api/xworkmate/bridge/route.ts | 76 + src/app/services/page.tsx | 4 +- src/app/xworkmate/page.tsx | 6 +- .../xworkmate/XWorkmateWorkspacePage.test.tsx | 161 +- .../xworkmate/XWorkmateWorkspacePage.tsx | 1567 +++++++---------- .../xworkmate/XWorkmateWorkspaceRoute.tsx | 91 +- 8 files changed, 815 insertions(+), 1112 deletions(-) create mode 100644 src/app/api/xworkmate/bridge/route.ts diff --git a/.env.example b/.env.example index e14b1f3..7b11974 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/README.md b/README.md index 4fd3ed3..931483d 100644 --- a/README.md +++ b/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,服务端 | + +以下变量用于旧助手和集成页的服务端默认值预填: | 变量 | 用途 | | ----------------------------- | ------------------------------------ | diff --git a/src/app/api/xworkmate/bridge/route.ts b/src/app/api/xworkmate/bridge/route.ts new file mode 100644 index 0000000..bd306a0 --- /dev/null +++ b/src/app/api/xworkmate/bridge/route.ts @@ -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 { + 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 { + 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", + }, + }); +} diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx index 2bbc566..1cb8020 100644 --- a/src/app/services/page.tsx +++ b/src/app/services/page.tsx @@ -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, }, diff --git a/src/app/xworkmate/page.tsx b/src/app/xworkmate/page.tsx index c02fdd9..035b552 100644 --- a/src/app/xworkmate/page.tsx +++ b/src/app/xworkmate/page.tsx @@ -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 (
}> - +
); diff --git a/src/components/xworkmate/XWorkmateWorkspacePage.test.tsx b/src/components/xworkmate/XWorkmateWorkspacePage.test.tsx index 1bdccab..cc7799f 100644 --- a/src/components/xworkmate/XWorkmateWorkspacePage.test.tsx +++ b/src/components/xworkmate/XWorkmateWorkspacePage.test.tsx @@ -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 ( -
- assistant-pane:{props.integrationsHref ?? "missing"} -
- ); - }, -})); - -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( - , - ); - - 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( - , - ); - - expect(screen.getByTestId("assistant-pane")).toBeInTheDocument(); - expect(assistantPaneMock).toHaveBeenCalledWith( - expect.objectContaining({ - integrationsHref: "/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 bridge workspace shell from the screenshot flow", async () => { + render(); + + 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(); + + 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(); + }); + }); }); diff --git a/src/components/xworkmate/XWorkmateWorkspacePage.tsx b/src/components/xworkmate/XWorkmateWorkspacePage.tsx index c80134a..0feca93 100644 --- a/src/components/xworkmate/XWorkmateWorkspacePage.tsx +++ b/src/components/xworkmate/XWorkmateWorkspacePage.tsx @@ -1,967 +1,724 @@ "use client"; -import { type ReactNode, useEffect, useMemo, useState } from "react"; -import type { LucideIcon } from "lucide-react"; import { - Bot, - Briefcase, - CheckCircle2, - ChevronRight, + type ChangeEvent, + type ReactNode, + useEffect, + useMemo, + useState, +} from "react"; +import { + ArrowUp, + ChevronDown, + ChevronLeft, ChevronsRight, Cloud, - Cpu, - Grip, + Copy, + File, + Folder, + Image as ImageIcon, KeyRound, - ListTodo, - MessageSquare, + Languages, + ListChecks, + Loader2, + Menu, + Pencil, Plus, - Puzzle, RefreshCw, Search, - Settings2, - Shield, - Sparkles, - UserCircle2, + Settings, + Sun, + X, + Zap, } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useLanguage } from "@/i18n/LanguageProvider"; -import type { IntegrationDefaults } from "@/lib/openclaw/types"; -import type { XWorkmateProfileResponse } from "@/lib/xworkmate/types"; import { cn } from "@/lib/utils"; -import { useOpenClawConsoleStore } from "@/state/openclawConsoleStore"; -import { - OpenClawAssistantPane, - type OpenClawAssistantViewState, -} from "@/components/openclaw/OpenClawAssistantPane"; -type WorkspaceDestination = - | "assistant" - | "tasks" - | "skills" - | "nodes" - | "agents" - | "mcpServer" - | "clawHub" - | "secrets" - | "aiGateway" - | "settings" - | "account"; - -type NavigationItem = { - key: WorkspaceDestination; - label: string; - icon: LucideIcon; +type BridgeRpcResult = { + success?: boolean; + status?: string; + output?: string; + summary?: string; + message?: string; + remoteWorkingDirectory?: string; + artifacts?: WorkspaceArtifact[]; + [key: string]: unknown; }; -type RailButtonProps = { - icon: LucideIcon; - label: string; - active: boolean; - onClick: () => void; -}; - -type DesktopChipProps = { - label: string; - icon?: LucideIcon; - active?: boolean; -}; - -type CounterBadgeProps = { - label: string; - value: number; -}; - -type SessionSidebarProps = { - isChinese: boolean; - searchValue: string; - onSearchChange: (value: string) => void; - onRefresh: () => void; - onCreateTask: () => void; - runningCount: number; - currentCount: number; - skillCount: number; - taskTitle: string; - taskPreview: string; - taskUpdatedLabel: string; - taskCount: number; - hasVisibleTask: boolean; - profileBadge?: string; -}; - -type WorkspaceHeaderProps = { - isChinese: boolean; - title: string; - statusLabel: string; - sessionLabel: string; - connectionLabel: string; -}; - -type GatewayEmptyStateProps = { - isChinese: boolean; - canManageIntegrations: boolean; - onPrimaryAction: () => void; - onSecondaryAction: () => void; - primaryActionLabel: string; - secondaryActionLabel: string; -}; - -type EmptyComposerProps = { - isChinese: boolean; - actionLabel: string; - onAction: () => void; -}; - -type PlaceholderPanelProps = { - isChinese: boolean; - sectionLabel: string; - onReturnHome: () => void; -}; - -function pickCopy(isChinese: boolean, zh: T, en: T): T { - return isChinese ? zh : en; -} - -function formatEndpoint(value: string, emptyLabel: string): string { - const trimmed = value.trim(); - if (!trimmed) { - return emptyLabel; - } - - try { - const normalized = trimmed.replace(/^wss?:\/\//, "https://"); - return new URL(normalized).host; - } catch { - return trimmed; - } -} - -function resolveTaskTitle( - label: string | undefined, - fallbackKey: string, - isChinese: boolean, -): string { - const trimmed = label?.trim() ?? ""; - const normalizedFallback = fallbackKey.trim().toLowerCase(); - const normalizedLabel = trimmed.toLowerCase(); - - if ( - !trimmed || - normalizedLabel === "main" || - normalizedLabel === "default task" || - normalizedFallback === "main" || - normalizedFallback === "agent:main:main" - ) { - return pickCopy(isChinese, "默认任务", "Default task"); - } - - return trimmed; -} - -function formatRelativeTime( - value: number | undefined, - isChinese: boolean, -): string { - if (!value) { - return pickCopy(isChinese, "刚刚", "Just now"); - } - - const elapsedMs = Math.max(0, Date.now() - value); - const elapsedMinutes = Math.floor(elapsedMs / 60000); - - if (elapsedMinutes < 1) { - return pickCopy(isChinese, "刚刚", "Just now"); - } - - if (elapsedMinutes < 60) { - return pickCopy( - isChinese, - `${elapsedMinutes} 分钟前`, - `${elapsedMinutes} min ago`, - ); - } - - const elapsedHours = Math.floor(elapsedMinutes / 60); - if (elapsedHours < 24) { - return pickCopy( - isChinese, - `${elapsedHours} 小时前`, - `${elapsedHours}h ago`, - ); - } - - const elapsedDays = Math.floor(elapsedHours / 24); - return pickCopy(isChinese, `${elapsedDays} 天前`, `${elapsedDays}d ago`); -} - -function buildNavigation(isChinese: boolean): { - primaryItems: NavigationItem[]; - workspaceItems: NavigationItem[]; - toolItems: NavigationItem[]; - footerItems: NavigationItem[]; -} { - return { - primaryItems: [ - { - key: "assistant", - label: pickCopy(isChinese, "主页", "Home"), - icon: ListTodo, - }, - { - key: "tasks", - label: pickCopy(isChinese, "任务", "Tasks"), - icon: Briefcase, - }, - { - key: "skills", - label: pickCopy(isChinese, "技能", "Skills"), - icon: Sparkles, - }, - ], - workspaceItems: [ - { - key: "nodes", - label: pickCopy(isChinese, "节点", "Nodes"), - icon: Grip, - }, - { - key: "agents", - label: pickCopy(isChinese, "代理", "Agents"), - icon: Bot, - }, - ], - toolItems: [ - { - key: "mcpServer", - label: "MCP Hub", - icon: Cpu, - }, - { - key: "clawHub", - label: "ClawHub", - icon: Puzzle, - }, - { - key: "secrets", - label: pickCopy(isChinese, "密钥", "Secrets"), - icon: KeyRound, - }, - { - key: "aiGateway", - label: "AI Gateway", - icon: Cloud, - }, - ], - footerItems: [ - { - key: "settings", - label: pickCopy(isChinese, "设置", "Settings"), - icon: Settings2, - }, - { - key: "account", - label: pickCopy(isChinese, "账号", "Account"), - icon: UserCircle2, - }, - ], +type BridgeRpcResponse = { + jsonrpc?: string; + id?: string; + result?: BridgeRpcResult; + error?: { + code?: number; + message?: string; + data?: unknown; }; +}; + +type PingResponse = { + status?: string; + version?: string; + tag?: string; + commit?: string; +}; + +type WorkspaceArtifact = { + name?: string; + path?: string; + url?: string; + mimeType?: string; + size?: number; +}; + +type TaskItem = { + id: string; + title: string; + preview: string; + updatedAt: number; + state: "idle" | "running" | "done" | "error"; + files: WorkspaceArtifact[]; +}; + +type ComposerFile = { + id: string; + name: string; + type: string; + size: number; +}; + +type XWorkmateWorkspacePageProps = { + initialPrompt?: string; + initialSessionKey?: string; +}; + +const SEED_TASKS: TaskItem[] = [ + { + id: "task-images", + title: "连续制作7张图片", + preview: "等待通过 bridge 重新提交任务。", + updatedAt: Date.now() - 55 * 60 * 1000, + state: "idle", + files: [], + }, + { + id: "task-new-1", + title: "新对话", + preview: "Bridge 响应读取中断,本轮结果未完成。请重新发送请求。", + updatedAt: Date.now() - 55 * 60 * 1000, + state: "idle", + files: [], + }, + { + id: "task-pdf", + title: "PDF制作", + preview: "完成了,PDF 已输出在任务工作区内。", + updatedAt: Date.now() - 65 * 60 * 1000, + state: "done", + files: [{ name: "result.pdf" }], + }, + { + id: "task-video", + title: "视频制作", + preview: "等待通过 bridge 重新提交任务。", + updatedAt: Date.now() - 55 * 60 * 1000, + state: "idle", + files: [], + }, + { + id: "task-new-2", + title: "新对话", + preview: "invalid handshake: first request must be connect", + updatedAt: Date.now() - 55 * 60 * 1000, + state: "idle", + files: [], + }, + { + id: "task-current", + title: "新对话", + preview: "", + updatedAt: Date.now() - 55 * 60 * 1000, + state: "idle", + files: [], + }, +]; + +function formatRelativeTime(value: number): string { + const elapsedMinutes = Math.max(0, Math.floor((Date.now() - value) / 60000)); + if (elapsedMinutes < 1) { + return "刚刚"; + } + if (elapsedMinutes < 60) { + return `${elapsedMinutes} 分钟前`; + } + const elapsedHours = Math.floor(elapsedMinutes / 60); + return `${elapsedHours} 小时前`; } -function RailButton({ - icon: Icon, - label, - active, - onClick, -}: RailButtonProps): ReactNode { +function makeId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function resultText(result: BridgeRpcResult | undefined): string { + if (!result) { + return ""; + } + return ( - + result.output?.trim() || + result.summary?.trim() || + result.message?.trim() || + JSON.stringify(result, null, 2) ); } -function DesktopChip({ - label, - icon: Icon, - active = false, -}: DesktopChipProps): ReactNode { - return ( -
- {Icon ? : null} - {label} -
- ); +async function callBridge( + payload: Record, +): Promise { + const response = await fetch("/api/xworkmate/bridge", { + method: "POST", + credentials: "include", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + const data = (await response.json()) as BridgeRpcResponse; + if (!response.ok) { + throw new Error( + data.error?.message || `Bridge request failed: ${response.status}`, + ); + } + + return data; } -function CounterBadge({ label, value }: CounterBadgeProps): ReactNode { - return ( -
- {label} - {value} -
- ); -} +async function pingBridge(): Promise { + const response = await fetch("/api/xworkmate/bridge?action=ping", { + credentials: "include", + cache: "no-store", + headers: { + Accept: "application/json", + }, + }); -function XWorkmateIconRail({ - navigation, - activeSection, - onSelect, - sidebarExpanded, - onToggleSidebar, -}: { - navigation: ReturnType; - activeSection: WorkspaceDestination; - onSelect: (section: WorkspaceDestination) => void; - sidebarExpanded: boolean; - onToggleSidebar: () => void; -}): ReactNode { - const groups = [ - navigation.primaryItems, - navigation.workspaceItems, - navigation.toolItems, - ]; + if (!response.ok) { + throw new Error(`Bridge ping failed: ${response.status}`); + } - return ( - - ); -} - -function XWorkmateSessionSidebar({ - isChinese, - searchValue, - onSearchChange, - onRefresh, - onCreateTask, - runningCount, - currentCount, - skillCount, - taskTitle, - taskPreview, - taskUpdatedLabel, - taskCount, - hasVisibleTask, - profileBadge, -}: SessionSidebarProps): ReactNode { - return ( - - ); -} - -function XWorkmateWorkspaceHeader({ - isChinese, - title, - statusLabel, - sessionLabel, - connectionLabel, -}: WorkspaceHeaderProps): ReactNode { - return ( -
-
-

- {title} -

-
- - - - -
-
- -
- - -
-
- ); -} - -function XWorkmateGatewayEmptyState({ - isChinese, - canManageIntegrations, - onPrimaryAction, - onSecondaryAction, - primaryActionLabel, - secondaryActionLabel, -}: GatewayEmptyStateProps): ReactNode { - const description = canManageIntegrations - ? pickCopy( - isChinese, - "请先在 Settings -> AI Gateway 中配置地址、API Key 和默认模型,然后继续当前任务。", - "Set the endpoint, API key, and default model in Settings -> AI Gateway before continuing this task.", - ) - : pickCopy( - isChinese, - "当前工作台使用共享连接配置。请联系管理员完成 AI Gateway 配置,然后回到当前任务继续。", - "This workspace uses a shared integration profile. Ask an administrator to finish the AI Gateway setup, then continue here.", - ); - - return ( -
-
-
- -
- -

- {pickCopy( - isChinese, - "先配置 AI Gateway", - "Configure AI Gateway first", - )} -

-

- {description} -

- -
- - -
-
-
- ); -} - -function XWorkmateEmptyComposer({ - isChinese, - actionLabel, - onAction, -}: EmptyComposerProps): ReactNode { - return ( -
-
-
- - -
- {pickCopy( - isChinese, - "输入后 XWorkmate 会沿用当前任务上下文持续处理。", - "XWorkmate will continue from the current task context after the gateway is configured.", - )} -
-
- -
-
- - {pickCopy(isChinese, "技能", "Skills")} -
-
- - {pickCopy(isChinese, "高", "High")} -
- - -
-
-
- ); -} - -function XWorkmatePlaceholderPanel({ - isChinese, - sectionLabel, - onReturnHome, -}: PlaceholderPanelProps): ReactNode { - return ( -
-
-
- -
-

- {sectionLabel} -

-

- {pickCopy( - isChinese, - "本次在线版优先复刻桌面端 assistant 首页,这个入口先保留导航位,不在当前改造范围内。", - "This pass focuses on the desktop-aligned assistant home. This destination stays as a navigation stub for now.", - )} -

- -
-
- ); + return (await response.json()) as PingResponse; } export function XWorkmateWorkspacePage({ - defaults, - profile, - scopeKey, - requestHost, initialPrompt = "", initialSessionKey = "", -}: { - defaults: IntegrationDefaults; - profile?: XWorkmateProfileResponse | null; - scopeKey: string; - requestHost?: string; - initialPrompt?: string; - initialSessionKey?: string; -}) { - const { language } = useLanguage(); - const isChinese = language === "zh"; - const router = useRouter(); - const [activeSection, setActiveSection] = - useState("assistant"); - const [sidebarExpanded, setSidebarExpanded] = useState(true); - const [searchValue, setSearchValue] = useState(""); - const [assistantState, setAssistantState] = - useState(null); +}: XWorkmateWorkspacePageProps): React.ReactNode { + const [tasks, setTasks] = useState(SEED_TASKS); + const [activeTaskId, setActiveTaskId] = useState("task-current"); + const [prompt, setPrompt] = useState(initialPrompt); + const [files, setFiles] = useState([]); + const [bridgeStatus, setBridgeStatus] = useState< + "checking" | "connected" | "error" + >("checking"); + const [bridgeVersion, setBridgeVersion] = useState(""); + const [rightPanelOpen, setRightPanelOpen] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [lastError, setLastError] = useState(""); + const [workingDirectory, setWorkingDirectory] = useState(""); - const setScope = useOpenClawConsoleStore((state) => state.setScope); - const applyDefaults = useOpenClawConsoleStore((state) => state.applyDefaults); - const setSelectedSessionKey = useOpenClawConsoleStore( - (state) => state.setSelectedSessionKey, + const activeTask = useMemo( + () => tasks.find((task) => task.id === activeTaskId) ?? tasks[0], + [activeTaskId, tasks], ); - const selectedSessionKey = useOpenClawConsoleStore( - (state) => state.selectedSessionKey, - ); - const openclawUrl = useOpenClawConsoleStore((state) => state.openclawUrl); - const vaultUrl = useOpenClawConsoleStore((state) => state.vaultUrl); - const apisixUrl = useOpenClawConsoleStore((state) => state.apisixUrl); + const currentFiles = activeTask?.files ?? []; + const sessionId = initialSessionKey || activeTaskId; useEffect(() => { - setScope(scopeKey, defaults); - applyDefaults(defaults); - }, [applyDefaults, defaults, scopeKey, setScope]); + let cancelled = false; - useEffect(() => { - if (!initialSessionKey.trim()) { + async function checkBridge() { + try { + const data = await pingBridge(); + if (cancelled) { + return; + } + setBridgeStatus(data.status === "ok" ? "connected" : "error"); + setBridgeVersion(data.version ?? data.tag ?? data.commit ?? ""); + } catch (error) { + if (!cancelled) { + setBridgeStatus("error"); + setLastError( + error instanceof Error ? error.message : "Bridge ping failed.", + ); + } + } + } + + void checkBridge(); + + return () => { + cancelled = true; + }; + }, []); + + const updateActiveTask = (partial: Partial) => { + setTasks((current) => + current.map((task) => + task.id === activeTaskId + ? { ...task, ...partial, updatedAt: Date.now() } + : task, + ), + ); + }; + + const createTask = () => { + const task: TaskItem = { + id: makeId("task"), + title: "新对话", + preview: "", + updatedAt: Date.now(), + state: "idle", + files: [], + }; + setTasks((current) => [task, ...current]); + setActiveTaskId(task.id); + setPrompt(""); + setLastError(""); + setWorkingDirectory(""); + }; + + const handleFileInput = (event: ChangeEvent) => { + const nextFiles = Array.from(event.target.files ?? []).map((file) => ({ + id: makeId("file"), + name: file.name, + type: file.type, + size: file.size, + })); + setFiles((current) => [...current, ...nextFiles]); + event.target.value = ""; + }; + + const submitPrompt = async () => { + const trimmed = prompt.trim(); + if (!trimmed && files.length === 0) { return; } - setSelectedSessionKey(initialSessionKey.trim()); - }, [initialSessionKey, setSelectedSessionKey]); + setIsSubmitting(true); + setLastError(""); + updateActiveTask({ + title: trimmed.slice(0, 24) || "附件任务", + preview: "Bridge 正在处理当前任务...", + state: "running", + }); - const navigation = useMemo(() => buildNavigation(isChinese), [isChinese]); - const activeItem = useMemo(() => { - const items = [ - ...navigation.primaryItems, - ...navigation.workspaceItems, - ...navigation.toolItems, - ...navigation.footerItems, - ]; - return items.find((item) => item.key === activeSection) ?? items[0]; - }, [activeSection, navigation]); + try { + const method = + activeTask?.state === "done" || activeTask?.state === "error" + ? "session.message" + : "session.start"; + const attachmentContext = files.length + ? `\n\n附件:${files.map((file) => file.name).join("、")}` + : ""; + const response = await callBridge({ + jsonrpc: "2.0", + id: makeId("rpc"), + method, + params: { + sessionId, + threadId: sessionId, + taskPrompt: `${trimmed}${attachmentContext}`.trim(), + workingDirectory: workingDirectory || undefined, + routing: { + routingMode: "explicit", + explicitExecutionTarget: "gateway", + preferredGatewayProviderId: "openclaw", + }, + }, + }); - const openclawEndpoint = openclawUrl || defaults.openclawUrl; - const connected = Boolean(openclawEndpoint.trim()); - const endpointLabel = formatEndpoint( - openclawEndpoint, - pickCopy(isChinese, "AI Gateway 未配置", "AI Gateway not configured"), - ); - const configuredCount = [ - openclawEndpoint, - vaultUrl || defaults.vaultUrl, - apisixUrl || defaults.apisixUrl, - ].filter((item) => item.trim().length > 0).length; + if (response.error) { + throw new Error(response.error.message || "Bridge returned an error."); + } - const integrationRoute = - profile?.profileScope === "tenant-shared" - ? "/xworkmate/admin" - : "/xworkmate/integrations"; - const canManageIntegrations = profile - ? Boolean(profile.canEditIntegrations) - : true; - const profileBadge = profile - ? [ - profile.edition === "shared_public" - ? pickCopy(isChinese, "共享版", "Shared edition") - : pickCopy(isChinese, "租户独享版", "Tenant edition"), - profile.membershipRole, - requestHost ?? "", - ] - .filter(Boolean) - .join(" · ") - : undefined; + const text = resultText(response.result); + const artifacts = Array.isArray(response.result?.artifacts) + ? response.result.artifacts + : []; + const remoteWorkingDirectory = + typeof response.result?.remoteWorkingDirectory === "string" + ? response.result.remoteWorkingDirectory + : ""; - const taskTitle = resolveTaskTitle( - assistantState?.selectedSessionLabel, - selectedSessionKey || initialSessionKey || "main", - isChinese, - ); - const lastMessage = - assistantState && assistantState.messages.length > 0 - ? assistantState.messages[assistantState.messages.length - 1] - : undefined; - const taskPreview = - lastMessage?.text?.trim() || - pickCopy( - isChinese, - "连接配置完成后,当前任务会在这里持续同步状态与结果。", - "Once the gateway is configured, the current task will keep syncing progress and results here.", - ); - const taskUpdatedLabel = formatRelativeTime( - lastMessage?.timestampMs, - isChinese, - ); - const runningCount = - assistantState?.connectionState === "connecting" || - Boolean(assistantState?.streamingText.trim()) - ? 1 - : 0; - const currentCount = - (assistantState?.messages.length ?? 0) > 0 || - Boolean(assistantState?.streamingText.trim()) - ? 1 - : 0; - const taskCount = searchValue.trim() - ? taskTitle.toLowerCase().includes(searchValue.trim().toLowerCase()) - ? 1 - : 0 - : 1; - const hasVisibleTask = taskCount > 0; - const statusLabel = connected - ? runningCount > 0 - ? pickCopy(isChinese, "运行中", "Running") - : pickCopy(isChinese, "当前任务", "Current task") - : pickCopy(isChinese, "排队中", "Queued"); - const connectionLabel = connected - ? `${pickCopy(isChinese, "仅 AI Gateway", "AI Gateway only")} · ${endpointLabel}` - : `${pickCopy(isChinese, "仅 AI Gateway", "AI Gateway only")} · ${pickCopy( - isChinese, - "AI Gateway 未配置", - "AI Gateway not configured", - )}`; - const primaryActionLabel = canManageIntegrations - ? pickCopy(isChinese, "配置 AI Gateway", "Configure AI Gateway") - : pickCopy(isChinese, "查看 AI Gateway", "View AI Gateway"); - const secondaryActionLabel = canManageIntegrations - ? pickCopy(isChinese, "打开 AI Gateway", "Open AI Gateway") - : pickCopy(isChinese, "等待管理员配置", "Await admin setup"); - - const openIntegrations = () => { - router.push(integrationRoute); - }; - - const resetToDefaultTask = () => { - setActiveSection("assistant"); - setSelectedSessionKey(""); + setWorkingDirectory(remoteWorkingDirectory || workingDirectory); + updateActiveTask({ + preview: text || "任务已提交,bridge 返回了空结果。", + state: response.result?.success === false ? "error" : "done", + files: artifacts, + }); + setPrompt(""); + setFiles([]); + } catch (error) { + const message = + error instanceof Error ? error.message : "Bridge request failed."; + setLastError(message); + updateActiveTask({ + preview: message, + state: "error", + }); + } finally { + setIsSubmitting(false); + } }; return ( -
-
+
+ + +
+
+ + + 渲染 + + + 已连接 · xworkmate-bridge.svc.plus + +
+ +
+
+

开始对话或运行任务

+

+ 输入需求后即可开始执行,结果会回到当前会话并同步到任务页。 +

+ +
+ + {activeTask?.preview ? ( +
+
+ {activeTask.state === "running" ? ( + + ) : null} + 当前任务结果 +
+
+                {activeTask.preview}
+              
+
+ ) : null} +
+ +
+
+
+ + + + Gateway + + + + + OpenClaw + + + +
+ + {files.length > 0 ? ( +
+ {files.map((file) => ( + + {file.type.startsWith("image/") ? ( + + ) : ( + + )} + {file.name} + + + ))} +
+ ) : null} + +