diff --git a/apps/desktop/src/app/contrib/surfaces.tsx b/apps/desktop/src/app/contrib/surfaces.tsx index 482d37a4bba8..80e6460c09e5 100644 --- a/apps/desktop/src/app/contrib/surfaces.tsx +++ b/apps/desktop/src/app/contrib/surfaces.tsx @@ -189,6 +189,7 @@ export const ChatRoutesSurface = memo(function ChatRoutesSurface({ + {/* Registry-contributed pages (core features + plugins) render in the workspace pane like any built-in view — behind the same blast wall as every other contribution mount. */} diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 978d328e228f..6688107aa827 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -111,6 +111,7 @@ import type { WiringActions, WiringApi } from './types' const AgentsView = lazy(async () => ({ default: (await import('../agents')).AgentsView })) const CommandCenterView = lazy(async () => ({ default: (await import('../command-center')).CommandCenterView })) const CronView = lazy(async () => ({ default: (await import('../cron')).CronView })) +const WebhooksView = lazy(async () => ({ default: (await import('../webhooks')).WebhooksView })) const ProfilesView = lazy(async () => ({ default: (await import('../profiles')).ProfilesView })) const SettingsView = lazy(async () => ({ default: (await import('../settings')).SettingsView })) const StarmapView = lazy(async () => ({ default: (await import('../starmap')).StarmapView })) @@ -197,7 +198,8 @@ export function ContribWiring({ children }: { children: ReactNode }) { resetOverlayReturnRoute, settingsOpen, starmapOpen, - toggleCommandCenter + toggleCommandCenter, + webhooksOpen } = useOverlayRouting() const { @@ -1006,6 +1008,12 @@ export function ContribWiring({ children }: { children: ReactNode }) { )} + {webhooksOpen && ( + + + + )} + {profilesOpen && ( diff --git a/apps/desktop/src/app/routes.ts b/apps/desktop/src/app/routes.ts index 8db7ee7c3900..8965cc6a27a0 100644 --- a/apps/desktop/src/app/routes.ts +++ b/apps/desktop/src/app/routes.ts @@ -9,6 +9,7 @@ export const SETTINGS_ROUTE = '/settings' export const COMMAND_CENTER_ROUTE = '/command-center' export const SKILLS_ROUTE = '/skills' export const MESSAGING_ROUTE = '/messaging' +export const WEBHOOKS_ROUTE = '/webhooks' export const ARTIFACTS_ROUTE = '/artifacts' export const CRON_ROUTE = '/cron' export const PROFILES_ROUTE = '/profiles' @@ -31,6 +32,7 @@ export type AppView = | 'settings' | 'skills' | 'starmap' + | 'webhooks' export type AppRouteId = | 'agents' @@ -43,6 +45,7 @@ export type AppRouteId = | 'settings' | 'skills' | 'starmap' + | 'webhooks' export interface AppRoute { id: AppRouteId @@ -56,6 +59,7 @@ export const APP_ROUTES = [ { id: 'command-center', path: COMMAND_CENTER_ROUTE, view: 'command-center' }, { id: 'skills', path: SKILLS_ROUTE, view: 'skills' }, { id: 'messaging', path: MESSAGING_ROUTE, view: 'messaging' }, + { id: 'webhooks', path: WEBHOOKS_ROUTE, view: 'webhooks' }, { id: 'artifacts', path: ARTIFACTS_ROUTE, view: 'artifacts' }, { id: 'cron', path: CRON_ROUTE, view: 'cron' }, { id: 'profiles', path: PROFILES_ROUTE, view: 'profiles' }, @@ -121,7 +125,8 @@ export const OVERLAY_VIEWS: ReadonlySet = new Set([ 'cron', 'profiles', 'settings', - 'starmap' + 'starmap', + 'webhooks' ]) export function isOverlayView(view: AppView): boolean { diff --git a/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts b/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts index 5d827f349b92..8a49d3b983ca 100644 --- a/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts +++ b/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts @@ -24,6 +24,7 @@ export function useOverlayRouting() { const starmapOpen = currentView === 'starmap' const cronOpen = currentView === 'cron' const profilesOpen = currentView === 'profiles' + const webhooksOpen = currentView === 'webhooks' const chatOpen = currentView === 'chat' const overlayOpen = isOverlayView(currentView) @@ -82,6 +83,7 @@ export function useOverlayRouting() { resetOverlayReturnRoute, settingsOpen, starmapOpen, - toggleCommandCenter + toggleCommandCenter, + webhooksOpen } } diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 62e469e8a082..8033e7f7e005 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -9,7 +9,7 @@ import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel' import { Codicon } from '@/components/ui/codicon' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { useI18n } from '@/i18n' -import { Activity, AlertCircle, Clock, Command, FolderOpen, Hash, Loader2, Terminal } from '@/lib/icons' +import { Activity, AlertCircle, Clock, Command, FolderOpen, Globe, Hash, Loader2, Terminal } from '@/lib/icons' import type { RuntimeReadinessResult } from '@/lib/runtime-readiness' import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar' import { cn } from '@/lib/utils' @@ -43,7 +43,7 @@ import { } from '@/store/updates' import type { StatusResponse, UsageStats } from '@/types/hermes' -import { CRON_ROUTE, SETTINGS_ROUTE } from '../../routes' +import { CRON_ROUTE, SETTINGS_ROUTE, WEBHOOKS_ROUTE } from '../../routes' import type { StatusbarItem } from '../statusbar-controls' const EMPTY_USAGE = { calls: 0, input: 0, output: 0, total: 0 } as const @@ -420,6 +420,14 @@ export function useStatusbarItems({ title: copy.openCron, to: CRON_ROUTE, variant: 'action' + }, + { + icon: , + id: 'webhooks', + label: copy.webhooks, + title: copy.openWebhooks, + to: WEBHOOKS_ROUTE, + variant: 'action' } ], [ diff --git a/apps/desktop/src/app/webhooks/index.tsx b/apps/desktop/src/app/webhooks/index.tsx new file mode 100644 index 000000000000..53d760e2f79b --- /dev/null +++ b/apps/desktop/src/app/webhooks/index.tsx @@ -0,0 +1,643 @@ +import { useStore } from '@nanostores/react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { PageLoader } from '@/components/page-loader' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { ConfirmDialog } from '@/components/ui/confirm-dialog' +import { CopyButton } from '@/components/ui/copy-button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { Textarea } from '@/components/ui/textarea' +import { + createWebhook, + deleteWebhook, + enableWebhooks, + getWebhooks, + setWebhookEnabled, + type WebhookRoute, + type WebhooksResponse +} from '@/hermes' +import { useI18n } from '@/i18n' +import { AlertTriangle, Globe, Plus, RefreshCw } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { notify, notifyError } from '@/store/notifications' +import { $profileScope } from '@/store/profile' +import { runGatewayRestart } from '@/store/system-actions' + +import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' +import { + Panel, + PanelAddButton, + PanelBlock, + PanelBody, + PanelDetail, + PanelEmpty, + PanelHeader, + PanelList, + PanelListRow, + PanelMeta, + PanelPill, + PanelRowMenu, + PanelSectionLabel +} from '../overlays/panel' +import { ListRow, ToggleRow } from '../settings/primitives' + +const DELIVER_OPTIONS: readonly string[] = ['log', 'telegram', 'discord', 'slack', 'email', 'github_comment'] + +interface CreatedWebhook { + secret: string + url: string +} + +// One affordance for "value + CopyButton": flat, token-backed (no border, no +// raw literals). DESIGN.md Principle 1 (flat, not boxed) + 4 (tokens, not +// literals). Reused by the detail URL row and the create-result URL/secret so +// there is a single copyable-value chrome in this file. +function CopyValueRow({ copyLabel, mono = true, value }: { copyLabel: string; mono?: boolean; value: string }) { + return ( +
+ {value} + +
+ ) +} + +interface WebhooksViewProps { + onClose: () => void +} + +export function WebhooksView({ onClose }: WebhooksViewProps) { + const { t } = useI18n() + const w = t.webhooks + // Re-load when the active profile changes so REST routes to the right backend. + const profileScope = useStore($profileScope) + const queryClient = useQueryClient() + const queryKey = useMemo(() => ['webhooks', profileScope] as const, [profileScope]) + + const [query, setQuery] = useState('') + const [enabling, setEnabling] = useState(false) + const [restartNeeded, setRestartNeeded] = useState(false) + const [restartError, setRestartError] = useState(null) + const [restarting, setRestarting] = useState(false) + // Master/detail: the subscription whose config fills the right pane. + const [selectedName, setSelectedName] = useState(null) + + const [createOpen, setCreateOpen] = useState(false) + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [events, setEvents] = useState('') + const [deliver, setDeliver] = useState('log') + const [deliverOnly, setDeliverOnly] = useState(false) + const [prompt, setPrompt] = useState('') + const [skills, setSkills] = useState('') + const [creating, setCreating] = useState(false) + const [created, setCreated] = useState(null) + + const [pendingDelete, setPendingDelete] = useState(null) + + const { + data, + error, + isPending: loading, + refetch + } = useQuery({ + queryKey, + queryFn: getWebhooks + }) + + // React Query v5 dropped useQuery onError; surface a load failure toast once + // per error object instead. + useEffect(() => { + if (error) { + notifyError(error, w.loadFailed) + } + }, [error, w.loadFailed]) + + const enabled = data?.enabled ?? false + const subscriptions = useMemo(() => data?.subscriptions ?? [], [data]) + + // Pull fresh backend truth into the cache. `silent` swallows the error toast + // for post-mutation reconciles (the mutation already reported success/failure). + const reload = useCallback( + async (silent = false) => { + try { + await queryClient.invalidateQueries({ queryKey }) + } catch (err) { + if (!silent) { + notifyError(err, w.loadFailed) + } + } + }, + [queryClient, queryKey, w.loadFailed] + ) + + useRefreshHotkey(() => void refetch()) + + const restartGatewayNow = useCallback(async () => { + setRestarting(true) + + try { + await runGatewayRestart() + setRestartNeeded(false) + setRestartError(null) + // Give the receiver a moment to bind before re-reading state. + window.setTimeout(() => void reload(true), 4000) + } catch (err) { + setRestartNeeded(true) + setRestartError(String(err)) + notifyError(err, w.restartFailed('')) + } finally { + setRestarting(false) + } + }, [reload, w]) + + const handleEnable = useCallback(async () => { + setEnabling(true) + setRestartNeeded(false) + setRestartError(null) + + try { + const result = await enableWebhooks() + await reload(true) + + if (result.restart_started) { + notify({ kind: 'success', message: w.enabledRestarting }) + window.setTimeout(() => void reload(true), 4000) + } else { + const detail = result.restart_error ? `: ${result.restart_error}` : '.' + setRestartNeeded(true) + setRestartError(w.restartFailed(detail)) + notify({ kind: 'error', message: w.restartFailed(detail) }) + } + } catch (err) { + notifyError(err, w.restartFailed('')) + } finally { + setEnabling(false) + } + }, [reload, w]) + + const resetForm = useCallback(() => { + setName('') + setDescription('') + setEvents('') + setDeliver('log') + setDeliverOnly(false) + setPrompt('') + setSkills('') + }, []) + + const closeCreate = useCallback(() => { + if (creating) { + return + } + + setCreateOpen(false) + setCreated(null) + }, [creating]) + + const handleCreate = useCallback(async () => { + if (!name.trim()) { + notify({ kind: 'error', message: w.nameRequired }) + + return + } + + setCreating(true) + + try { + const eventsList = events + .split(',') + .map(e => e.trim()) + .filter(Boolean) + + const skillsList = skills + .split(',') + .map(s => s.trim()) + .filter(Boolean) + + const res = await createWebhook({ + deliver, + deliver_only: deliverOnly, + description: description.trim() || undefined, + events: eventsList.length ? eventsList : undefined, + name: name.trim(), + prompt: prompt.trim() || undefined, + skills: skillsList.length ? skillsList : undefined + }) + + notify({ kind: 'success', message: w.created }) + setCreated({ secret: res.secret, url: res.url }) + resetForm() + void reload(true) + } catch (err) { + notifyError(err, w.createFailed('')) + } finally { + setCreating(false) + } + }, [deliver, deliverOnly, description, events, name, prompt, reload, resetForm, skills, w]) + + const handleToggle = useCallback( + async (subName: string, nextEnabled: boolean) => { + // Optimistic cache paint; the invalidate below lets backend truth win. + queryClient.setQueryData(queryKey, current => + current + ? { + ...current, + subscriptions: current.subscriptions.map(s => + s.name === subName ? { ...s, enabled: nextEnabled } : s + ) + } + : current + ) + + try { + await setWebhookEnabled(subName, nextEnabled) + notify({ kind: 'success', message: nextEnabled ? w.enabled(subName) : w.disabled(subName) }) + void reload(true) + } catch (err) { + await reload(true) + notifyError(err, w.toggleFailed(subName)) + } + }, + [queryClient, queryKey, reload, w] + ) + + // ConfirmDialog owns the pending→done→close beat; throw to surface its inline + // error and keep the dialog open. Success toast matches the cron delete idiom + // (title + name). + const handleDelete = useCallback(async () => { + if (!pendingDelete) { + return + } + + try { + await deleteWebhook(pendingDelete) + notify({ kind: 'success', title: w.deleted, message: pendingDelete }) + void reload(true) + } catch (err) { + notifyError(err, w.deleteFailed(pendingDelete)) + throw err + } + }, [pendingDelete, reload, w]) + + const visible = useMemo(() => { + const q = query.trim().toLowerCase() + + if (!q) { + return subscriptions + } + + return subscriptions.filter(s => + [s.name, s.description, s.deliver, ...s.events].filter(Boolean).some(v => v.toLowerCase().includes(q)) + ) + }, [query, subscriptions]) + + // Detail always reflects a concrete sub: the explicitly selected one, else the + // first visible row, so the right pane is never empty while subs exist. + const selectedSub = useMemo( + () => visible.find(s => s.name === selectedName) ?? visible[0] ?? null, + [visible, selectedName] + ) + + const banners = ( + <> + {!enabled && ( + + + {w.disabledTitle} + +

{w.disabledBody}

+ +
+
+ )} + + {restartNeeded && ( + + + +

{restartError ?? w.restartNeeded}

+ +
+
+ )} + + ) + + return ( + + {loading ? ( + + ) : subscriptions.length === 0 ? ( + <> + {banners} + { + setCreated(null) + setCreateOpen(true) + }} + size="sm" + > + + {w.newSubscription} + + } + description={w.empty} + icon="globe" + /> + + ) : ( + <> + + {banners} + + + {visible.map(sub => ( + void handleToggle(sub.name, !sub.enabled) + }, + { icon: 'trash', label: w.delete, onSelect: () => setPendingDelete(sub.name), tone: 'danger' } + ]} + /> + } + onSelect={() => setSelectedName(sub.name)} + title={sub.name} + /> + ))} + {visible.length === 0 && ( +

{w.empty}

+ )} + { + setCreated(null) + setCreateOpen(true) + }} + /> +
+ + {selectedSub ? ( + + ) : ( + + )} +
+ + )} + + {/* Create subscription dialog */} + !open && closeCreate()} open={createOpen}> + + + {created ? w.createdTitle : w.newSubscription} + {created && {w.createdSecretHint}} + + + {created ? ( +
+ } title={w.webhookUrl} wide /> + } + title={w.secretOnce} + wide + /> + + + +
+ ) : ( +
+
+ setName(e.target.value)} + placeholder={w.fieldNamePlaceholder} + value={name} + /> + } + title={} + wide + /> + setDescription(e.target.value)} + placeholder={w.fieldDescriptionPlaceholder} + value={description} + /> + } + title={} + wide + /> +
+ setPrompt(e.target.value)} + placeholder={w.fieldPromptPlaceholder} + value={prompt} + /> + } + title={} + wide + /> +
+ setEvents(e.target.value)} + placeholder={w.fieldEventsPlaceholder} + value={events} + /> + } + title={} + wide + /> + setSkills(e.target.value)} + placeholder={w.fieldSkillsPlaceholder} + value={skills} + /> + } + title={} + wide + /> +
+
+ + + + + + {DELIVER_OPTIONS.map(opt => ( + + {w.deliverOptions[opt] ?? opt} + + ))} + + + } + title={} + wide + /> + +
+ + + +
+ )} +
+
+ + + {w.deleteDescPrefix} + {pendingDelete} + {w.deleteDescSuffix} + + ) : null + } + destructive + onClose={() => setPendingDelete(null)} + onConfirm={handleDelete} + open={pendingDelete !== null} + title={w.deleteTitle} + /> +
+ ) +} + +function WebhookDetail({ sub }: { sub: WebhookRoute }) { + const { t } = useI18n() + const w = t.webhooks + + return ( + +
+
+

{sub.name}

+ + {sub.enabled ? t.messaging.states.enabled : t.messaging.states.disabled} + + {sub.deliver_only && {w.deliverOnly}} +
+ + + {sub.events.map(evt => ( + {evt} + ))} + + ) + }, + ...(sub.skills.length > 0 + ? [ + { + label: w.fieldSkills, + value: ( + + {sub.skills.map(skill => ( + {skill} + ))} + + ) + } + ] + : []) + ]} + /> + + +
+ + {sub.description ? ( +
+ {w.fieldDescription} +

{sub.description}

+
+ ) : null} + + {sub.prompt ? ( +
+ {w.fieldPrompt} + {sub.prompt} +
+ ) : null} +
+ ) +} diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 1ac35a863fe5..3a7447cab42d 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -58,7 +58,11 @@ import type { TerminalBackendsResponse, ToolsetConfig, ToolsetInfo, - ToolsetModelsResponse + ToolsetModelsResponse, + WebhookCreatePayload, + WebhookCreateResponse, + WebhookEnableResponse, + WebhooksResponse } from '@/types/hermes' // Desktop startup fires a burst of read-only data calls (config, profiles, @@ -202,7 +206,12 @@ export type { ToolsetConfig, ToolsetInfo, ToolsetModel, - ToolsetModelsResponse + ToolsetModelsResponse, + WebhookCreatePayload, + WebhookCreateResponse, + WebhookEnableResponse, + WebhookRoute, + WebhooksResponse } from '@/types/hermes' export class HermesGateway extends JsonRpcGatewayClient { @@ -1122,6 +1131,55 @@ export function testMessagingPlatform(platformId: string): Promise { + return window.hermesDesktop.api({ + ...profileScoped(), + path: '/api/webhooks' + }) +} + +export function enableWebhooks(): Promise { + return window.hermesDesktop.api({ + ...profileScoped(), + path: '/api/webhooks/enable', + method: 'POST' + }) +} + +export function createWebhook(body: WebhookCreatePayload): Promise { + return window.hermesDesktop.api({ + ...profileScoped(), + path: '/api/webhooks', + method: 'POST', + body + }) +} + +export function deleteWebhook(name: string): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + ...profileScoped(), + path: `/api/webhooks/${encodeURIComponent(name)}`, + method: 'DELETE' + }) +} + +export function setWebhookEnabled( + name: string, + enabled: boolean +): Promise<{ enabled: boolean; name: string; ok: boolean }> { + return window.hermesDesktop.api<{ enabled: boolean; name: string; ok: boolean }>({ + ...profileScoped(), + path: `/api/webhooks/${encodeURIComponent(name)}/enabled`, + method: 'PUT', + body: { enabled } + }) +} + // Cron jobs are stored per-profile (/cron/jobs.json), and the // backend's list endpoint defaults to 'all'. Pass a concrete profile key to // list just that profile's jobs, or 'all' for the unified cross-profile view. diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index c7397784cef5..ca18522043e2 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1422,6 +1422,73 @@ export const en: Translations = { platformIntro: {} }, + webhooks: { + search: 'Search webhooks...', + loading: 'Loading webhooks...', + loadFailed: 'Webhooks failed to load', + subscriptions: (count: number) => `Subscriptions (${count})`, + hint: 'Subscription changes hot-reload once the receiver is running. Disabled subscriptions reject incoming events.', + empty: 'No webhook subscriptions yet.', + disabledTitle: 'Webhook receiver disabled', + disabledBody: + 'Webhooks are their own gateway platform. Enable them here to accept incoming HTTP events; chat channels are only needed when a subscription delivers to Telegram, Discord, Slack, or another channel.', + enable: 'Enable webhooks', + enabling: 'Enabling...', + enabled: (name: string) => `Enabled: "${name}"`, + disabled: (name: string) => `Disabled: "${name}"`, + enableRow: 'Enable', + disableRow: 'Disable', + delete: 'Delete', + deleting: 'Deleting...', + deleted: 'Webhook deleted', + deleteTitle: 'Delete webhook', + deleteDescPrefix: 'This will permanently remove ', + deleteDescSuffix: '. This cannot be undone.', + deleteFailed: (name: string) => `Failed to delete "${name}"`, + toggleFailed: (name: string) => `Failed to update "${name}"`, + newSubscription: 'New subscription', + restarting: 'Gateway restarting...', + restartNeeded: + 'Webhooks are enabled, but the gateway still needs a restart before the receiver can come online.', + restartGateway: 'Restart gateway', + restartingGateway: 'Restarting...', + restartFailed: (detail: string) => `Gateway restart failed${detail}`, + enabledRestarting: 'Webhooks enabled; gateway restarting...', + all: '(all)', + deliverOnly: 'deliver only', + createdTitle: 'Subscription created', + createdSecretHint: 'Copy the secret now — it is only shown once.', + webhookUrl: 'Webhook URL', + secretOnce: 'Secret (shown once)', + done: 'Done', + fieldName: 'Name', + fieldNamePlaceholder: 'e.g. github-push', + fieldDescription: 'Description', + fieldDescriptionPlaceholder: 'What this webhook does (optional)', + fieldEvents: 'Events', + fieldEventsPlaceholder: 'comma-separated, leave empty for all', + fieldSkills: 'Skills', + fieldSkillsPlaceholder: 'comma-separated skill names (optional)', + fieldDeliver: 'Deliver to', + fieldDeliverOnly: 'Deliver payload only', + fieldPrompt: 'Prompt', + fieldPromptPlaceholder: 'Instructions for the agent when this webhook fires (optional)', + nameRequired: 'Name required', + create: 'Create', + creating: 'Creating...', + created: 'Created', + createFailed: (detail: string) => `Failed to create: ${detail}`, + copy: 'Copy', + deliverOptions: { + log: 'Log', + telegram: 'Telegram', + discord: 'Discord', + slack: 'Slack', + email: 'Email', + github_comment: 'GitHub comment' + } + }, + profiles: { close: 'Close profiles', nameHint: 'Lowercase letters, digits, hyphens, and underscores. Must start with a letter or digit.', @@ -2269,6 +2336,8 @@ export const en: Translations = { running: count => `${count} running`, cron: 'Cron', openCron: 'Open cron jobs', + webhooks: 'Webhooks', + openWebhooks: 'Open webhooks', starmap: 'Memory Graph', openStarmap: 'Open memory graph', turnRunning: 'Running', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 58836148a45d..3d163d6d5f8e 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1181,6 +1181,64 @@ export interface Translations { platformIntro: Record } + webhooks: { + search: string + loading: string + loadFailed: string + subscriptions: (count: number) => string + hint: string + empty: string + disabledTitle: string + disabledBody: string + enable: string + enabling: string + enabled: (name: string) => string + disabled: (name: string) => string + enableRow: string + disableRow: string + delete: string + deleting: string + deleted: string + deleteTitle: string + deleteDescPrefix: string + deleteDescSuffix: string + deleteFailed: (name: string) => string + toggleFailed: (name: string) => string + newSubscription: string + restarting: string + restartNeeded: string + restartGateway: string + restartingGateway: string + restartFailed: (detail: string) => string + enabledRestarting: string + all: string + deliverOnly: string + createdTitle: string + createdSecretHint: string + webhookUrl: string + secretOnce: string + done: string + fieldName: string + fieldNamePlaceholder: string + fieldDescription: string + fieldDescriptionPlaceholder: string + fieldEvents: string + fieldEventsPlaceholder: string + fieldSkills: string + fieldSkillsPlaceholder: string + fieldDeliver: string + fieldDeliverOnly: string + fieldPrompt: string + fieldPromptPlaceholder: string + nameRequired: string + create: string + creating: string + created: string + createFailed: (detail: string) => string + copy: string + deliverOptions: Record + } + profiles: { close: string nameHint: string @@ -1886,6 +1944,8 @@ export interface Translations { running: (count: number) => string cron: string openCron: string + webhooks: string + openWebhooks: string starmap: string openStarmap: string turnRunning: string diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 0f2a1ba36295..e81b141bb698 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1614,6 +1614,72 @@ export const zh: Translations = { } }, + webhooks: { + search: '搜索 Webhook…', + loading: '正在加载 Webhook…', + loadFailed: 'Webhook 加载失败', + subscriptions: (count: number) => `订阅(${count})`, + hint: '接收器运行后,订阅更改会热重载。已禁用的订阅会拒绝传入事件。', + empty: '暂无 Webhook 订阅。', + disabledTitle: 'Webhook 接收器已禁用', + disabledBody: + 'Webhook 是独立的网关平台。在此启用以接受传入的 HTTP 事件;仅当订阅投递到 Telegram、Discord、Slack 或其他渠道时才需要聊天渠道。', + enable: '启用 Webhook', + enabling: '正在启用…', + enabled: (name: string) => `已启用:“${name}”`, + disabled: (name: string) => `已禁用:“${name}”`, + enableRow: '启用', + disableRow: '禁用', + delete: '删除', + deleting: '删除中…', + deleted: 'Webhook 已删除', + deleteTitle: '删除 Webhook', + deleteDescPrefix: '这将永久移除 ', + deleteDescSuffix: '。此操作无法撤销。', + deleteFailed: (name: string) => `删除“${name}”失败`, + toggleFailed: (name: string) => `更新“${name}”失败`, + newSubscription: '新建订阅', + restarting: '网关正在重启…', + restartNeeded: 'Webhook 已启用,但网关仍需重启后接收器才能上线。', + restartGateway: '重启网关', + restartingGateway: '正在重启…', + restartFailed: (detail: string) => `网关重启失败${detail}`, + enabledRestarting: 'Webhook 已启用;网关正在重启…', + all: '(全部)', + deliverOnly: '仅投递', + createdTitle: '订阅已创建', + createdSecretHint: '请立即复制密钥 —— 它只显示一次。', + webhookUrl: 'Webhook URL', + secretOnce: '密钥(仅显示一次)', + done: '完成', + fieldName: '名称', + fieldNamePlaceholder: '例如 github-push', + fieldDescription: '描述', + fieldDescriptionPlaceholder: '此 Webhook 的用途(可选)', + fieldEvents: '事件', + fieldEventsPlaceholder: '以逗号分隔,留空表示全部', + fieldSkills: '技能', + fieldSkillsPlaceholder: '以逗号分隔的技能名称(可选)', + fieldDeliver: '投递到', + fieldDeliverOnly: '仅投递载荷', + fieldPrompt: '提示词', + fieldPromptPlaceholder: '此 Webhook 触发时给智能体的说明(可选)', + nameRequired: '需要名称', + create: '创建', + creating: '正在创建…', + created: '已创建', + createFailed: (detail: string) => `创建失败:${detail}`, + copy: '复制', + deliverOptions: { + log: '日志', + telegram: 'Telegram', + discord: 'Discord', + slack: 'Slack', + email: '电子邮件', + github_comment: 'GitHub 评论' + } + }, + profiles: { close: '关闭配置档案', nameHint: '小写字母、数字、连字符和下划线。必须以字母或数字开头。', @@ -2447,6 +2513,8 @@ export const zh: Translations = { running: count => `${count} 个运行中`, cron: '排程', openCron: '打开排程任务', + webhooks: 'Webhook', + openWebhooks: '打开 Webhook', starmap: '记忆图谱', openStarmap: '打开记忆图谱', turnRunning: '运行中', diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index c49c736e079a..6afdb5dbf07e 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -243,6 +243,58 @@ export interface MessagingPlatformTestResponse { state?: null | string } +// -- Webhooks (subscription CRUD) -------------------------------------------- +// Incoming HTTP event routes served by the webhook gateway platform. Backed by +// the same JSON store the CLI/dashboard use; per-route HMAC secrets are +// redacted on read and surfaced exactly once on create. + +export interface WebhookRoute { + created_at: null | string + deliver: string + deliver_only: boolean + description: string + enabled: boolean + events: string[] + name: string + prompt: string + secret_set: boolean + skills: string[] + url: string +} + +export interface WebhooksResponse { + base_url: string + enabled: boolean + subscriptions: WebhookRoute[] +} + +export interface WebhookCreatePayload { + deliver?: string + deliver_chat_id?: string + deliver_only?: boolean + description?: string + events?: string[] + name: string + prompt?: string + skills?: string[] +} + +// Create echoes the route summary plus the one-time secret. +export interface WebhookCreateResponse extends WebhookRoute { + secret: string +} + +export interface WebhookEnableResponse { + enabled: true + needs_restart: boolean + ok: boolean + platform: 'webhook' + restart_action?: string + restart_error?: string + restart_pid?: null | number + restart_started?: boolean +} + export interface GatewayReadyPayload { skin?: unknown } diff --git a/apps/desktop/src/webhooks-rest.test.ts b/apps/desktop/src/webhooks-rest.test.ts new file mode 100644 index 000000000000..a8dc8e83a869 --- /dev/null +++ b/apps/desktop/src/webhooks-rest.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { createWebhook, deleteWebhook, enableWebhooks, getWebhooks, setWebhookEnabled } from './hermes' + +describe('Webhook REST parity helpers', () => { + let api: ReturnType + + beforeEach(() => { + api = vi.fn().mockResolvedValue({}) + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { api } + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + Reflect.deleteProperty(window, 'hermesDesktop') + }) + + it('lists webhooks from the admin endpoint', async () => { + await getWebhooks() + + expect(api).toHaveBeenCalledWith(expect.objectContaining({ path: '/api/webhooks' })) + }) + + it('enables the webhook platform with POST', async () => { + await enableWebhooks() + + expect(api).toHaveBeenCalledWith(expect.objectContaining({ method: 'POST', path: '/api/webhooks/enable' })) + }) + + it('creates a subscription with the full payload', async () => { + const body = { + deliver: 'telegram', + deliver_only: true, + description: 'push events', + events: ['push'], + name: 'github-push', + prompt: 'summarize the push' + } + + await createWebhook(body) + + expect(api).toHaveBeenCalledWith(expect.objectContaining({ body, method: 'POST', path: '/api/webhooks' })) + }) + + it('encodes the name when deleting a subscription', async () => { + await deleteWebhook('my hook') + + expect(api).toHaveBeenCalledWith(expect.objectContaining({ method: 'DELETE', path: '/api/webhooks/my%20hook' })) + }) + + it('toggles a subscription enabled state via PUT', async () => { + await setWebhookEnabled('github-push', false) + + expect(api).toHaveBeenCalledWith( + expect.objectContaining({ + body: { enabled: false }, + method: 'PUT', + path: '/api/webhooks/github-push/enabled' + }) + ) + }) +})