From 047d672f05f6147b6f1306c154c655c19ae23941 Mon Sep 17 00:00:00 2001 From: yunpeng7 Date: Thu, 28 May 2026 20:08:02 +0800 Subject: [PATCH] feat(devices): add cloud device connection controls --- backend/app/api/endpoints/devices.py | 97 +++ .../ai-devices/local-device-support.md | 30 +- .../ai-devices/local-device-support.md | 30 +- wework/src/api/devices.test.ts | 20 + wework/src/api/devices.ts | 61 +- .../layout/DesktopWorkbenchLayout.test.tsx | 47 +- .../settings/AddCloudDeviceDialog.tsx | 106 +++ .../settings/ConnectionsSettingsPage.test.tsx | 130 +++ .../settings/ConnectionsSettingsPage.tsx | 744 ++++++++++++++---- wework/src/pages/PluginManagementPage.tsx | 45 +- wework/src/pages/PluginsPage.tsx | 45 +- wework/src/types/devices.ts | 41 + wework/tailwind.config.js | 2 +- wework/tsconfig.app.json | 5 +- wework/tsconfig.json | 4 +- wework/vite.config.ts | 1 + 16 files changed, 1221 insertions(+), 187 deletions(-) create mode 100644 wework/src/api/devices.test.ts create mode 100644 wework/src/components/settings/AddCloudDeviceDialog.tsx create mode 100644 wework/src/components/settings/ConnectionsSettingsPage.test.tsx create mode 100644 wework/src/types/devices.ts diff --git a/backend/app/api/endpoints/devices.py b/backend/app/api/endpoints/devices.py index 5c3453d62e..3e17a223bf 100644 --- a/backend/app/api/endpoints/devices.py +++ b/backend/app/api/endpoints/devices.py @@ -438,3 +438,100 @@ async def execute_device_command( result.get("duration"), ) return DeviceCommandResponse(**result) + + +# ==================== Device Session Endpoints ==================== + +DEFAULT_DEVICE_SESSION_PATH = "/home/ubuntu/.wegent-executor/workspace" + + +class DeviceSessionResponse(BaseModel): + """Response model for device session creation.""" + + session_id: str = Field(..., description="Unique session identifier") + device_id: str = Field(..., description="Target device ID") + type: str = Field(..., description="Session type (terminal or code_server)") + path: str = Field(..., description="Working directory path") + url: str = Field(default="", description="Browser-accessible session URL") + + +@router.post("/{device_id}/terminal", response_model=DeviceSessionResponse) +async def start_device_terminal( + device_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(security.get_current_user), +): + """Start a terminal session on a device. + + Sends a Socket.IO RPC to the online device executor to start a ttyd + terminal session. Requires the device to be online. + """ + from app.services.device.session_service import ( + DeviceSessionError, + local_device_session_service, + ) + + try: + result = await local_device_session_service.start_session( + db=db, + user_id=current_user.id, + device_id=device_id, + project_id=0, + session_type="terminal", + path=DEFAULT_DEVICE_SESSION_PATH, + create_if_missing=True, + ) + except DeviceSessionError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) + + return DeviceSessionResponse( + session_id=result.get("session_id", ""), + device_id=result.get("device_id", device_id), + type="terminal", + path=result.get("path", DEFAULT_DEVICE_SESSION_PATH), + url=result.get("url", ""), + ) + + +@router.post("/{device_id}/code-server", response_model=DeviceSessionResponse) +async def start_device_code_server( + device_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(security.get_current_user), +): + """Start a code-server (IDE) session on a device. + + Sends a Socket.IO RPC to the online device executor to start a + code-server session. Requires the device to be online. + """ + from app.services.device.session_service import ( + DeviceSessionError, + local_device_session_service, + ) + + try: + result = await local_device_session_service.start_session( + db=db, + user_id=current_user.id, + device_id=device_id, + project_id=0, + session_type="code_server", + path=DEFAULT_DEVICE_SESSION_PATH, + create_if_missing=True, + ) + except DeviceSessionError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) + + return DeviceSessionResponse( + session_id=result.get("session_id", ""), + device_id=result.get("device_id", device_id), + type="code_server", + path=result.get("path", DEFAULT_DEVICE_SESSION_PATH), + url=result.get("url", ""), + ) diff --git a/docs/en/user-guide/ai-devices/local-device-support.md b/docs/en/user-guide/ai-devices/local-device-support.md index bddfebb5a1..9e7146204a 100644 --- a/docs/en/user-guide/ai-devices/local-device-support.md +++ b/docs/en/user-guide/ai-devices/local-device-support.md @@ -234,7 +234,31 @@ Simply change the device selection before sending each message. Access your devices through: 1. **Device Selector**: Quick access in chat interface -2. **API**: `GET /devices` for programmatic access +2. **Settings Page**: Go to **Settings** → **Connections** to view connectable devices +3. **API**: `GET /devices` for programmatic access + +### Managing Cloud Devices + +The **Settings** → **Connections** page lists Claude Code cloud devices that the current account can connect to. It only shows devices with `device_type=cloud` and `bind_shell=claudecode`, and displays online status, executor version, CPU, memory, and disk usage. + +When no cloud device exists, click **Add** to create one. After the create request returns, the page keeps a "cloud device creating" notice visible. Initialization usually takes 2-3 minutes, and the device appears in the list automatically when it comes online. + +Online cloud devices can open interactive sessions directly: + +| Action | Backend API | Description | +|--------|-------------|-------------| +| **Terminal** | `POST /api/devices/{device_id}/terminal` | Starts ttyd in the default working directory `/home/ubuntu/.wegent-executor/workspace` | +| **IDE** | `POST /api/devices/{device_id}/code-server` | Opens a code-server session | + +The returned URL includes a short-lived session token and is exposed through the device-side session gateway. Terminal and IDE buttons are disabled while the device is offline. + +The more menu contains lower-frequency management actions: + +| Action | Description | +|--------|-------------| +| **Rename** | Click the device name or edit icon; the list refreshes after saving | +| **Restart Device** | Requires confirmation; the device briefly goes offline and active connections may be interrupted | +| **Delete Device** | Requires confirmation; the cloud resources are released | ### Device Information @@ -244,6 +268,8 @@ Each device shows: |-------|-------------| | **Name** | Device hostname (e.g., "Darwin - MacBook-Pro.local") | | **Status** | Online/Offline indicator | +| **Version** | Executor version, when available | +| **Resource Usage** | CPU, memory, and disk usage, when reported by the device | | **Slots** | Concurrent task capacity (X/5) | | **Default** | Star indicator if set as default | @@ -255,7 +281,7 @@ Each device shows: | **Remove Default** | Click star again on current default | | **Delete Device** | Click delete icon | -> **Note**: Deleting a device only removes the registration. If the device reconnects, it will automatically re-register. +> **Note**: Deleting a local device only removes the registration. If the device reconnects, it will automatically re-register. Deleting a cloud device from the Connections settings page releases the corresponding cloud resources. ### Offline Device Handling diff --git a/docs/zh/user-guide/ai-devices/local-device-support.md b/docs/zh/user-guide/ai-devices/local-device-support.md index c23146eb16..bdd803c4da 100644 --- a/docs/zh/user-guide/ai-devices/local-device-support.md +++ b/docs/zh/user-guide/ai-devices/local-device-support.md @@ -236,7 +236,31 @@ EXECUTOR_MODE=local wegent-executor 通过以下方式访问您的设备: 1. **设备选择器**:聊天界面中快速访问 -2. **API**:`GET /devices` 用于程序化访问 +2. **设置页**:进入 **设置** → **连接** 查看可连接设备 +3. **API**:`GET /devices` 用于程序化访问 + +### 管理云设备 + +**设置** → **连接** 页面会列出当前账号可连接的 Claude Code 云设备。页面仅展示 `device_type=cloud` 且 `bind_shell=claudecode` 的设备,并显示在线状态、executor 版本、CPU、内存和磁盘使用率。 + +当没有云设备时,点击 **添加** 可以创建一台新的云设备。创建请求返回后,页面会保留“云设备创建中”的提示;初始化通常需要 2-3 分钟,设备上线后会自动出现在列表中。 + +在线云设备支持直接打开交互式会话: + +| 操作 | 后端接口 | 说明 | +|------|----------|------| +| **终端** | `POST /api/devices/{device_id}/terminal` | 在默认工作目录 `/home/ubuntu/.wegent-executor/workspace` 启动 ttyd | +| **IDE** | `POST /api/devices/{device_id}/code-server` | 打开 code-server 会话 | + +返回的访问地址带有短期 session token,并通过设备侧 session gateway 暴露。设备离线时,终端和 IDE 按钮不可用。 + +更多菜单提供低频管理操作: + +| 操作 | 说明 | +|------|------| +| **重命名** | 点击设备名称或编辑图标,保存后会刷新列表 | +| **重启设备** | 需要二次确认;设备会短暂离线,进行中的连接可能中断 | +| **删除设备** | 需要二次确认;云资源会被释放 | ### 设备信息 @@ -246,6 +270,8 @@ EXECUTOR_MODE=local wegent-executor |------|------| | **名称** | 设备主机名(如 "Darwin - MacBook-Pro.local") | | **状态** | 在线/离线指示器 | +| **版本** | executor 版本(如适用) | +| **资源使用率** | CPU、内存、磁盘使用率(如设备上报) | | **槽位** | 并发任务容量(X/5) | | **默认** | 如果设为默认则显示星号 | @@ -257,7 +283,7 @@ EXECUTOR_MODE=local wegent-executor | **取消默认** | 再次点击当前默认设备的星号 | | **删除设备** | 点击删除图标 | -> **注意**:删除设备只是移除注册记录。如果设备重新连接,它会自动重新注册。 +> **注意**:本地设备的删除只是移除注册记录。如果设备重新连接,它会自动重新注册。云设备在连接设置页删除时会释放对应云资源。 ### 离线设备处理 diff --git a/wework/src/api/devices.test.ts b/wework/src/api/devices.test.ts new file mode 100644 index 0000000000..87e718dec2 --- /dev/null +++ b/wework/src/api/devices.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test, vi } from 'vitest' +import { createDeviceApi } from './devices' +import type { HttpClient } from './http' + +describe('createDeviceApi', () => { + test('uses cloud device endpoints for restart and delete actions', async () => { + const client = { + delete: vi.fn().mockResolvedValue({ message: 'deleted' }), + post: vi.fn().mockResolvedValue({ message: 'restart sent' }), + } as unknown as HttpClient + + const api = createDeviceApi(client) + + await api.restartCloudDevice('device/1') + await api.deleteCloudDevice('device/1') + + expect(client.post).toHaveBeenCalledWith('/cloud-devices/device%2F1/restart') + expect(client.delete).toHaveBeenCalledWith('/cloud-devices/device%2F1') + }) +}) diff --git a/wework/src/api/devices.ts b/wework/src/api/devices.ts index 713eaf341f..cf6c2e7519 100644 --- a/wework/src/api/devices.ts +++ b/wework/src/api/devices.ts @@ -1,17 +1,23 @@ -import type { DeviceCommandResponse, DeviceInfo } from '@/types/api' +import type { DeviceCommandResponse } from '@/types/api' +import type { + CloudDeviceResponse, + DeviceInfo, + DeviceListResponse, + DeviceSessionResponse, +} from '@/types/devices' import type { HttpClient } from './http' -interface DeviceListResponse { - items: DeviceInfo[] - total: number -} - export function createDeviceApi(client: HttpClient) { + async function fetchDevices(): Promise { + const response = await client.get('/devices') + return response.items + } + return { - async listDevices(): Promise { - const response = await client.get('/devices') - return response.items - }, + listDevices: fetchDevices, + + getAllDevices: fetchDevices, + async listDirectories(deviceId: string, path: string): Promise { const response = await client.post( `/devices/${encodeURIComponent(deviceId)}/commands`, @@ -27,5 +33,40 @@ export function createDeviceApi(client: HttpClient) { } return Array.isArray(response.stdout) ? response.stdout : [] }, + + async startTerminal(deviceId: string): Promise { + return client.post( + `/devices/${encodeURIComponent(deviceId)}/terminal`, + ) + }, + + async startCodeServer(deviceId: string): Promise { + return client.post( + `/devices/${encodeURIComponent(deviceId)}/code-server`, + ) + }, + + async createCloudDevice(): Promise { + return client.post('/cloud-devices') + }, + + async restartCloudDevice(deviceId: string): Promise<{ message: string }> { + return client.post<{ message: string }>( + `/cloud-devices/${encodeURIComponent(deviceId)}/restart`, + ) + }, + + async deleteCloudDevice(deviceId: string): Promise<{ message: string }> { + return client.delete<{ message: string }>( + `/cloud-devices/${encodeURIComponent(deviceId)}`, + ) + }, + + async renameDevice(deviceId: string, alias: string): Promise { + await client.put<{ message: string }>( + `/devices/${encodeURIComponent(deviceId)}/alias`, + { alias }, + ) + }, } } diff --git a/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx b/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx index ebcd1652b5..1a28246752 100644 --- a/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx +++ b/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx @@ -1,12 +1,50 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, test, vi } from 'vitest' +import { createDeviceApi } from '@/api/devices' import { DesktopWorkbenchLayout } from './DesktopWorkbenchLayout' +vi.mock('@/config/runtime', () => ({ + getRuntimeConfig: () => ({ apiBaseUrl: '/api' }), +})) + +vi.mock('@/api/http', () => ({ + createHttpClient: vi.fn(() => ({})), +})) + +vi.mock('@/api/devices', () => ({ + createDeviceApi: vi.fn(), +})) + +const createDeviceApiMock = vi.mocked(createDeviceApi) + describe('DesktopWorkbenchLayout', () => { beforeEach(() => { vi.clearAllMocks() localStorage.clear() + createDeviceApiMock.mockReturnValue({ + getAllDevices: vi.fn().mockResolvedValue([ + { + id: 1, + device_id: '24a59054-4638-4744-983d-372706c30fcd', + name: 'yunpeng7-executor-372706c30fcd', + status: 'online', + is_default: false, + device_type: 'cloud', + bind_shell: 'claudecode', + executor_version: '1.712', + cpu_usage: 42, + memory_usage: 68, + disk_usage: 57, + }, + ]), + startTerminal: vi.fn(), + startCodeServer: vi.fn(), + createCloudDevice: vi.fn(), + renameDevice: vi.fn(), + restartCloudDevice: vi.fn(), + deleteCloudDevice: vi.fn(), + }) }) const baseProps = { @@ -416,13 +454,10 @@ describe('DesktopWorkbenchLayout', () => { expect(screen.queryByText('Linux-Device-481b616e8e0b')).not.toBeInTheDocument() expect(screen.getByText('可连接的设备')).toBeInTheDocument() expect(screen.queryByText('可连接这台设备的云设备')).not.toBeInTheDocument() - expect(screen.getByText('云设备')).toBeInTheDocument() + expect(await screen.findByText('云设备')).toBeInTheDocument() expect( - screen.getByTestId('connection-device-icon-24a59054-4638-4744-983d-372706c30fcd'), - ).toHaveClass('text-[#3c4043]') - expect( - screen.getByTestId('connection-device-icon-24a59054-4638-4744-983d-372706c30fcd'), - ).not.toHaveClass('bg-[#f7f7f8]') + screen.getByTestId('connection-device-24a59054-4638-4744-983d-372706c30fcd'), + ).toBeInTheDocument() expect(screen.getByText('yunpeng7-executor-372706c30fcd')).toBeInTheDocument() expect(screen.getByText('v1.712')).toBeInTheDocument() expect(screen.getByText('在线')).toBeInTheDocument() diff --git a/wework/src/components/settings/AddCloudDeviceDialog.tsx b/wework/src/components/settings/AddCloudDeviceDialog.tsx new file mode 100644 index 0000000000..66d112b628 --- /dev/null +++ b/wework/src/components/settings/AddCloudDeviceDialog.tsx @@ -0,0 +1,106 @@ +import { Cloud, X } from 'lucide-react' +import { useCallback, useState } from 'react' +import { createHttpClient } from '@/api/http' +import { createDeviceApi } from '@/api/devices' +import { getRuntimeConfig } from '@/config/runtime' + +interface AddCloudDeviceDialogProps { + open: boolean + onClose: () => void + onCreated: () => void + onCreatingChange?: (creating: boolean) => void +} + +export function AddCloudDeviceDialog({ + open, + onClose, + onCreated, + onCreatingChange, +}: AddCloudDeviceDialogProps) { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const handleCreate = useCallback(async () => { + setLoading(true) + setError(null) + try { + const { apiBaseUrl } = getRuntimeConfig() + const client = createHttpClient({ baseUrl: apiBaseUrl }) + const deviceApi = createDeviceApi(client) + await deviceApi.createCloudDevice() + onCreatingChange?.(true) + onClose() + onCreated() + } catch (e) { + setError(e instanceof Error ? e.message : '创建失败,请重试') + onCreatingChange?.(false) + } finally { + setLoading(false) + } + }, [onClose, onCreated, onCreatingChange]) + + if (!open) return null + + return ( +
{ + if (!loading && e.target === e.currentTarget) onClose() + }} + > +
e.stopPropagation()} + > +
+
+ +
+
+

添加云设备

+

+ 将创建一台新的云设备,设备初始化约需 2-3 分钟。 +

+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+ ) +} diff --git a/wework/src/components/settings/ConnectionsSettingsPage.test.tsx b/wework/src/components/settings/ConnectionsSettingsPage.test.tsx new file mode 100644 index 0000000000..5f995af790 --- /dev/null +++ b/wework/src/components/settings/ConnectionsSettingsPage.test.tsx @@ -0,0 +1,130 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { ConnectionsSettingsPage } from './ConnectionsSettingsPage' +import { createDeviceApi } from '@/api/devices' +import type { DeviceInfo } from '@/types/devices' + +vi.mock('@/config/runtime', () => ({ + getRuntimeConfig: () => ({ apiBaseUrl: '/api' }), +})) + +vi.mock('@/api/http', () => ({ + createHttpClient: vi.fn(() => ({})), +})) + +vi.mock('@/api/devices', () => ({ + createDeviceApi: vi.fn(), +})) + +vi.mock('@wecode/components/DeviceMetrics', () => ({ + DeviceMetrics: () =>
metrics
, +})) + +vi.mock('@wecode/components/VncDesktopButton', () => ({ + VncDesktopButton: ({ deviceId }: { deviceId: string }) => ( + + ), +})) + +const createDeviceApiMock = vi.mocked(createDeviceApi) + +function cloudDevice(overrides: Partial = {}): DeviceInfo { + return { + id: 1, + device_id: 'device-1', + name: 'yunpeng7-executor-device-1', + status: 'online', + is_default: false, + device_type: 'cloud', + bind_shell: 'claudecode', + executor_version: '1.712', + cloud_config: { + sandboxId: 'sandbox-1', + }, + ...overrides, + } +} + +describe('ConnectionsSettingsPage', () => { + const api = { + getAllDevices: vi.fn(), + startTerminal: vi.fn(), + startCodeServer: vi.fn(), + createCloudDevice: vi.fn(), + renameDevice: vi.fn(), + restartCloudDevice: vi.fn(), + deleteCloudDevice: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + createDeviceApiMock.mockReturnValue(api) + }) + + test('keeps the cloud device creation notice visible after the create request resolves', async () => { + api.getAllDevices.mockResolvedValue([]) + api.createCloudDevice.mockResolvedValue({ + id: 1, + device_id: 'device-1', + name: 'yunpeng7-executor-device-1', + status: 'offline', + device_type: 'cloud', + message: 'created', + }) + + render() + + await userEvent.click(await screen.findByTestId('connection-add-device-button')) + const createDialog = screen.getByTestId('add-cloud-device-dialog') + expect(createDialog.querySelector('.text-\\[\\#0d9488\\]')).toBeNull() + expect(screen.getByTestId('add-cloud-device-confirm')).toHaveClass('bg-[#409eff]') + await userEvent.click(screen.getByTestId('add-cloud-device-confirm')) + + await waitFor(() => expect(api.createCloudDevice).toHaveBeenCalledTimes(1)) + const creatingNotice = screen.getByText( + '云设备创建中,初始化约需 2-3 分钟,完成后将自动出现在列表中', + ) + expect(creatingNotice).toHaveClass('text-[#2563eb]') + }) + + test('keeps uncommon cloud device actions in a compact more menu with confirmation', async () => { + api.getAllDevices.mockResolvedValue([cloudDevice()]) + api.restartCloudDevice.mockResolvedValue({ message: 'restart sent' }) + api.deleteCloudDevice.mockResolvedValue({ message: 'deleted' }) + + render() + + await screen.findByTestId('connection-device-device-1') + expect(screen.queryByTestId('connection-restart-button-device-1')).not.toBeInTheDocument() + expect(screen.queryByTestId('connection-delete-button-device-1')).not.toBeInTheDocument() + + const moreButton = screen.getByTestId('connection-more-button-device-1') + expect(moreButton).toHaveClass('h-7', 'w-7') + expect(moreButton).toHaveAccessibleName('更多操作') + + await userEvent.click(moreButton) + const restartMenuItem = screen.getByTestId('connection-restart-menu-item-device-1') + const deleteMenuItem = screen.getByTestId('connection-delete-menu-item-device-1') + expect(restartMenuItem).toHaveTextContent('重启设备') + expect(deleteMenuItem).toHaveTextContent('删除设备') + + await userEvent.click(restartMenuItem) + expect(api.restartCloudDevice).not.toHaveBeenCalled() + const restartDialog = screen.getByTestId('confirm-restart-device-dialog') + const restartConfirmButton = screen.getByTestId('confirm-restart-device-button') + expect(restartDialog.querySelector('.text-\\[\\#0d9488\\]')).toBeNull() + expect(restartConfirmButton).toHaveClass('bg-[#2d2d2d]') + await userEvent.click(restartConfirmButton) + + await userEvent.click(moreButton) + await userEvent.click(screen.getByTestId('connection-delete-menu-item-device-1')) + expect(api.deleteCloudDevice).not.toHaveBeenCalled() + await userEvent.click(screen.getByTestId('confirm-delete-device-button')) + + expect(api.restartCloudDevice).toHaveBeenCalledWith('device-1') + expect(api.deleteCloudDevice).toHaveBeenCalledWith('device-1') + }) +}) diff --git a/wework/src/components/settings/ConnectionsSettingsPage.tsx b/wework/src/components/settings/ConnectionsSettingsPage.tsx index d863b4d8b8..ee95681486 100644 --- a/wework/src/components/settings/ConnectionsSettingsPage.tsx +++ b/wework/src/components/settings/ConnectionsSettingsPage.tsx @@ -2,29 +2,40 @@ import { ArrowLeft, Archive, BookOpen, + Check, Cloud, Code2, Cpu, Folder, Globe2, HardDrive, + Loader2, MemoryStick, Monitor, + MoreHorizontal, + Pencil, Plus, - Trash2, + RotateCcw, Terminal, + Trash2, + X, } from 'lucide-react' import type { ComponentType } from 'react' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { createDeviceApi } from '@/api/devices' +import { createHttpClient } from '@/api/http' +import { getRuntimeConfig } from '@/config/runtime' import { useTranslation } from '@/hooks/useTranslation' import type { ArchivedTask } from '@/types/api' +import type { DeviceInfo } from '@/types/devices' +import { AddCloudDeviceDialog } from './AddCloudDeviceDialog' interface ConnectionsSettingsPageProps { onBack: () => void - onListArchivedTasks: () => Promise<{ items: ArchivedTask[]; total: number }> - onUnarchiveTask: (taskId: number) => Promise - onDeleteTask: (taskId: number) => Promise - onDeleteArchivedTasks: () => Promise + onListArchivedTasks?: () => Promise<{ items: ArchivedTask[]; total: number }> + onUnarchiveTask?: (taskId: number) => Promise + onDeleteTask?: (taskId: number) => Promise + onDeleteArchivedTasks?: () => Promise } interface SettingsNavItem { @@ -34,14 +45,13 @@ interface SettingsNavItem { fallback: string } -interface DeviceRow { - name: string - id: string - status: 'online' | 'offline' - version?: string - cpuUsage: number - memoryUsage: number - diskUsage: number +interface DeviceMetricFields { + cpu_usage?: number | null + memory_usage?: number | null + disk_usage?: number | null + cpuUsage?: number | null + memoryUsage?: number | null + diskUsage?: number | null } const settingsNavItems: SettingsNavItem[] = [ @@ -60,125 +70,180 @@ const settingsNavItems: SettingsNavItem[] = [ }, ] -const cloudDevices: DeviceRow[] = [ - { - name: 'yunpeng7-executor-372706c30fcd', - id: '24a59054-4638-4744-983d-372706c30fcd', - status: 'online', - version: 'v1.712', - cpuUsage: 42, - memoryUsage: 68, - diskUsage: 57, - }, -] +const emptyArchivedTasks = async () => ({ items: [], total: 0 }) +const noopArchivedAction = async () => undefined -function StatusPill({ status }: { status: DeviceRow['status'] }) { - const { t } = useTranslation('common') +function StatusPill({ status }: { status: DeviceInfo['status'] }) { const isOnline = status === 'online' return ( - + ) } -function DeviceCard({ device }: { device: DeviceRow }) { - const { t } = useTranslation('common') - +function DeviceActionButton({ + testId, + icon: Icon, + label, + onClick, + disabled, +}: { + testId: string + icon: ComponentType<{ className?: string }> + label: string + onClick?: () => void + disabled?: boolean +}) { return ( -
-
-
-
- -
-
-
-

{device.name}

- - {device.version || '-'} - -
-
- -
-
-
- -
- - - -
-
- -
- - - -
-
+ + {label} + ) } -function DeviceActionButton({ +function DeviceIconActionButton({ testId, icon: Icon, label, + onClick, + disabled, }: { testId: string icon: ComponentType<{ className?: string }> label: string + onClick?: () => void + disabled?: boolean }) { return ( ) } +type ConfirmDeviceAction = 'restart' | 'delete' + +function ConfirmDeviceActionDialog({ + device, + action, + loading, + onCancel, + onConfirm, +}: { + device: DeviceInfo + action: ConfirmDeviceAction + loading: boolean + onCancel: () => void + onConfirm: () => void +}) { + const isDelete = action === 'delete' + const Icon = isDelete ? Trash2 : RotateCcw + const title = isDelete ? '删除云设备' : '重启云设备' + const description = isDelete + ? `将删除 ${device.name},相关云设备资源会被释放。` + : `将重启 ${device.name},设备会短暂离线,进行中的连接可能中断。` + const confirmLabel = isDelete ? '确认删除' : '确认重启' + const dialogTestId = isDelete + ? 'confirm-delete-device-dialog' + : 'confirm-restart-device-dialog' + const confirmTestId = isDelete + ? 'confirm-delete-device-button' + : 'confirm-restart-device-button' + const iconClassName = isDelete + ? 'bg-[#fef2f2] text-[#dc2626]' + : 'bg-[#f7f7f8] text-[#5f6368]' + const confirmClassName = isDelete + ? 'bg-[#dc2626] hover:bg-[#b91c1c]' + : 'bg-[#2d2d2d] hover:bg-[#111]' + + return ( +
{ + if (!loading && e.target === e.currentTarget) onCancel() + }} + > +
e.stopPropagation()} + > +
+
+ +
+
+

{title}

+

+ {description} +

+
+
+
+ + +
+
+
+ ) +} + +function readMetric( + device: DeviceInfo, + snakeKey: keyof DeviceMetricFields, + camelKey: keyof DeviceMetricFields, +) { + const metrics = device as DeviceInfo & DeviceMetricFields + const value = metrics[snakeKey] ?? metrics[camelKey] + return typeof value === 'number' ? value : null +} + function ResourceMetric({ icon: Icon, label, @@ -186,8 +251,10 @@ function ResourceMetric({ }: { icon: ComponentType<{ className?: string }> label: string - value: number + value: number | null }) { + const color = value === null ? '#d1d5db' : '#409eff' + return (
@@ -195,28 +262,321 @@ function ResourceMetric({ {label}
-
+
- {value}% + + {value === null ? '--%' : `${value}%`} +
) } -function DeviceSection({ title, devices }: { title: string; devices: DeviceRow[] }) { +function DeviceMetricsPanel({ device }: { device: DeviceInfo }) { + const { t } = useTranslation('common') + + return ( +
+ + + +
+ ) +} + +function DeviceCard({ device, onChanged }: { device: DeviceInfo; onChanged: () => void }) { + const [sessionLoading, setSessionLoading] = useState(null) + const [editing, setEditing] = useState(false) + const [editName, setEditName] = useState(device.name) + const [saving, setSaving] = useState(false) + const [restarting, setRestarting] = useState(false) + const [deleting, setDeleting] = useState(false) + const [actionMenuOpen, setActionMenuOpen] = useState(false) + const [confirmAction, setConfirmAction] = useState(null) + const inputRef = useRef(null) + const actionMenuRef = useRef(null) + + const handleStartSession = useCallback( + async (type: 'terminal' | 'code-server') => { + if (device.status !== 'online') return + setSessionLoading(type) + try { + const { apiBaseUrl } = getRuntimeConfig() + const client = createHttpClient({ baseUrl: apiBaseUrl }) + const deviceApi = createDeviceApi(client) + const result = + type === 'terminal' + ? await deviceApi.startTerminal(device.device_id) + : await deviceApi.startCodeServer(device.device_id) + if (result.url) { + window.open(result.url, '_blank', 'noopener') + } + } catch (e) { + console.error(`Failed to start ${type}:`, e) + } finally { + setSessionLoading(null) + } + }, + [device.device_id, device.status], + ) + + const handleStartEdit = () => { + setEditName(device.name) + setEditing(true) + setTimeout(() => inputRef.current?.select(), 0) + } + + const handleCancelEdit = () => { + setEditing(false) + setEditName(device.name) + } + + const handleSaveEdit = async () => { + const trimmed = editName.trim() + if (!trimmed || trimmed === device.name) { + handleCancelEdit() + return + } + setSaving(true) + try { + const { apiBaseUrl } = getRuntimeConfig() + const client = createHttpClient({ baseUrl: apiBaseUrl }) + const deviceApi = createDeviceApi(client) + await deviceApi.renameDevice(device.device_id, trimmed) + setEditing(false) + onChanged() + } catch (e) { + console.error('Failed to rename device:', e) + } finally { + setSaving(false) + } + } + + const handleRestartDevice = async () => { + setRestarting(true) + try { + const { apiBaseUrl } = getRuntimeConfig() + const client = createHttpClient({ baseUrl: apiBaseUrl }) + const deviceApi = createDeviceApi(client) + await deviceApi.restartCloudDevice(device.device_id) + setConfirmAction(null) + onChanged() + } catch (e) { + console.error('Failed to restart cloud device:', e) + } finally { + setRestarting(false) + } + } + + const handleDeleteDevice = async () => { + setDeleting(true) + try { + const { apiBaseUrl } = getRuntimeConfig() + const client = createHttpClient({ baseUrl: apiBaseUrl }) + const deviceApi = createDeviceApi(client) + await deviceApi.deleteCloudDevice(device.device_id) + setConfirmAction(null) + onChanged() + } catch (e) { + console.error('Failed to delete cloud device:', e) + } finally { + setDeleting(false) + } + } + + useEffect(() => { + if (!actionMenuOpen) return + + const closeActionMenu = (event: MouseEvent) => { + if (!actionMenuRef.current?.contains(event.target as Node)) { + setActionMenuOpen(false) + } + } + + document.addEventListener('mousedown', closeActionMenu) + return () => document.removeEventListener('mousedown', closeActionMenu) + }, [actionMenuOpen]) + + const openConfirmAction = (action: ConfirmDeviceAction) => { + setActionMenuOpen(false) + setConfirmAction(action) + } + + const isOnline = device.status === 'online' + + return ( + <> +
+
+
+ {editing ? ( +
+ setEditName(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') handleSaveEdit() + if (e.key === 'Escape') handleCancelEdit() + }} + disabled={saving} + className="h-6 w-48 rounded border border-[#409eff] bg-white px-1.5 text-sm text-[#2d2d2d] outline-none" + /> + + +
+ ) : ( +
+

+ {device.name} +

+ +
+ )} + + {device.executor_version ? `v${device.executor_version}` : '-'} + + +
+ +
+ handleStartSession('terminal')} + disabled={!isOnline || sessionLoading === 'terminal'} + /> + handleStartSession('code-server')} + disabled={!isOnline || sessionLoading === 'code-server'} + /> + +
+ setActionMenuOpen(open => !open)} + disabled={restarting || deleting} + /> + {actionMenuOpen && ( +
+ + +
+ )} +
+
+
+ + +
+ + {confirmAction && ( + setConfirmAction(null)} + onConfirm={confirmAction === 'delete' ? handleDeleteDevice : handleRestartDevice} + /> + )} + + ) +} + +function DeviceSection({ + title, + devices, + onChanged, +}: { + title: string + devices: DeviceInfo[] + onChanged: () => void +}) { const { t } = useTranslation('common') return (
- +

{title}

({devices.length})
{devices.map(device => ( - + ))}
([]) + const [loading, setLoading] = useState(true) + const [addDialogOpen, setAddDialogOpen] = useState(false) + const [creating, setCreating] = useState(false) + + const fetchDevices = useCallback(async () => { + try { + const { apiBaseUrl } = getRuntimeConfig() + const client = createHttpClient({ baseUrl: apiBaseUrl }) + const deviceApi = createDeviceApi(client) + const allDevices = await deviceApi.getAllDevices() + const cloudClaudeDevices = allDevices.filter( + d => d.device_type === 'cloud' && d.bind_shell === 'claudecode', + ) + setDevices(cloudClaudeDevices) + if (cloudClaudeDevices.length > 0) { + setCreating(false) + } + } catch (e) { + console.error('Failed to fetch devices:', e) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void Promise.resolve().then(fetchDevices) + }, [fetchDevices]) + + return ( + <> +
+

+ {t('workbench.connections_title', '连接')} +

+ +
+ {[t('workbench.connections_tab_this_mac', '连接设备')].map((tab, index) => ( + + ))} +
+ +
+
+
+

+ {t('workbench.connections_authorized_devices', '可连接的设备')} +

+ {!loading && devices.length === 0 && ( + + )} +
+ +
+ {creating && ( +
+ + 云设备创建中,初始化约需 2-3 分钟,完成后将自动出现在列表中 +
+ )} + {loading ? ( +
加载中...
+ ) : devices.length === 0 ? ( +
暂无云设备
+ ) : ( + + )} +
+
+
+
+ + setAddDialogOpen(false)} + onCreated={fetchDevices} + onCreatingChange={setCreating} + /> + + ) +} + function formatArchivedDate(value: string) { const date = new Date(value) if (Number.isNaN(date.getTime())) return value @@ -259,7 +728,7 @@ function ArchivedChatsSettingsPage({ onUnarchiveTask, onDeleteTask, onDeleteArchivedTasks, -}: Omit) { +}: Required>) { const [items, setItems] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -374,10 +843,10 @@ function ArchivedChatsSettingsPage({ export function ConnectionsSettingsPage({ onBack, - onListArchivedTasks, - onUnarchiveTask, - onDeleteTask, - onDeleteArchivedTasks, + onListArchivedTasks = emptyArchivedTasks, + onUnarchiveTask = noopArchivedAction, + onDeleteTask = noopArchivedAction, + onDeleteArchivedTasks = noopArchivedAction, }: ConnectionsSettingsPageProps) { const { t } = useTranslation('common') const [activeNav, setActiveNav] = useState('connections') @@ -407,7 +876,9 @@ export function ConnectionsSettingsPage({ onClick={() => setActiveNav(item.key)} className={[ 'flex min-h-[31px] w-full items-center gap-2 rounded-lg px-2.5 text-left text-sm font-medium', - activeNav === item.key ? 'bg-black/5 text-[#202124]' : 'text-[#202124] hover:bg-black/5', + activeNav === item.key + ? 'bg-black/5 text-[#202124]' + : 'text-[#202124] hover:bg-black/5', ].join(' ')} > @@ -428,56 +899,7 @@ export function ConnectionsSettingsPage({ onDeleteArchivedTasks={onDeleteArchivedTasks} /> ) : ( -
-

- {t('workbench.connections_title', '连接')} -

- -
- {[ - t('workbench.connections_tab_this_mac', '连接设备'), - ].map((tab, index) => ( - - ))} -
- -
-
-
-

- {t('workbench.connections_authorized_devices', '可连接的设备')} -

- -
- -
- -
-
-
-
+ )}
diff --git a/wework/src/pages/PluginManagementPage.tsx b/wework/src/pages/PluginManagementPage.tsx index 8dd5ba5da7..bfc8dbcb6e 100644 --- a/wework/src/pages/PluginManagementPage.tsx +++ b/wework/src/pages/PluginManagementPage.tsx @@ -1,12 +1,32 @@ +import { useState } from 'react' import { DesktopSidebar } from '@/components/layout/DesktopSidebar' import { PluginManagementWorkspace } from '@/components/plugins/PluginManagementWorkspace' +import { ConnectionsSettingsPage } from '@/components/settings/ConnectionsSettingsPage' import { useAuth } from '@/features/auth/useAuth' import { useWorkbench } from '@/features/workbench/useWorkbench' import { navigateTo } from '@/lib/navigation' export function PluginManagementPage() { const { logout } = useAuth() - const { state, selectProject, openTask } = useWorkbench() + const { + state, + selectProject, + startNewProjectChat, + openTask, + createProject, + updateProjectName, + removeProject, + archiveAllChats, + archiveProjectChats, + archiveTask, + renameTask, + listArchivedTasks, + unarchiveTask, + deleteTask, + deleteArchivedTasks, + listDeviceDirectories, + } = useWorkbench() + const [settingsOpen, setSettingsOpen] = useState(false) const handleSelectProject = (projectId: number) => { navigateTo('/') @@ -18,19 +38,42 @@ export function PluginManagementPage() { void openTask(taskId) } + if (settingsOpen) { + return ( + setSettingsOpen(false)} + onListArchivedTasks={listArchivedTasks} + onUnarchiveTask={unarchiveTask} + onDeleteTask={deleteTask} + onDeleteArchivedTasks={deleteArchivedTasks} + /> + ) + } + return (
{}} onNewChat={() => navigateTo('/')} onSelectProject={handleSelectProject} + onStartNewProjectChat={startNewProjectChat} onOpenTask={handleOpenTask} onOpenPlugins={() => navigateTo('/plugins')} + onCreateProject={createProject} + onUpdateProjectName={updateProjectName} + onRemoveProject={removeProject} + onArchiveAllChats={archiveAllChats} + onArchiveProjectChats={archiveProjectChats} + onArchiveTask={archiveTask} + onRenameTask={renameTask} + onListDeviceDirectories={listDeviceDirectories} + onOpenSettings={() => setSettingsOpen(true)} onLogout={logout} /> diff --git a/wework/src/pages/PluginsPage.tsx b/wework/src/pages/PluginsPage.tsx index 2ecea857f1..4252deeabd 100644 --- a/wework/src/pages/PluginsPage.tsx +++ b/wework/src/pages/PluginsPage.tsx @@ -1,12 +1,32 @@ +import { useState } from 'react' import { DesktopSidebar } from '@/components/layout/DesktopSidebar' import { PluginsWorkspace } from '@/components/plugins/PluginsWorkspace' +import { ConnectionsSettingsPage } from '@/components/settings/ConnectionsSettingsPage' import { useAuth } from '@/features/auth/useAuth' import { useWorkbench } from '@/features/workbench/useWorkbench' import { navigateTo } from '@/lib/navigation' export function PluginsPage() { const { logout } = useAuth() - const { state, selectProject, openTask } = useWorkbench() + const { + state, + selectProject, + startNewProjectChat, + openTask, + createProject, + updateProjectName, + removeProject, + archiveAllChats, + archiveProjectChats, + archiveTask, + renameTask, + listArchivedTasks, + unarchiveTask, + deleteTask, + deleteArchivedTasks, + listDeviceDirectories, + } = useWorkbench() + const [settingsOpen, setSettingsOpen] = useState(false) const handleSelectProject = (projectId: number) => { navigateTo('/') @@ -18,19 +38,42 @@ export function PluginsPage() { void openTask(taskId) } + if (settingsOpen) { + return ( + setSettingsOpen(false)} + onListArchivedTasks={listArchivedTasks} + onUnarchiveTask={unarchiveTask} + onDeleteTask={deleteTask} + onDeleteArchivedTasks={deleteArchivedTasks} + /> + ) + } + return (
{}} onNewChat={() => navigateTo('/')} onSelectProject={handleSelectProject} + onStartNewProjectChat={startNewProjectChat} onOpenTask={handleOpenTask} onOpenPlugins={() => navigateTo('/plugins')} + onCreateProject={createProject} + onUpdateProjectName={updateProjectName} + onRemoveProject={removeProject} + onArchiveAllChats={archiveAllChats} + onArchiveProjectChats={archiveProjectChats} + onArchiveTask={archiveTask} + onRenameTask={renameTask} + onListDeviceDirectories={listDeviceDirectories} + onOpenSettings={() => setSettingsOpen(true)} onLogout={logout} /> diff --git a/wework/src/types/devices.ts b/wework/src/types/devices.ts new file mode 100644 index 0000000000..57e62a694a --- /dev/null +++ b/wework/src/types/devices.ts @@ -0,0 +1,41 @@ +export interface DeviceInfo { + id: number + device_id: string + name: string + status: 'online' | 'offline' | 'busy' + is_default: boolean + device_type: 'local' | 'cloud' + bind_shell: 'claudecode' | 'openclaw' + executor_version?: string + latest_version?: string + update_available?: boolean + cloud_config?: { + sandboxId?: string + imageId?: string + deviceId?: string + deviceName?: string + createdAt?: string + } +} + +export interface DeviceListResponse { + items: DeviceInfo[] + total: number +} + +export interface DeviceSessionResponse { + session_id: string + device_id: string + type: string + path: string + url: string +} + +export interface CloudDeviceResponse { + id: number + device_id: string + name: string + status: string + device_type: string + message: string +} diff --git a/wework/tailwind.config.js b/wework/tailwind.config.js index 38d66f9f1e..354bfc197e 100644 --- a/wework/tailwind.config.js +++ b/wework/tailwind.config.js @@ -1,6 +1,6 @@ /** @type {import('tailwindcss').Config} */ export default { - content: ['./index.html', './src/**/*.{ts,tsx}'], + content: ['./index.html', './src/**/*.{ts,tsx}', './wecode/**/*.{ts,tsx}'], theme: { extend: { colors: { diff --git a/wework/tsconfig.app.json b/wework/tsconfig.app.json index cf44d01b32..7c871eac7b 100644 --- a/wework/tsconfig.app.json +++ b/wework/tsconfig.app.json @@ -3,7 +3,8 @@ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@wecode/*": ["./wecode/*"] }, "ignoreDeprecations": "6.0", "target": "es2023", @@ -26,6 +27,6 @@ "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"], + "include": ["src", "wecode"], "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] } diff --git a/wework/tsconfig.json b/wework/tsconfig.json index aa3c04f1be..812ac57a8b 100644 --- a/wework/tsconfig.json +++ b/wework/tsconfig.json @@ -1,8 +1,10 @@ { "compilerOptions": { + "ignoreDeprecations": "6.0", "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@wecode/*": ["./wecode/*"] } }, "files": [], diff --git a/wework/vite.config.ts b/wework/vite.config.ts index 470b7c323a..6e5576a118 100644 --- a/wework/vite.config.ts +++ b/wework/vite.config.ts @@ -25,6 +25,7 @@ export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, './src'), + '@wecode': path.resolve(__dirname, './wecode'), }, }, test: {