mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-26 17:38:36 +00:00
feat(desktop): add Webhooks page for subscription CRUD (#69687)
* feat(desktop): add Webhooks page for subscription CRUD Brings the desktop GUI to parity with the dashboard's Webhooks page. Adds a /webhooks route that lists webhook subscriptions, enables the webhook gateway platform, and creates/toggles/deletes subscriptions, hitting the same /api/webhooks* endpoints the dashboard and CLI use. - types/hermes.ts: WebhookRoute, WebhooksResponse, WebhookCreatePayload, WebhookCreateResponse, WebhookEnableResponse - hermes.ts: getWebhooks, enableWebhooks, createWebhook, deleteWebhook, setWebhookEnabled (profile-scoped) + type re-exports - app/webhooks/index.tsx: WebhooksView (enable card, restart banner, subscription list with copy/toggle/delete, create dialog with one-time secret reveal); optimistic toggle, profile re-home - routing: routes.ts, contrib/surfaces.tsx, chat/route-tile.tsx - nav: command palette, keybinds (nav.webhooks), sidebar row - i18n: en + zh full, types interface; ja/zh-hant fall back to English - test: webhooks-rest.test.ts covers the REST helper contracts * feat(desktop): surface Webhooks in the status bar instead of nav Moves the Webhooks entry point from the sidebar nav / command palette / keybind to a status bar action next to Cron, matching where scheduled jobs live. The /webhooks route, page, and REST helpers are unchanged. - use-statusbar-items.tsx: add webhooks action (Globe icon) after cron - i18n: shell.statusbar.webhooks / openWebhooks (en, zh, types) - revert nav wiring: sidebar row, command palette entry, nav.webhooks keybind action + label, commandCenter.nav webhooks entry * feat(desktop): add skills field to webhook create form Exposes the backend's per-subscription skills list in the create dialog (comma-separated) and shows skill badges on subscription rows, so this page covers the skill-backed endpoint case as well as general CRUD. - create form: Skills input; passes skills[] to createWebhook - rows: render skill badges - i18n: fieldSkills / fieldSkillsPlaceholder (en, zh, types) Consolidates the skill-endpoint framing from #42817. Co-authored-by: LionGateOS <98371158+LionGateOS@users.noreply.github.com> * feat(desktop): render Webhooks as an overlay instead of a full page Webhooks took over the whole workspace/chat pane because 'webhooks' was missing from OVERLAY_VIEWS, so it routed through PageSearchShell while cron rendered through the Panel overlay. Add it to OVERLAY_VIEWS and wire it up like cron: mount WebhooksView as a floating overlay in wiring.tsx, render null for the webhooks route in the workspace table, and convert WebhooksView from PageSearchShell to the Panel primitive with an onClose. Drop webhooks from route-tile BUILTIN_PAGES so it can't be tiled as a page either. * fix(desktop): place webhook URL copy button next to the URL The URL span used flex-1, stretching it across the row and pushing the copy button to the far right. Drop flex-1 so the span sizes to content and the copy button sits directly after the URL. * fix(desktop): top-align the deliver-only checkbox with its wrapped label The label used h-9 items-center, centering the checkbox against the two-line hint text. Switch to items-start so the checkbox aligns to the first line. * feat(desktop): give Webhooks a cron-style master/detail layout Replace the single-column subscription list with the same Panel master/detail cron uses: a left PanelList of subscription rows (status dot + kebab menu) capped by a PanelAddButton, and a right PanelDetail showing the selected subscription's deliver/events/skills, URL with copy, description, and prompt. Enable/restart banners sit above the body; the empty state keeps its own New subscription action. * fix(desktop): drop header on the Webhooks empty state to match cron The zero-subscriptions state still rendered the PanelHeader with the title and the refresh/new buttons on the right. Remove it so the empty state is just the centered PanelEmpty (icon, message, New subscription), matching the cron empty state. Also drop the header from the loading state. * fix(desktop): top-align deliver-only checkbox and its wrapped label Drop the min-h-9/pt-1.5 baseline shim that pushed the row down and made the wrapped hint look misaligned. Use plain items-start with a mt-0.5 on the checkbox so it sits at the first line, and wrap the hint in a leading-snug span. * fix(desktop): drop header refresh/new buttons; + button owns create flow The populated Webhooks header carried a refresh icon and a New subscription button. Remove both — the PanelAddButton at the bottom of the list is the create flow, matching cron. Profile-change reload and the refresh hotkey still run; the restart banner keeps its own refresh. * fix(desktop): nudge deliver-only checkbox down 2px Bump the checkbox top margin from mt-0.5 to mt-[4px] so it sits level with the first line of the wrapped hint. * refactor(desktop): reuse shared primitives on the Webhooks page Address OutThisLife's review — stop reinventing primitives the app already ships: - copy: drop the local navigator.clipboard button for the shared CopyButton (routes through the Electron clipboard bridge + haptic + error state instead of swallowing failures) - banners: enable/restart callouts now use Alert variant=warning (primary color-mix tokens) instead of a hand-rolled amber palette - toggle: detail Enable/Disable is a Switch (messaging idiom), not a text ghost button - checkbox: create dialog uses the Checkbox primitive, not a raw input Rows/chips already moved to PanelListRow/PanelDetail/PanelPill in the earlier cron-layout pass. Left the main-list fetch on manual load and the local Field helper: cron itself does both, so useQuery here would diverge from the reference idiom rather than align with it. * refactor(desktop): move Webhooks fetch to the react-query layer Replace the manual useState/useEffect load with useQuery keyed by ['webhooks', profileScope] — profile change re-fetches automatically, no effect. reload() invalidates the query; the optimistic toggle writes the cache via queryClient.setQueryData then invalidates so backend truth wins. Load failures surface via an error-watching effect (react-query v5 dropped useQuery onError). Refresh hotkey calls refetch(). Left the create-dialog Field helper as-is: settings ListRow is a side-by-side settings row, wrong for a stacked dialog form, and cron's editor dialog (the reference) defines the same local Field. * refactor(desktop): drop bespoke Field for shared ListRow/ToggleRow Remove the local Field wrapper entirely. Every create-dialog field now uses settings ListRow (wide, so label stacks over the full-width control), the deliver-only pref uses ToggleRow (ListRow + Switch, haptic baked in) instead of a bare Checkbox, and the created URL/secret reveal rows use ListRow too. No component in this file is hand-rolled anymore. * fix(desktop): pair webhook create fields into a 2-column layout The single-column dialog scrolled awkwardly. Group fields: name + description side by side, prompt full-width under them, events + skills side by side, deliver-to + deliver-only side by side. Drop the deliver-only help text and rename the label to 'Deliver payload only' (remove the now-unused fieldDeliverOnlyHint i18n key from en/zh/types). * fix(desktop): align Webhooks page with cron conventions and DESIGN.md - Delete copy uses deleteDescPrefix + bolded name + deleteDescSuffix (no em-dash) - Drop the duplicated detail-header Switch + Trash2; enable/disable and delete live only in the row kebab, matching CronJobDetail - Collapse three copyable-value chromes into one flat token-backed CopyValueRow - Delete success toast gains a w.deleted title - Replace hand-rolled delete Dialog with shared ConfirmDialog --------- Co-authored-by: LionGateOS <98371158+LionGateOS@users.noreply.github.com>
This commit is contained in:
parent
6ece52fc73
commit
d82dfb6924
12 changed files with 1046 additions and 7 deletions
|
|
@ -189,6 +189,7 @@ export const ChatRoutesSurface = memo(function ChatRoutesSurface({
|
|||
<Route element={null} path="profiles" />
|
||||
<Route element={null} path="settings" />
|
||||
<Route element={null} path="starmap" />
|
||||
<Route element={null} path="webhooks" />
|
||||
{/* 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. */}
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
</Suspense>
|
||||
)}
|
||||
|
||||
{webhooksOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<WebhooksView onClose={closeOverlayToPreviousRoute} />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{profilesOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<ProfilesView onClose={closeOverlayToPreviousRoute} />
|
||||
|
|
|
|||
|
|
@ -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<AppView> = new Set([
|
|||
'cron',
|
||||
'profiles',
|
||||
'settings',
|
||||
'starmap'
|
||||
'starmap',
|
||||
'webhooks'
|
||||
])
|
||||
|
||||
export function isOverlayView(view: AppView): boolean {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: <Globe className="size-3" />,
|
||||
id: 'webhooks',
|
||||
label: copy.webhooks,
|
||||
title: copy.openWebhooks,
|
||||
to: WEBHOOKS_ROUTE,
|
||||
variant: 'action'
|
||||
}
|
||||
],
|
||||
[
|
||||
|
|
|
|||
643
apps/desktop/src/app/webhooks/index.tsx
Normal file
643
apps/desktop/src/app/webhooks/index.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex items-center gap-1 rounded bg-foreground/5 px-2.5 py-1.5 text-[0.7rem]">
|
||||
<span className={cn('min-w-0 flex-1 truncate text-foreground/80', mono && 'font-mono')}>{value}</span>
|
||||
<CopyButton appearance="icon" buttonSize="icon-sm" label={copyLabel} text={value} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 | string>(null)
|
||||
const [restarting, setRestarting] = useState(false)
|
||||
// Master/detail: the subscription whose config fills the right pane.
|
||||
const [selectedName, setSelectedName] = useState<null | string>(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<CreatedWebhook | null>(null)
|
||||
|
||||
const [pendingDelete, setPendingDelete] = useState<null | string>(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<WebhooksResponse>(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 && (
|
||||
<Alert className="mb-4" variant="warning">
|
||||
<Globe />
|
||||
<AlertTitle>{w.disabledTitle}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>{w.disabledBody}</p>
|
||||
<Button className="mt-1" disabled={enabling} onClick={() => void handleEnable()} size="sm">
|
||||
<Globe />
|
||||
{enabling ? w.enabling : w.enable}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{restartNeeded && (
|
||||
<Alert className="mb-4" variant="warning">
|
||||
<AlertTriangle />
|
||||
<AlertDescription>
|
||||
<p>{restartError ?? w.restartNeeded}</p>
|
||||
<Button
|
||||
className="mt-1"
|
||||
disabled={restarting}
|
||||
onClick={() => void restartGatewayNow()}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
<RefreshCw />
|
||||
{restarting ? w.restartingGateway : w.restartGateway}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Panel onClose={onClose}>
|
||||
{loading ? (
|
||||
<PageLoader label={w.loading} />
|
||||
) : subscriptions.length === 0 ? (
|
||||
<>
|
||||
{banners}
|
||||
<PanelEmpty
|
||||
action={
|
||||
<Button
|
||||
disabled={!enabled || enabling}
|
||||
onClick={() => {
|
||||
setCreated(null)
|
||||
setCreateOpen(true)
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
<Plus />
|
||||
{w.newSubscription}
|
||||
</Button>
|
||||
}
|
||||
description={w.empty}
|
||||
icon="globe"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PanelHeader subtitle={w.hint} title={w.subscriptions(subscriptions.length)} />
|
||||
{banners}
|
||||
<PanelBody>
|
||||
<PanelList
|
||||
onSearchChange={setQuery}
|
||||
searchLabel={w.search}
|
||||
searchPlaceholder={w.search}
|
||||
searchValue={query}
|
||||
>
|
||||
{visible.map(sub => (
|
||||
<PanelListRow
|
||||
active={selectedSub?.name === sub.name}
|
||||
dotClassName={sub.enabled ? 'bg-emerald-500' : 'bg-muted-foreground/50'}
|
||||
key={sub.name}
|
||||
menu={
|
||||
<PanelRowMenu
|
||||
items={[
|
||||
{
|
||||
icon: sub.enabled ? 'circle-slash' : 'check',
|
||||
label: sub.enabled ? w.disableRow : w.enableRow,
|
||||
onSelect: () => 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 && (
|
||||
<p className="px-2 py-4 text-center text-xs text-muted-foreground">{w.empty}</p>
|
||||
)}
|
||||
<PanelAddButton
|
||||
label={w.newSubscription}
|
||||
onClick={() => {
|
||||
setCreated(null)
|
||||
setCreateOpen(true)
|
||||
}}
|
||||
/>
|
||||
</PanelList>
|
||||
|
||||
{selectedSub ? (
|
||||
<WebhookDetail sub={selectedSub} />
|
||||
) : (
|
||||
<PanelEmpty description={w.empty} icon="search" />
|
||||
)}
|
||||
</PanelBody>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Create subscription dialog */}
|
||||
<Dialog onOpenChange={open => !open && closeCreate()} open={createOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{created ? w.createdTitle : w.newSubscription}</DialogTitle>
|
||||
{created && <DialogDescription>{w.createdSecretHint}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
|
||||
{created ? (
|
||||
<div className="grid gap-2">
|
||||
<ListRow action={<CopyValueRow copyLabel={w.copy} value={created.url} />} title={w.webhookUrl} wide />
|
||||
<ListRow
|
||||
action={<CopyValueRow copyLabel={w.copy} value={created.secret} />}
|
||||
title={w.secretOnce}
|
||||
wide
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button onClick={closeCreate} size="sm">
|
||||
{w.done}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-1">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
autoFocus
|
||||
id="webhook-name"
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder={w.fieldNamePlaceholder}
|
||||
value={name}
|
||||
/>
|
||||
}
|
||||
title={<label htmlFor="webhook-name">{w.fieldName}</label>}
|
||||
wide
|
||||
/>
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
id="webhook-description"
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder={w.fieldDescriptionPlaceholder}
|
||||
value={description}
|
||||
/>
|
||||
}
|
||||
title={<label htmlFor="webhook-description">{w.fieldDescription}</label>}
|
||||
wide
|
||||
/>
|
||||
</div>
|
||||
<ListRow
|
||||
action={
|
||||
<Textarea
|
||||
className="min-h-[80px]"
|
||||
id="webhook-prompt"
|
||||
onChange={e => setPrompt(e.target.value)}
|
||||
placeholder={w.fieldPromptPlaceholder}
|
||||
value={prompt}
|
||||
/>
|
||||
}
|
||||
title={<label htmlFor="webhook-prompt">{w.fieldPrompt}</label>}
|
||||
wide
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
id="webhook-events"
|
||||
onChange={e => setEvents(e.target.value)}
|
||||
placeholder={w.fieldEventsPlaceholder}
|
||||
value={events}
|
||||
/>
|
||||
}
|
||||
title={<label htmlFor="webhook-events">{w.fieldEvents}</label>}
|
||||
wide
|
||||
/>
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
id="webhook-skills"
|
||||
onChange={e => setSkills(e.target.value)}
|
||||
placeholder={w.fieldSkillsPlaceholder}
|
||||
value={skills}
|
||||
/>
|
||||
}
|
||||
title={<label htmlFor="webhook-skills">{w.fieldSkills}</label>}
|
||||
wide
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 items-start gap-4">
|
||||
<ListRow
|
||||
action={
|
||||
<Select onValueChange={setDeliver} value={deliver}>
|
||||
<SelectTrigger className="h-9 rounded-md" id="webhook-deliver">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DELIVER_OPTIONS.map(opt => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{w.deliverOptions[opt] ?? opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
title={<label htmlFor="webhook-deliver">{w.fieldDeliver}</label>}
|
||||
wide
|
||||
/>
|
||||
<ToggleRow checked={deliverOnly} label={w.fieldDeliverOnly} onChange={setDeliverOnly} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button disabled={creating} onClick={() => void handleCreate()} size="sm">
|
||||
{creating ? w.creating : w.create}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
busyLabel={w.deleting}
|
||||
cancelLabel={t.common.cancel}
|
||||
confirmLabel={w.delete}
|
||||
description={
|
||||
pendingDelete ? (
|
||||
<>
|
||||
{w.deleteDescPrefix}
|
||||
<span className="font-medium text-foreground">{pendingDelete}</span>
|
||||
{w.deleteDescSuffix}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
destructive
|
||||
onClose={() => setPendingDelete(null)}
|
||||
onConfirm={handleDelete}
|
||||
open={pendingDelete !== null}
|
||||
title={w.deleteTitle}
|
||||
/>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function WebhookDetail({ sub }: { sub: WebhookRoute }) {
|
||||
const { t } = useI18n()
|
||||
const w = t.webhooks
|
||||
|
||||
return (
|
||||
<PanelDetail>
|
||||
<header className="space-y-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3 className="text-[0.95rem] font-semibold tracking-tight text-foreground">{sub.name}</h3>
|
||||
<PanelPill tone={sub.enabled ? 'good' : 'muted'}>
|
||||
{sub.enabled ? t.messaging.states.enabled : t.messaging.states.disabled}
|
||||
</PanelPill>
|
||||
{sub.deliver_only && <PanelPill tone="warn">{w.deliverOnly}</PanelPill>}
|
||||
</div>
|
||||
|
||||
<PanelMeta
|
||||
rows={[
|
||||
{ label: w.fieldDeliver, value: w.deliverOptions[sub.deliver] ?? sub.deliver },
|
||||
{
|
||||
label: w.fieldEvents,
|
||||
value:
|
||||
sub.events.length === 0 ? (
|
||||
w.all
|
||||
) : (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{sub.events.map(evt => (
|
||||
<PanelPill key={evt}>{evt}</PanelPill>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
...(sub.skills.length > 0
|
||||
? [
|
||||
{
|
||||
label: w.fieldSkills,
|
||||
value: (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{sub.skills.map(skill => (
|
||||
<PanelPill key={skill}>{skill}</PanelPill>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]}
|
||||
/>
|
||||
|
||||
<CopyValueRow copyLabel={w.copy} value={sub.url} />
|
||||
</header>
|
||||
|
||||
{sub.description ? (
|
||||
<div className="space-y-1.5">
|
||||
<PanelSectionLabel>{w.fieldDescription}</PanelSectionLabel>
|
||||
<p className="text-xs leading-relaxed text-foreground/80">{sub.description}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sub.prompt ? (
|
||||
<div className="space-y-1.5">
|
||||
<PanelSectionLabel>{w.fieldPrompt}</PanelSectionLabel>
|
||||
<PanelBlock>{sub.prompt}</PanelBlock>
|
||||
</div>
|
||||
) : null}
|
||||
</PanelDetail>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<MessagingPlat
|
|||
})
|
||||
}
|
||||
|
||||
// -- Webhooks (subscription CRUD) --------------------------------------------
|
||||
// The webhook receiver is its own gateway platform; subscriptions live in a
|
||||
// shared JSON store the CLI/dashboard also drive. Enable mutates config and
|
||||
// best-effort restarts the gateway; subscription changes hot-reload.
|
||||
|
||||
export function getWebhooks(): Promise<WebhooksResponse> {
|
||||
return window.hermesDesktop.api<WebhooksResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/webhooks'
|
||||
})
|
||||
}
|
||||
|
||||
export function enableWebhooks(): Promise<WebhookEnableResponse> {
|
||||
return window.hermesDesktop.api<WebhookEnableResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/webhooks/enable',
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function createWebhook(body: WebhookCreatePayload): Promise<WebhookCreateResponse> {
|
||||
return window.hermesDesktop.api<WebhookCreateResponse>({
|
||||
...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 (<HERMES_HOME>/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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1181,6 +1181,64 @@ export interface Translations {
|
|||
platformIntro: Record<string, string>
|
||||
}
|
||||
|
||||
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<string, string>
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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: '运行中',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
65
apps/desktop/src/webhooks-rest.test.ts
Normal file
65
apps/desktop/src/webhooks-rest.test.ts
Normal file
|
|
@ -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<typeof vi.fn>
|
||||
|
||||
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'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue