mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-02 10:33:00 +08:00
feat: add Hermes gateway controls
This commit is contained in:
45
docs/hermes-gateway.md
Normal file
45
docs/hermes-gateway.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Hermes Gateway Controls
|
||||
|
||||
CloudCLI can manage the Hermes Gateway process from **Settings -> Agents -> Hermes -> Gateway**.
|
||||
|
||||
The gateway is optional. Normal CloudCLI chat and automation should continue to use `POST /api/agent` with `provider: "hermes"`. The gateway is for long-running Hermes integrations such as Telegram, Discord, WhatsApp, and other messaging surfaces configured by Hermes.
|
||||
|
||||
## What The Gateway Tab Does
|
||||
|
||||
- Shows whether Hermes is installed and whether the gateway is running.
|
||||
- Starts Hermes with `hermes gateway run` in the current CloudCLI server environment.
|
||||
- Stops or restarts a gateway process started by CloudCLI.
|
||||
- Opens `hermes gateway setup` in the terminal for platform configuration.
|
||||
- Shows detected Hermes profiles for visibility.
|
||||
- Shows recent logs from the gateway process managed by CloudCLI.
|
||||
|
||||
## What It Does Not Do
|
||||
|
||||
- It does not expose the Hermes HTTP gateway as a raw public API.
|
||||
- It does not add a new authentication model.
|
||||
- It does not replace the CloudCLI Agent API.
|
||||
- It does not create Docker containers or require Docker.
|
||||
|
||||
## API Boundaries
|
||||
|
||||
CloudCLI has two separate surfaces:
|
||||
|
||||
- **CloudCLI Agent API**: `POST /api/agent`
|
||||
Use this for programmatic CloudCLI tasks, including Hermes tasks. It supports `projectPath`, `githubUrl`, sessions, branches, and other CloudCLI workflow options.
|
||||
|
||||
- **Hermes Gateway controls**: `/api/providers/hermes/gateway/*`
|
||||
These are browser-authenticated UI control endpoints used by the settings page.
|
||||
|
||||
The gateway controls intentionally stay inside the authenticated provider API. They are not a customer-facing replacement for `POST /api/agent`.
|
||||
|
||||
## Hosted Environments
|
||||
|
||||
In hosted or containerized environments, CloudCLI runs the gateway in foreground mode because system service managers such as systemd or launchd may not be available. This matches Hermes' recommended foreground mode for containers and similar runtimes.
|
||||
|
||||
If Docker is not available, the Gateway tab still works for the local Hermes process. Docker is only relevant for advanced deployments where a user chooses to run Hermes separately.
|
||||
|
||||
## References
|
||||
|
||||
- [Hermes Agent releases](https://github.com/NousResearch/hermes-agent/releases) for current gateway and messaging platform capabilities.
|
||||
- [Hermes hooks documentation](https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks) for non-interactive gateway runs and hook approval behavior.
|
||||
- [Hermes environment variables](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) for Docker-image-specific gateway supervision details.
|
||||
@@ -2,6 +2,7 @@ import express, { type Request, type Response } from 'express';
|
||||
|
||||
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
|
||||
import { providerCapabilitiesService } from '@/modules/providers/services/provider-capabilities.service.js';
|
||||
import { hermesGatewayService } from '@/modules/providers/services/hermes-gateway.service.js';
|
||||
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
|
||||
import { providerModelsService } from '@/modules/providers/services/provider-models.service.js';
|
||||
import { providerSkillsService } from '@/modules/providers/services/skills.service.js';
|
||||
@@ -340,6 +341,18 @@ const parseProvider = (value: unknown): LLMProvider => {
|
||||
});
|
||||
};
|
||||
|
||||
const parseHermesProvider = (value: unknown): 'hermes' => {
|
||||
const provider = parseProvider(value);
|
||||
if (provider !== 'hermes') {
|
||||
throw new AppError('Gateway controls are only available for Hermes.', {
|
||||
code: 'HERMES_GATEWAY_UNSUPPORTED_PROVIDER',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return provider;
|
||||
};
|
||||
|
||||
const parseSessionRenameSummary = (payload: unknown): string => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
throw new AppError('Request body must be an object.', {
|
||||
@@ -637,6 +650,51 @@ router.get(
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------- Hermes gateway routes -----------------
|
||||
router.get(
|
||||
'/:provider/gateway/status',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
parseHermesProvider(req.params.provider);
|
||||
const status = await hermesGatewayService.getStatus();
|
||||
res.json(createApiSuccessResponse(status));
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:provider/gateway/logs',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
parseHermesProvider(req.params.provider);
|
||||
res.json(createApiSuccessResponse({ logs: hermesGatewayService.getLogs() }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:provider/gateway/start',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
parseHermesProvider(req.params.provider);
|
||||
const status = await hermesGatewayService.start();
|
||||
res.json(createApiSuccessResponse(status));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:provider/gateway/stop',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
parseHermesProvider(req.params.provider);
|
||||
const status = await hermesGatewayService.stop();
|
||||
res.json(createApiSuccessResponse(status));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:provider/gateway/restart',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
parseHermesProvider(req.params.provider);
|
||||
const status = await hermesGatewayService.restart();
|
||||
res.json(createApiSuccessResponse(status));
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------- Session routes -----------------
|
||||
/**
|
||||
* Session gateway entry point: allocates the stable app-facing session id for
|
||||
|
||||
317
server/modules/providers/services/hermes-gateway.service.ts
Normal file
317
server/modules/providers/services/hermes-gateway.service.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { spawn, execFile, type ChildProcess } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const gatewayCommandParts = (process.env.HERMES_GATEWAY_COMMAND || '').trim().split(/\s+/).filter(Boolean);
|
||||
const fallbackHermesCommand = (
|
||||
process.env.HERMES_COMMAND_PATH
|
||||
|| process.env.HERMES_CLI_PATH
|
||||
|| 'hermes'
|
||||
).trim().split(/\s+/)[0] || 'hermes';
|
||||
const HERMES_COMMAND = gatewayCommandParts[0] || fallbackHermesCommand;
|
||||
const HERMES_BASE_ARGS = gatewayCommandParts.slice(1);
|
||||
|
||||
const MAX_LOG_LINES = 300;
|
||||
const COMMAND_TIMEOUT_MS = 10_000;
|
||||
|
||||
type CommandResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type HermesGatewayProfile = {
|
||||
name: string;
|
||||
current: boolean;
|
||||
model: string | null;
|
||||
gateway: string | null;
|
||||
alias: string | null;
|
||||
distribution: string | null;
|
||||
};
|
||||
|
||||
export type HermesGatewayStatus = {
|
||||
installed: boolean;
|
||||
command: string;
|
||||
version: string | null;
|
||||
running: boolean;
|
||||
managedByCloudCLI: boolean;
|
||||
state: 'running' | 'stopped' | 'unknown';
|
||||
statusOutput: string;
|
||||
profiles: HermesGatewayProfile[];
|
||||
logs: string[];
|
||||
lastExit: {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
at: string;
|
||||
} | null;
|
||||
commands: {
|
||||
setup: string;
|
||||
run: string;
|
||||
};
|
||||
};
|
||||
|
||||
const removeAnsi = (value: string): string => value.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
|
||||
|
||||
const compactOutput = (result: CommandResult): string => (
|
||||
[result.stdout, result.stderr].filter(Boolean).join('\n').trim()
|
||||
);
|
||||
|
||||
const parseGatewayRunning = (output: string, managedByCloudCLI: boolean): boolean => {
|
||||
if (managedByCloudCLI) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalized = output.toLowerCase();
|
||||
if (/\b(not running|stopped|inactive|failed)\b/.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /\b(running|active)\b/.test(normalized);
|
||||
};
|
||||
|
||||
const parseProfiles = (output: string): HermesGatewayProfile[] => {
|
||||
const profiles: HermesGatewayProfile[] = [];
|
||||
for (const rawLine of removeAnsi(output).split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('Profile ') || /^[-─\s]+$/.test(line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const current = line.startsWith('◆');
|
||||
const cleaned = line.replace(/^[◆*]\s*/, '').trim();
|
||||
const columns = cleaned.split(/\s{2,}/).map((column) => column.trim());
|
||||
const [name, model, gateway, alias, distribution] = columns;
|
||||
if (!name || name.toLowerCase() === 'profile') {
|
||||
continue;
|
||||
}
|
||||
|
||||
profiles.push({
|
||||
name,
|
||||
current,
|
||||
model: model && model !== '—' ? model : null,
|
||||
gateway: gateway && gateway !== '—' ? gateway : null,
|
||||
alias: alias && alias !== '—' ? alias : null,
|
||||
distribution: distribution && distribution !== '—' ? distribution : null,
|
||||
});
|
||||
}
|
||||
|
||||
return profiles;
|
||||
};
|
||||
|
||||
class HermesGatewayService {
|
||||
private gatewayProcess: ChildProcess | null = null;
|
||||
private readonly logs: string[] = [];
|
||||
private lastExit: HermesGatewayStatus['lastExit'] = null;
|
||||
|
||||
async getStatus(): Promise<HermesGatewayStatus> {
|
||||
const versionResult = await this.runHermes(['--version'], { timeout: 5000 });
|
||||
const installed = versionResult.exitCode === 0;
|
||||
const version = installed ? compactOutput(versionResult).split(/\r?\n/)[0] || null : null;
|
||||
|
||||
const statusResult = installed
|
||||
? await this.runHermes(['gateway', 'status'], { timeout: COMMAND_TIMEOUT_MS })
|
||||
: { stdout: '', stderr: versionResult.error || 'Hermes is not installed.', exitCode: 1 };
|
||||
const profilesResult = installed
|
||||
? await this.runHermes(['profile', 'list'], { timeout: COMMAND_TIMEOUT_MS })
|
||||
: { stdout: '', stderr: '', exitCode: 1 };
|
||||
|
||||
const statusOutput = compactOutput(statusResult);
|
||||
const managedByCloudCLI = this.isManagedProcessRunning();
|
||||
const running = parseGatewayRunning(statusOutput, managedByCloudCLI);
|
||||
|
||||
return {
|
||||
installed,
|
||||
command: this.commandPrefix().join(' '),
|
||||
version,
|
||||
running,
|
||||
managedByCloudCLI,
|
||||
state: running ? 'running' : statusOutput ? 'stopped' : 'unknown',
|
||||
statusOutput,
|
||||
profiles: parseProfiles(profilesResult.stdout),
|
||||
logs: this.getLogs(),
|
||||
lastExit: this.lastExit,
|
||||
commands: {
|
||||
setup: [...this.commandPrefix(), 'gateway', 'setup'].join(' '),
|
||||
run: [...this.commandPrefix(), 'gateway', 'run'].join(' '),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async start(): Promise<HermesGatewayStatus> {
|
||||
await this.assertInstalled();
|
||||
if (this.isManagedProcessRunning()) {
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
const currentStatus = await this.getStatus();
|
||||
if (currentStatus.running) {
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
const args = [...HERMES_BASE_ARGS, 'gateway', 'run', '--accept-hooks'];
|
||||
this.appendLog(`[cloudcli] starting Hermes gateway: ${HERMES_COMMAND} ${args.join(' ')}`);
|
||||
this.lastExit = null;
|
||||
const child = spawn(HERMES_COMMAND, args, {
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_ACCEPT_HOOKS: '1',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
this.gatewayProcess = child;
|
||||
|
||||
child.stdout?.on('data', (chunk) => this.appendLog(String(chunk)));
|
||||
child.stderr?.on('data', (chunk) => this.appendLog(String(chunk)));
|
||||
child.on('error', (error) => {
|
||||
this.appendLog(`[cloudcli] gateway process error: ${error.message}`);
|
||||
});
|
||||
child.on('exit', (code, signal) => {
|
||||
this.lastExit = {
|
||||
code,
|
||||
signal,
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
this.appendLog(`[cloudcli] gateway exited with code ${code ?? 'null'}${signal ? ` signal ${signal}` : ''}`);
|
||||
this.gatewayProcess = null;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
|
||||
if (!this.isManagedProcessRunning()) {
|
||||
throw new AppError('Hermes gateway exited before it could start.', {
|
||||
code: 'HERMES_GATEWAY_START_FAILED',
|
||||
statusCode: 500,
|
||||
details: {
|
||||
logs: this.getLogs().slice(-20),
|
||||
lastExit: this.lastExit,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
async stop(): Promise<HermesGatewayStatus> {
|
||||
if (this.isManagedProcessRunning() && this.gatewayProcess) {
|
||||
this.appendLog('[cloudcli] stopping managed Hermes gateway');
|
||||
await this.stopManagedProcess();
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
await this.runHermes(['gateway', 'stop'], { timeout: COMMAND_TIMEOUT_MS });
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
async restart(): Promise<HermesGatewayStatus> {
|
||||
if (this.isManagedProcessRunning()) {
|
||||
await this.stopManagedProcess();
|
||||
} else {
|
||||
await this.runHermes(['gateway', 'stop'], { timeout: COMMAND_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
return this.start();
|
||||
}
|
||||
|
||||
getLogs(): string[] {
|
||||
return [...this.logs];
|
||||
}
|
||||
|
||||
private async assertInstalled(): Promise<void> {
|
||||
const result = await this.runHermes(['--version'], { timeout: 5000 });
|
||||
if (result.exitCode !== 0) {
|
||||
throw new AppError('Hermes is not installed or is not available on PATH.', {
|
||||
code: 'HERMES_NOT_INSTALLED',
|
||||
statusCode: 400,
|
||||
details: compactOutput(result),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private isManagedProcessRunning(): boolean {
|
||||
return Boolean(this.gatewayProcess && !this.gatewayProcess.killed && this.gatewayProcess.exitCode === null);
|
||||
}
|
||||
|
||||
private async stopManagedProcess(): Promise<void> {
|
||||
const child = this.gatewayProcess;
|
||||
if (!child) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exited = new Promise<void>((resolve) => {
|
||||
child.once('exit', () => resolve());
|
||||
});
|
||||
child.kill('SIGTERM');
|
||||
|
||||
await Promise.race([
|
||||
exited,
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
if (this.gatewayProcess === child && this.isManagedProcessRunning()) {
|
||||
this.appendLog('[cloudcli] gateway did not stop after SIGTERM; sending SIGKILL');
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
resolve();
|
||||
}, 5000);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
private async runHermes(args: string[], options: { timeout: number }): Promise<CommandResult> {
|
||||
try {
|
||||
const result = await execFileAsync(HERMES_COMMAND, [...HERMES_BASE_ARGS, ...args], {
|
||||
timeout: options.timeout,
|
||||
maxBuffer: 1024 * 1024,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_ACCEPT_HOOKS: '1',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
stdout: result.stdout ?? '',
|
||||
stderr: result.stderr ?? '',
|
||||
exitCode: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
const execError = error as NodeJS.ErrnoException & {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
code?: number | string | null;
|
||||
};
|
||||
|
||||
return {
|
||||
stdout: execError.stdout ?? '',
|
||||
stderr: execError.stderr ?? '',
|
||||
exitCode: typeof execError.code === 'number' ? execError.code : 1,
|
||||
error: execError.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private appendLog(chunk: string): void {
|
||||
const lines = removeAnsi(chunk)
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trimEnd())
|
||||
.filter(Boolean);
|
||||
if (lines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logs.push(...lines.map((line) => `${new Date().toISOString()} ${line}`));
|
||||
if (this.logs.length > MAX_LOG_LINES) {
|
||||
this.logs.splice(0, this.logs.length - MAX_LOG_LINES);
|
||||
}
|
||||
}
|
||||
|
||||
private commandPrefix(): string[] {
|
||||
return [HERMES_COMMAND, ...HERMES_BASE_ARGS];
|
||||
}
|
||||
}
|
||||
|
||||
export const hermesGatewayService = new HermesGatewayService();
|
||||
@@ -5,7 +5,7 @@ import type { ProviderAuthStatus } from '../../provider-auth/types';
|
||||
|
||||
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'voice' | 'tasks' | 'browser' | 'notifications' | 'plugins' | 'about';
|
||||
export type AgentProvider = LLMProvider;
|
||||
export type AgentCategory = 'account' | 'permissions' | 'mcp' | 'skills';
|
||||
export type AgentCategory = 'account' | 'permissions' | 'gateway' | 'mcp' | 'skills';
|
||||
export type ProjectSortOrder = 'name' | 'date';
|
||||
export type SaveStatus = 'success' | 'error' | null;
|
||||
export type CodexPermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions';
|
||||
|
||||
@@ -25,7 +25,9 @@ export default function AgentsSettingsTab({
|
||||
const visibleCategories = useMemo<AgentCategory[]>(() => (
|
||||
selectedAgent === 'opencode'
|
||||
? ['account', 'permissions', 'mcp']
|
||||
: ['account', 'permissions', 'mcp', 'skills']
|
||||
: selectedAgent === 'hermes'
|
||||
? ['account', 'gateway', 'mcp', 'skills']
|
||||
: ['account', 'permissions', 'mcp', 'skills']
|
||||
), [selectedAgent]);
|
||||
|
||||
const visibleAgents = useMemo<AgentProvider[]>(() => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { SkillsProject } from '../../../../../skills/types';
|
||||
import { ProviderSkills } from '../../../../../skills';
|
||||
|
||||
import AccountContent from './content/AccountContent';
|
||||
import GatewayContent from './content/GatewayContent';
|
||||
import PermissionsContent from './content/PermissionsContent';
|
||||
|
||||
export default function AgentCategoryContentSection({
|
||||
@@ -29,6 +30,12 @@ export default function AgentCategoryContentSection({
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedCategory === 'gateway' && selectedAgent === 'hermes' && (
|
||||
<GatewayContent
|
||||
onOpenSetup={agentContextById.hermes.onLogin}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedCategory === 'permissions' && selectedAgent === 'claude' && (
|
||||
<PermissionsContent
|
||||
agent="claude"
|
||||
|
||||
@@ -29,6 +29,7 @@ export default function AgentCategoryTabsSection({
|
||||
>
|
||||
{category === 'account' && t('tabs.account')}
|
||||
{category === 'permissions' && t('tabs.permissions')}
|
||||
{category === 'gateway' && t('tabs.gateway', { defaultValue: 'Gateway' })}
|
||||
{category === 'mcp' && t('tabs.mcpServers')}
|
||||
{category === 'skills' && t('tabs.skills', {
|
||||
defaultValue: selectedAgent === 'opencode' ? 'Shared Skills' : 'Skills',
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
Layers3,
|
||||
Play,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Square,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Badge, Button, HelpTooltip } from '../../../../../../../shared/view/ui';
|
||||
import { authenticatedFetch } from '../../../../../../../utils/api';
|
||||
import SettingsCard from '../../../../SettingsCard';
|
||||
import SettingsSection from '../../../../SettingsSection';
|
||||
|
||||
type GatewayProfile = {
|
||||
name: string;
|
||||
current: boolean;
|
||||
model: string | null;
|
||||
gateway: string | null;
|
||||
alias: string | null;
|
||||
distribution: string | null;
|
||||
};
|
||||
|
||||
type GatewayStatus = {
|
||||
installed: boolean;
|
||||
command: string;
|
||||
version: string | null;
|
||||
running: boolean;
|
||||
managedByCloudCLI: boolean;
|
||||
state: 'running' | 'stopped' | 'unknown';
|
||||
statusOutput: string;
|
||||
profiles: GatewayProfile[];
|
||||
logs: string[];
|
||||
lastExit: {
|
||||
code: number | null;
|
||||
signal: string | null;
|
||||
at: string;
|
||||
} | null;
|
||||
commands: {
|
||||
setup: string;
|
||||
run: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ApiSuccess<T> = {
|
||||
success: boolean;
|
||||
data: T;
|
||||
};
|
||||
|
||||
type GatewayContentProps = {
|
||||
onOpenSetup: (customCommand?: string, customTitle?: string) => void;
|
||||
};
|
||||
|
||||
const gatewayTooltip = 'Use this when tools outside CloudCLI need to reach Hermes through messaging integrations. Normal CloudCLI chat uses the built-in agent API.';
|
||||
const setupTooltip = 'Opens Hermes setup in the terminal so you can connect Telegram, Discord, WhatsApp, or another supported platform.';
|
||||
const profilesTooltip = 'Profiles are isolated Hermes configurations. This page shows them for visibility and controls the active gateway process.';
|
||||
const logsTooltip = 'These logs come from the gateway process started by CloudCLI in this server session.';
|
||||
|
||||
async function readGatewayResponse<T>(response: Response): Promise<T> {
|
||||
const payload = await response.json().catch(() => null) as ApiSuccess<T> | { error?: string } | null;
|
||||
if (!response.ok || !payload || !('success' in payload) || !payload.success) {
|
||||
throw new Error((payload && 'error' in payload && payload.error) || 'Gateway request failed');
|
||||
}
|
||||
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
export default function GatewayContent({ onOpenSetup }: GatewayContentProps) {
|
||||
const [status, setStatus] = useState<GatewayStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [action, setAction] = useState<'start' | 'stop' | 'restart' | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/providers/hermes/gateway/status');
|
||||
const nextStatus = await readGatewayResponse<GatewayStatus>(response);
|
||||
setStatus(nextStatus);
|
||||
} catch (refreshError) {
|
||||
setError(refreshError instanceof Error ? refreshError.message : 'Could not load gateway status');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const runAction = useCallback(async (nextAction: 'start' | 'stop' | 'restart') => {
|
||||
setAction(nextAction);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await authenticatedFetch(`/api/providers/hermes/gateway/${nextAction}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const nextStatus = await readGatewayResponse<GatewayStatus>(response);
|
||||
setStatus(nextStatus);
|
||||
} catch (actionError) {
|
||||
setError(actionError instanceof Error ? actionError.message : `Could not ${nextAction} gateway`);
|
||||
void refreshStatus();
|
||||
} finally {
|
||||
setAction(null);
|
||||
}
|
||||
}, [refreshStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshStatus();
|
||||
}, [refreshStatus]);
|
||||
|
||||
const busy = Boolean(action);
|
||||
const running = Boolean(status?.running);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-200">
|
||||
<Activity className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-medium text-foreground">Hermes Gateway</h3>
|
||||
<HelpTooltip content={gatewayTooltip} position="right" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage Hermes messaging gateway runtime for the active environment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsSection
|
||||
title="Runtime"
|
||||
description="Start the gateway in the current CloudCLI server environment."
|
||||
>
|
||||
<SettingsCard>
|
||||
<div className="flex flex-col gap-4 p-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={running
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'}
|
||||
>
|
||||
{loading ? 'Checking' : running ? 'Running' : 'Stopped'}
|
||||
</Badge>
|
||||
{status?.managedByCloudCLI && (
|
||||
<Badge variant="outline">Managed by CloudCLI</Badge>
|
||||
)}
|
||||
{status && !status.installed && (
|
||||
<Badge variant="destructive">Hermes not installed</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="text-foreground">
|
||||
Command: <span className="font-mono text-muted-foreground">{status?.command ?? 'hermes'}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{status?.version ?? 'Hermes status will appear after refresh.'}
|
||||
</div>
|
||||
{status?.lastExit && (
|
||||
<div className="text-muted-foreground">
|
||||
Last exit: code {status.lastExit.code ?? 'null'}
|
||||
{status.lastExit.signal ? `, signal ${status.lastExit.signal}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 lg:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void refreshStatus()}
|
||||
disabled={loading || busy}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => void runAction('start')}
|
||||
disabled={!status?.installed || running || busy}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
{action === 'start' ? 'Starting' : 'Start'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void runAction('restart')}
|
||||
disabled={!status?.installed || busy}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
{action === 'restart' ? 'Restarting' : 'Restart'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void runAction('stop')}
|
||||
disabled={!status?.installed || !running || busy}
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
{action === 'stop' ? 'Stopping' : 'Stop'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status?.statusOutput && (
|
||||
<div className="border-t border-border px-4 py-3">
|
||||
<pre className="max-h-36 overflow-auto whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground">
|
||||
{status.statusOutput}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="border-t border-border px-4 py-3 text-sm text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Setup"
|
||||
description="Configure messaging platforms through the Hermes CLI."
|
||||
>
|
||||
<SettingsCard>
|
||||
<div className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
Platform setup
|
||||
<HelpTooltip content={setupTooltip} position="right" />
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
Opens <span className="font-mono">hermes gateway setup</span> in the shell.
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => onOpenSetup('hermes gateway setup', 'Hermes Gateway Setup')}
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
Open setup
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Profiles"
|
||||
description="View Hermes profiles detected in this environment."
|
||||
>
|
||||
<SettingsCard>
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3 text-sm font-medium text-foreground">
|
||||
<Layers3 className="h-4 w-4 text-muted-foreground" />
|
||||
Hermes profiles
|
||||
<HelpTooltip content={profilesTooltip} position="right" />
|
||||
</div>
|
||||
{status?.profiles.length ? (
|
||||
<div className="divide-y divide-border">
|
||||
{status.profiles.map((profile) => (
|
||||
<div key={profile.name} className="grid gap-2 px-4 py-3 text-sm sm:grid-cols-[1fr_auto_auto] sm:items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 font-medium text-foreground">
|
||||
{profile.name}
|
||||
{profile.current && <Badge variant="outline">Current</Badge>}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-muted-foreground">
|
||||
{profile.model ?? 'No model configured'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Gateway: {profile.gateway ?? 'unknown'}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Alias: {profile.alias ?? 'none'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-4 text-sm text-muted-foreground">
|
||||
{loading ? 'Loading profiles...' : 'No Hermes profiles were found.'}
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Logs"
|
||||
description="Recent output from the gateway process managed by CloudCLI."
|
||||
>
|
||||
<SettingsCard>
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3 text-sm font-medium text-foreground">
|
||||
Gateway output
|
||||
<HelpTooltip content={logsTooltip} position="right" />
|
||||
</div>
|
||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap px-4 py-3 text-xs leading-relaxed text-muted-foreground">
|
||||
{status?.logs.length ? status.logs.join('\n') : 'No CloudCLI-managed gateway logs yet.'}
|
||||
</pre>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
"tabs": {
|
||||
"account": "Account",
|
||||
"permissions": "Permissions",
|
||||
"gateway": "Gateway",
|
||||
"mcpServers": "MCP Servers",
|
||||
"skills": "Skills",
|
||||
"appearance": "Appearance"
|
||||
|
||||
32
src/shared/view/ui/HelpTooltip.tsx
Normal file
32
src/shared/view/ui/HelpTooltip.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { CircleHelp } from 'lucide-react';
|
||||
|
||||
import Tooltip from './Tooltip';
|
||||
|
||||
type HelpTooltipProps = {
|
||||
content: string;
|
||||
label?: string;
|
||||
position?: 'top' | 'bottom' | 'left' | 'right';
|
||||
};
|
||||
|
||||
export default function HelpTooltip({
|
||||
content,
|
||||
label = 'Help',
|
||||
position = 'top',
|
||||
}: HelpTooltipProps) {
|
||||
return (
|
||||
<Tooltip
|
||||
content={content}
|
||||
position={position}
|
||||
className="max-w-[260px] whitespace-normal px-3 py-2 text-left text-xs leading-relaxed"
|
||||
>
|
||||
<span
|
||||
aria-label={label}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
role="img"
|
||||
title={content}
|
||||
>
|
||||
<CircleHelp className="h-4 w-4" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export { Collapsible, CollapsibleTrigger, CollapsibleContent } from './Collapsib
|
||||
export { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator } from './Command';
|
||||
export { default as DarkModeToggle } from './DarkModeToggle';
|
||||
export { Dialog, DialogTrigger, DialogContent, DialogTitle } from './Dialog';
|
||||
export { default as HelpTooltip } from './HelpTooltip';
|
||||
export { Input } from './Input';
|
||||
export { ScrollArea } from './ScrollArea';
|
||||
export { Reasoning, ReasoningTrigger, ReasoningContent, useReasoning } from './Reasoning';
|
||||
|
||||
Reference in New Issue
Block a user