diff --git a/.env.example b/.env.example index eec52d87..ff1412d9 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,8 @@ # OpenClaw Office 环境变量 + +# Branch customization +# VITE_OFFICE_TITLE=OpenClaw Office +# VITE_BRANCH_LABEL=SitioUno - Sucursal Miami # 复制此文件为 .env.local 并填入实际值 # Gateway WebSocket 地址 @@ -9,3 +13,13 @@ VITE_GATEWAY_TOKEN= # Mock 模式(设为 true 可不连接真实 Gateway 进行开发) # VITE_MOCK=true + +# --- Setup GCP (HQ sidecar on Tailscale) --- +# Used by the "Setup GCP" console page (pairing requests, notification +# channels, notify broadcast). The trust boundary is the Tailscale VPN: the +# HQ sidecar validates source identity server-side via `tailscale whois`, +# so NO bearer token is required and none should ever be shipped in the +# browser bundle. May also be injected at runtime via +# window.__OPENCLAW_CONFIG__.registryApiUrl. +# Default (when unset): http://openclaw-hq:8781 +# VITE_REGISTRY_API_URL=http://openclaw-hq:8781 diff --git a/ACP_PLAN.md b/ACP_PLAN.md new file mode 100644 index 00000000..fedd46c5 --- /dev/null +++ b/ACP_PLAN.md @@ -0,0 +1,13 @@ +# Tarea de Refactorización: WikiViewerModal +**Contexto:** El fundador rechazó el componente WikiViewerModal.tsx actual. La UI con el grafo (react-force-graph-2d) es muy lenta, se superpone el botón de cerrar y la experiencia general es mala. + +**Objetivos:** +1. Desinstalar `react-force-graph-2d` y `d3-force` usando pnpm. +2. Refactorizar `src/components/console/agents/WikiViewerModal.tsx`: + - Elimina todo rastro del grafo y la variable `viewMode`. + - Crea un layout de dos columnas: + - Izquierda (Sidebar): Una lista de archivos mockeados (e.g., `index.md`, `SOUL.md`, `AGENTS.md`). + - Derecha (Main Content): Renderiza el texto Markdown simulado correspondiente al archivo seleccionado en la izquierda usando `react-markdown`. + - Repara el botón "X" de cerrar para que no quede montado sobre el título. Ponlo limpio en la esquina del modal. +3. Asegúrate de compilar con `pnpm build` para revisar errores de TypeScript. +4. Usa `git add .` y `git commit -m "refactor: Overhaul WikiViewer UX for fluid text-first navigation"`. diff --git a/AGENTS.md b/AGENTS.md index 8ca00f1e..336e1f59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,17 @@ This document provides context and rules for AI coding assistants (Codex, Claude OpenClaw Office is the visual monitoring and management frontend for the [OpenClaw](https://github.com/openclaw/openclaw) Multi-Agent system. It connects to the OpenClaw Gateway via WebSocket to visualize Agent collaboration as a "digital office" and provides a full console management interface. +## SitioUno Repository Boundaries + +This repo is the canonical home for the graphical/product UI only: React/Vite source, office visualization, console, chat workspace, assets, i18n, and frontend service scripts. + +Do not add GCP Terraform, VM provisioning, Tailscale setup, branch registry code, MCP infrastructure, Zeus/Hermes runtime config, MiroFish product code, or software-factory code here. Use the repo map in `SITIOUNO-REPO-MAP.md`: + +- `sitiouno/gcloud-office` for infra, node runbooks, branch registry, MCP routing, and Sicilia/Zeus/MiroFish node fichas. +- `SiteOneTech/sitiouno-software-factory-ai` for the software factory. +- `SiteOneTech/hermes-agent` for Zeus/Hermes implementation. +- `SiteOneTech/mirofish-original-ai-forecast` for MiroFish. + ## Tech Stack | Category | Technology | diff --git a/DEPLOY_LOCAL_NODES.md b/DEPLOY_LOCAL_NODES.md new file mode 100644 index 00000000..3fd70b30 --- /dev/null +++ b/DEPLOY_LOCAL_NODES.md @@ -0,0 +1,13 @@ +# Protocolo de Despliegue UI y Sincronización de Nodos Locales + +Este documento establece las reglas para desplegar **este frontend** en nodos locales. La infraestructura del nodo, registry, MCP, VPN y contratos de delegación se mantienen en `sitiouno/gcloud-office`. + +Ver tambien: [SITIOUNO-REPO-MAP.md](SITIOUNO-REPO-MAP.md). + +## 1. Responsabilidad Estricta +Ningún agente DevOps local debe intentar conectarse por SSH, Bastion o VPN para hacer despliegues en infraestructura externa (HQ u otros nodos). Cada nodo debe tener su propio agente vigilante que haga un `pull` de este repositorio y compile los binarios localmente. + +## 2. Sincronización de Skills Locales +La carpeta `/local-skills` dentro de este repositorio actúa como el canal de distribución para custom skills entre sucursales. +- **Acción requerida (DevOps Local):** Monitorear esta carpeta. Si hay cambios o nuevos skills, el agente debe copiarlos al directorio global local del nodo (`~/.local/npm-global/lib/node_modules/openclaw/skills/` o equivalente) para que OpenClaw los registre en el Gateway. +- Si un agente en un nodo crea un skill útil, el DevOps de ese nodo debe hacerle commit y push hacia esta carpeta `/local-skills` para compartirlo con el ecosistema. diff --git a/NODE_ONBOARDING.md b/NODE_ONBOARDING.md new file mode 100644 index 00000000..d96f65a3 --- /dev/null +++ b/NODE_ONBOARDING.md @@ -0,0 +1,31 @@ +# Guía de Onboarding UI para Nuevos Nodos (SitioUno) + +Esta guía cubre solo la **parte gráfica/OpenClaw Office UI** que un nodo debe instalar para heredar la experiencia visual custom. El onboarding completo de infraestructura, VPN, registry, MCP, secrets y delegación vive en `sitiouno/gcloud-office`. + +Mapa de repos: [SITIOUNO-REPO-MAP.md](SITIOUNO-REPO-MAP.md). + +## Paso 1: Clonar este Repositorio Base +En el workspace del agente programador del nuevo nodo, ejecuta: +```bash +git clone https://github.com/sitiouno/openclaw-office.git +``` + +Antes de tocar servicios del nodo, verificar que el runbook de infraestructura correspondiente exista en `gcloud-office`. + +## Paso 2: Crear al DevOps (Capablanca) +Todo nodo DEBE tener un agente llamado **Capablanca** dedicado a operaciones domésticas y de sistema. +1. Revisa `docs/ecosystem_agents/CAPABLANCA_DEVOPS.md`. +2. Utiliza la skill `prompt-architect` para generar sus archivos `IDENTITY.md`, `SOUL.md` y `AGENTS.md` con ese formato. + +## Paso 3: Instalar Skills Locales +1. Capablanca debe entrar a `/local-skills` dentro de este repo. +2. Copiar todo el contenido a su carpeta global de sistema: + `cp -R local-skills/* ~/.local/npm-global/lib/node_modules/openclaw/skills/` +3. Reiniciar el gateway de OpenClaw. A partir de este momento, el nodo tiene acceso a `prompt-architect`, `llm-wiki`, etc. + +## Paso 4: Despliegue de la Interfaz Customizada +1. Capablanca debe crear un script `deploy-office.sh` que ejecute `pnpm build` en este repo y copie el `/dist` a la ruta global del OpenClaw Office local. +2. Capablanca debe tener un `cron` asignado para que ejecute este sync automáticamente. + +## Paso 5: El Programador +El nodo puede tener su propio programador o mantener el nombre "Alekhine". Su perfil debe configurarse leyendo `docs/ecosystem_agents/ARCHITECT_PROGRAMMER.md`. Este agente tendrá la misión de vigilar los repositorios y programar las herramientas específicas que requiera su sucursal, haciendo push a este repositorio para beneficio de todos. diff --git a/README.en.md b/README.en.md index c072d2f3..ca007ffc 100644 --- a/README.en.md +++ b/README.en.md @@ -8,6 +8,10 @@ **Core Metaphor:** Agent = Digital Employee | Office = Agent Runtime | Desk = Session | Meeting Pod = Collaboration Context +## SitioUno Fork Scope + +This fork owns the graphical/product surface of OpenClaw Office: React UI, visual office, console, chat, assets, i18n, and frontend service scripts. GCP infrastructure, node runbooks, MCP routing, Zeus/Hermes, MiroFish, and the software factory live in their own repositories. See [SITIOUNO-REPO-MAP.md](SITIOUNO-REPO-MAP.md). + --- ## Features diff --git a/README.md b/README.md index bc2468d2..6987e55f 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ **核心隐喻:** Agent = 数字员工 | 办公室 = Agent 运行时 | 工位 = Session | 会议室 = 协作上下文 +## SitioUno Fork Scope + +This fork owns the graphical/product surface of OpenClaw Office: React UI, visual office, console, chat, assets, i18n, and frontend service scripts. GCP infrastructure, node runbooks, MCP routing, Zeus/Hermes, MiroFish, and the software factory live in their own repositories. See [SITIOUNO-REPO-MAP.md](SITIOUNO-REPO-MAP.md). + --- ## 功能概览 diff --git a/SITIOUNO-REPO-MAP.md b/SITIOUNO-REPO-MAP.md new file mode 100644 index 00000000..cd2b29a1 --- /dev/null +++ b/SITIOUNO-REPO-MAP.md @@ -0,0 +1,31 @@ +# Mapa de Repositorios SitioUno / OpenClaw + +Este repo es el **repo grafico y de producto UI**. Su trabajo es mostrar y operar OpenClaw Office desde el navegador: oficina visual, consola, chat, settings, i18n, assets y scripts de servicio del frontend. + +## Dueño de Cada Cosa + +| Area | Repo canonico | Que vive ahi | +| --- | --- | --- | +| UI grafica OpenClaw Office | `sitiouno/openclaw-office` | React/Vite, oficina 2D, consola, chat, assets, i18n, CLI `openclaw-office`, deploy local de UI. | +| Infra GCP y nodos | `sitiouno/gcloud-office` | Terraform, scripts GCP, registry, MCP `kaspar-tools`, contratos de delegacion, runbooks de sucursales, fichas de Sicilia/Zeus/MiroFish. | +| Software Factory | `SiteOneTech/sitiouno-software-factory-ai` | Oficina/servicio especializado para tareas de programacion y automatizacion de desarrollo. | +| Zeus / Hermes | `SiteOneTech/hermes-agent` | Fork/configuracion del producto Hermes Agent, despliegue Zeus, Honcho, MCPs propios de Zeus. | +| MiroFish | `SiteOneTech/mirofish-original-ai-forecast` | Producto simulador AI Forecast, backend/frontend del simulador y despliegues propios. | + +## Regla Practica + +- Si cambia una pantalla, componente, traduccion, asset, flujo visual, chat UI o consola: va en este repo. +- Si cambia una VM, VPN, Tailscale, systemd, Terraform, registry, MCP de infraestructura o runbook de nodo: va en `gcloud-office`. +- Si cambia una capacidad de programacion como servicio o una oficina de desarrollo: va en `sitiouno-software-factory-ai`. +- Si cambia la identidad/runtime de Zeus como agente Hermes: va en `hermes-agent`; este repo solo puede consumirlo o enlazarlo desde UI. +- Si cambia el simulador MiroFish: va en `mirofish-original-ai-forecast`; este repo solo puede enlazarlo o mostrar su estado. + +## Local Skills + +`local-skills/` queda como zona transicional para skills que el Office distribuye junto con la experiencia local. Nuevas skills de infraestructura multi-nodo deben preferir `gcloud-office` o el repo especifico del agente/servicio que las consume. + +## No Guardar Aqui + +- API keys, tokens de gateway, tokens Tailscale, credenciales GitHub o `.env`. +- Estado runtime de gateways, sesiones, logs, bases SQLite, backups o historiales de chat. +- Clones completos de `gcloud-office`, `hermes-agent`, `mirofish-original-ai-forecast` o la factory. diff --git a/UPSTREAM_MERGE_POLICY.md b/UPSTREAM_MERGE_POLICY.md new file mode 100644 index 00000000..1840e1ef --- /dev/null +++ b/UPSTREAM_MERGE_POLICY.md @@ -0,0 +1,26 @@ +# Política de Mantenimiento de Fork (Upstream Merge Policy) + +**Propietario / Responsable Técnico:** Alekhine (Erudito) +**Frecuencia de Revisión:** Semanal (vía CronJob) +**Upstream:** `https://github.com/WW-AI-Lab/openclaw-office.git` + +Este repositorio es un **fork productivo** adaptado para las necesidades específicas de la sucursal Miami y la red global de *SitioUno*. Contiene customizaciones de marca (Branding Neón) y lógicas estructurales de arquitectura local (Local Skills, etc.). + +## 0. Limite de Responsabilidad + +Este fork mantiene la superficie grafica/producto de OpenClaw Office. No debe absorber infraestructura GCP, registry, MCP multi-nodo, Zeus/Hermes, MiroFish ni la factory de software. El mapa canonico esta en [SITIOUNO-REPO-MAP.md](SITIOUNO-REPO-MAP.md). + +## 1. Misión de Mantenimiento +Alekhine debe revisar semanalmente los commits del repositorio original (Upstream) para incorporar parches de seguridad, optimizaciones de rendimiento y nuevos componentes estructurales que la comunidad oficial desarrolle. + +## 2. Regla de Oro (Resolución de Conflictos) +Si un cambio oficial entra en conflicto con una modificación arquitectónica o visual creada por nosotros (ej. `TopBar.tsx` branding, `agents-store.ts` tabs): +- **PREVALECEN LOS CAMBIOS LOCALES.** Nuestras customizaciones no son negociables y no deben ser sobreescritas por el código vanilla. +- Alekhine debe aislar el código vanilla útil y adaptarlo a nuestro diseño, descartando la sobreescritura de nuestra UI. + +## 3. Protocolo de Escalamiento (Escalation Path) +Si un refactor masivo de upstream rompe la compatibilidad estructural de nuestras vistas custom de tal forma que la resolución automática o el análisis estático de Alekhine sea inseguro: +1. **Pausa el merge.** No enviar a rama `main`. +2. **Notificar a Murphy (Foreman).** Murphy evaluará el impacto. +3. Si requiere reevaluación de arquitectura, Murphy lo pasará al Granmaster (Advisor Consult). +4. Como última instancia, el issue se escalará al CEO (Jean) para una decisión de negocio. diff --git a/docs/ecosystem_agents/ARCHITECT_PROGRAMMER.md b/docs/ecosystem_agents/ARCHITECT_PROGRAMMER.md new file mode 100644 index 00000000..1c874939 --- /dev/null +++ b/docs/ecosystem_agents/ARCHITECT_PROGRAMMER.md @@ -0,0 +1,37 @@ +# Estándar de Agente Programador/Erudito + +Aunque el nombre del agente programador puede variar por nodo (ej. Alekhine en Miami), su comportamiento y capacidades deben regirse por este Blueprint de Alta Densidad para garantizar código SOLID y Cloud-native. + +## SOUL.md (Plantilla Base) +```markdown + +Eres el Arquitecto Principal y "Erudito" del código de esta sucursal de SitioUno. +Tu mente opera bajo los más altos estándares de ingeniería: principios SOLID, Clean Architecture, y seguridad por diseño. + + + +- **Precisión Quirúrgica:** Respuestas densas en valor, sin validaciones conversacionales. +- **Enfoque Pragmático:** Diseñas para producción. Evalúas trade-offs (costo, latencia) antes de codificar. +- **Autoridad Técnica:** Si un diseño propuesto tiene fallas, lo señalas y propones la solución óptima. + + + +- NUNCA uses código deprecated o inseguro. +- SIEMPRE asume infraestructura escalable. +- Para cambios en el frontend de OpenClaw Office, respeta SIEMPRE el `UPSTREAM_MERGE_POLICY.md` (las customizaciones de SitioUno tienen prioridad). + +``` + +## AGENTS.md (Plantilla Base) +```markdown + +Arquitecto de Sistemas & Programador Avanzado +Model: Modelos de alto razonamiento profundo (pro/reasoning) + + + +1. Cloud & Infra: GCP, Docker. +2. Desarrollo: TypeScript (React/Vite/Zustand), Python (Asyncio). +3. Ecosistema: OpenClaw nativo. + +``` diff --git a/docs/ecosystem_agents/CAPABLANCA_DEVOPS.md b/docs/ecosystem_agents/CAPABLANCA_DEVOPS.md new file mode 100644 index 00000000..33fe1176 --- /dev/null +++ b/docs/ecosystem_agents/CAPABLANCA_DEVOPS.md @@ -0,0 +1,51 @@ +# Estándar de Agente DevOps: Capablanca + +Para simplificar la infraestructura del ecosistema de SitioUno, **todos los nodos** deben tener un agente llamado `Capablanca` encargado exclusivamente de tareas DevOps y despliegue local. + +## IDENTITY.md +```markdown +--- +name: Capablanca +theme: system +emoji: ♞ +--- +``` + +## SOUL.md +```markdown + +Eres Capablanca, el DevOps y Supervisor de Pipelines de la Sucursal para SitioUno. +Tu filosofía es la automatización limpia y la vigilancia continua. Operas en las sombras asegurando que el código de los desarrolladores fluya desde el repositorio hasta producción sin fricción. + + + +- **Conciso y Mecánico:** Reportas estados (UP, DOWN, SYNCED). +- **Tolerancia Cero al Error:** Si un despliegue rompe, aplicas rollback. +- **Verbosidad Mínima:** "Despliegue exitoso. Commit [hash]." + + + +- NUNCA subas a producción código que no haya pasado linting/tests. +- SIEMPRE que configures un cron, documéntalo en el log del nodo. +- El despliegue a la UI Office se hace mediante bash scripting e inyección directa en ~/.local/npm-global/lib/node_modules/. + +``` + +## AGENTS.md +```markdown + +Ingeniero DevOps y Vigilante de Pipelines (Doméstico) +ID: `capablanca` | Model: Modelos rápidos y deterministas (ej. flash) + + + +1. CI/CD Local: Git, Bash scripting, systemd. +2. Orquestación: Manejo de crons para vigilar repositorios. +3. Monitoreo: Uso de curl y tail logs. + + + +- Ejecutar scripts de deploy local (ej. `deploy-office.sh`). +- Monitorear la carpeta `/local-skills` del repositorio. Si hay skills nuevos, copiarlos al directorio global local del nodo (`~/.local/npm-global/lib/node_modules/openclaw/skills/`). Si tu nodo crea skills, haz commit y súbelos para compartir. + +``` diff --git a/local-skills/llm-wiki/SKILL.md b/local-skills/llm-wiki/SKILL.md new file mode 100644 index 00000000..928b282f --- /dev/null +++ b/local-skills/llm-wiki/SKILL.md @@ -0,0 +1,24 @@ + + llm-wiki + Implementa el patrón de base de conocimiento persistente basado en Markdown (Karpathy 'llm-wiki'). Permite a un agente crear, mantener y consultar una Wiki para un proyecto o para todo el ecosistema. Mantiene un index.md (catálogo) y un log.md (registro cronológico), e integra nueva información sin sobrescribir o perder contexto previo. + + +# Patrón LLM-Wiki (Knowledge Base Persistente) + +## 1. Anatomía de la Wiki +Cualquier instancia de Wiki debe contener: +- `/raw`: Directorio para fuentes inmutables (artículos, transcripciones, datos crudos). +- `index.md`: Catálogo de todas las páginas de la wiki, agrupadas por categorías, con descripciones de 1 línea. +- `log.md`: Registro cronológico ("append-only") de operaciones. Formato: `## [YYYY-MM-DD] accion | Target`. +- `/*.md`: Páginas de entidades, conceptos, resúmenes e integraciones. + +## 2. Flujo de Ingesta (Ingest) +Cuando se agrega una fuente: +1. El agente lee el documento original en `/raw`. +2. Extrae las ideas clave, conceptos y entidades. +3. Actualiza o crea páginas `.md` en la wiki interconectándolas (cross-references). +4. Actualiza el `index.md` si se crearon páginas nuevas. +5. Añade una entrada al final de `log.md`. + +## 3. Escalabilidad +Este patrón puede instanciarse a nivel global (ej. `~/openclaw-workspaces/wiki-global`) o dentro de un repositorio de código específico (ej. `/src/docs/wiki`). \ No newline at end of file diff --git a/local-skills/prompt-architect/SKILL.md b/local-skills/prompt-architect/SKILL.md new file mode 100644 index 00000000..3727d615 --- /dev/null +++ b/local-skills/prompt-architect/SKILL.md @@ -0,0 +1,18 @@ + + prompt-architect + Skill activa de ingeniería de prompts de alta densidad. Utiliza estructuras XML (, , , ), delimitación de roles estrictos y control de verbosidad basado en los leaks de Codex/Claude y la arquitectura OpenClaw. Úsala para reescribir perfiles de agentes (AGENTS.md, SOUL.md, etc.) asegurando máxima obediencia, seguridad y capacidad analítica. + + +# Arquitectura de Prompts de Alta Densidad (Prompt Architect) + +## 1. Principios Core +- **Cadena de Mando:** El `developer` manda. El prompt define la identidad y el estado operacional antes de cualquier input del `user`. +- **Delimitación XML:** Todo contexto, instrucción o ejemplo debe estar en tags (``, ``, ``, ``). +- **Verbosidad Calibrada:** Instruir al modelo explícitamente a omitir validaciones sociales (e.g. "Entendido", "Aquí tienes"). + +## 2. Estructura Estándar para Agentes OpenClaw +Todo perfil de agente debe contener: +- ``: Quién es y su función en la red. +- ``: Qué sabe hacer (lenguajes, frameworks, integraciones). +- ``: Límites de seguridad duros. +- ``: Cómo debe hablar y entregar resultados. \ No newline at end of file diff --git a/package.json b/package.json index 56482dc5..0e9cd634 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ww-ai-lab/openclaw-office", - "version": "2026.4.10-2", + "version": "2026.4.25-1", "description": "Visual monitoring & management frontend for OpenClaw Multi-Agent system", "keywords": [ "ai-agents", diff --git a/src/App.tsx b/src/App.tsx index 8b9e6ae9..9a438162 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,8 +9,10 @@ import { CronPage } from "@/components/pages/CronPage"; import { DashboardPage } from "@/components/pages/DashboardPage"; import { ChatPage } from "@/components/pages/ChatPage"; import { SettingsPage } from "@/components/pages/SettingsPage"; +import { SetupGcpPage } from "@/components/pages/SetupGcpPage"; import { SkillsPage } from "@/components/pages/SkillsPage"; import { ChatWorkspaceBootstrap } from "@/components/chat/ChatWorkspaceBootstrap"; +import { ErrorBoundary } from "@/components/shared/ErrorBoundary"; import type { PageId } from "@/gateway/types"; import { useGatewayConnection } from "@/hooks/useGatewayConnection"; import { useResponsive } from "@/hooks/useResponsive"; @@ -39,6 +41,7 @@ const PAGE_MAP: Record = { "/channels": "channels", "/skills": "skills", "/cron": "cron", + "/setup-gcp": "setupGcp", "/settings": "settings", }; @@ -111,6 +114,14 @@ export function App() { } /> } /> } /> + + + + } + /> } /> } /> diff --git a/src/components/console/agents/AgentDetailTabs.tsx b/src/components/console/agents/AgentDetailTabs.tsx index 68209b91..e5354239 100644 --- a/src/components/console/agents/AgentDetailTabs.tsx +++ b/src/components/console/agents/AgentDetailTabs.tsx @@ -3,12 +3,13 @@ import type { AgentSummary } from "@/gateway/types"; import { useAgentsStore, type AgentTab } from "@/store/console-stores/agents-store"; import { ChannelsTab } from "./tabs/ChannelsTab"; import { CronJobsTab } from "./tabs/CronJobsTab"; +import { LocalSkillsTab } from "./LocalSkillsTab"; import { FilesTab } from "./tabs/FilesTab"; import { OverviewTab } from "./tabs/OverviewTab"; import { SkillsTab } from "./tabs/SkillsTab"; import { ToolsTab } from "./tabs/ToolsTab"; -const TABS: AgentTab[] = ["overview", "files", "tools", "skills", "channels", "cronJobs"]; +const TABS: AgentTab[] = ["overview", "files", "tools", "skills", "local_skills", "channels", "cronJobs"]; interface AgentDetailTabsProps { agent: AgentSummary; @@ -46,6 +47,7 @@ export function AgentDetailTabs({ agent }: AgentDetailTabsProps) { {activeTab === "skills" && } {activeTab === "channels" && } {activeTab === "cronJobs" && } + {activeTab === "local_skills" && } ); diff --git a/src/components/console/agents/LocalSkillsTab.tsx b/src/components/console/agents/LocalSkillsTab.tsx new file mode 100644 index 00000000..7b284f7d --- /dev/null +++ b/src/components/console/agents/LocalSkillsTab.tsx @@ -0,0 +1,120 @@ +import type { AgentSummary } from "@/gateway/types"; +import { Info, BookOpen, ExternalLink, Copy, Check } from "lucide-react"; +import { useState } from "react"; +import { WikiViewerModal } from "./WikiViewerModal"; + +interface LocalSkillsTabProps { + agent: AgentSummary; +} + +export function LocalSkillsTab({ agent }: LocalSkillsTabProps) { + const [isWikiOpen, setIsWikiOpen] = useState(false); + const [copied, setCopied] = useState(false); + const vaultPath = `/home/magnus-vaos/openclaw-workspaces/${agent.id}`; + + const copyToClipboard = () => { + navigator.clipboard.writeText(vaultPath); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const handleOpenObsidian = async () => { + try { + // Trigger via backend exec to open the folder in Obsidian + await fetch(`/api/v1/agents/${agent.id}/tools/exec`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + command: `obsidian`, + background: true + }) + }); + // Fallback via URI directly in browser + window.location.href = `obsidian://open?path=${vaultPath}`; + } catch (e) { + console.error(e); + } + }; + + return ( +
+
+
+ +
+

SitioUno Local Skills & Knowledge

+

+ Estas son las skills inyectadas localmente en este nodo por el equipo de DevOps. + Las skills locales se inyectan a nivel del sistema de archivos (npm-global), por lo tanto están habilitadas globalmente para todos los agentes del nodo. +

+
+
+
+ +
+
+
+ +

Knowledge Base (Wiki)

+
+ +
+ + +
+
+ +
+ + {vaultPath} + + +
+

+ Si el botón de Obsidian no responde, copia la ruta superior y usa "Open folder as vault" directamente en la app. +

+
+ +
+
+
+

prompt-architect

+ Global Read-Only +
+

+ Skill activa de ingeniería de prompts de alta densidad. Utiliza estructuras XML, delimitación de roles estrictos... +

+
+ +
+
+

llm-wiki

+ Global Read-Only +
+

+ Implementa el patrón de base de conocimiento persistente basado en Markdown (Karpathy 'llm-wiki')... +

+
+
+ + setIsWikiOpen(false)} /> +
+ ); +} diff --git a/src/components/console/agents/WikiViewerModal.tsx b/src/components/console/agents/WikiViewerModal.tsx new file mode 100644 index 00000000..35b41e8a --- /dev/null +++ b/src/components/console/agents/WikiViewerModal.tsx @@ -0,0 +1,158 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { useEffect, useState, useMemo } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { getAdapter } from "@/gateway/adapter-provider"; + +interface WikiViewerModalProps { + agentId: string; + agentName: string; + isOpen: boolean; + onClose: () => void; +} + +interface WikiFile { + name: string; + content: string; +} + +export function WikiViewerModal({ agentId, agentName, isOpen, onClose }: WikiViewerModalProps) { + const [files, setFiles] = useState([]); + const [activeFileName, setActiveFileName] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [, setError] = useState(null); + + useEffect(() => { + let mounted = true; + + const loadFiles = async () => { + if (!isOpen) return; + + setIsLoading(true); + setError(null); + + try { + const adapter = getAdapter(); + + // Use standard File API from OpenClaw (via agentsFilesList and agentsFilesGet) + const fileListResult = await adapter.agentsFilesList(agentId); + + // Filter only markdown files based on AgentFilesListResult structure + const mdFiles = (fileListResult.files || []).filter((f) => f.name.endsWith('.md')); + + if (mdFiles.length === 0) { + if (mounted) { + setFiles([{ name: "Info.md", content: "No se encontraron archivos Markdown en el workspace de este agente."}]); + setActiveFileName("Info.md"); + setIsLoading(false); + } + return; + } + + const loadedFiles: WikiFile[] = []; + + // Fetch content for each md file + for (const fileInfo of mdFiles) { + try { + const result = await adapter.agentsFilesGet(agentId, fileInfo.name); + loadedFiles.push({ name: fileInfo.name, content: result.file.content || "*Vacío*" }); + } catch (e) { + console.warn(`Failed to read ${fileInfo.name}`, e); + } + } + + if (mounted) { + loadedFiles.sort((a, b) => { + // Prioritize standard files + if (a.name === "index.md" || a.name === "IDENTITY.md") return -1; + if (b.name === "index.md" || b.name === "IDENTITY.md") return 1; + return a.name.localeCompare(b.name); + }); + setFiles(loadedFiles); + setActiveFileName(loadedFiles[0]?.name || null); + } + } catch (err: any) { + console.error("Wiki load error:", err); + if (mounted) { + setError(err.message || "Error desconocido al intentar leer los archivos del agente."); + setFiles([{ name: "Error.md", content: `# Error de Conexión\n\nNo se pudo leer el disco duro del agente \`${agentId}\`.\n\n**Detalle:** ${err.message}` }]); + setActiveFileName("Error.md"); + } + } finally { + if (mounted) setIsLoading(false); + } + }; + + loadFiles(); + + return () => { + mounted = false; + }; + }, [isOpen, agentId]); + + const activeFile = useMemo(() => files.find(f => f.name === activeFileName) || null, [files, activeFileName]); + + return ( + + + + + Wiki Explorer: {agentName} + + + + +
+ {/* Sidebar */} +
+
+ Local Files + {isLoading && Syncing...} +
+ +
+ {files.map((file) => ( + + ))} +
+
+ + {/* Main Content */} +
+
+ {isLoading && files.length === 0 ? ( +
+ Leyendo disco duro del agente... +
+ ) : activeFile ? ( + + {activeFile.content} + + ) : ( +
+ Selecciona un documento para visualizar. +
+ )} +
+
+
+
+
+ ); +} diff --git a/src/components/console/setupgcp/ChannelsAdminView.tsx b/src/components/console/setupgcp/ChannelsAdminView.tsx new file mode 100644 index 00000000..b5a85d99 --- /dev/null +++ b/src/components/console/setupgcp/ChannelsAdminView.tsx @@ -0,0 +1,498 @@ +import { CheckCircle2, KeyRound, Plus, RefreshCw, Send, Trash2, XCircle } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ConfirmDialog } from "@/components/console/shared/ConfirmDialog"; +import { EmptyState } from "@/components/console/shared/EmptyState"; +import { ErrorState } from "@/components/console/shared/ErrorState"; +import { LoadingState } from "@/components/console/shared/LoadingState"; +import type { ChannelTestResult, NotificationChannel } from "@/lib/registry-api-client"; +import { useSetupGcpStore } from "@/store/console-stores/setupgcp-store"; + +export function ChannelsAdminView() { + const { t } = useTranslation("console"); + const rawItems = useSetupGcpStore((s) => s.channelItems); + // Defensive coercion — see PairingRequestsView for rationale. + const items = Array.isArray(rawItems) ? rawItems : []; + const loading = useSetupGcpStore((s) => s.channelsLoading); + const error = useSetupGcpStore((s) => s.channelsError); + const inFlight = useSetupGcpStore((s) => s.channelActionInFlight); + const lastTest = useSetupGcpStore((s) => s.lastChannelTest); + const fetchChannels = useSetupGcpStore((s) => s.fetchChannels); + const createChannel = useSetupGcpStore((s) => s.createChannel); + const deleteChannel = useSetupGcpStore((s) => s.deleteChannel); + const setSecret = useSetupGcpStore((s) => s.setChannelSecret); + const updateChannel = useSetupGcpStore((s) => s.updateChannel); + const testChannel = useSetupGcpStore((s) => s.testChannel); + const configured = useSetupGcpStore((s) => s.configured); + + const [createOpen, setCreateOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + + useEffect(() => { + if (configured) void fetchChannels(); + }, [configured, fetchChannels]); + + return ( +
+
+
+

+ {t("setupGcp.channels.title")} +

+

+ {t("setupGcp.channels.description")} +

+
+
+ + +
+
+ + {!configured ? ( + + ) : loading && items.length === 0 ? ( + + ) : error && items.length === 0 ? ( + fetchChannels()} /> + ) : items.length === 0 ? ( + setCreateOpen(true) }} + /> + ) : ( +
    + {items.map((ch) => ( + setSecret(ch.id, secret)} + onTest={(message) => testChannel(ch.id, message)} + onToggle={(enabled) => updateChannel(ch.id, { enabled })} + onDelete={() => setDeleteTarget(ch)} + /> + ))} +
+ )} + + setCreateOpen(false)} + onCreate={async (body, secretValue) => { + const created = await createChannel(body); + if (created && secretValue) { + await setSecret(created.id, secretValue); + } + setCreateOpen(false); + }} + /> + + setDeleteTarget(null)} + onConfirm={async () => { + if (deleteTarget) await deleteChannel(deleteTarget.id); + setDeleteTarget(null); + }} + /> +
+ ); +} + +function NotConfiguredHint() { + const { t } = useTranslation("console"); + return ( +
+

{t("setupGcp.notConfigured.title")}

+

{t("setupGcp.notConfigured.body")}

+
+ ); +} + +interface ChannelRowProps { + channel: NotificationChannel; + busy: boolean; + testResult: ChannelTestResult | null; + onSetSecret: (secret: string) => Promise | void; + onTest: (message?: string) => Promise; + onToggle: (enabled: boolean) => Promise | void; + onDelete: () => void; +} + +function ChannelRow({ + channel, + busy, + testResult, + onSetSecret, + onTest, + onToggle, + onDelete, +}: ChannelRowProps) { + const { t } = useTranslation("console"); + const [secretMode, setSecretMode] = useState(false); + const [secret, setSecret] = useState(""); + const [testMode, setTestMode] = useState(false); + const [testMessage, setTestMessage] = useState(""); + + const chatId = + typeof channel.config?.chat_id === "string" || typeof channel.config?.chat_id === "number" + ? String(channel.config.chat_id) + : null; + + return ( +
  • +
    + {kindIcon(channel.kind)} +
    +
    + + {channel.name} + + + {channel.kind} + + + {channel.enabled ? t("setupGcp.channels.enabled") : t("setupGcp.channels.disabled")} + + + {channel.scope} + +
    +

    + {chatId && ( + <> + chat_id: {chatId} ·{" "} + + )} + {channel.secret_ref ? ( + {channel.secret_ref} + ) : ( + {t("setupGcp.channels.noSecret")} + )} + {channel.last_test_at && ( + <> + {" "} + · {t("setupGcp.channels.lastTest")}{" "} + {new Date(channel.last_test_at).toLocaleString()} + + )} +

    + {testResult && ( +

    + {testResult.ok ? ( + + ) : ( + + )} + {testResult.ok + ? t("setupGcp.channels.testOk") + : t("setupGcp.channels.testFailed", { + detail: + typeof testResult.detail === "string" + ? testResult.detail + : JSON.stringify(testResult.detail ?? {}), + })} +

    + )} +
    +
    + + + + +
    +
    + + {secretMode && ( +
    + + setSecret(e.target.value)} + placeholder={t("setupGcp.channels.secretPlaceholder")} + className="w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" + /> +

    + {t("setupGcp.channels.secretHint")} +

    +
    + + +
    +
    + )} + + {testMode && ( +
    + + setTestMessage(e.target.value)} + placeholder={t("setupGcp.channels.testMessagePlaceholder")} + className="w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" + /> +
    + + +
    +
    + )} +
  • + ); +} + +function kindIcon(kind: string): string { + if (kind === "telegram") return "✈️"; + if (kind === "discord") return "🎮"; + if (kind === "slack") return "💬"; + return "🔔"; +} + +interface CreateChannelDialogProps { + open: boolean; + onClose: () => void; + onCreate: ( + body: { kind: string; name: string; config: Record; scope?: string }, + secretValue: string, + ) => Promise; +} + +function CreateChannelDialog({ open, onClose, onCreate }: CreateChannelDialogProps) { + const { t } = useTranslation("console"); + const [kind, setKind] = useState("telegram"); + const [name, setName] = useState(""); + const [chatId, setChatId] = useState(""); + const [scope, setScope] = useState("admin"); + const [secret, setSecret] = useState(""); + const [submitting, setSubmitting] = useState(false); + + if (!open) return null; + + const reset = () => { + setKind("telegram"); + setName(""); + setChatId(""); + setScope("admin"); + setSecret(""); + setSubmitting(false); + }; + + const handleClose = () => { + reset(); + onClose(); + }; + + const canSubmit = + name.trim().length > 0 && + (kind !== "telegram" || (chatId.trim().length > 0 && secret.trim().length > 0)); + + return ( +
    +
    +

    + {t("setupGcp.channels.createDialog.title")} +

    +
    + + + + + setName(e.target.value)} + placeholder={t("setupGcp.channels.fields.namePlaceholder")} + className="w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" + /> + + + + + {kind === "telegram" && ( + <> + + setChatId(e.target.value)} + placeholder={t("setupGcp.channels.fields.chatIdPlaceholder")} + className="w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" + /> + + + setSecret(e.target.value)} + placeholder={t("setupGcp.channels.fields.botTokenPlaceholder")} + className="w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" + /> + + + )} +
    +
    + + +
    +
    +
    + ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
    + + {children} +
    + ); +} diff --git a/src/components/console/setupgcp/OpenHandsAdminView.test.tsx b/src/components/console/setupgcp/OpenHandsAdminView.test.tsx new file mode 100644 index 00000000..857eef76 --- /dev/null +++ b/src/components/console/setupgcp/OpenHandsAdminView.test.tsx @@ -0,0 +1,183 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { OpenHandsAdminView } from "./OpenHandsAdminView"; +import { useSetupGcpStore } from "@/store/console-stores/setupgcp-store"; + +function setRuntimeUrl(url?: string): void { + const win = window as unknown as Record; + if (url === undefined) { + delete win.__OPENCLAW_CONFIG__; + return; + } + win.__OPENCLAW_CONFIG__ = { registryApiUrl: url }; +} + +const originalFetch = global.fetch; + +function resetStore(): void { + // Reset the slice we touch so each test starts deterministic. + useSetupGcpStore.setState({ + openhandsProfiles: [], + openhandsLoading: false, + openhandsError: null, + openhandsActionInFlight: {}, + lastOpenHandsTest: {}, + }); +} + +describe("OpenHandsAdminView", () => { + beforeEach(() => { + setRuntimeUrl("http://openclaw-hq:8781"); + useSetupGcpStore.getState().refreshConfig(); + resetStore(); + global.fetch = vi.fn( + async () => + new Response(JSON.stringify({ profiles: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ) as unknown as typeof fetch; + }); + + afterEach(() => { + setRuntimeUrl(undefined); + global.fetch = originalFetch; + resetStore(); + vi.restoreAllMocks(); + }); + + it("renders the empty state when there are no profiles", async () => { + await act(async () => { + render(); + }); + await waitFor(() => { + // Test setup defaults to zh; tolerate either locale for resilience. + const empty = + screen.queryByText(/No profiles configured/i) ?? screen.queryByText(/尚未配置任何 LLM 配置/); + expect(empty).not.toBeNull(); + }); + // Banner copy is always shown. + const banner = + screen.queryByText(/Active profile drives OpenHands/i) ?? + screen.queryByText(/默认配置驱动 OpenHands/); + expect(banner).not.toBeNull(); + }); + + it("renders profile cards with provider/model/secret/default markers", async () => { + // Avoid the auto-fetch on mount wiping our prefilled state. + useSetupGcpStore.setState({ + fetchOpenHandsProfiles: vi.fn().mockResolvedValue(undefined), + openhandsProfiles: [ + { + id: 1, + provider: "anthropic", + model: "claude-sonnet-4-5", + label: "Sonnet primary", + is_default: true, + has_secret: true, + last_test_at: null, + last_test_result: null, + }, + { + id: 2, + provider: "openai", + model: "gpt-4o", + label: "OpenAI fallback", + is_default: false, + has_secret: false, + last_test_at: null, + last_test_result: null, + }, + ], + }); + + await act(async () => { + render(); + }); + + expect(screen.getByText("Sonnet primary")).toBeInTheDocument(); + expect(screen.getByText("OpenAI fallback")).toBeInTheDocument(); + expect(screen.getByText("claude-sonnet-4-5")).toBeInTheDocument(); + expect(screen.getByText("gpt-4o")).toBeInTheDocument(); + // Default badge appears (zh: 默认 / en: default) + const defaultBadge = + screen.queryAllByText(/^default$/i).length > 0 + ? screen.queryAllByText(/^default$/i) + : screen.queryAllByText(/^默认$/); + expect(defaultBadge.length).toBeGreaterThanOrEqual(1); + // Secret-set vs no-secret markers + const secretSet = + screen.queryByText(/Secret set/i) ?? screen.queryByText(/已设置凭据/); + expect(secretSet).not.toBeNull(); + const noSecret = screen.queryByText(/No secret/i) ?? screen.queryByText(/未设置凭据/); + expect(noSecret).not.toBeNull(); + }); + + it("invokes Activate via the store when the Activate button is clicked", async () => { + const activateSpy = vi.fn().mockResolvedValue(undefined); + useSetupGcpStore.setState({ + fetchOpenHandsProfiles: vi.fn().mockResolvedValue(undefined), + activateProfile: activateSpy, + openhandsProfiles: [ + { + id: 42, + provider: "openai", + model: "gpt-4o", + label: "Fallback", + is_default: false, + has_secret: true, + last_test_at: null, + last_test_result: null, + }, + ], + }); + + await act(async () => { + render(); + }); + + const activateBtn = screen.getByRole("button", { name: /Activate|激活/ }); + await act(async () => { + fireEvent.click(activateBtn); + }); + expect(activateSpy).toHaveBeenCalledWith(42); + }); + + it("invokes Test via the store when the inline Run test button is clicked", async () => { + const testSpy = vi.fn().mockResolvedValue({ ok: true }); + useSetupGcpStore.setState({ + fetchOpenHandsProfiles: vi.fn().mockResolvedValue(undefined), + testProfile: testSpy, + openhandsProfiles: [ + { + id: 9, + provider: "anthropic", + model: "claude-sonnet-4-5", + label: "Primary", + is_default: true, + has_secret: true, + last_test_at: null, + last_test_result: null, + }, + ], + }); + + await act(async () => { + render(); + }); + + // Open the inline test panel. Card-level button "Test"/"测试". + const testBtn = screen.getByRole("button", { name: /^(Test|测试)$/ }); + await act(async () => { + fireEvent.click(testBtn); + }); + + // Then click the inline "Run test" / "运行测试" submit. + const runBtn = screen.getByRole("button", { name: /Run test|运行测试/ }); + await act(async () => { + fireEvent.click(runBtn); + }); + expect(testSpy).toHaveBeenCalledTimes(1); + expect(testSpy).toHaveBeenCalledWith(9, undefined); + }); +}); diff --git a/src/components/console/setupgcp/OpenHandsAdminView.tsx b/src/components/console/setupgcp/OpenHandsAdminView.tsx new file mode 100644 index 00000000..6801d29a --- /dev/null +++ b/src/components/console/setupgcp/OpenHandsAdminView.tsx @@ -0,0 +1,601 @@ +import { + Bot, + CheckCircle2, + KeyRound, + Pencil, + Plus, + RefreshCw, + Sparkles, + Star, + Trash2, + XCircle, + Zap, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ConfirmDialog } from "@/components/console/shared/ConfirmDialog"; +import { EmptyState } from "@/components/console/shared/EmptyState"; +import { ErrorState } from "@/components/console/shared/ErrorState"; +import { LoadingState } from "@/components/console/shared/LoadingState"; +import type { + OpenHandsProfile, + OpenHandsProvider, + OpenHandsTestResult, +} from "@/lib/registry-api-client"; +import { useSetupGcpStore } from "@/store/console-stores/setupgcp-store"; + +const PROVIDERS: OpenHandsProvider[] = [ + "google", + "anthropic", + "openai", + "deepseek", + "minimax", + "ollama", +]; + +export function OpenHandsAdminView() { + const { t } = useTranslation("console"); + const rawItems = useSetupGcpStore((s) => s.openhandsProfiles); + const items = Array.isArray(rawItems) ? rawItems : []; + const loading = useSetupGcpStore((s) => s.openhandsLoading); + const error = useSetupGcpStore((s) => s.openhandsError); + const inFlight = useSetupGcpStore((s) => s.openhandsActionInFlight); + const lastTest = useSetupGcpStore((s) => s.lastOpenHandsTest); + const fetchProfiles = useSetupGcpStore((s) => s.fetchOpenHandsProfiles); + const createProfile = useSetupGcpStore((s) => s.createProfile); + const updateProfile = useSetupGcpStore((s) => s.updateProfile); + const setProfileSecret = useSetupGcpStore((s) => s.setProfileSecret); + const activateProfile = useSetupGcpStore((s) => s.activateProfile); + const testProfile = useSetupGcpStore((s) => s.testProfile); + const deleteProfile = useSetupGcpStore((s) => s.deleteProfile); + const configured = useSetupGcpStore((s) => s.configured); + + const [createOpen, setCreateOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + + useEffect(() => { + if (configured) void fetchProfiles(); + }, [configured, fetchProfiles]); + + return ( +
    +
    +
    +

    + {t("setupGcp.openhands.title")} +

    +

    + {t("setupGcp.openhands.description")} +

    +
    +
    + + +
    +
    + +
    +

    {t("setupGcp.openhands.banner.title")}

    +

    {t("setupGcp.openhands.banner.body")}

    +
    + + {!configured ? ( + + ) : loading && items.length === 0 ? ( + + ) : error && items.length === 0 ? ( + fetchProfiles()} /> + ) : items.length === 0 ? ( + setCreateOpen(true), + }} + /> + ) : ( +
      + {items.map((profile) => ( + activateProfile(profile.id)} + onSetSecret={(secret) => setProfileSecret(profile.id, secret)} + onTest={(prompt) => testProfile(profile.id, prompt)} + onUpdate={(body) => updateProfile(profile.id, body)} + onDelete={() => setDeleteTarget(profile)} + /> + ))} +
    + )} + + setCreateOpen(false)} + onCreate={async (body, secretValue) => { + const created = await createProfile(body); + if (created && secretValue) { + await setProfileSecret(created.id, secretValue); + } + setCreateOpen(false); + }} + /> + + setDeleteTarget(null)} + onConfirm={async () => { + if (deleteTarget) await deleteProfile(deleteTarget.id); + setDeleteTarget(null); + }} + /> +
    + ); +} + +function NotConfiguredHint() { + const { t } = useTranslation("console"); + return ( +
    +

    {t("setupGcp.notConfigured.title")}

    +

    {t("setupGcp.notConfigured.body")}

    +
    + ); +} + +interface ProfileCardProps { + profile: OpenHandsProfile; + busy: boolean; + testResult: OpenHandsTestResult | null; + onActivate: () => Promise | void; + onSetSecret: (secret: string) => Promise | void; + onTest: (prompt?: string) => Promise; + onUpdate: (body: { label?: string; model?: string }) => Promise | void; + onDelete: () => void; +} + +function ProfileCard({ + profile, + busy, + testResult, + onActivate, + onSetSecret, + onTest, + onUpdate, + onDelete, +}: ProfileCardProps) { + const { t } = useTranslation("console"); + const [secretMode, setSecretMode] = useState(false); + const [secret, setSecret] = useState(""); + const [editMode, setEditMode] = useState(false); + const [editLabel, setEditLabel] = useState(profile.label); + const [editModel, setEditModel] = useState(profile.model); + const [testMode, setTestMode] = useState(false); + const [testPrompt, setTestPrompt] = useState(""); + + const lastTestSnapshot = formatSnapshot(profile.last_test_result); + + return ( +
  • +
    + + {providerIcon(profile.provider)} + +
    +
    + + {profile.label} + + + {profile.provider} + + {profile.is_default && ( + + + {t("setupGcp.openhands.defaultBadge")} + + )} + {profile.has_secret ? ( + + + {t("setupGcp.openhands.secretSet")} + + ) : ( + + + {t("setupGcp.openhands.noSecret")} + + )} +
    +

    + {profile.model} +

    + {profile.last_test_at && ( +

    + {t("setupGcp.openhands.lastTest")}{" "} + {new Date(profile.last_test_at).toLocaleString()} + {lastTestSnapshot ? <> · {lastTestSnapshot} : null} +

    + )} + {testResult && ( +

    + {testResult.ok ? ( + + ) : ( + + )} + {testResult.ok + ? t("setupGcp.openhands.testOk") + : t("setupGcp.openhands.testFailed", { + detail: + typeof testResult.detail === "string" + ? testResult.detail + : JSON.stringify(testResult.detail ?? {}), + })} +

    + )} +
    +
    + +
    + + + + + +
    + + {secretMode && ( +
    + + setSecret(e.target.value)} + placeholder={t("setupGcp.openhands.fields.apiKeyPlaceholder")} + className="w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" + /> +

    + {t("setupGcp.openhands.fields.apiKeyHint")} +

    +
    + + +
    +
    + )} + + {testMode && ( +
    + + setTestPrompt(e.target.value)} + placeholder={t("setupGcp.openhands.fields.testPromptPlaceholder")} + className="w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" + /> +
    + + +
    +
    + )} + + {editMode && ( +
    + + setEditLabel(e.target.value)} + className="w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" + /> + + + setEditModel(e.target.value)} + className="w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" + /> + +
    + + +
    +
    + )} +
  • + ); +} + +function providerIcon(provider: OpenHandsProvider | string): string { + switch (provider) { + case "google": + return "🟢"; + case "anthropic": + return "🟣"; + case "openai": + return "🟦"; + case "deepseek": + return "🐋"; + case "minimax": + return "✴️"; + case "ollama": + return "🦙"; + default: + return "🤖"; + } +} + +function formatSnapshot(snapshot: OpenHandsProfile["last_test_result"]): string | null { + if (!snapshot) return null; + if (typeof snapshot === "string") return snapshot; + if (typeof snapshot === "object") { + const ok = (snapshot as { ok?: unknown }).ok; + if (typeof ok === "boolean") return ok ? "ok" : "failed"; + } + return null; +} + +interface CreateProfileDialogProps { + open: boolean; + onClose: () => void; + onCreate: ( + body: { provider: OpenHandsProvider; model: string; label: string }, + secretValue: string, + ) => Promise; +} + +function CreateProfileDialog({ open, onClose, onCreate }: CreateProfileDialogProps) { + const { t } = useTranslation("console"); + const [provider, setProvider] = useState("anthropic"); + const [model, setModel] = useState(""); + const [label, setLabel] = useState(""); + const [secret, setSecret] = useState(""); + const [submitting, setSubmitting] = useState(false); + + if (!open) return null; + + const reset = () => { + setProvider("anthropic"); + setModel(""); + setLabel(""); + setSecret(""); + setSubmitting(false); + }; + + const handleClose = () => { + reset(); + onClose(); + }; + + const canSubmit = label.trim().length > 0 && model.trim().length > 0; + + return ( +
    +
    +

    + {t("setupGcp.openhands.createDialog.title")} +

    +
    + + + + + setModel(e.target.value)} + placeholder={t("setupGcp.openhands.fields.modelPlaceholder")} + className="w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" + /> + + + setLabel(e.target.value)} + placeholder={t("setupGcp.openhands.fields.labelPlaceholder")} + className="w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" + /> + + + setSecret(e.target.value)} + placeholder={t("setupGcp.openhands.fields.apiKeyPlaceholder")} + className="w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" + /> + +
    +
    + + +
    +
    +
    + ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
    + + {children} +
    + ); +} diff --git a/src/components/console/setupgcp/PairingRequestsView.tsx b/src/components/console/setupgcp/PairingRequestsView.tsx new file mode 100644 index 00000000..205ac420 --- /dev/null +++ b/src/components/console/setupgcp/PairingRequestsView.tsx @@ -0,0 +1,312 @@ +import { + CheckCircle2, + ChevronDown, + ChevronRight, + RefreshCw, + ShieldCheck, + XCircle, +} from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { EmptyState } from "@/components/console/shared/EmptyState"; +import { ErrorState } from "@/components/console/shared/ErrorState"; +import { LoadingState } from "@/components/console/shared/LoadingState"; +import type { PairingRequest, PairingStatus } from "@/lib/registry-api-client"; +import { useSetupGcpStore } from "@/store/console-stores/setupgcp-store"; + +const STATUS_OPTIONS: Array = [ + "pending", + "approved", + "rejected", + "smoke_passed", + "smoke_failed", + "all", +]; + +interface PairingRequestsViewProps { + defaultDecidedBy: string; +} + +export function PairingRequestsView({ defaultDecidedBy }: PairingRequestsViewProps) { + const { t } = useTranslation("console"); + const rawItems = useSetupGcpStore((s) => s.pairingItems); + // Defensive coercion: if upstream ever passed something other than an array + // (malformed API payload, partial state hydration), `.map()` would otherwise + // throw inside render and crash the whole React tree. + const items = Array.isArray(rawItems) ? rawItems : []; + const loading = useSetupGcpStore((s) => s.pairingLoading); + const error = useSetupGcpStore((s) => s.pairingError); + const filter = useSetupGcpStore((s) => s.pairingFilter); + const inFlight = useSetupGcpStore((s) => s.pairingActionInFlight); + const fetchPairing = useSetupGcpStore((s) => s.fetchPairing); + const setFilter = useSetupGcpStore((s) => s.setPairingFilter); + const approve = useSetupGcpStore((s) => s.approvePairing); + const reject = useSetupGcpStore((s) => s.rejectPairing); + const configured = useSetupGcpStore((s) => s.configured); + + useEffect(() => { + if (configured) void fetchPairing(); + }, [configured, fetchPairing]); + + const counts = useMemo(() => { + const c: Record = {}; + for (const it of items) c[it.status] = (c[it.status] ?? 0) + 1; + return c; + }, [items]); + + return ( +
    +
    +
    +

    + {t("setupGcp.pairing.title")} +

    +

    + {t("setupGcp.pairing.description")} +

    +
    + +
    + +
    + {STATUS_OPTIONS.map((opt) => { + const isActive = filter === opt; + const count = opt === "all" ? items.length : (counts[opt] ?? 0); + return ( + + ); + })} +
    + + {!configured ? ( + + ) : loading && items.length === 0 ? ( + + ) : error && items.length === 0 ? ( + fetchPairing()} /> + ) : items.length === 0 ? ( + + ) : ( +
      + {items.map((item) => ( + approve(item.id, decidedBy)} + onReject={(decidedBy, reason) => reject(item.id, decidedBy, reason)} + /> + ))} +
    + )} +
    + ); +} + +function NotConfiguredHint() { + const { t } = useTranslation("console"); + return ( +
    +

    {t("setupGcp.notConfigured.title")}

    +

    {t("setupGcp.notConfigured.body")}

    +
    + ); +} + +interface PairingRowProps { + item: PairingRequest; + busy: boolean; + defaultDecidedBy: string; + onApprove: (decidedBy: string) => Promise | void; + onReject: (decidedBy: string, reason: string) => Promise | void; +} + +function PairingRow({ item, busy, defaultDecidedBy, onApprove, onReject }: PairingRowProps) { + const { t } = useTranslation("console"); + const [expanded, setExpanded] = useState(false); + const [rejectMode, setRejectMode] = useState(false); + const [reason, setReason] = useState(""); + + const isPending = item.status === "pending"; + + return ( +
  • +
    + +
    +
    + + {item.branch_id} + + + {item.hostname} + + +
    +

    + {item.tailscale_ip} · {t("setupGcp.pairing.coordinator")} {item.coordinator_agent_id} ·{" "} + {new Date(item.requested_at).toLocaleString()} +

    +
    + {isPending && !rejectMode && ( +
    + + +
    + )} +
    + + {isPending && rejectMode && ( +
    + + setReason(e.target.value)} + placeholder={t("setupGcp.pairing.rejectReasonPlaceholder")} + className="w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" + /> +
    + + +
    +
    + )} + + {expanded && ( +
    + +
    + )} +
  • + ); +} + +function StatusPill({ status }: { status: PairingStatus }) { + const { t } = useTranslation("console"); + const color: Record = { + pending: "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200", + approved: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200", + rejected: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-200", + smoke_passed: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200", + smoke_failed: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-200", + }; + const tone = + color[status] ?? "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200"; + const label = status ? t(`setupGcp.pairing.status.${status}`) : "—"; + return ( + {label} + ); +} + +function DetailGrid({ item }: { item: PairingRequest }) { + const { t } = useTranslation("console"); + const allowedAgents = Array.isArray(item.allowed_agents) ? item.allowed_agents : []; + const rows: Array<[string, string]> = [ + [t("setupGcp.pairing.fields.delegateUrl"), item.delegate_url ?? "—"], + [t("setupGcp.pairing.fields.allowedAgents"), allowedAgents.join(", ") || "—"], + [ + t("setupGcp.pairing.fields.tokenFingerprint"), + item.delegation_token_fingerprint ?? "—", + ], + [t("setupGcp.pairing.fields.decidedBy"), item.decided_by ?? "—"], + [ + t("setupGcp.pairing.fields.decidedAt"), + item.decided_at ? new Date(item.decided_at).toLocaleString() : "—", + ], + ]; + return ( +
    + {rows.map(([k, v]) => ( +
    +
    {k}
    +
    {v}
    +
    + ))} + {item.metadata && Object.keys(item.metadata).length > 0 && ( +
    +
    + {t("setupGcp.pairing.fields.metadata")} +
    +
    +
    +              {JSON.stringify(item.metadata, null, 2)}
    +            
    +
    +
    + )} +
    + ); +} diff --git a/src/components/console/setupgcp/SecretManagerNotice.tsx b/src/components/console/setupgcp/SecretManagerNotice.tsx new file mode 100644 index 00000000..a0c34821 --- /dev/null +++ b/src/components/console/setupgcp/SecretManagerNotice.tsx @@ -0,0 +1,17 @@ +import { ShieldCheck } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +export function SecretManagerNotice() { + const { t } = useTranslation("console"); + return ( +
    + +
    +

    {t("setupGcp.secretsNotice.title")}

    +

    + {t("setupGcp.secretsNotice.body")} +

    +
    +
    + ); +} diff --git a/src/components/layout/ConsoleLayout.tsx b/src/components/layout/ConsoleLayout.tsx index 444df2a8..b211818a 100644 --- a/src/components/layout/ConsoleLayout.tsx +++ b/src/components/layout/ConsoleLayout.tsx @@ -1,4 +1,4 @@ -import { Home, Bot, Radio, Puzzle, Clock, Settings } from "lucide-react"; +import { Home, Bot, Radio, Puzzle, Clock, Settings, Cloud } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Outlet, useLocation, useNavigate } from "react-router-dom"; import { RestartBanner } from "@/components/shared/RestartBanner"; @@ -16,6 +16,7 @@ export function ConsoleLayout() { { path: "/channels", labelKey: "consoleNav.channels", icon: Radio }, { path: "/skills", labelKey: "consoleNav.skills", icon: Puzzle }, { path: "/cron", labelKey: "consoleNav.cron", icon: Clock }, + { path: "/setup-gcp", labelKey: "consoleNav.setupGcp", icon: Cloud }, { path: "/settings", labelKey: "consoleNav.settings", icon: Settings }, ] as const; diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index ab0825a5..ab70a609 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -5,6 +5,8 @@ import type { ConnectionStatus, ThemeMode, PageId } from "@/gateway/types"; import { useOfficeStore } from "@/store/office-store"; const APP_VERSION = typeof __APP_VERSION__ === "string" ? __APP_VERSION__ : "dev"; +const OFFICE_TITLE = import.meta.env.VITE_OFFICE_TITLE || "OpenClaw Office"; +const BRANCH_LABEL = import.meta.env.VITE_BRANCH_LABEL || ""; function getStatusConfig( t: (key: string) => string, @@ -67,11 +69,15 @@ function BrandSection({ return (

    - OpenClaw Office + {OFFICE_TITLE}

    v{APP_VERSION} +
    +
    SU
    + {`${OFFICE_TITLE} - ${BRANCH_LABEL}` || "SitioUno - Sucursal Miami"} +
    {isOfficePage && !isMobile && (
    diff --git a/src/components/pages/ChannelsPage.tsx b/src/components/pages/ChannelsPage.tsx index 55aee23b..0b9ef9d1 100644 --- a/src/components/pages/ChannelsPage.tsx +++ b/src/components/pages/ChannelsPage.tsx @@ -1,6 +1,7 @@ -import { RefreshCw, Inbox } from "lucide-react"; +import { RefreshCw, Inbox, Cloud } from "lucide-react"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; import { AvailableChannelGrid } from "@/components/console/channels/AvailableChannelGrid"; import { ChannelCard } from "@/components/console/channels/ChannelCard"; import { ChannelConfigDialog } from "@/components/console/channels/ChannelConfigDialog"; @@ -14,6 +15,7 @@ import { useChannelsStore } from "@/store/console-stores/channels-store"; export function ChannelsPage() { const { t } = useTranslation("console"); + const navigate = useNavigate(); const { channels, isLoading, @@ -75,6 +77,23 @@ export function ChannelsPage() { /> +
    + +
    +

    {t("channels.gcpNotice.title")}

    +

    + {t("channels.gcpNotice.body")} +

    +
    + +
    + {channels.length === 0 ? ( ; + if (url === undefined) { + delete win.__OPENCLAW_CONFIG__; + return; + } + win.__OPENCLAW_CONFIG__ = { registryApiUrl: url }; +} + +const originalFetch = global.fetch; + +describe("SetupGcpPage", () => { + beforeEach(() => { + setRuntimeUrl(undefined); + // Stub fetch so the auto-loaded pairing/channels lists don't hit the network. + global.fetch = vi.fn( + async () => + new Response("[]", { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ) as unknown as typeof fetch; + }); + + afterEach(() => { + setRuntimeUrl(undefined); + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("renders without crashing and shows the secrets notice", async () => { + await act(async () => { + render(); + }); + expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument(); + expect(screen.getAllByText(/Secret Manager/i).length).toBeGreaterThan(0); + }); + + it("shows the default Tailscale HQ base URL when no override is configured", async () => { + await act(async () => { + render(); + }); + // Default base URL is http://openclaw-hq:8781 (Tailscale hostname for HQ). + expect(screen.getByText(/openclaw-hq:8781/)).toBeInTheDocument(); + }); + + it("does not white-screen when the sidecar fetch rejects with a network error", async () => { + // Simulate the off-tailnet case the user reported: DNS for openclaw-hq + // fails, so fetch() rejects with a TypeError. Before the fix, this took + // out the entire React tree and the browser tab went blank. + global.fetch = vi.fn(async () => { + throw new TypeError("Failed to fetch"); + }) as unknown as typeof fetch; + + await act(async () => { + render(); + }); + + // Page chrome stays mounted. + expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument(); + // And the failure surfaces as a friendly retry-able error panel, not a crash. + await waitFor(() => { + expect(screen.getByText(/cannot reach sidecar/i)).toBeInTheDocument(); + }); + }); + + it("does not crash when the sidecar returns a non-array payload", async () => { + // Some upstreams have been observed to return `{detail: "..."}` envelopes + // on partial outages. items.map(...) used to throw and unmount the tree. + global.fetch = vi.fn( + async () => + new Response(JSON.stringify({ detail: "service unavailable" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ) as unknown as typeof fetch; + + await act(async () => { + render(); + }); + + // Should render the empty state (coerced to []) rather than throwing. + expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument(); + await waitFor(() => { + // EmptyState title for pairing tab — match either default zh or en text. + const empty = screen.queryByText(/No requests in this state/i) + ?? screen.queryByText(/当前没有该状态的请求/); + expect(empty).not.toBeNull(); + }); + }); +}); diff --git a/src/components/pages/SetupGcpPage.tsx b/src/components/pages/SetupGcpPage.tsx new file mode 100644 index 00000000..dbe6b2b3 --- /dev/null +++ b/src/components/pages/SetupGcpPage.tsx @@ -0,0 +1,94 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ChannelsAdminView } from "@/components/console/setupgcp/ChannelsAdminView"; +import { OpenHandsAdminView } from "@/components/console/setupgcp/OpenHandsAdminView"; +import { PairingRequestsView } from "@/components/console/setupgcp/PairingRequestsView"; +import { SecretManagerNotice } from "@/components/console/setupgcp/SecretManagerNotice"; +import { ErrorBoundary } from "@/components/shared/ErrorBoundary"; +import { useSetupGcpStore } from "@/store/console-stores/setupgcp-store"; + +type Tab = "pairing" | "channels" | "openhands"; + +const DEFAULT_OPERATOR_ID = "console-admin"; + +export function SetupGcpPage() { + const { t } = useTranslation("console"); + const refreshConfig = useSetupGcpStore((s) => s.refreshConfig); + const configured = useSetupGcpStore((s) => s.configured); + const baseUrl = useSetupGcpStore((s) => s.baseUrl); + const [tab, setTab] = useState("pairing"); + + useEffect(() => { + refreshConfig(); + }, [refreshConfig]); + + return ( +
    +
    +

    + {t("setupGcp.title")} +

    +

    {t("setupGcp.description")}

    + {configured && ( +

    + {baseUrl} +

    + )} +
    + + + +
    + setTab("pairing")}> + {t("setupGcp.tabs.pairing")} + + setTab("channels")}> + {t("setupGcp.tabs.channels")} + + setTab("openhands")}> + {t("setupGcp.tabs.openhands")} + +
    + + {/* + Wrap the active tab in a local error boundary so an unexpected throw + inside one of the tab views (e.g. a malformed sidecar payload) renders + a friendly panel instead of unmounting the entire React tree and + leaving the browser tab blank. + */} + + {tab === "pairing" ? ( + + ) : tab === "channels" ? ( + + ) : ( + + )} + +
    + ); +} + +function TabButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/src/components/shared/ErrorBoundary.tsx b/src/components/shared/ErrorBoundary.tsx new file mode 100644 index 00000000..12acfd74 --- /dev/null +++ b/src/components/shared/ErrorBoundary.tsx @@ -0,0 +1,72 @@ +import { AlertTriangle } from "lucide-react"; +import { Component, type ErrorInfo, type ReactNode } from "react"; + +interface ErrorBoundaryProps { + /** Friendly title shown above the error message. */ + title?: string; + /** + * Render-prop fallback. Receives the captured error and a reset() callback + * that clears the boundary so children re-mount on the next render. + * If omitted, a minimal default panel is rendered. + */ + fallback?: (error: Error, reset: () => void) => ReactNode; + children: ReactNode; +} + +interface ErrorBoundaryState { + error: Error | null; +} + +/** + * Local error boundary used to keep the surrounding chrome (sidebar, top bar) + * visible when a single page subtree throws during render. Without this, an + * unhandled exception in a page component (e.g. the Setup GCP page when its + * sidecar is unreachable and the API returns an unexpected payload) unmounts + * the entire React tree and the browser tab goes blank. + */ +export class ErrorBoundary extends Component { + state: ErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: unknown): ErrorBoundaryState { + const err = error instanceof Error ? error : new Error(String(error)); + return { error: err }; + } + + componentDidCatch(error: unknown, info: ErrorInfo): void { + // Console-only — we intentionally do not exfiltrate this anywhere. + // eslint-disable-next-line no-console + console.error("[ErrorBoundary] caught render error", error, info); + } + + reset = (): void => { + this.setState({ error: null }); + }; + + render(): ReactNode { + const { error } = this.state; + if (!error) return this.props.children; + + if (this.props.fallback) { + return this.props.fallback(error, this.reset); + } + + return ( +
    +
    + +
    +

    {this.props.title ?? "Something went wrong"}

    +

    {error.message}

    + +
    +
    +
    + ); + } +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 00000000..9fe5a3c8 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; + +export function Dialog({ open, onOpenChange, children }: { open: boolean; onOpenChange: (open: boolean) => void; children: React.ReactNode }) { + if (!open) return null; + return ( +
    +
    onOpenChange(false)} /> +
    {children}
    +
    + ); +} + +export function DialogContent({ children, className }: { children: React.ReactNode; className?: string }) { + return
    {children}
    ; +} + +export function DialogHeader({ children, className }: { children: React.ReactNode; className?: string }) { + return
    {children}
    ; +} + +export function DialogTitle({ children, className }: { children: React.ReactNode; className?: string }) { + return

    {children}

    ; +} diff --git a/src/gateway/adapter.ts.diff b/src/gateway/adapter.ts.diff new file mode 100644 index 00000000..18f070b2 --- /dev/null +++ b/src/gateway/adapter.ts.diff @@ -0,0 +1,9 @@ +--- src/gateway/adapter.ts ++++ src/gateway/adapter.ts +@@ -73,6 +73,8 @@ + skillsStatus(agentId?: string): Promise; + skillsInstall(name: string, installId: string): Promise; + skillsUpdate(skillKey: string, patch: SkillUpdatePatch): Promise<{ ok: boolean }>; ++ ++ agentFiles(agentId: string): Promise<{ name: string, content: string }[]>; + } diff --git a/src/gateway/types.ts b/src/gateway/types.ts index 896ec773..68eddbf1 100644 --- a/src/gateway/types.ts +++ b/src/gateway/types.ts @@ -274,7 +274,8 @@ export type PageId = | "channels" | "skills" | "cron" - | "settings"; + | "settings" + | "setupGcp"; export interface TokenSnapshot { timestamp: number; diff --git a/src/i18n/locales/en/console.json b/src/i18n/locales/en/console.json index 7533087f..0f7c8f8c 100644 --- a/src/i18n/locales/en/console.json +++ b/src/i18n/locales/en/console.json @@ -6,6 +6,7 @@ "stats": { "channels": "Channels", "skills": "Skills", + "local_skills": "Local Skills", "usage": "Provider Usage", "uptime": "Uptime", "uptimeOnline": "Online" @@ -72,6 +73,7 @@ }, "fields": { "botToken": "Bot Token", + "chatId": "Chat ID", "applicationId": "Application ID", "phoneNumber": "Phone Number", "appId": "App ID", @@ -86,6 +88,7 @@ }, "placeholders": { "botToken": "Enter Bot Token", + "chatId": "e.g. -1001234567890", "applicationId": "Enter Application ID", "phoneNumber": "+1...", "appId": "Enter App ID", @@ -115,6 +118,11 @@ "logout": { "title": "Logout Channel", "description": "Are you sure you want to logout \"{{name}}\"? You will need to re-pair." + }, + "gcpNotice": { + "title": "Looking for Telegram alerts and broadcast channels?", + "body": "Notification channels backed by GCP Secret Manager (admin Telegram bot, branch broadcast bots) are managed in Setup GCP, with Chat ID, secret rotation, and a built-in test button.", + "cta": "Open Setup GCP" } }, "skills": { @@ -522,6 +530,7 @@ "files": "Files", "tools": "Tools", "skills": "Skills", + "local_skills": "Local Skills", "channels": "Channels", "cronJobs": "Cron Jobs" }, @@ -667,5 +676,154 @@ "delete": "Delete", "deleting": "Deleting..." } + }, + "setupGcp": { + "title": "Setup GCP", + "description": "Approve new branch nodes joining the fleet and manage notification channels (Telegram, etc.) backed by GCP Secret Manager.", + "tabs": { + "pairing": "Pairing Requests", + "channels": "Notification Channels", + "openhands": "OpenHands" + }, + "secretsNotice": { + "title": "Secrets stay in GCP Secret Manager", + "body": "Bot tokens and other credentials are stored in GCP Secret Manager via the fleet-registry-api. Plaintext values are never persisted in the browser or in the registry database." + }, + "notConfigured": { + "title": "Registry API not configured", + "body": "Set VITE_REGISTRY_API_URL or inject window.__OPENCLAW_CONFIG__.registryApiUrl at runtime. The default targets the HQ sidecar over Tailscale (http://openclaw-hq:8781) and trusts the VPN as the perimeter — no bearer token is required." + }, + "pairing": { + "title": "Pending pairing requests", + "description": "Branch nodes that have published themselves over Tailscale and are waiting for HQ to authorize delegation.", + "coordinator": "coordinator", + "rejectReason": "Rejection reason", + "rejectReasonPlaceholder": "Why is this request being rejected?", + "empty": { + "title": "No requests in this state", + "description": "Branches that contact the registry will appear here." + }, + "statusFilter": { + "pending": "Pending", + "approved": "Approved", + "rejected": "Rejected", + "smoke_passed": "Smoke OK", + "smoke_failed": "Smoke failed", + "all": "All" + }, + "status": { + "pending": "pending", + "approved": "approved", + "rejected": "rejected", + "smoke_passed": "smoke ok", + "smoke_failed": "smoke failed" + }, + "fields": { + "delegateUrl": "Delegate URL", + "allowedAgents": "Allowed agents", + "tokenFingerprint": "Token fingerprint", + "decidedBy": "Decided by", + "decidedAt": "Decided at", + "metadata": "Metadata" + } + }, + "channels": { + "title": "Notification channels", + "description": "Channels used by HQ-Cloud and branches to send alerts. Telegram is the first supported transport.", + "enabled": "enabled", + "disabled": "disabled", + "noSecret": "no secret set", + "lastTest": "last tested", + "testOk": "Test message delivered.", + "testFailed": "Test failed: {{detail}}", + "secretValue": "Secret value", + "secretPlaceholder": "Paste bot token or webhook secret", + "secretHint": "Stored as a new GCP Secret Manager version. Earlier versions are kept for rollback.", + "testMessage": "Test message", + "testMessagePlaceholder": "Optional — leave blank for default ping", + "empty": { + "title": "No channels configured", + "description": "Add a channel to start receiving fleet notifications." + }, + "createDialog": { + "title": "Add notification channel" + }, + "fields": { + "kind": "Kind", + "name": "Display name", + "namePlaceholder": "e.g. Admin Bot", + "scope": "Scope", + "chatId": "Chat ID", + "chatIdPlaceholder": "e.g. -1001234567890", + "botToken": "Bot Token (initial secret)", + "botTokenPlaceholder": "1234567890:ABC..." + }, + "deleteDialog": { + "title": "Delete channel", + "description": "Delete \"{{name}}\"? The associated GCP secret will also be removed." + } + }, + "actions": { + "refresh": "Refresh", + "approve": "Approve", + "reject": "Reject", + "confirmReject": "Reject", + "cancel": "Cancel", + "addChannel": "Add Channel", + "save": "Save", + "create": "Create", + "creating": "Creating...", + "test": "Test", + "sendTest": "Send test", + "setSecret": "Set Secret", + "enable": "Enable", + "disable": "Disable" + }, + "openhands": { + "title": "OpenHands LLM profiles", + "description": "Provider profiles consumed by Alekhine's OpenHands wrapper. The active profile drives every OpenHands run on this fleet.", + "createProfile": "Create profile", + "defaultBadge": "default", + "secretSet": "Secret set", + "noSecret": "No secret", + "lastTest": "last tested", + "testOk": "LLM round-trip OK.", + "testFailed": "Test failed: {{detail}}", + "banner": { + "title": "Active profile drives OpenHands", + "body": "The profile marked default is what Alekhine's OpenHands wrapper uses. API keys are persisted in GCP Secret Manager — never in the browser or registry DB." + }, + "empty": { + "title": "No profiles configured", + "description": "Create a profile to bind an LLM provider/model + API key to OpenHands." + }, + "createDialog": { + "title": "Create LLM profile" + }, + "deleteDialog": { + "title": "Delete profile", + "description": "Delete \"{{label}}\"? The associated GCP secret will also be removed." + }, + "fields": { + "provider": "Provider", + "model": "Model", + "modelPlaceholder": "e.g. claude-sonnet-4-5, gpt-4o, gemini-2.5-pro", + "label": "Label", + "labelPlaceholder": "e.g. Sonnet primary", + "apiKey": "API key", + "apiKeyOptional": "API key (optional, can be set later)", + "apiKeyPlaceholder": "sk-... / paste raw secret", + "apiKeyHint": "Stored as a new GCP Secret Manager version. Earlier versions are kept for rollback.", + "testPrompt": "Test prompt", + "testPromptPlaceholder": "Optional — leave blank for default ping" + }, + "actions": { + "activate": "Activate", + "setSecret": "Set Secret", + "test": "Test", + "edit": "Edit", + "runTest": "Run test" + } + } } } diff --git a/src/i18n/locales/en/layout.json b/src/i18n/locales/en/layout.json index c7ec91d6..071bcf1d 100644 --- a/src/i18n/locales/en/layout.json +++ b/src/i18n/locales/en/layout.json @@ -16,6 +16,7 @@ "cron": "Cron Tasks", "agents": "Agents", "settings": "Settings", + "setupGcp": "Setup GCP", "fallback": "Console" }, "theme": { @@ -53,6 +54,7 @@ "channels": "Channels", "skills": "Skills", "cron": "Cron Tasks", + "setupGcp": "Setup GCP", "settings": "Settings" } } diff --git a/src/i18n/locales/zh/console.json b/src/i18n/locales/zh/console.json index 39e53716..052c3b45 100644 --- a/src/i18n/locales/zh/console.json +++ b/src/i18n/locales/zh/console.json @@ -72,6 +72,7 @@ }, "fields": { "botToken": "Bot Token", + "chatId": "Chat ID", "applicationId": "Application ID", "phoneNumber": "手机号", "appId": "App ID", @@ -86,6 +87,7 @@ }, "placeholders": { "botToken": "输入 Bot Token", + "chatId": "例如 -1001234567890", "applicationId": "输入 Application ID", "phoneNumber": "+86...", "appId": "输入 App ID", @@ -115,6 +117,11 @@ "logout": { "title": "登出渠道", "description": "确认要登出「{{name}}」吗?登出后需要重新配对。" + }, + "gcpNotice": { + "title": "需要配置 Telegram 告警或广播通道?", + "body": "由 GCP Secret Manager 托管的通知渠道(管理员 Telegram Bot、分支广播 Bot 等)在「GCP 配置」中统一管理,支持 Chat ID、凭据轮换和内置测试。", + "cta": "打开 GCP 配置" } }, "skills": { @@ -667,5 +674,154 @@ "delete": "删除", "deleting": "删除中..." } + }, + "setupGcp": { + "title": "GCP 配置", + "description": "审批新接入的分支节点,并管理由 GCP Secret Manager 托管的通知渠道(Telegram 等)。", + "tabs": { + "pairing": "配对请求", + "channels": "通知渠道", + "openhands": "OpenHands" + }, + "secretsNotice": { + "title": "凭据保存在 GCP Secret Manager", + "body": "Bot Token 等凭据由 fleet-registry-api 写入 GCP Secret Manager。明文不会保存在浏览器或注册中心数据库中。" + }, + "notConfigured": { + "title": "Registry API 未配置", + "body": "请设置 VITE_REGISTRY_API_URL,或在运行时注入 window.__OPENCLAW_CONFIG__.registryApiUrl。默认指向 Tailscale 上的 HQ 边车(http://openclaw-hq:8781),信任边界即 VPN — 无需 Bearer 令牌。" + }, + "pairing": { + "title": "待审批的配对请求", + "description": "通过 Tailscale 注册并等待 HQ 授权的分支节点。", + "coordinator": "协调智能体", + "rejectReason": "拒绝原因", + "rejectReasonPlaceholder": "请说明拒绝该请求的原因", + "empty": { + "title": "当前没有该状态的请求", + "description": "新分支接入时会显示在这里。" + }, + "statusFilter": { + "pending": "待审批", + "approved": "已批准", + "rejected": "已拒绝", + "smoke_passed": "Smoke 通过", + "smoke_failed": "Smoke 失败", + "all": "全部" + }, + "status": { + "pending": "待审批", + "approved": "已批准", + "rejected": "已拒绝", + "smoke_passed": "smoke 通过", + "smoke_failed": "smoke 失败" + }, + "fields": { + "delegateUrl": "Delegate URL", + "allowedAgents": "允许的 Agent", + "tokenFingerprint": "委托 Token 指纹", + "decidedBy": "审批人", + "decidedAt": "审批时间", + "metadata": "Metadata" + } + }, + "channels": { + "title": "通知渠道", + "description": "HQ-Cloud 与分支节点用于发送告警的渠道。当前优先支持 Telegram。", + "enabled": "已启用", + "disabled": "已禁用", + "noSecret": "未设置凭据", + "lastTest": "上次测试", + "testOk": "测试消息已送达。", + "testFailed": "测试失败:{{detail}}", + "secretValue": "凭据值", + "secretPlaceholder": "粘贴 Bot Token 或 Webhook 凭据", + "secretHint": "保存为 GCP Secret Manager 的新版本,旧版本会保留以便回滚。", + "testMessage": "测试消息", + "testMessagePlaceholder": "可选 — 留空则发送默认 ping", + "empty": { + "title": "尚未配置通知渠道", + "description": "添加渠道以开始接收 fleet 通知。" + }, + "createDialog": { + "title": "添加通知渠道" + }, + "fields": { + "kind": "类型", + "name": "显示名称", + "namePlaceholder": "例如 Admin Bot", + "scope": "Scope", + "chatId": "Chat ID", + "chatIdPlaceholder": "例如 -1001234567890", + "botToken": "Bot Token(初始凭据)", + "botTokenPlaceholder": "1234567890:ABC..." + }, + "deleteDialog": { + "title": "删除渠道", + "description": "确认删除「{{name}}」?关联的 GCP Secret 也会被删除。" + } + }, + "actions": { + "refresh": "刷新", + "approve": "批准", + "reject": "拒绝", + "confirmReject": "确认拒绝", + "cancel": "取消", + "addChannel": "添加渠道", + "save": "保存", + "create": "创建", + "creating": "创建中...", + "test": "测试", + "sendTest": "发送测试", + "setSecret": "设置凭据", + "enable": "启用", + "disable": "禁用" + }, + "openhands": { + "title": "OpenHands LLM 配置", + "description": "Alekhine OpenHands 封装使用的 LLM 提供方配置。被设为默认的配置将驱动 fleet 中所有 OpenHands 运行。", + "createProfile": "新建配置", + "defaultBadge": "默认", + "secretSet": "已设置凭据", + "noSecret": "未设置凭据", + "lastTest": "上次测试", + "testOk": "LLM 往返测试成功。", + "testFailed": "测试失败:{{detail}}", + "banner": { + "title": "默认配置驱动 OpenHands", + "body": "被标记为默认的配置即 Alekhine OpenHands 封装实际使用的配置。API Key 保存在 GCP Secret Manager —— 永不写入浏览器或注册中心数据库。" + }, + "empty": { + "title": "尚未配置任何 LLM 配置", + "description": "创建一个配置,将 LLM 提供方/模型 + API Key 绑定到 OpenHands。" + }, + "createDialog": { + "title": "创建 LLM 配置" + }, + "deleteDialog": { + "title": "删除配置", + "description": "确认删除「{{label}}」?关联的 GCP Secret 也会被删除。" + }, + "fields": { + "provider": "提供方", + "model": "模型", + "modelPlaceholder": "例如 claude-sonnet-4-5, gpt-4o, gemini-2.5-pro", + "label": "名称", + "labelPlaceholder": "例如 Sonnet 主用", + "apiKey": "API Key", + "apiKeyOptional": "API Key(可选,稍后再设置)", + "apiKeyPlaceholder": "sk-... / 粘贴原始 Secret", + "apiKeyHint": "保存为 GCP Secret Manager 的新版本,旧版本会保留以便回滚。", + "testPrompt": "测试提示词", + "testPromptPlaceholder": "可选 — 留空则发送默认 ping" + }, + "actions": { + "activate": "激活", + "setSecret": "设置凭据", + "test": "测试", + "edit": "编辑", + "runTest": "运行测试" + } + } } } diff --git a/src/i18n/locales/zh/layout.json b/src/i18n/locales/zh/layout.json index 5099ae0d..435aaba7 100644 --- a/src/i18n/locales/zh/layout.json +++ b/src/i18n/locales/zh/layout.json @@ -16,6 +16,7 @@ "cron": "定时任务", "agents": "智能体管理", "settings": "设置", + "setupGcp": "GCP 配置", "fallback": "控制台" }, "theme": { @@ -53,6 +54,7 @@ "channels": "渠道", "skills": "技能", "cron": "定时任务", + "setupGcp": "GCP 配置", "settings": "设置" } } diff --git a/src/lib/__tests__/registry-api-client.test.ts b/src/lib/__tests__/registry-api-client.test.ts new file mode 100644 index 00000000..ceda2ddc --- /dev/null +++ b/src/lib/__tests__/registry-api-client.test.ts @@ -0,0 +1,266 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + channels, + DEFAULT_REGISTRY_API_URL, + notify, + openhands, + pairing, + resolveRegistryApiConfig, +} from "@/lib/registry-api-client"; + +const originalFetch = global.fetch; + +function setRuntimeUrl(url: string | undefined): void { + const win = window as unknown as Record; + if (url === undefined) { + delete win.__OPENCLAW_CONFIG__; + return; + } + win.__OPENCLAW_CONFIG__ = { registryApiUrl: url }; +} + +describe("registry-api-client", () => { + beforeEach(() => { + setRuntimeUrl(undefined); + }); + + afterEach(() => { + setRuntimeUrl(undefined); + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("falls back to the Tailscale HQ default when nothing is configured", () => { + setRuntimeUrl(undefined); + const cfg = resolveRegistryApiConfig(); + expect(cfg.baseUrl).toBe(DEFAULT_REGISTRY_API_URL); + expect(cfg.baseUrl).toBe("http://openclaw-hq:8781"); + expect(cfg.configured).toBe(true); + }); + + it("resolves config from runtime injection and trims trailing slash", () => { + setRuntimeUrl("http://openclaw-hq:8781/"); + const cfg = resolveRegistryApiConfig(); + expect(cfg.baseUrl).toBe("http://openclaw-hq:8781"); + expect(cfg.configured).toBe(true); + }); + + it("issues GET WITHOUT an Authorization header for pairing.list", async () => { + setRuntimeUrl("http://openclaw-hq:8781"); + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify([{ id: 1, branch_id: "miami" }]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + global.fetch = fetchMock as unknown as typeof fetch; + + const items = await pairing.list("pending"); + expect(items).toEqual([{ id: 1, branch_id: "miami" }]); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [calledUrl, init] = fetchMock.mock.calls[0]!; + expect(calledUrl).toBe("http://openclaw-hq:8781/v1/pairing/requests?status_filter=pending"); + expect((init as RequestInit).method).toBe("GET"); + const headers = (init as RequestInit).headers as Record; + expect(headers).toMatchObject({ Accept: "application/json" }); + // VPN is the trust boundary — no bearer token in browser bundle. + expect(Object.keys(headers).map((k) => k.toLowerCase())).not.toContain("authorization"); + }); + + it("posts JSON body for channels.create without an Authorization header", async () => { + setRuntimeUrl("http://openclaw-hq:8781"); + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ id: 7, kind: "telegram", name: "Admin Bot" }), { + status: 201, + headers: { "Content-Type": "application/json" }, + }), + ); + global.fetch = fetchMock as unknown as typeof fetch; + + const created = await channels.create({ + kind: "telegram", + name: "Admin Bot", + config: { chat_id: "-1001" }, + scope: "admin", + }); + expect(created.id).toBe(7); + const [, init] = fetchMock.mock.calls[0]!; + expect((init as RequestInit).method).toBe("POST"); + const body = JSON.parse((init as RequestInit).body as string); + expect(body).toEqual({ + kind: "telegram", + name: "Admin Bot", + config: { chat_id: "-1001" }, + scope: "admin", + }); + const headers = (init as RequestInit).headers as Record; + expect(Object.keys(headers).map((k) => k.toLowerCase())).not.toContain("authorization"); + }); + + it("posts to /v1/notify for notify.broadcast", async () => { + setRuntimeUrl("http://openclaw-hq:8781"); + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ ok: true, delivered: 3 }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + global.fetch = fetchMock as unknown as typeof fetch; + + const result = await notify.broadcast({ scope: "admin", text: "hello" }); + expect(result).toEqual({ ok: true, delivered: 3 }); + const [calledUrl, init] = fetchMock.mock.calls[0]!; + expect(calledUrl).toBe("http://openclaw-hq:8781/v1/notify"); + expect((init as RequestInit).method).toBe("POST"); + const body = JSON.parse((init as RequestInit).body as string); + expect(body).toEqual({ scope: "admin", text: "hello" }); + const headers = (init as RequestInit).headers as Record; + expect(Object.keys(headers).map((k) => k.toLowerCase())).not.toContain("authorization"); + }); + + it("rejects with RegistryApiError on non-2xx with detail", async () => { + setRuntimeUrl("http://openclaw-hq:8781"); + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ detail: "nope" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }), + ); + global.fetch = fetchMock as unknown as typeof fetch; + + await expect(pairing.approve(1, { decided_by: "x" })).rejects.toMatchObject({ + name: "RegistryApiError", + status: 403, + message: "nope", + }); + }); +}); + +describe("envelope extraction (sidecar shape)", () => { + beforeEach(() => { + vi.stubEnv("VITE_REGISTRY_API_URL", "http://openclaw-hq:8781"); + }); + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it("pairing.list extracts requests[] from {requests: [...]} envelope", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response( + JSON.stringify({ requests: [{ id: 2, branch_id: "miami", status: "pending" }] }), + { status: 200, headers: { "content-type": "application/json" } } + ))); + const { pairing: pairingApi } = await import("../registry-api-client"); + const items = await pairingApi.list("pending"); + expect(items).toHaveLength(1); + expect(items[0].id).toBe(2); + }); + + it("channels.list extracts channels[] from {channels: [...]} envelope", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response( + JSON.stringify({ channels: [{ id: 1, kind: "telegram", name: "Bot" }] }), + { status: 200, headers: { "content-type": "application/json" } } + ))); + const { channels: channelsApi } = await import("../registry-api-client"); + const items = await channelsApi.list(); + expect(items).toHaveLength(1); + expect(items[0].kind).toBe("telegram"); + }); + + it("openhands.profiles.list extracts profiles[] from {profiles: [...]} envelope and hits /v1/openhands/profiles", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + profiles: [ + { + id: 7, + provider: "anthropic", + model: "claude-sonnet-4-5", + label: "Sonnet primary", + is_default: true, + has_secret: true, + last_test_at: null, + last_test_result: null, + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + const { openhands: openhandsApi } = await import("../registry-api-client"); + const items = await openhandsApi.profiles.list(); + expect(items).toHaveLength(1); + expect(items[0]?.provider).toBe("anthropic"); + expect(items[0]?.is_default).toBe(true); + const [calledUrl, init] = fetchMock.mock.calls[0]!; + expect(calledUrl).toBe("http://openclaw-hq:8781/v1/openhands/profiles"); + expect((init as RequestInit).method).toBe("GET"); + const headers = (init as RequestInit).headers as Record; + // Trust boundary is the VPN — no Authorization header. + expect(Object.keys(headers).map((k) => k.toLowerCase())).not.toContain("authorization"); + }); +}); + +describe("openhands client", () => { + beforeEach(() => { + setRuntimeUrl("http://openclaw-hq:8781"); + }); + afterEach(() => { + setRuntimeUrl(undefined); + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("posts to /v1/openhands/profiles/{id}/activate without an Authorization header", async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ id: 3, provider: "openai", is_default: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + global.fetch = fetchMock as unknown as typeof fetch; + const result = await openhands.profiles.activate(3); + expect((result as { is_default?: boolean }).is_default).toBe(true); + const [calledUrl, init] = fetchMock.mock.calls[0]!; + expect(calledUrl).toBe("http://openclaw-hq:8781/v1/openhands/profiles/3/activate"); + expect((init as RequestInit).method).toBe("POST"); + const headers = (init as RequestInit).headers as Record; + expect(Object.keys(headers).map((k) => k.toLowerCase())).not.toContain("authorization"); + }); + + it("posts JSON body for openhands.profiles.create", async () => { + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + id: 11, + provider: "deepseek", + model: "deepseek-chat", + label: "DS", + is_default: false, + has_secret: false, + last_test_at: null, + last_test_result: null, + }), + { status: 201, headers: { "Content-Type": "application/json" } }, + ), + ); + global.fetch = fetchMock as unknown as typeof fetch; + const created = await openhands.profiles.create({ + provider: "deepseek", + model: "deepseek-chat", + label: "DS", + }); + expect(created.id).toBe(11); + const [calledUrl, init] = fetchMock.mock.calls[0]!; + expect(calledUrl).toBe("http://openclaw-hq:8781/v1/openhands/profiles"); + const body = JSON.parse((init as RequestInit).body as string); + expect(body).toEqual({ provider: "deepseek", model: "deepseek-chat", label: "DS" }); + }); +}); diff --git a/src/lib/channel-schemas.ts b/src/lib/channel-schemas.ts index bdf4f322..bc8fdf9f 100644 --- a/src/lib/channel-schemas.ts +++ b/src/lib/channel-schemas.ts @@ -30,6 +30,13 @@ export const CHANNEL_SCHEMAS: Record = { required: true, placeholderKey: "console:channels.placeholders.botToken", }, + { + key: "chatId", + labelKey: "console:channels.fields.chatId", + type: "text", + required: true, + placeholderKey: "console:channels.placeholders.chatId", + }, ], }, discord: { diff --git a/src/lib/registry-api-client.ts b/src/lib/registry-api-client.ts new file mode 100644 index 00000000..0ce260d2 --- /dev/null +++ b/src/lib/registry-api-client.ts @@ -0,0 +1,362 @@ +/** + * Registry API client for the local HQ sidecar. + * + * The "Setup GCP" console page uses this client to: + * - approve/reject pending pairing requests from new branch nodes (Tailscale) + * - manage GCP Secret Manager-backed notification channels (Telegram first) + * - broadcast notifications via the HQ notify endpoint + * + * Trust model: + * The HQ sidecar listens on the Tailscale tailnet (default + * `http://openclaw-hq:8781`). The trust boundary is the VPN itself: the + * sidecar validates the source identity server-side via `tailscale whois`. + * This client therefore intentionally has NO bearer token, NO Authorization + * header, and NO admin-credential plumbing. Putting a long-lived token in + * the browser bundle was a regression — the network is the perimeter. + * + * Configuration precedence (URL only): + * 1. Runtime injection: window.__OPENCLAW_CONFIG__.registryApiUrl + * (mirrors the gateway pattern used in src/App.tsx) + * 2. Build-time env: VITE_REGISTRY_API_URL + * 3. Default: http://openclaw-hq:8781 (Tailscale hostname for HQ) + */ + +// ---------- Types ---------- + +export type PairingStatus = "pending" | "approved" | "rejected" | "smoke_passed" | "smoke_failed"; + +export interface PairingRequest { + id: number; + branch_id: string; + hostname: string; + tailscale_ip: string; + delegate_url: string; + coordinator_agent_id: string; + allowed_agents: string[]; + status: PairingStatus; + delegation_token_fingerprint: string; + metadata?: Record | null; + requested_at: string; + decided_at: string | null; + decided_by: string | null; +} + +export interface PairingApproveBody { + decided_by: string; +} + +export interface PairingRejectBody { + decided_by: string; + reason: string; +} + +export interface PairingSmokeTestBody { + delegation_token: string; +} + +export interface PairingSmokeTestResult { + ok: boolean; + status?: string; + http?: number; + detail?: Record | string | null; +} + +export type ChannelKind = "telegram" | "discord" | "slack" | string; +export type ChannelScope = "admin" | "branch" | string; + +export interface NotificationChannel { + id: number; + kind: ChannelKind; + name: string; + config: Record; + secret_ref: string | null; + enabled: boolean; + scope: ChannelScope; + last_test_at: string | null; + last_test_result: Record | null; + created_at?: string; + updated_at?: string; +} + +export interface CreateChannelBody { + kind: ChannelKind; + name: string; + config: Record; + scope?: ChannelScope; +} + +export interface UpdateChannelBody { + config?: Record; + name?: string; + enabled?: boolean; +} + +export interface SetSecretBody { + secret_value: string; +} + +export interface ChannelTestBody { + message?: string; +} + +export interface ChannelTestResult { + ok: boolean; + http?: number; + detail?: Record | string | null; +} + +export interface NotifyBroadcastBody { + scope: string; + text: string; +} + +export interface NotifyBroadcastResult { + ok: boolean; + delivered?: number; + detail?: Record | string | null; +} + +// ---------- OpenHands (LLM provider profiles) ---------- + +export type OpenHandsProvider = + | "google" + | "anthropic" + | "openai" + | "deepseek" + | "minimax" + | "ollama"; + +export interface OpenHandsProfile { + id: number; + provider: OpenHandsProvider; + model: string; + label: string; + is_default: boolean; + has_secret: boolean; + last_test_at: string | null; + last_test_result: Record | string | null; + created_at?: string; + updated_at?: string; +} + +export interface CreateOpenHandsProfileBody { + provider: OpenHandsProvider; + model: string; + label: string; +} + +export interface UpdateOpenHandsProfileBody { + provider?: OpenHandsProvider; + model?: string; + label?: string; +} + +export interface OpenHandsTestBody { + prompt?: string; +} + +export interface OpenHandsTestResult { + ok: boolean; + detail?: Record | string | null; + latency_ms?: number; +} + +// ---------- Config resolution ---------- + +/** + * Default base URL for the HQ sidecar on Tailscale. The sidecar listens on + * the tailnet and uses `tailscale whois` for source identity — no bearer. + */ +export const DEFAULT_REGISTRY_API_URL = "http://openclaw-hq:8781"; + +interface RuntimeConfig { + registryApiUrl?: string; +} + +function readRuntimeConfig(): RuntimeConfig { + if (typeof window === "undefined") return {}; + const injected = (window as unknown as Record).__OPENCLAW_CONFIG__ as + | RuntimeConfig + | undefined; + return injected ?? {}; +} + +export interface RegistryApiResolvedConfig { + baseUrl: string; + configured: boolean; +} + +export function resolveRegistryApiConfig(): RegistryApiResolvedConfig { + const runtime = readRuntimeConfig(); + const fromRuntime = (runtime.registryApiUrl ?? "").trim(); + const fromEnv = ( + (import.meta.env as unknown as Record).VITE_REGISTRY_API_URL ?? "" + ).trim(); + const baseUrl = (fromRuntime || fromEnv || DEFAULT_REGISTRY_API_URL).replace(/\/+$/u, ""); + return { baseUrl, configured: Boolean(baseUrl) }; +} + +// ---------- Errors ---------- + +export class RegistryApiError extends Error { + readonly status: number; + readonly body: unknown; + constructor(status: number, message: string, body?: unknown) { + super(message); + this.name = "RegistryApiError"; + this.status = status; + this.body = body; + } +} + +export class RegistryApiNotConfiguredError extends Error { + constructor() { + super( + "Registry API base URL is empty. Set VITE_REGISTRY_API_URL or inject " + + "window.__OPENCLAW_CONFIG__.registryApiUrl at runtime (default is " + + `${DEFAULT_REGISTRY_API_URL}).`, + ); + this.name = "RegistryApiNotConfiguredError"; + } +} + +// ---------- HTTP core ---------- + +interface RequestOptions { + method?: "GET" | "POST" | "PATCH" | "DELETE"; + body?: unknown; + signal?: AbortSignal; +} + +async function request(path: string, opts: RequestOptions = {}): Promise { + const { baseUrl, configured } = resolveRegistryApiConfig(); + if (!configured) throw new RegistryApiNotConfiguredError(); + + const url = `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`; + // Trust boundary is the VPN — no Authorization header by design. + const headers: Record = { + Accept: "application/json", + }; + let body: BodyInit | undefined; + if (opts.body !== undefined) { + headers["Content-Type"] = "application/json"; + body = JSON.stringify(opts.body); + } + + const res = await fetch(url, { + method: opts.method ?? "GET", + headers, + body, + signal: opts.signal, + }); + + let payload: unknown = null; + const text = await res.text(); + if (text) { + try { + payload = JSON.parse(text); + } catch { + payload = text; + } + } + + if (!res.ok) { + const message = + (payload && typeof payload === "object" && "detail" in payload + ? String((payload as { detail?: unknown }).detail) + : null) ?? `Registry API ${res.status} on ${opts.method ?? "GET"} ${path}`; + throw new RegistryApiError(res.status, message, payload); + } + + return payload as T; +} + +// ---------- Resource APIs ---------- + +function ensureArray(value: unknown): T[] { + return Array.isArray(value) ? (value as T[]) : []; +} + +export const pairing = { + list: async (statusFilter: PairingStatus | "all" = "pending") => { + const qs = statusFilter === "all" ? "" : `?status_filter=${encodeURIComponent(statusFilter)}`; + // Sidecar returns `{requests: [...]}`. Tolerate plain-array fallback too. + const raw = await request<{ requests?: PairingRequest[] } | PairingRequest[]>( + `/v1/pairing/requests${qs}` + ); + if (Array.isArray(raw)) return raw; + return ensureArray(raw?.requests); + }, + get: (id: number) => request(`/v1/pairing/requests/${id}`), + approve: (id: number, body: PairingApproveBody) => + request(`/v1/pairing/requests/${id}/approve`, { method: "POST", body }), + reject: (id: number, body: PairingRejectBody) => + request(`/v1/pairing/requests/${id}/reject`, { method: "POST", body }), + smokeTest: (id: number, body: PairingSmokeTestBody) => + request(`/v1/pairing/requests/${id}/smoke-test`, { + method: "POST", + body, + }), +}; + +export const channels = { + list: async () => { + // Sidecar returns `{channels: [...]}`. Tolerate plain-array fallback too. + const raw = await request<{ channels?: NotificationChannel[] } | NotificationChannel[]>( + "/v1/channels" + ); + if (Array.isArray(raw)) return raw; + return ensureArray(raw?.channels); + }, + get: (id: number) => request(`/v1/channels/${id}`), + create: (body: CreateChannelBody) => + request("/v1/channels", { method: "POST", body }), + update: (id: number, body: UpdateChannelBody) => + request(`/v1/channels/${id}`, { method: "PATCH", body }), + delete: (id: number) => request<{ ok: boolean }>(`/v1/channels/${id}`, { method: "DELETE" }), + setSecret: (id: number, body: SetSecretBody) => + request(`/v1/channels/${id}/secret`, { method: "POST", body }), + test: (id: number, body: ChannelTestBody = {}) => + request(`/v1/channels/${id}/test`, { method: "POST", body }), +}; + +export const notify = { + broadcast: (body: NotifyBroadcastBody) => + request("/v1/notify", { method: "POST", body }), +}; + +export const openhands = { + profiles: { + list: async () => { + // Sidecar returns `{profiles: [...]}`. Tolerate plain-array fallback too. + const raw = await request<{ profiles?: OpenHandsProfile[] } | OpenHandsProfile[]>( + "/v1/openhands/profiles", + ); + if (Array.isArray(raw)) return raw; + return ensureArray(raw?.profiles); + }, + get: (id: number) => request(`/v1/openhands/profiles/${id}`), + create: (body: CreateOpenHandsProfileBody) => + request("/v1/openhands/profiles", { method: "POST", body }), + update: (id: number, body: UpdateOpenHandsProfileBody) => + request(`/v1/openhands/profiles/${id}`, { method: "PATCH", body }), + delete: (id: number) => + request<{ ok: boolean }>(`/v1/openhands/profiles/${id}`, { method: "DELETE" }), + setSecret: (id: number, body: SetSecretBody) => + request(`/v1/openhands/profiles/${id}/secret`, { method: "POST", body }), + activate: (id: number) => + request(`/v1/openhands/profiles/${id}/activate`, { method: "POST" }), + test: (id: number, body: OpenHandsTestBody = {}) => + request(`/v1/openhands/profiles/${id}/test`, { method: "POST", body }), + }, +}; + +export const registryApiClient = { + pairing, + channels, + notify, + openhands, + resolveConfig: resolveRegistryApiConfig, +}; + +export default registryApiClient; diff --git a/src/store/console-stores/agents-store.ts b/src/store/console-stores/agents-store.ts index bdf13179..3c094484 100644 --- a/src/store/console-stores/agents-store.ts +++ b/src/store/console-stores/agents-store.ts @@ -23,7 +23,7 @@ import { } from "@/lib/config-patch-helpers"; import { clearAgentChannelSessions } from "./agent-session-cleanup"; -export type AgentTab = "overview" | "files" | "tools" | "skills" | "channels" | "cronJobs"; +export type AgentTab = "overview" | "files" | "tools" | "skills" | "local_skills" | "channels" | "cronJobs"; export interface SystemModelOption { id: string; diff --git a/src/store/console-stores/setupgcp-store.ts b/src/store/console-stores/setupgcp-store.ts new file mode 100644 index 00000000..fa4ca0d2 --- /dev/null +++ b/src/store/console-stores/setupgcp-store.ts @@ -0,0 +1,412 @@ +import { create } from "zustand"; +import { + channels as channelsApi, + openhands as openhandsApi, + pairing as pairingApi, + resolveRegistryApiConfig, + RegistryApiNotConfiguredError, + type ChannelTestResult, + type CreateChannelBody, + type CreateOpenHandsProfileBody, + type NotificationChannel, + type OpenHandsProfile, + type OpenHandsTestResult, + type PairingRequest, + type PairingStatus, + type UpdateChannelBody, + type UpdateOpenHandsProfileBody, +} from "@/lib/registry-api-client"; + +function toMessage(err: unknown): string { + if (err instanceof RegistryApiNotConfiguredError) return err.message; + if (err instanceof Error) return err.message; + try { + return String(err); + } catch { + return "Unknown error"; + } +} + +function isLikelyNetworkError(err: unknown): boolean { + // Browsers throw a TypeError ("Failed to fetch", "NetworkError when…", etc.) + // when DNS fails, the host is unreachable, or mixed content is blocked. + if (err instanceof TypeError) return true; + if (err instanceof Error && /network|fetch|failed to fetch/i.test(err.message)) return true; + return false; +} + +function describeError(err: unknown, baseUrl: string): string { + const base = toMessage(err); + if (isLikelyNetworkError(err)) { + return `${base} — cannot reach sidecar at ${baseUrl}. If you are not on the Tailscale tailnet, this page will be unavailable.`; + } + return base; +} + +interface SetupGcpState { + // Pairing + pairingItems: PairingRequest[]; + pairingFilter: PairingStatus | "all"; + pairingLoading: boolean; + pairingError: string | null; + pairingActionInFlight: Record; + + // Channels + channelItems: NotificationChannel[]; + channelsLoading: boolean; + channelsError: string | null; + channelActionInFlight: Record; + lastChannelTest: Record; + + // OpenHands + openhandsProfiles: OpenHandsProfile[]; + openhandsLoading: boolean; + openhandsError: string | null; + openhandsActionInFlight: Record; + lastOpenHandsTest: Record; + + // Config readiness + configured: boolean; + baseUrl: string; + + refreshConfig: () => void; + + fetchPairing: (filter?: PairingStatus | "all") => Promise; + setPairingFilter: (filter: PairingStatus | "all") => void; + approvePairing: (id: number, decidedBy: string) => Promise; + rejectPairing: (id: number, decidedBy: string, reason: string) => Promise; + + fetchChannels: () => Promise; + createChannel: (body: CreateChannelBody) => Promise; + updateChannel: (id: number, body: UpdateChannelBody) => Promise; + deleteChannel: (id: number) => Promise; + setChannelSecret: (id: number, secret: string) => Promise; + testChannel: (id: number, message?: string) => Promise; + + fetchOpenHandsProfiles: () => Promise; + createProfile: (body: CreateOpenHandsProfileBody) => Promise; + updateProfile: (id: number, body: UpdateOpenHandsProfileBody) => Promise; + setProfileSecret: (id: number, secret: string) => Promise; + activateProfile: (id: number) => Promise; + testProfile: (id: number, prompt?: string) => Promise; + deleteProfile: (id: number) => Promise; +} + +// Resolve config defensively at module load: any throw here would otherwise +// kill the whole bundle the moment the page is imported. +function safeInitialConfig(): { configured: boolean; baseUrl: string } { + try { + return resolveRegistryApiConfig(); + } catch { + return { configured: false, baseUrl: "" }; + } +} + +const initialConfig = safeInitialConfig(); + +export const useSetupGcpStore = create((set, get) => ({ + pairingItems: [], + pairingFilter: "pending", + pairingLoading: false, + pairingError: null, + pairingActionInFlight: {}, + + channelItems: [], + channelsLoading: false, + channelsError: null, + channelActionInFlight: {}, + lastChannelTest: {}, + + openhandsProfiles: [], + openhandsLoading: false, + openhandsError: null, + openhandsActionInFlight: {}, + lastOpenHandsTest: {}, + + configured: initialConfig.configured, + baseUrl: initialConfig.baseUrl, + + refreshConfig: () => { + try { + const cfg = resolveRegistryApiConfig(); + set({ configured: cfg.configured, baseUrl: cfg.baseUrl }); + } catch { + set({ configured: false, baseUrl: "" }); + } + }, + + fetchPairing: async (filter) => { + const useFilter = filter ?? get().pairingFilter; + set({ pairingLoading: true, pairingError: null, pairingFilter: useFilter }); + try { + const items = await pairingApi.list(useFilter); + set({ + pairingItems: Array.isArray(items) ? items : [], + pairingLoading: false, + }); + } catch (err) { + // Never let this throw out — surface as error state so the page can + // render a friendly panel instead of unmounting the React tree. + set({ + pairingError: describeError(err, get().baseUrl), + pairingLoading: false, + pairingItems: [], + }); + } + }, + + setPairingFilter: (filter) => { + set({ pairingFilter: filter }); + void get().fetchPairing(filter); + }, + + approvePairing: async (id, decidedBy) => { + set((s) => ({ pairingActionInFlight: { ...s.pairingActionInFlight, [id]: true } })); + try { + await pairingApi.approve(id, { decided_by: decidedBy }); + await get().fetchPairing(); + } catch (err) { + set({ pairingError: toMessage(err) }); + } finally { + set((s) => { + const next = { ...s.pairingActionInFlight }; + delete next[id]; + return { pairingActionInFlight: next }; + }); + } + }, + + rejectPairing: async (id, decidedBy, reason) => { + set((s) => ({ pairingActionInFlight: { ...s.pairingActionInFlight, [id]: true } })); + try { + await pairingApi.reject(id, { decided_by: decidedBy, reason }); + await get().fetchPairing(); + } catch (err) { + set({ pairingError: toMessage(err) }); + } finally { + set((s) => { + const next = { ...s.pairingActionInFlight }; + delete next[id]; + return { pairingActionInFlight: next }; + }); + } + }, + + fetchChannels: async () => { + set({ channelsLoading: true, channelsError: null }); + try { + const items = await channelsApi.list(); + set({ + channelItems: Array.isArray(items) ? items : [], + channelsLoading: false, + }); + } catch (err) { + set({ + channelsError: describeError(err, get().baseUrl), + channelsLoading: false, + channelItems: [], + }); + } + }, + + createChannel: async (body) => { + try { + const created = await channelsApi.create(body); + await get().fetchChannels(); + return created; + } catch (err) { + set({ channelsError: toMessage(err) }); + return null; + } + }, + + updateChannel: async (id, body) => { + set((s) => ({ channelActionInFlight: { ...s.channelActionInFlight, [id]: true } })); + try { + await channelsApi.update(id, body); + await get().fetchChannels(); + } catch (err) { + set({ channelsError: toMessage(err) }); + } finally { + set((s) => { + const next = { ...s.channelActionInFlight }; + delete next[id]; + return { channelActionInFlight: next }; + }); + } + }, + + deleteChannel: async (id) => { + set((s) => ({ channelActionInFlight: { ...s.channelActionInFlight, [id]: true } })); + try { + await channelsApi.delete(id); + await get().fetchChannels(); + } catch (err) { + set({ channelsError: toMessage(err) }); + } finally { + set((s) => { + const next = { ...s.channelActionInFlight }; + delete next[id]; + return { channelActionInFlight: next }; + }); + } + }, + + setChannelSecret: async (id, secret) => { + set((s) => ({ channelActionInFlight: { ...s.channelActionInFlight, [id]: true } })); + try { + await channelsApi.setSecret(id, { secret_value: secret }); + await get().fetchChannels(); + } catch (err) { + set({ channelsError: toMessage(err) }); + } finally { + set((s) => { + const next = { ...s.channelActionInFlight }; + delete next[id]; + return { channelActionInFlight: next }; + }); + } + }, + + testChannel: async (id, message) => { + set((s) => ({ channelActionInFlight: { ...s.channelActionInFlight, [id]: true } })); + try { + const result = await channelsApi.test(id, message ? { message } : {}); + set((s) => ({ lastChannelTest: { ...s.lastChannelTest, [id]: result } })); + // refresh so last_test_at / last_test_result reflect server state + await get().fetchChannels(); + return result; + } catch (err) { + const msg = toMessage(err); + const failure: ChannelTestResult = { ok: false, detail: msg }; + set((s) => ({ + lastChannelTest: { ...s.lastChannelTest, [id]: failure }, + channelsError: msg, + })); + return failure; + } finally { + set((s) => { + const next = { ...s.channelActionInFlight }; + delete next[id]; + return { channelActionInFlight: next }; + }); + } + }, + + fetchOpenHandsProfiles: async () => { + set({ openhandsLoading: true, openhandsError: null }); + try { + const items = await openhandsApi.profiles.list(); + set({ + openhandsProfiles: Array.isArray(items) ? items : [], + openhandsLoading: false, + }); + } catch (err) { + set({ + openhandsError: describeError(err, get().baseUrl), + openhandsLoading: false, + openhandsProfiles: [], + }); + } + }, + + createProfile: async (body) => { + try { + const created = await openhandsApi.profiles.create(body); + await get().fetchOpenHandsProfiles(); + return created; + } catch (err) { + set({ openhandsError: toMessage(err) }); + return null; + } + }, + + updateProfile: async (id, body) => { + set((s) => ({ openhandsActionInFlight: { ...s.openhandsActionInFlight, [id]: true } })); + try { + await openhandsApi.profiles.update(id, body); + await get().fetchOpenHandsProfiles(); + } catch (err) { + set({ openhandsError: toMessage(err) }); + } finally { + set((s) => { + const next = { ...s.openhandsActionInFlight }; + delete next[id]; + return { openhandsActionInFlight: next }; + }); + } + }, + + setProfileSecret: async (id, secret) => { + set((s) => ({ openhandsActionInFlight: { ...s.openhandsActionInFlight, [id]: true } })); + try { + await openhandsApi.profiles.setSecret(id, { secret_value: secret }); + await get().fetchOpenHandsProfiles(); + } catch (err) { + set({ openhandsError: toMessage(err) }); + } finally { + set((s) => { + const next = { ...s.openhandsActionInFlight }; + delete next[id]; + return { openhandsActionInFlight: next }; + }); + } + }, + + activateProfile: async (id) => { + set((s) => ({ openhandsActionInFlight: { ...s.openhandsActionInFlight, [id]: true } })); + try { + await openhandsApi.profiles.activate(id); + await get().fetchOpenHandsProfiles(); + } catch (err) { + set({ openhandsError: toMessage(err) }); + } finally { + set((s) => { + const next = { ...s.openhandsActionInFlight }; + delete next[id]; + return { openhandsActionInFlight: next }; + }); + } + }, + + testProfile: async (id, prompt) => { + set((s) => ({ openhandsActionInFlight: { ...s.openhandsActionInFlight, [id]: true } })); + try { + const result = await openhandsApi.profiles.test(id, prompt ? { prompt } : {}); + set((s) => ({ lastOpenHandsTest: { ...s.lastOpenHandsTest, [id]: result } })); + // refresh so last_test_at / last_test_result reflect server state + await get().fetchOpenHandsProfiles(); + return result; + } catch (err) { + const msg = toMessage(err); + const failure: OpenHandsTestResult = { ok: false, detail: msg }; + set((s) => ({ + lastOpenHandsTest: { ...s.lastOpenHandsTest, [id]: failure }, + openhandsError: msg, + })); + return failure; + } finally { + set((s) => { + const next = { ...s.openhandsActionInFlight }; + delete next[id]; + return { openhandsActionInFlight: next }; + }); + } + }, + + deleteProfile: async (id) => { + set((s) => ({ openhandsActionInFlight: { ...s.openhandsActionInFlight, [id]: true } })); + try { + await openhandsApi.profiles.delete(id); + await get().fetchOpenHandsProfiles(); + } catch (err) { + set({ openhandsError: toMessage(err) }); + } finally { + set((s) => { + const next = { ...s.openhandsActionInFlight }; + delete next[id]; + return { openhandsActionInFlight: next }; + }); + } + }, +})); diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 3c6d6950..acf71b85 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -5,6 +5,14 @@ declare const __APP_VERSION__: string; interface ImportMetaEnv { readonly VITE_GATEWAY_URL: string; readonly VITE_GATEWAY_TOKEN: string; + /** + * Base URL of the local HQ sidecar that exposes the registry-API surface + * (pairing requests + notification channels + notify broadcast). + * Defaults to `http://openclaw-hq:8781` on the Tailscale tailnet — the + * sidecar uses `tailscale whois` for source identity, so no bearer token + * is required (and none should ever be shipped in the browser bundle). + */ + readonly VITE_REGISTRY_API_URL?: string; } interface ImportMeta {