From 6ea9b4d76216bd0a46592a6c8ae3d64d29232506 Mon Sep 17 00:00:00 2001 From: viettranx Date: Fri, 27 Mar 2026 19:52:28 +0700 Subject: [PATCH] feat(desktop): add channel management, paired devices, and multiple fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Channel Management: - Add Channels settings tab with full CRUD for Telegram/Discord (Lite: max 1 each) - Channel detail panel with tabs: General, Credentials, Managers - Advanced settings dialog (network, limits, streaming, behavior, access control) - Schema-driven field renderer with Combobox selects, Switch toggles - Paired Devices section: approve/deny pending, revoke paired, WS event auto-refresh - Pairing notification badge in sidebar footer with pending count - i18n support (en/vi/zh) for all channel strings Bug Fixes: - Fix Vietnamese slug generation (NFD normalize + đ/Đ handling) in lib/slug.ts - Make agent key field editable instead of read-only - Fix trace detail input/output: use MarkdownRenderer with copy button instead of forced-dark CodePreview - Fix API error parsing to handle both {error: "string"} and {error: {message}} formats - Add Accept-Language header to desktop API client for i18n error messages - Wrap raw err.Error() with i18n messages in HTTP handlers (agents, channel_instances) - Increase SQLite MaxOpenConns from 2 to 4 to reduce SQLITE_BUSY contention - Add retryOnBusy wrapper for context file seeding writes - Update EditionCompareModal: channels moved from "false" to "1 Telegram + 1 Discord" --- internal/bootstrap/seed_store.go | 28 ++- internal/http/agents.go | 3 +- internal/http/channel_instances.go | 6 +- internal/store/sqlitestore/pool.go | 5 +- .../components/common/EditionCompareModal.tsx | 2 +- .../layout/sidebar/SidebarFooter.tsx | 20 ++ .../components/onboarding/ProviderStep.tsx | 3 +- .../components/settings/SettingsTabBar.tsx | 2 +- .../src/components/settings/SettingsView.tsx | 2 + .../settings/agents/AgentFormDialog.tsx | 17 +- .../channels/ChannelAdvancedDialog.tsx | 137 +++++++++++ .../settings/channels/ChannelCard.tsx | 83 +++++++ .../channels/ChannelCredentialsTab.tsx | 65 ++++++ .../settings/channels/ChannelDetailPanel.tsx | 119 ++++++++++ .../settings/channels/ChannelFormDialog.tsx | 164 +++++++++++++ .../settings/channels/ChannelGeneralTab.tsx | 129 ++++++++++ .../settings/channels/ChannelList.tsx | 134 +++++++++++ .../settings/channels/ChannelManagersTab.tsx | 220 ++++++++++++++++++ .../channels/PairedDevicesSection.tsx | 153 ++++++++++++ .../channels/channel-field-renderer.tsx | 135 +++++++++++ .../settings/channels/channel-schemas.ts | 103 ++++++++ .../settings/providers/ProviderFormDialog.tsx | 3 +- .../settings/traces/TraceDetailDialog.tsx | 52 ++--- .../frontend/src/constants/providers.ts | 4 - .../frontend/src/hooks/use-channel-crud.ts | 62 +++++ .../frontend/src/hooks/use-channel-detail.ts | 75 ++++++ .../frontend/src/hooks/use-channel-status.ts | 24 ++ .../frontend/src/hooks/use-paired-devices.ts | 51 ++++ .../src/hooks/use-pending-pairings-count.ts | 30 +++ ui/desktop/frontend/src/i18n/index.ts | 11 +- .../src/i18n/locales/en/channels.json | 151 ++++++++++++ .../frontend/src/i18n/locales/en/desktop.json | 1 + .../src/i18n/locales/vi/channels.json | 151 ++++++++++++ .../frontend/src/i18n/locales/vi/desktop.json | 1 + .../src/i18n/locales/zh/channels.json | 151 ++++++++++++ .../frontend/src/i18n/locales/zh/desktop.json | 1 + ui/desktop/frontend/src/lib/api.ts | 14 +- ui/desktop/frontend/src/lib/slug.ts | 23 ++ ui/desktop/frontend/src/stores/ui-store.ts | 2 +- ui/desktop/frontend/src/types/channel.ts | 73 ++++++ 40 files changed, 2346 insertions(+), 64 deletions(-) create mode 100644 ui/desktop/frontend/src/components/settings/channels/ChannelAdvancedDialog.tsx create mode 100644 ui/desktop/frontend/src/components/settings/channels/ChannelCard.tsx create mode 100644 ui/desktop/frontend/src/components/settings/channels/ChannelCredentialsTab.tsx create mode 100644 ui/desktop/frontend/src/components/settings/channels/ChannelDetailPanel.tsx create mode 100644 ui/desktop/frontend/src/components/settings/channels/ChannelFormDialog.tsx create mode 100644 ui/desktop/frontend/src/components/settings/channels/ChannelGeneralTab.tsx create mode 100644 ui/desktop/frontend/src/components/settings/channels/ChannelList.tsx create mode 100644 ui/desktop/frontend/src/components/settings/channels/ChannelManagersTab.tsx create mode 100644 ui/desktop/frontend/src/components/settings/channels/PairedDevicesSection.tsx create mode 100644 ui/desktop/frontend/src/components/settings/channels/channel-field-renderer.tsx create mode 100644 ui/desktop/frontend/src/components/settings/channels/channel-schemas.ts create mode 100644 ui/desktop/frontend/src/hooks/use-channel-crud.ts create mode 100644 ui/desktop/frontend/src/hooks/use-channel-detail.ts create mode 100644 ui/desktop/frontend/src/hooks/use-channel-status.ts create mode 100644 ui/desktop/frontend/src/hooks/use-paired-devices.ts create mode 100644 ui/desktop/frontend/src/hooks/use-pending-pairings-count.ts create mode 100644 ui/desktop/frontend/src/i18n/locales/en/channels.json create mode 100644 ui/desktop/frontend/src/i18n/locales/vi/channels.json create mode 100644 ui/desktop/frontend/src/i18n/locales/zh/channels.json create mode 100644 ui/desktop/frontend/src/lib/slug.ts create mode 100644 ui/desktop/frontend/src/types/channel.ts diff --git a/internal/bootstrap/seed_store.go b/internal/bootstrap/seed_store.go index bbc3d002..f7302384 100644 --- a/internal/bootstrap/seed_store.go +++ b/internal/bootstrap/seed_store.go @@ -4,12 +4,32 @@ import ( "context" "log/slog" "path/filepath" + "strings" + "time" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/store" ) +// retryOnBusy retries fn up to 3 times on SQLITE_BUSY errors with 500ms delay. +func retryOnBusy(fn func() error) error { + for attempt := 0; attempt < 3; attempt++ { + err := fn() + if err == nil { + return nil + } + if !strings.Contains(err.Error(), "SQLITE_BUSY") && !strings.Contains(err.Error(), "database is locked") { + return err + } + if attempt < 2 { + slog.Warn("bootstrap: retrying after SQLITE_BUSY", "attempt", attempt+1) + time.Sleep(500 * time.Millisecond) + } + } + return nil // unreachable, but satisfies compiler +} + // SeedToStore seeds embedded templates into agent_context_files (agent-level). // Used for predefined agents only — open agents get per-user files via SeedUserFiles. // Only writes files that don't already have content. @@ -55,7 +75,7 @@ func SeedToStore(ctx context.Context, agentStore store.AgentStore, agentID uuid. continue } - if err := agentStore.SetAgentContextFile(ctx, agentID, name, string(content)); err != nil { + if err := retryOnBusy(func() error { return agentStore.SetAgentContextFile(ctx, agentID, name, string(content)) }); err != nil { return seeded, err } seeded = append(seeded, name) @@ -66,7 +86,7 @@ func SeedToStore(ctx context.Context, agentStore store.AgentStore, agentID uuid. if !hasContent[UserPredefinedFile] { content, err := templateFS.ReadFile(filepath.Join("templates", UserPredefinedFile)) if err == nil { - if err := agentStore.SetAgentContextFile(ctx, agentID, UserPredefinedFile, string(content)); err != nil { + if err := retryOnBusy(func() error { return agentStore.SetAgentContextFile(ctx, agentID, UserPredefinedFile, string(content)) }); err != nil { return seeded, err } seeded = append(seeded, UserPredefinedFile) @@ -167,7 +187,7 @@ func SeedUserFiles(ctx context.Context, agentStore store.AgentStore, agentID uui // This propagates wizard/dashboard-configured owner profile to the first user. if agentType == store.AgentTypePredefined && name == UserFile { if agentContent, ok := agentLevelFiles[name]; ok { - if err := agentStore.SetUserContextFile(ctx, agentID, userID, name, agentContent); err != nil { + if err := retryOnBusy(func() error { return agentStore.SetUserContextFile(ctx, agentID, userID, name, agentContent) }); err != nil { return seeded, err } seeded = append(seeded, name) @@ -188,7 +208,7 @@ func SeedUserFiles(ctx context.Context, agentStore store.AgentStore, agentID uui continue } - if err := agentStore.SetUserContextFile(ctx, agentID, userID, name, string(content)); err != nil { + if err := retryOnBusy(func() error { return agentStore.SetUserContextFile(ctx, agentID, userID, name, string(content)) }); err != nil { return seeded, err } seeded = append(seeded, name) diff --git a/internal/http/agents.go b/internal/http/agents.go index 3d2d97fb..a5e07812 100644 --- a/internal/http/agents.go +++ b/internal/http/agents.go @@ -190,7 +190,8 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "23505") { writeJSON(w, http.StatusConflict, map[string]string{"error": i18n.T(locale, i18n.MsgAlreadyExists, "agent", req.AgentKey)}) } else { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + slog.Error("agents.create", "agent_key", req.AgentKey, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToCreate, "agent", "internal error")}) } return } diff --git a/internal/http/channel_instances.go b/internal/http/channel_instances.go index ef050498..17fe0f35 100644 --- a/internal/http/channel_instances.go +++ b/internal/http/channel_instances.go @@ -168,7 +168,7 @@ func (h *ChannelInstancesHandler) handleCreate(w http.ResponseWriter, r *http.Re if err := h.store.Create(r.Context(), inst); err != nil { slog.Error("channel_instances.create", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToCreate, "channel instance", "internal error")}) return } @@ -213,7 +213,7 @@ func (h *ChannelInstancesHandler) handleUpdate(w http.ResponseWriter, r *http.Re if err := h.store.Update(r.Context(), id, updates); err != nil { slog.Error("channel_instances.update", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToUpdate, "channel instance", "internal error")}) return } @@ -243,7 +243,7 @@ func (h *ChannelInstancesHandler) handleDelete(w http.ResponseWriter, r *http.Re if err := h.store.Delete(r.Context(), id); err != nil { slog.Error("channel_instances.delete", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToDelete, "channel instance", "internal error")}) return } diff --git a/internal/store/sqlitestore/pool.go b/internal/store/sqlitestore/pool.go index 84af8280..3b7316f5 100644 --- a/internal/store/sqlitestore/pool.go +++ b/internal/store/sqlitestore/pool.go @@ -26,8 +26,9 @@ func OpenDB(path string) (*sql.DB, error) { } // SQLite is single-writer; WAL allows concurrent readers. - // Keep pool small to reduce lock contention. - db.SetMaxOpenConns(2) + // 4 connections: up to 3 readers + 1 writer can proceed in parallel, + // reducing connection pool starvation during concurrent operations. + db.SetMaxOpenConns(4) // Set PRAGMAs explicitly — DSN params may not be applied by modernc.org/sqlite. pragmas := []string{ diff --git a/ui/desktop/frontend/src/components/common/EditionCompareModal.tsx b/ui/desktop/frontend/src/components/common/EditionCompareModal.tsx index 18159d40..89139d82 100644 --- a/ui/desktop/frontend/src/components/common/EditionCompareModal.tsx +++ b/ui/desktop/frontend/src/components/common/EditionCompareModal.tsx @@ -26,6 +26,7 @@ const FEATURES: FeatureGroup[] = [ { key: 'teams', lite: 'Max 1', standard: true }, { key: 'teamMembers', lite: 'Max 5', standard: true }, { key: 'sessions', lite: 'Max 50', standard: true }, + { key: 'channels', lite: '1 Telegram + 1 Discord', standard: true }, ], }, { @@ -44,7 +45,6 @@ const FEATURES: FeatureGroup[] = [ group: 'standardOnly', rows: [ { key: 'taskActions', lite: 'Core lifecycle', standard: 'Full + review/approve' }, - { key: 'channels', lite: false, standard: true }, { key: 'heartbeat', lite: false, standard: true }, { key: 'storage', lite: false, standard: true }, { key: 'skillManage', lite: false, standard: true }, diff --git a/ui/desktop/frontend/src/components/layout/sidebar/SidebarFooter.tsx b/ui/desktop/frontend/src/components/layout/sidebar/SidebarFooter.tsx index ab50768c..60c76dc4 100644 --- a/ui/desktop/frontend/src/components/layout/sidebar/SidebarFooter.tsx +++ b/ui/desktop/frontend/src/components/layout/sidebar/SidebarFooter.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next' import { useSessions } from '../../../hooks/use-sessions' import { useUiStore } from '../../../stores/ui-store' import { getWsClient } from '../../../lib/ws' +import { usePendingPairingsCount } from '../../../hooks/use-pending-pairings-count' export function SidebarFooter() { const { t } = useTranslation('desktop') @@ -12,6 +13,8 @@ export function SidebarFooter() { const toggleTheme = useUiStore((s) => s.toggleTheme) const theme = useUiStore((s) => s.theme) + const { pendingCount } = usePendingPairingsCount() + const [connected, setConnected] = useState(() => { try { return getWsClient().isConnected } catch { return false } }) @@ -54,6 +57,23 @@ export function SidebarFooter() { )} + {/* Pairing notification */} + {pendingCount > 0 && ( + + )} + {/* Settings */} + + + {/* Scrollable body */} +
+ {groups.length === 0 ? ( +

{t('advanced.noFields')}

+ ) : ( + groups.map((g) => ( +
+

{g.label}

+ +
+ )) + )} + {error &&

{error}

} +
+ + {/* Footer */} +
+ + +
+ + + ) +} diff --git a/ui/desktop/frontend/src/components/settings/channels/ChannelCard.tsx b/ui/desktop/frontend/src/components/settings/channels/ChannelCard.tsx new file mode 100644 index 00000000..d3b69cd4 --- /dev/null +++ b/ui/desktop/frontend/src/components/settings/channels/ChannelCard.tsx @@ -0,0 +1,83 @@ +import { useTranslation } from 'react-i18next' +import { Switch } from '../../common/Switch' +import type { ChannelInstanceData, ChannelStatus } from '../../../types/channel' + +interface ChannelCardProps { + instance: ChannelInstanceData + status: ChannelStatus | null + agentName: string + onToggleEnabled: (enabled: boolean) => void + onClick: () => void +} + +function TelegramIcon() { + return ( + + + + ) +} + +function DiscordIcon() { + return ( + + + + ) +} + +export function ChannelCard({ instance, status, agentName, onToggleEnabled, onClick }: ChannelCardProps) { + const { t } = useTranslation('channels') + const isTelegram = instance.channel_type === 'telegram' + + // Status dot color + let dotColor = 'bg-gray-400' + let statusText = t('status.disabled') + if (instance.enabled && status?.running) { + dotColor = 'bg-emerald-500' + statusText = t('status.running') + } else if (instance.enabled) { + dotColor = 'bg-amber-500' + statusText = t('status.stopped') + } + + return ( +
+
+ {/* Channel icon */} +
+ {isTelegram ? : } +
+ + {/* Info */} +
+
+ + {instance.display_name || instance.name} + + + {t(`channelTypes.${instance.channel_type}`)} + +
+
+ + {statusText} + · + {agentName} +
+
+ + {/* Enable toggle */} +
e.stopPropagation()}> + +
+
+
+ ) +} diff --git a/ui/desktop/frontend/src/components/settings/channels/ChannelCredentialsTab.tsx b/ui/desktop/frontend/src/components/settings/channels/ChannelCredentialsTab.tsx new file mode 100644 index 00000000..094b0863 --- /dev/null +++ b/ui/desktop/frontend/src/components/settings/channels/ChannelCredentialsTab.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ChannelFields } from './channel-field-renderer' +import { credentialsSchema } from './channel-schemas' +import type { ChannelInstanceData } from '../../../types/channel' + +interface ChannelCredentialsTabProps { + instance: ChannelInstanceData + onUpdate: (updates: Record) => Promise +} + +export function ChannelCredentialsTab({ instance, onUpdate }: ChannelCredentialsTabProps) { + const { t } = useTranslation('channels') + const fields = credentialsSchema[instance.channel_type] ?? [] + const [values, setValues] = useState>({}) + const [saving, setSaving] = useState(false) + + const handleChange = (key: string, value: unknown) => { + setValues((prev) => ({ ...prev, [key]: String(value ?? '') })) + } + + const handleSave = async () => { + // Only send non-empty values (empty = keep current) + const filtered = Object.fromEntries( + Object.entries(values).filter(([, v]) => v.trim() !== ''), + ) + if (Object.keys(filtered).length === 0) return + + setSaving(true) + try { + await onUpdate({ credentials: filtered }) + setValues({}) // Clear form after save + } catch { + // toast shown by hook + } finally { + setSaving(false) + } + } + + if (fields.length === 0) { + return

No credentials required for this channel type.

+ } + + return ( +
+

{t('detail.credentials.hint')}

+ + + + +
+ ) +} diff --git a/ui/desktop/frontend/src/components/settings/channels/ChannelDetailPanel.tsx b/ui/desktop/frontend/src/components/settings/channels/ChannelDetailPanel.tsx new file mode 100644 index 00000000..3e510d40 --- /dev/null +++ b/ui/desktop/frontend/src/components/settings/channels/ChannelDetailPanel.tsx @@ -0,0 +1,119 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useChannelDetail } from '../../../hooks/use-channel-detail' +import { useAgentCrud } from '../../../hooks/use-agent-crud' +import { ChannelGeneralTab } from './ChannelGeneralTab' +import { ChannelCredentialsTab } from './ChannelCredentialsTab' +import { ChannelManagersTab } from './ChannelManagersTab' +import { ChannelAdvancedDialog } from './ChannelAdvancedDialog' +import type { ChannelStatus } from '../../../types/channel' + +interface ChannelDetailPanelProps { + instanceId: string + status: ChannelStatus | null + onBack: () => void + onDelete: () => void +} + +const TABS = ['general', 'credentials', 'managers'] as const +type TabKey = (typeof TABS)[number] + +export function ChannelDetailPanel({ instanceId, status, onBack, onDelete }: ChannelDetailPanelProps) { + const { t } = useTranslation('channels') + const { instance, loading, updateInstance, listManagerGroups, listManagers, addManager, removeManager, listContacts } = useChannelDetail(instanceId) + const { agents } = useAgentCrud() + const [activeTab, setActiveTab] = useState('general') + const [advancedOpen, setAdvancedOpen] = useState(false) + + if (loading || !instance) { + return ( +
+ +
+ {[1, 2, 3].map((i) =>
)} +
+
+ ) + } + + // Status + let dotColor = 'bg-gray-400' + let statusText = t('status.disabled') + if (instance.enabled && status?.running) { dotColor = 'bg-emerald-500'; statusText = t('status.running') } + else if (instance.enabled) { dotColor = 'bg-amber-500'; statusText = t('status.stopped') } + + const agentName = (() => { + const a = agents.find((a) => a.id === instance.agent_id) + return a?.display_name || a?.agent_key || instance.agent_id.slice(0, 8) + })() + + return ( +
+ {/* Header */} +
+
+ + {instance.display_name || instance.name} + + {t(`channelTypes.${instance.channel_type}`)} + + + {statusText} + · {agentName} +
+
+ + +
+
+ + {/* Tabs */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* Tab content */} +
+ {activeTab === 'general' && } + {activeTab === 'credentials' && } + {activeTab === 'managers' && ( + + )} +
+ + {/* Advanced dialog */} + +
+ ) +} diff --git a/ui/desktop/frontend/src/components/settings/channels/ChannelFormDialog.tsx b/ui/desktop/frontend/src/components/settings/channels/ChannelFormDialog.tsx new file mode 100644 index 00000000..50a7393d --- /dev/null +++ b/ui/desktop/frontend/src/components/settings/channels/ChannelFormDialog.tsx @@ -0,0 +1,164 @@ +import { useState, useEffect, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { Combobox } from '../../common/Combobox' +import { Switch } from '../../common/Switch' +import { ChannelFields } from './channel-field-renderer' +import { credentialsSchema } from './channel-schemas' +import type { ChannelInstanceInput } from '../../../types/channel' +import type { AgentData } from '../../../types/agent' + +interface ChannelFormDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + agents: AgentData[] + telegramExists: boolean + discordExists: boolean + onSubmit: (input: ChannelInstanceInput) => Promise +} + +export function ChannelFormDialog({ open, onOpenChange, agents, telegramExists, discordExists, onSubmit }: ChannelFormDialogProps) { + const { t } = useTranslation('channels') + + const [displayName, setDisplayName] = useState('') + const [channelType, setChannelType] = useState('') + const [agentId, setAgentId] = useState('') + const [enabled, setEnabled] = useState(true) + const [credentials, setCredentials] = useState>({}) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + // Reset form when dialog opens + useEffect(() => { + if (!open) return + setDisplayName('') + setChannelType('') + setAgentId('') + setEnabled(true) + setCredentials({}) + setError('') + + // Auto-select the only available type + const available = [] + if (!telegramExists) available.push('telegram') + if (!discordExists) available.push('discord') + if (available.length === 1) setChannelType(available[0]) + }, [open, telegramExists, discordExists]) + + const typeOptions = useMemo(() => { + const opts = [] + if (!telegramExists) opts.push({ value: 'telegram', label: 'Telegram' }) + if (!discordExists) opts.push({ value: 'discord', label: 'Discord' }) + return opts + }, [telegramExists, discordExists]) + + const agentOptions = useMemo( + () => agents.map((a) => ({ value: a.id, label: a.display_name || a.agent_key })), + [agents], + ) + + const credFields = channelType ? (credentialsSchema[channelType] ?? []) : [] + + const handleCredChange = (key: string, value: unknown) => { + setCredentials((prev) => ({ ...prev, [key]: String(value ?? '') })) + } + + const canCreate = !!channelType && !!agentId + && credFields.filter((f) => f.required).every((f) => credentials[f.key]?.trim()) + + const handleCreate = async () => { + setLoading(true) + setError('') + try { + await onSubmit({ + name: channelType, // auto-slug: "telegram" or "discord" + displayName: displayName.trim(), + channelType, + agentId, + credentials, + config: {}, + enabled, + }) + onOpenChange(false) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create channel') + } finally { + setLoading(false) + } + } + + if (!open) return null + + return ( +
+
+ {/* Header */} +
+

{t('form.createTitle')}

+ +
+ + {/* Body */} +
+ {/* Display Name */} +
+ + setDisplayName(e.target.value)} placeholder={t('form.displayNamePlaceholder')} className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent" /> +
+ + {/* Channel Type */} +
+ + {typeOptions.length === 1 ? ( +
+ {typeOptions[0].label} +
+ ) : ( + { setChannelType(v); setCredentials({}) }} + options={typeOptions} + placeholder={t('form.selectType')} + allowCustom={false} + /> + )} +
+ + {/* Agent */} +
+ + +
+ + {/* Enabled */} +
+ + {t('form.enabled')} +
+ + {/* Credentials */} + {credFields.length > 0 && ( +
+

{t('form.credentials')}

+ +
+ )} +
+ + {/* Footer */} + {error &&

{error}

} +
+ + +
+
+
+ ) +} diff --git a/ui/desktop/frontend/src/components/settings/channels/ChannelGeneralTab.tsx b/ui/desktop/frontend/src/components/settings/channels/ChannelGeneralTab.tsx new file mode 100644 index 00000000..52dddbb3 --- /dev/null +++ b/ui/desktop/frontend/src/components/settings/channels/ChannelGeneralTab.tsx @@ -0,0 +1,129 @@ +import { useState, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { Switch } from '../../common/Switch' +import { Combobox } from '../../common/Combobox' +import { ChannelFields } from './channel-field-renderer' +import { configSchema, ESSENTIAL_CONFIG_KEYS } from './channel-schemas' +import type { ChannelInstanceData } from '../../../types/channel' +import type { AgentData } from '../../../types/agent' + +interface ChannelGeneralTabProps { + instance: ChannelInstanceData + agents: AgentData[] + onUpdate: (updates: Record) => Promise +} + +export function ChannelGeneralTab({ instance, agents, onUpdate }: ChannelGeneralTabProps) { + const { t } = useTranslation('channels') + + const [displayName, setDisplayName] = useState(instance.display_name ?? '') + const [agentId, setAgentId] = useState(instance.agent_id) + const [enabled, setEnabled] = useState(instance.enabled) + const [saving, setSaving] = useState(false) + + // Essential config fields (policies) + const allConfigFields = configSchema[instance.channel_type] ?? [] + const essentialKeys = ESSENTIAL_CONFIG_KEYS[instance.channel_type] ?? ESSENTIAL_CONFIG_KEYS._default ?? [] + const essentialFields = allConfigFields.filter((f) => essentialKeys.includes(f.key)) + const existingConfig = (instance.config ?? {}) as Record + const initialPolicyValues = Object.fromEntries( + essentialKeys.filter((k) => existingConfig[k] !== undefined).map((k) => [k, existingConfig[k]]), + ) + const [policyValues, setPolicyValues] = useState>(initialPolicyValues) + + const handlePolicyChange = useCallback((key: string, value: unknown) => { + setPolicyValues((prev) => ({ ...prev, [key]: value })) + }, []) + + const agentOptions = agents.map((a) => ({ + value: a.id, + label: a.display_name || a.agent_key, + })) + + const handleSave = async () => { + setSaving(true) + try { + const cleanPolicies = Object.fromEntries( + Object.entries(policyValues).filter(([, v]) => v !== undefined && v !== '' && v !== null), + ) + const mergedConfig = { ...existingConfig, ...cleanPolicies } + await onUpdate({ + display_name: displayName || null, + agent_id: agentId, + enabled, + config: mergedConfig, + }) + } catch { + // toast shown by hook + } finally { + setSaving(false) + } + } + + return ( +
+ {/* Identity section */} +
+

{t('detail.general.identity')}

+ +
+ +
+ {instance.name} +
+

{t('detail.general.nameHint')}

+
+ +
+ +
+ {t(`channelTypes.${instance.channel_type}`)} +
+
+ +
+ + setDisplayName(e.target.value)} + placeholder={t('detail.general.displayNamePlaceholder')} + className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent" + /> +
+ +
+ + +
+ +
+ + {t('detail.general.enabled')} +
+
+ + {/* Policies section */} + {essentialFields.length > 0 && ( +
+

{t('detail.policies')}

+ +
+ )} + + {/* Save button */} + +
+ ) +} diff --git a/ui/desktop/frontend/src/components/settings/channels/ChannelList.tsx b/ui/desktop/frontend/src/components/settings/channels/ChannelList.tsx new file mode 100644 index 00000000..500d02e7 --- /dev/null +++ b/ui/desktop/frontend/src/components/settings/channels/ChannelList.tsx @@ -0,0 +1,134 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useChannelCrud } from '../../../hooks/use-channel-crud' +import { useChannelStatus } from '../../../hooks/use-channel-status' +import { useAgentCrud } from '../../../hooks/use-agent-crud' +import { ChannelCard } from './ChannelCard' +import { ChannelFormDialog } from './ChannelFormDialog' +import { ChannelDetailPanel } from './ChannelDetailPanel' +import { PairedDevicesSection } from './PairedDevicesSection' +import { ConfirmDialog } from '../../common/ConfirmDialog' +import { RefreshButton } from '../../common/RefreshButton' +import type { ChannelInstanceData } from '../../../types/channel' + +export function ChannelList() { + const { t } = useTranslation('channels') + const { instances, loading, atLimit, telegramExists, discordExists, fetchInstances, createInstance, updateInstance, deleteInstance } = useChannelCrud() + const { statusMap, refreshStatus } = useChannelStatus() + const { agents } = useAgentCrud() + + const [formOpen, setFormOpen] = useState(false) + const [detailId, setDetailId] = useState(null) + const [deleteTarget, setDeleteTarget] = useState(null) + + const refresh = async () => { await fetchInstances(); await refreshStatus() } + + // Detail view replaces list + if (detailId) { + const inst = instances.find((i) => i.id === detailId) + return ( + { setDetailId(null); refresh() }} + onDelete={() => { + if (inst) setDeleteTarget(inst) + }} + /> + ) + } + + const agentNameMap = new Map(agents.map((a) => [a.id, a.display_name || a.agent_key])) + + return ( +
+ {/* Header */} +
+
+

{t('title')}

+

{t('description')}

+
+
+ + +
+
+ + {/* Limit warning */} + {atLimit && ( +
+

{t('atLimit')}

+
+ )} + + {/* Channel cards */} + {loading && instances.length === 0 ? ( +
+ {[1, 2].map((i) =>
)} +
+ ) : instances.length === 0 ? ( +
+ + + + +

{t('emptyTitle')}

+

{t('emptyDesc')}

+
+ ) : ( +
+ {instances.map((inst) => ( + updateInstance(inst.id, { enabled })} + onClick={() => setDetailId(inst.id)} + /> + ))} +
+ )} + + {/* Divider */} +
+ + {/* Paired devices */} + + + {/* Create dialog */} + + + {/* Delete confirm */} + {deleteTarget && ( + setDeleteTarget(null)} + title={t('delete.title')} + description={t('delete.description', { name: deleteTarget.display_name || deleteTarget.name })} + confirmLabel={t('delete.confirmLabel')} + variant="destructive" + onConfirm={async () => { + await deleteInstance(deleteTarget.id) + setDeleteTarget(null) + setDetailId(null) + }} + /> + )} +
+ ) +} diff --git a/ui/desktop/frontend/src/components/settings/channels/ChannelManagersTab.tsx b/ui/desktop/frontend/src/components/settings/channels/ChannelManagersTab.tsx new file mode 100644 index 00000000..8bb74779 --- /dev/null +++ b/ui/desktop/frontend/src/components/settings/channels/ChannelManagersTab.tsx @@ -0,0 +1,220 @@ +import { useState, useEffect, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { Combobox } from '../../common/Combobox' +import type { GroupManagerGroupInfo, GroupManagerData, ChannelContact } from '../../../types/channel' + +interface ChannelManagersTabProps { + listManagerGroups: () => Promise + listManagers: (groupId: string) => Promise + addManager: (groupId: string, userId: string, displayName?: string, username?: string) => Promise + removeManager: (groupId: string, userId: string) => Promise + listContacts: (search: string) => Promise +} + +function shortGroupId(id: string): string { + return id.match(/^group:[^:]+:(.+)$/)?.[1] ?? id +} + +export function ChannelManagersTab({ + listManagerGroups, listManagers, addManager, removeManager, listContacts, +}: ChannelManagersTabProps) { + const { t } = useTranslation('channels') + const [groups, setGroups] = useState([]) + const [expanded, setExpanded] = useState>({}) + const [managersMap, setManagersMap] = useState>({}) + const [loadingMap, setLoadingMap] = useState>({}) + const [contactOptions, setContactOptions] = useState<{ value: string; label: string }[]>([]) + // Per-group inline add: userId + const [inlineUserId, setInlineUserId] = useState>({}) + // Standalone add form + const [newGroupId, setNewGroupId] = useState('') + const [newUserId, setNewUserId] = useState('') + const [addingMap, setAddingMap] = useState>({}) + const [error, setError] = useState('') + + const loadGroups = useCallback(async () => { + try { + const data = await listManagerGroups() + setGroups(data) + } catch { + setGroups([]) + } + }, [listManagerGroups]) + + useEffect(() => { loadGroups() }, [loadGroups]) + + const handleToggle = async (groupId: string) => { + const next = !expanded[groupId] + setExpanded((prev) => ({ ...prev, [groupId]: next })) + if (next && !managersMap[groupId]) { + setLoadingMap((prev) => ({ ...prev, [groupId]: true })) + try { + const data = await listManagers(groupId) + setManagersMap((prev) => ({ ...prev, [groupId]: data })) + } finally { + setLoadingMap((prev) => ({ ...prev, [groupId]: false })) + } + } + } + + const handleContactSearch = useCallback(async (search: string) => { + try { + const contacts = await listContacts(search) + setContactOptions(contacts.map((c) => ({ + value: c.sender_id, + label: c.display_name ? `${c.display_name} (${c.sender_id})` : c.sender_id, + }))) + } catch { + setContactOptions([]) + } + }, [listContacts]) + + const handleInlineAdd = async (groupId: string) => { + const userId = inlineUserId[groupId]?.trim() + if (!userId) return + setAddingMap((prev) => ({ ...prev, [groupId]: true })) + setError('') + try { + await addManager(groupId, userId) + setInlineUserId((prev) => ({ ...prev, [groupId]: '' })) + const data = await listManagers(groupId) + setManagersMap((prev) => ({ ...prev, [groupId]: data })) + await loadGroups() + } catch (err) { + setError(err instanceof Error ? err.message : t('managers.addFailed')) + } finally { + setAddingMap((prev) => ({ ...prev, [groupId]: false })) + } + } + + const handleRemove = async (groupId: string, userId: string) => { + setError('') + try { + await removeManager(groupId, userId) + setManagersMap((prev) => ({ + ...prev, + [groupId]: (prev[groupId] ?? []).filter((m) => m.user_id !== userId), + })) + await loadGroups() + } catch (err) { + setError(err instanceof Error ? err.message : t('managers.removeFailed')) + } + } + + const handleStandaloneAdd = async () => { + const gid = newGroupId.trim() + const uid = newUserId.trim() + if (!gid || !uid) return + setAddingMap((prev) => ({ ...prev, _new: true })) + setError('') + try { + await addManager(gid, uid) + setNewGroupId('') + setNewUserId('') + await loadGroups() + } catch (err) { + setError(err instanceof Error ? err.message : t('managers.addFailed')) + } finally { + setAddingMap((prev) => ({ ...prev, _new: false })) + } + } + + const inputClass = 'bg-surface-tertiary border border-border rounded-lg px-2.5 py-1.5 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent' + + return ( +
+ {error &&

{error}

} + + {groups.length === 0 ? ( +

{t('managers.empty')}

+ ) : ( +
+ {groups.map((g) => ( +
+ + + {expanded[g.group_id] && ( +
+ {loadingMap[g.group_id] ? ( +

{t('common.loading')}

+ ) : (managersMap[g.group_id] ?? []).length === 0 ? ( +

{t('managers.noManagers')}

+ ) : ( +
+ {(managersMap[g.group_id] ?? []).map((m) => ( +
+
+ {m.user_id} + {m.display_name && {m.display_name}} + {m.username && @{m.username}} +
+ +
+ ))} +
+ )} +
+
+ { setInlineUserId((prev) => ({ ...prev, [g.group_id]: v })); handleContactSearch(v) }} + options={contactOptions} + placeholder={t('managers.userIdPlaceholder')} + /> +
+ +
+
+ )} +
+ ))} +
+ )} + + {/* Standalone add form for new groups */} +
+

{t('managers.addToGroup')}

+ setNewGroupId(e.target.value)} + placeholder={t('managers.groupIdPlaceholder')} + className={`w-full ${inputClass}`} + /> +
+
+ { setNewUserId(v); handleContactSearch(v) }} + options={contactOptions} + placeholder={t('managers.userIdPlaceholder')} + /> +
+ +
+
+
+ ) +} diff --git a/ui/desktop/frontend/src/components/settings/channels/PairedDevicesSection.tsx b/ui/desktop/frontend/src/components/settings/channels/PairedDevicesSection.tsx new file mode 100644 index 00000000..ddcd8113 --- /dev/null +++ b/ui/desktop/frontend/src/components/settings/channels/PairedDevicesSection.tsx @@ -0,0 +1,153 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { usePairedDevices } from '../../../hooks/use-paired-devices' +import { ConfirmDialog } from '../../common/ConfirmDialog' +import type { PendingPairing, PairedDevice } from '../../../types/channel' + +function formatRelativeTime(ms: number): string { + const diff = Date.now() - ms + if (diff < 60000) return 'just now' + if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago` + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago` + return `${Math.floor(diff / 86400000)}d ago` +} + +function formatDate(ms: number): string { + return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) +} + +export function PairedDevicesSection() { + const { t } = useTranslation('channels') + const { pendingPairings, pairedDevices, loading, refresh, approvePairing, denyPairing, revokePairing } = usePairedDevices() + const [approveTarget, setApproveTarget] = useState(null) + const [denyTarget, setDenyTarget] = useState(null) + const [revokeTarget, setRevokeTarget] = useState(null) + + const isEmpty = pendingPairings.length === 0 && pairedDevices.length === 0 + + return ( +
+ {/* Header */} +
+

{t('pairing.title')}

+ +
+ + {isEmpty && !loading ? ( +
+

{t('pairing.empty')}

+

{t('pairing.emptyDesc')}

+
+ ) : ( +
+ {/* Pending */} + {pendingPairings.length > 0 && ( +
+

{t('pairing.pending', { count: pendingPairings.length })}

+
+ {pendingPairings.map((p) => ( +
+
+
+ {p.channel} + {p.code} +
+
+ {t('pairing.sender')}{p.sender_id} + {p.chat_id && ` | ${t('pairing.chat')}${p.chat_id}`} + {' | '}{formatRelativeTime(p.created_at)} +
+
+
+ + +
+
+ ))} +
+
+ )} + + {/* Paired */} + {pairedDevices.length > 0 && ( +
+

{t('pairing.paired', { count: pairedDevices.length })}

+
+ + + + + + + + + + + {pairedDevices.map((d) => ( + + + + + + + + ))} + +
ChannelSender IDPairedBy +
+ {d.channel} + {d.sender_id}{formatDate(d.paired_at)}{d.paired_by} + +
+
+
+ )} +
+ )} + + {/* Confirm dialogs */} + {approveTarget && ( + setApproveTarget(null)} + title={t('pairing.confirmApprove.title')} + description={t('pairing.confirmApprove.description', { channel: approveTarget.channel, senderId: approveTarget.sender_id, code: approveTarget.code })} + confirmLabel={t('pairing.confirmApprove.confirmLabel')} + onConfirm={async () => { await approvePairing(approveTarget.code); setApproveTarget(null) }} + /> + )} + {denyTarget && ( + setDenyTarget(null)} + title={t('pairing.confirmDeny.title')} + description={t('pairing.confirmDeny.description', { channel: denyTarget.channel, senderId: denyTarget.sender_id, code: denyTarget.code })} + confirmLabel={t('pairing.confirmDeny.confirmLabel')} + variant="destructive" + onConfirm={async () => { await denyPairing(denyTarget.code); setDenyTarget(null) }} + /> + )} + {revokeTarget && ( + setRevokeTarget(null)} + title={t('pairing.confirmRevoke.title')} + description={t('pairing.confirmRevoke.description', { channel: revokeTarget.channel, senderId: revokeTarget.sender_id })} + confirmLabel={t('pairing.confirmRevoke.confirmLabel')} + variant="destructive" + onConfirm={async () => { await revokePairing(revokeTarget.sender_id, revokeTarget.channel); setRevokeTarget(null) }} + /> + )} +
+ ) +} diff --git a/ui/desktop/frontend/src/components/settings/channels/channel-field-renderer.tsx b/ui/desktop/frontend/src/components/settings/channels/channel-field-renderer.tsx new file mode 100644 index 00000000..b6c04a39 --- /dev/null +++ b/ui/desktop/frontend/src/components/settings/channels/channel-field-renderer.tsx @@ -0,0 +1,135 @@ +import { useTranslation } from 'react-i18next' +import { Switch } from '../../common/Switch' +import { Combobox } from '../../common/Combobox' +import type { FieldDef } from './channel-schemas' + +interface ChannelFieldsProps { + fields: FieldDef[] + values: Record + onChange: (key: string, value: unknown) => void + idPrefix: string + isEdit?: boolean + contextValues?: Record +} + +export function ChannelFields({ fields, values, onChange, idPrefix, isEdit, contextValues }: ChannelFieldsProps) { + const allValues = contextValues ? { ...contextValues, ...values } : values + return ( +
+ {fields.map((field) => { + if (field.showWhen) { + const depValue = allValues[field.showWhen.key] ?? fields.find((f) => f.key === field.showWhen!.key)?.defaultValue + if (String(depValue) !== field.showWhen.value) return null + } + let disabled = false + let disabledHint: string | undefined + if (field.disabledWhen) { + const depValue = allValues[field.disabledWhen.key] ?? fields.find((f) => f.key === field.disabledWhen!.key)?.defaultValue + if (String(depValue) === field.disabledWhen.value) { + disabled = true + disabledHint = field.disabledWhen.hint + } + } + return ( + onChange(field.key, v)} + id={`${idPrefix}-${field.key}`} + isEdit={isEdit} + disabled={disabled} + disabledHint={disabledHint} + /> + ) + })} +
+ ) +} + +interface FieldRendererProps { + field: FieldDef + value: unknown + onChange: (v: unknown) => void + id: string + isEdit?: boolean + disabled?: boolean + disabledHint?: string +} + +function FieldRenderer({ field, value, onChange, id, isEdit, disabled, disabledHint }: FieldRendererProps) { + const { t } = useTranslation('channels') + const label = field.label + const help = field.help ?? '' + const labelSuffix = field.required && !isEdit ? ' *' : '' + const editHint = isEdit && field.type === 'password' ? ` ${t('form.credentialsHint')}` : '' + + const inputClass = 'w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent' + + switch (field.type) { + case 'text': + case 'password': + return ( +
+ + onChange(e.target.value)} placeholder={field.placeholder} className={inputClass} /> + {help &&

{help}

} +
+ ) + + case 'number': + return ( +
+ + onChange(e.target.value ? Number(e.target.value) : undefined)} placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : undefined} className={inputClass} /> + {help &&

{help}

} +
+ ) + + case 'boolean': + return ( +
+ onChange(v)} disabled={disabled} /> + + {disabledHint && — {disabledHint}} + {!disabledHint && help && — {help}} +
+ ) + + case 'select': + return ( +
+ + onChange(v)} + options={field.options?.map((opt) => ({ value: opt.value, label: opt.label })) ?? []} + allowCustom={false} + /> + {help &&

{help}

} +
+ ) + + case 'tags': + return ( +
+ +