feat(web-console): cherry-pick React web console GUI from gary-the-ai fork
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 59s
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 59s
Cherry-pick the Hermes Web Console from gary-the-ai/hermes-web-console-gui. React + TypeScript frontend with Vite, Python aiohttp backend API. Components: - web_console/ — React frontend (chat, sessions, memory, settings, skills, gateway config, cron, workspace, tools, browser, insights pages) - gateway/web_console/ — Python backend API (23 endpoints, SSE event bus, 11 service modules) - gateway/platforms/api_server_ui.py — embedded browser UI for API server - gateway/platforms/api_server.py — route registration refactored into _register_routes(), web console mounted via maybe_register_web_console() - run-gui.sh / setup-gui.sh — one-command launch and setup scripts - tests/gateway/test_api_server_gui_mount.py — 4 integration tests (passing) - tests/web_console/ — 13 backend test files (51 passing) - docs/plans/ — implementation plan, API schema, frontend architecture Fix: added missing ModelContextError class and CRON_MIN_CONTEXT_TOKENS to cron/scheduler.py (pre-existing import bug). Closes #325
This commit is contained in:
188
web_console/src/components/gateway/PlatformConfigModal.tsx
Normal file
188
web_console/src/components/gateway/PlatformConfigModal.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { apiClient } from '../../lib/api';
|
||||
import { toastStore } from '../../store/toastStore';
|
||||
|
||||
interface PlatformConfigModalProps {
|
||||
platformId: string;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export function PlatformConfigModal({ platformId, onClose, onSaved }: PlatformConfigModalProps) {
|
||||
const [config, setConfig] = useState<Record<string, any>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadConfig() {
|
||||
const res = await apiClient.get<any>(`/gateway/platforms/${platformId}/config`);
|
||||
if (res?.ok) {
|
||||
setConfig(res.config || {});
|
||||
} else {
|
||||
toastStore.error(`Failed to load ${platformId} configuration.`);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
loadConfig();
|
||||
}, [platformId]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
const res = await apiClient.patch<any>(`/gateway/platforms/${platformId}/config`, config);
|
||||
setSaving(false);
|
||||
if (res?.ok) {
|
||||
toastStore.success(`${platformId} configuration saved.`);
|
||||
if (res.reload_required) {
|
||||
toastStore.info('Please restart Hermes for changes to take effect.');
|
||||
}
|
||||
onSaved();
|
||||
} else {
|
||||
toastStore.error(res?.error?.message || `Failed to save ${platformId} configuration.`);
|
||||
}
|
||||
};
|
||||
|
||||
const getFieldsForPlatform = () => {
|
||||
let fields = [
|
||||
{ key: 'token', label: 'Bot Token', type: 'password', placeholder: 'Enter API Token' },
|
||||
];
|
||||
|
||||
if (platformId === 'telegram') {
|
||||
fields.push({ key: 'home_channel', label: 'Home Channel ID', type: 'text', placeholder: 'Optional' });
|
||||
fields.push({ key: 'master_user', label: 'Master User ID', type: 'text', placeholder: 'Optional' });
|
||||
fields.push({ key: 'webhook_url', label: 'Webhook URL (Overrides Polling)', type: 'text', placeholder: 'https://...' });
|
||||
fields.push({ key: 'mention_behavior', label: 'Group Mention Behavior', type: 'text', placeholder: 'always | mentioned | regex' });
|
||||
fields.push({ key: 'regex_trigger', label: 'Regex Trigger', type: 'text', placeholder: 'Optional regex pattern' });
|
||||
} else if (platformId === 'discord') {
|
||||
fields.push({ key: 'home_channel', label: 'Home Channel ID', type: 'text', placeholder: 'Optional' });
|
||||
fields.push({ key: 'client_id', label: 'Client ID', type: 'text', placeholder: 'Optional' });
|
||||
} else if (platformId === 'slack') {
|
||||
fields.push({ key: 'app_token', label: 'App Token (xapp-...)', type: 'password', placeholder: 'Optional' });
|
||||
fields.push({ key: 'signing_secret', label: 'Signing Secret', type: 'password', placeholder: 'Optional' });
|
||||
} else if (platformId === 'feishu') {
|
||||
fields = []; // Remove standard token
|
||||
fields.push({ key: 'app_id', label: 'App ID', type: 'text', placeholder: 'cli_...' });
|
||||
fields.push({ key: 'app_secret', label: 'App Secret', type: 'password', placeholder: 'Secret' });
|
||||
fields.push({ key: 'verification_token', label: 'Verification Token', type: 'password', placeholder: 'Optional' });
|
||||
fields.push({ key: 'encrypt_key', label: 'Encrypt Key', type: 'password', placeholder: 'Optional' });
|
||||
} else if (platformId === 'weixin') {
|
||||
fields = []; // Remove standard token
|
||||
fields.push({ key: 'token', label: 'WeChat Token', type: 'password', placeholder: 'Token' });
|
||||
fields.push({ key: 'account_id', label: 'Account ID', type: 'text', placeholder: 'gh_...' });
|
||||
fields.push({ key: 'base_url', label: 'Base URL', type: 'text', placeholder: 'Optional (e.g. https://api.weixin.qq.com)' });
|
||||
} else if (platformId === 'wecom') {
|
||||
fields = []; // Remove standard token
|
||||
fields.push({ key: 'bot_id', label: 'Bot ID', type: 'text', placeholder: 'Bot ID' });
|
||||
fields.push({ key: 'secret', label: 'Secret', type: 'password', placeholder: 'Secret' });
|
||||
} else if (platformId === 'mattermost') {
|
||||
fields.push({ key: 'url', label: 'Mattermost URL', type: 'text', placeholder: 'https://...' });
|
||||
fields.push({ key: 'bot_id', label: 'Bot ID', type: 'text', placeholder: 'Optional' });
|
||||
fields.push({ key: 'webhook_port', label: 'Webhook Port', type: 'text', placeholder: 'Optional' });
|
||||
} else if (platformId === 'matrix') {
|
||||
fields.push({ key: 'homeserver', label: 'Homeserver URL', type: 'text', placeholder: 'https://matrix.org' });
|
||||
fields.push({ key: 'username', label: 'Username', type: 'text', placeholder: '@bot:matrix.org' });
|
||||
fields.push({ key: 'password', label: 'Password (if no token)', type: 'password', placeholder: 'Optional' });
|
||||
} else if (platformId === 'homeassistant') {
|
||||
fields.push({ key: 'url', label: 'Home Assistant URL', type: 'text', placeholder: 'https://...' });
|
||||
fields.push({ key: 'agent_id', label: 'Conversation Agent ID', type: 'text', placeholder: 'Optional' });
|
||||
} else if (platformId === 'signal') {
|
||||
fields = [];
|
||||
fields.push({ key: 'http_url', label: 'Signal-CLI HTTP URL', type: 'text', placeholder: 'http://127.0.0.1:8080' });
|
||||
fields.push({ key: 'account', label: 'Phone Number Account', type: 'text', placeholder: '+1234567890' });
|
||||
} else if (platformId === 'bluebubbles') {
|
||||
fields = [];
|
||||
fields.push({ key: 'server_url', label: 'Server URL', type: 'text', placeholder: 'https://...' });
|
||||
fields.push({ key: 'password', label: 'Password', type: 'password', placeholder: 'Secret' });
|
||||
} else if (platformId === 'email') {
|
||||
fields = [];
|
||||
fields.push({ key: 'address', label: 'Email Address', type: 'text', placeholder: 'bot@example.com' });
|
||||
fields.push({ key: 'imap_host', label: 'IMAP Host', type: 'text', placeholder: 'imap.example.com' });
|
||||
fields.push({ key: 'imap_user', label: 'IMAP User', type: 'text', placeholder: 'bot@example.com' });
|
||||
fields.push({ key: 'imap_pass', label: 'IMAP Password', type: 'password', placeholder: 'Secret' });
|
||||
fields.push({ key: 'smtp_host', label: 'SMTP Host', type: 'text', placeholder: 'smtp.example.com' });
|
||||
fields.push({ key: 'smtp_user', label: 'SMTP User', type: 'text', placeholder: 'bot@example.com' });
|
||||
fields.push({ key: 'smtp_pass', label: 'SMTP Password', type: 'password', placeholder: 'Secret' });
|
||||
} else if (platformId === 'whatsapp' || platformId === 'sms' || platformId === 'webhook' || platformId === 'api_server' || platformId === 'cli') {
|
||||
fields = [];
|
||||
}
|
||||
|
||||
return fields;
|
||||
};
|
||||
|
||||
const fields = getFieldsForPlatform();
|
||||
|
||||
const handleFieldChange = (key: string, value: any) => {
|
||||
setConfig(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const inputStyle = {
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
background: 'rgba(0, 0, 0, 0.2)',
|
||||
color: '#e2e8f0',
|
||||
fontSize: '0.9rem',
|
||||
marginBottom: '12px'
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh',
|
||||
background: 'rgba(0, 0, 0, 0.6)', backdropFilter: 'blur(4px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#1e293b', border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: '16px', padding: '24px', width: '400px', maxWidth: '90vw'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px', color: '#e2e8f0', textTransform: 'capitalize' }}>
|
||||
Configure {platformId}
|
||||
</h3>
|
||||
|
||||
{loading ? (
|
||||
<p style={{ color: '#94a3b8' }}>Loading configuration...</p>
|
||||
) : (
|
||||
<form onSubmit={e => { e.preventDefault(); handleSave(); }}>
|
||||
{fields.map(field => (
|
||||
<div key={field.key}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '0.85rem', color: '#94a3b8' }}>
|
||||
{field.label}
|
||||
</label>
|
||||
<input
|
||||
type={field.type}
|
||||
value={config[field.key] || ''}
|
||||
placeholder={field.placeholder}
|
||||
onChange={e => handleFieldChange(field.key, e.target.value)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', marginTop: '16px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: '8px', background: 'transparent',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)', color: '#e2e8f0', cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: '8px', background: 'rgba(56, 189, 248, 0.2)',
|
||||
border: '1px solid rgba(56, 189, 248, 0.3)', color: '#38bdf8', cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user