feat(desktop): add channel management, paired devices, and multiple fixes

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"
This commit is contained in:
viettranx
2026-03-27 19:52:28 +07:00
parent 2bc0f5f14a
commit 6ea9b4d762
40 changed files with 2346 additions and 64 deletions
+24 -4
View File
@@ -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)
+2 -1
View File
@@ -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
}
+3 -3
View File
@@ -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
}
+3 -2
View File
@@ -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{
@@ -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 },
@@ -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() {
)}
</button>
{/* Pairing notification */}
{pendingCount > 0 && (
<button
onClick={() => openSettings('channels')}
className="relative w-6 h-6 flex items-center justify-center rounded text-text-muted hover:text-text-primary hover:bg-surface-tertiary transition-colors"
title={`${pendingCount} pending pairing request(s)`}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
<span className="absolute -top-0.5 -right-0.5 w-3.5 h-3.5 rounded-full bg-amber-500 text-[8px] text-white font-bold flex items-center justify-center">
{pendingCount}
</span>
</button>
)}
{/* Settings */}
<button
onClick={() => openSettings()}
@@ -1,7 +1,8 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getApiClient } from '../../lib/api'
import { PROVIDER_TYPES, slugify } from '../../constants/providers'
import { PROVIDER_TYPES } from '../../constants/providers'
import { slugify } from '../../lib/slug'
import { Combobox } from '../common/Combobox'
import type { ProviderData } from '../../types/provider'
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'
import type { SettingsTab } from '../../stores/ui-store'
const TAB_KEYS: SettingsTab[] = [
'appearance', 'providers', 'agents', 'mcp', 'skills', 'tools', 'cron', 'traces', 'storage', 'about',
'appearance', 'providers', 'agents', 'channels', 'mcp', 'skills', 'tools', 'cron', 'traces', 'storage', 'about',
]
interface SettingsTabBarProps {
@@ -10,6 +10,7 @@ import { ToolList } from './tools/ToolList'
import { CronList } from './cron/CronList'
import { TraceList } from './traces/TraceList'
import { StorageTab } from './storage/StorageTab'
import { ChannelList } from './channels/ChannelList'
export function SettingsView() {
const settingsTab = useUiStore((s) => s.settingsTab)
@@ -60,6 +61,7 @@ function TabContent({ tab }: { tab: string }) {
case 'appearance': return <AppearanceTab />
case 'providers': return <ProviderList />
case 'agents': return <AgentList />
case 'channels': return <ChannelList />
case 'mcp': return <McpServerList />
case 'skills': return <SkillList />
case 'tools': return <ToolList />
@@ -4,7 +4,7 @@ import { Combobox } from '../../common/Combobox'
import { useProviders } from '../../../hooks/use-providers'
import { getApiClient } from '../../../lib/api'
import { Switch } from '../../common/Switch'
import { slugify } from '../../../constants/providers'
import { slugify } from '../../../lib/slug'
import type { AgentData, AgentInput } from '../../../types/agent'
// Preset keys match agents.json presets (foxSpirit, artisan, astrologer)
@@ -56,6 +56,7 @@ export function AgentFormDialog({ open, onOpenChange, agent, onSubmit }: AgentFo
setIsDefault(agent?.is_default ?? false)
setError('')
setSelectedPresetKey('')
setAgentKeyOverride('')
setVerifyResult(isEditing ? { valid: true } : null) // editing = already verified
setModels([])
}, [open, agent, isEditing])
@@ -90,11 +91,13 @@ export function AgentFormDialog({ open, onOpenChange, agent, onSubmit }: AgentFo
if (!isEditing) setVerifyResult(null)
}, [providerName, model, isEditing])
const [agentKeyOverride, setAgentKeyOverride] = useState('')
const agentKey = useMemo(() => {
if (isEditing) return agent!.agent_key
if (agentKeyOverride) return agentKeyOverride
// Use preset agentKey if selected, otherwise slugify display name
return selectedPresetKey || slugify(displayName) || 'agent'
}, [isEditing, agent, selectedPresetKey, displayName])
}, [isEditing, agent, agentKeyOverride, selectedPresetKey, displayName])
const providerOptions = useMemo(
() => providers.filter((p) => p.enabled).map((p) => ({
@@ -201,9 +204,11 @@ export function AgentFormDialog({ open, onOpenChange, agent, onSubmit }: AgentFo
{!isEditing && (
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">Agent Key</label>
<div className="px-3 py-2 rounded-lg border border-border bg-surface-tertiary/50 text-xs text-text-muted font-mono">
{agentKey}
</div>
<input
value={agentKey}
onChange={(e) => setAgentKeyOverride(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-'))}
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary font-mono focus:outline-none focus:ring-1 focus:ring-accent"
/>
</div>
)}
@@ -253,7 +258,7 @@ export function AgentFormDialog({ open, onOpenChange, agent, onSubmit }: AgentFo
<button
key={p.key}
type="button"
onClick={() => { setDescription(prompt); setEmoji(p.emoji); setDisplayName(displayName); setSelectedPresetKey(p.agentKey) }}
onClick={() => { setDescription(prompt); setEmoji(p.emoji); setDisplayName(displayName); setSelectedPresetKey(p.agentKey); setAgentKeyOverride('') }}
className={`rounded-full border px-2.5 py-1 text-[11px] transition-colors ${
description === prompt
? 'border-accent bg-accent/10 text-accent font-medium'
@@ -0,0 +1,137 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { ChannelFields } from './channel-field-renderer'
import { configSchema, ESSENTIAL_CONFIG_KEYS, NETWORK_KEYS, LIMITS_KEYS, STREAMING_KEYS, BEHAVIOR_KEYS, ACCESS_KEYS } from './channel-schemas'
import type { ChannelInstanceData } from '../../../types/channel'
interface ChannelAdvancedDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
instance: ChannelInstanceData
onUpdate: (updates: Record<string, unknown>) => Promise<void>
}
const ESSENTIAL_CONFIG_KEYS_SET = new Set(['dm_policy', 'group_policy', 'require_mention', 'mention_mode'])
function getAdvancedFields(channelType: string) {
const allFields = configSchema[channelType] ?? []
const advanced = allFields.filter((f) => !ESSENTIAL_CONFIG_KEYS_SET.has(f.key))
return {
network: advanced.filter((f) => NETWORK_KEYS.has(f.key)),
limits: advanced.filter((f) => LIMITS_KEYS.has(f.key)),
streaming: advanced.filter((f) => STREAMING_KEYS.has(f.key)),
behavior: advanced.filter((f) => BEHAVIOR_KEYS.has(f.key)),
access: advanced.filter((f) => ACCESS_KEYS.has(f.key)),
}
}
function deriveInitialValues(instance: ChannelInstanceData): Record<string, unknown> {
const config = (instance.config ?? {}) as Record<string, unknown>
const { groups: _groups, ...rest } = config
return Object.fromEntries(
Object.entries(rest).filter(([k]) => !ESSENTIAL_CONFIG_KEYS_SET.has(k))
)
}
export function ChannelAdvancedDialog({ open, onOpenChange, instance, onUpdate }: ChannelAdvancedDialogProps) {
const { t } = useTranslation('channels')
const [values, setValues] = useState<Record<string, unknown>>({})
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (open) {
setValues(deriveInitialValues(instance))
setError('')
}
}, [open, instance])
if (!open) return null
const fields = getAdvancedFields(instance.channel_type)
const essentialKeys = ESSENTIAL_CONFIG_KEYS[instance.channel_type] ?? ESSENTIAL_CONFIG_KEYS['_default']
const handleChange = (key: string, value: unknown) => {
setValues((prev) => ({ ...prev, [key]: value }))
}
const handleSave = async () => {
setSaving(true)
setError('')
try {
const existingConfig = (instance.config ?? {}) as Record<string, unknown>
// Preserve essential keys + groups key
const essential = Object.fromEntries(
Object.entries(existingConfig).filter(([k]) => essentialKeys.includes(k) || k === 'groups')
)
await onUpdate({ ...essential, ...values })
onOpenChange(false)
} catch (err) {
setError(err instanceof Error ? err.message : t('advanced.saveFailed'))
} finally {
setSaving(false)
}
}
const groups = [
{ key: 'network', label: t('advanced.network'), fields: fields.network },
{ key: 'limits', label: t('advanced.limits'), fields: fields.limits },
{ key: 'streaming', label: t('advanced.streaming'), fields: fields.streaming },
{ key: 'behavior', label: t('advanced.behavior'), fields: fields.behavior },
{ key: 'access', label: t('advanced.access'), fields: fields.access },
].filter((g) => g.fields.length > 0)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="bg-surface-secondary border border-border rounded-xl shadow-xl max-w-lg w-full mx-4 flex flex-col" style={{ maxHeight: '85vh' }}>
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-5 py-4 shrink-0">
<h3 className="text-sm font-semibold text-text-primary">{t('advanced.title')}</h3>
<button onClick={() => onOpenChange(false)} className="p-1 text-text-muted hover:text-text-primary transition-colors cursor-pointer">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
</div>
{/* Scrollable body */}
<div className="flex-1 overflow-y-auto overscroll-contain px-5 py-4 space-y-5">
{groups.length === 0 ? (
<p className="text-sm text-text-muted text-center py-6">{t('advanced.noFields')}</p>
) : (
groups.map((g) => (
<div key={g.key}>
<p className="text-xs font-semibold text-text-secondary uppercase tracking-wider mb-3">{g.label}</p>
<ChannelFields
fields={g.fields}
values={values}
onChange={handleChange}
idPrefix={`adv-${g.key}`}
isEdit
/>
</div>
))
)}
{error && <p className="text-xs text-error">{error}</p>}
</div>
{/* Footer */}
<div className="flex justify-end gap-2 border-t border-border px-5 py-3 shrink-0">
<button
onClick={() => onOpenChange(false)}
className="px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary transition-colors cursor-pointer"
>
{t('common.cancel')}
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-1.5 bg-accent text-white text-sm rounded-lg disabled:opacity-50 cursor-pointer hover:opacity-90 transition-opacity"
>
{saving ? t('common.saving') : t('common.save')}
</button>
</div>
</div>
</div>
)
}
@@ -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 (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
)
}
function DiscordIcon() {
return (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z" />
</svg>
)
}
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 (
<div
onClick={onClick}
className="border border-border rounded-xl p-4 hover:bg-surface-tertiary/30 transition-colors cursor-pointer"
>
<div className="flex items-start gap-3">
{/* Channel icon */}
<div className="shrink-0 text-text-muted mt-0.5">
{isTelegram ? <TelegramIcon /> : <DiscordIcon />}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text-primary truncate">
{instance.display_name || instance.name}
</span>
<span className="rounded-full px-1.5 py-0.5 text-[10px] bg-surface-tertiary text-text-secondary border border-border shrink-0">
{t(`channelTypes.${instance.channel_type}`)}
</span>
</div>
<div className="flex items-center gap-2 mt-1">
<span className={`w-1.5 h-1.5 rounded-full ${dotColor}`} />
<span className="text-[11px] text-text-muted">{statusText}</span>
<span className="text-[11px] text-text-muted">·</span>
<span className="text-[11px] text-text-muted truncate">{agentName}</span>
</div>
</div>
{/* Enable toggle */}
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
<Switch
checked={instance.enabled}
onCheckedChange={onToggleEnabled}
/>
</div>
</div>
</div>
)
}
@@ -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<string, unknown>) => Promise<void>
}
export function ChannelCredentialsTab({ instance, onUpdate }: ChannelCredentialsTabProps) {
const { t } = useTranslation('channels')
const fields = credentialsSchema[instance.channel_type] ?? []
const [values, setValues] = useState<Record<string, string>>({})
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 <p className="text-sm text-text-muted py-4">No credentials required for this channel type.</p>
}
return (
<div className="space-y-4">
<p className="text-xs text-text-muted">{t('detail.credentials.hint')}</p>
<ChannelFields
fields={fields}
values={values}
onChange={handleChange}
idPrefix="cc-cred"
isEdit
/>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-1.5 text-xs bg-accent text-white rounded-lg font-medium hover:bg-accent-hover transition-colors disabled:opacity-50 cursor-pointer"
>
{saving ? t('detail.credentials.saving') : t('detail.credentials.updateCredentials')}
</button>
</div>
)
}
@@ -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<TabKey>('general')
const [advancedOpen, setAdvancedOpen] = useState(false)
if (loading || !instance) {
return (
<div className="space-y-3">
<button onClick={onBack} className="text-xs text-text-muted hover:text-text-primary transition-colors cursor-pointer">
{t('detail.back')}
</button>
<div className="space-y-2">
{[1, 2, 3].map((i) => <div key={i} className="h-10 rounded-lg bg-surface-tertiary/50 animate-pulse" />)}
</div>
</div>
)
}
// 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 (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 min-w-0">
<button onClick={onBack} className="text-xs text-text-muted hover:text-text-primary transition-colors shrink-0 cursor-pointer">
{t('detail.back')}
</button>
<span className="text-sm font-medium text-text-primary truncate">{instance.display_name || instance.name}</span>
<span className="rounded-full px-1.5 py-0.5 text-[10px] bg-surface-tertiary text-text-secondary border border-border shrink-0">
{t(`channelTypes.${instance.channel_type}`)}
</span>
<span className={`w-1.5 h-1.5 rounded-full ${dotColor} shrink-0`} />
<span className="text-[11px] text-text-muted shrink-0">{statusText}</span>
<span className="text-[11px] text-text-muted shrink-0">· {agentName}</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button onClick={() => setAdvancedOpen(true)} className="px-2.5 py-1 text-[11px] border border-border rounded-lg text-text-secondary hover:bg-surface-tertiary transition-colors cursor-pointer">
{t('detail.advanced')}
</button>
<button onClick={onDelete} className="px-2.5 py-1 text-[11px] border border-border rounded-lg text-error hover:bg-error/10 transition-colors cursor-pointer">
{t('delete.confirmLabel')}
</button>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-border">
{TABS.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-3 py-1.5 text-xs transition-colors cursor-pointer ${
activeTab === tab
? 'text-accent font-medium border-b-2 border-accent -mb-px'
: 'text-text-muted hover:text-text-primary'
}`}
>
{t(`detail.tabs.${tab}`)}
</button>
))}
</div>
{/* Tab content */}
<div>
{activeTab === 'general' && <ChannelGeneralTab instance={instance} agents={agents} onUpdate={updateInstance} />}
{activeTab === 'credentials' && <ChannelCredentialsTab instance={instance} onUpdate={updateInstance} />}
{activeTab === 'managers' && (
<ChannelManagersTab
listManagerGroups={listManagerGroups}
listManagers={listManagers}
addManager={addManager}
removeManager={removeManager}
listContacts={listContacts}
/>
)}
</div>
{/* Advanced dialog */}
<ChannelAdvancedDialog
open={advancedOpen}
onOpenChange={setAdvancedOpen}
instance={instance}
onUpdate={updateInstance}
/>
</div>
)
}
@@ -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<unknown>
}
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<Record<string, string>>({})
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="bg-surface-secondary border border-border rounded-xl shadow-xl max-w-lg w-full mx-4 overflow-hidden flex flex-col" style={{ maxHeight: '85vh' }}>
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-5 py-4 shrink-0">
<h3 className="text-sm font-semibold text-text-primary">{t('form.createTitle')}</h3>
<button onClick={() => onOpenChange(false)} className="p-1 text-text-muted hover:text-text-primary transition-colors cursor-pointer">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto overscroll-contain p-5 space-y-4">
{/* Display Name */}
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('form.displayName')}</label>
<input value={displayName} onChange={(e) => 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" />
</div>
{/* Channel Type */}
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('form.channelType')} *</label>
{typeOptions.length === 1 ? (
<div className="px-3 py-2 rounded-lg border border-border bg-surface-tertiary/50 text-sm text-text-muted">
{typeOptions[0].label}
</div>
) : (
<Combobox
value={channelType}
onChange={(v) => { setChannelType(v); setCredentials({}) }}
options={typeOptions}
placeholder={t('form.selectType')}
allowCustom={false}
/>
)}
</div>
{/* Agent */}
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('form.agent')} *</label>
<Combobox value={agentId} onChange={setAgentId} options={agentOptions} placeholder={t('form.selectAgent')} />
</div>
{/* Enabled */}
<div className="flex items-center gap-2">
<Switch checked={enabled} onCheckedChange={setEnabled} />
<span className="text-xs text-text-secondary">{t('form.enabled')}</span>
</div>
{/* Credentials */}
{credFields.length > 0 && (
<div className="space-y-2 border-t border-border pt-4">
<h4 className="text-xs font-semibold text-text-secondary">{t('form.credentials')}</h4>
<ChannelFields fields={credFields} values={credentials} onChange={handleCredChange} idPrefix="cf-cred" />
</div>
)}
</div>
{/* Footer */}
{error && <div className="px-5"><p className="text-xs text-error">{error}</p></div>}
<div className="flex items-center justify-end gap-2 border-t border-border px-5 py-4 shrink-0">
<button onClick={() => onOpenChange(false)} className="px-3 py-1.5 text-xs border border-border rounded-lg text-text-secondary hover:bg-surface-tertiary transition-colors cursor-pointer">
{t('form.cancel')}
</button>
<button onClick={handleCreate} disabled={!canCreate || loading} className="px-4 py-1.5 text-xs bg-accent text-white rounded-lg font-medium hover:bg-accent-hover transition-colors disabled:opacity-50 cursor-pointer">
{loading ? t('form.saving') : t('form.create')}
</button>
</div>
</div>
</div>
)
}
@@ -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<string, unknown>) => Promise<void>
}
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<string, unknown>
const initialPolicyValues = Object.fromEntries(
essentialKeys.filter((k) => existingConfig[k] !== undefined).map((k) => [k, existingConfig[k]]),
)
const [policyValues, setPolicyValues] = useState<Record<string, unknown>>(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 (
<div className="space-y-4">
{/* Identity section */}
<section className="space-y-3 rounded-lg border border-border p-4">
<h3 className="text-xs font-semibold text-text-secondary">{t('detail.general.identity')}</h3>
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('detail.general.name')}</label>
<div className="px-3 py-2 rounded-lg border border-border bg-surface-tertiary/50 text-xs text-text-muted font-mono">
{instance.name}
</div>
<p className="text-[11px] text-text-muted">{t('detail.general.nameHint')}</p>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('detail.general.channelType')}</label>
<div className="px-3 py-2 rounded-lg border border-border bg-surface-tertiary/50 text-xs text-text-muted">
{t(`channelTypes.${instance.channel_type}`)}
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('detail.general.displayName')}</label>
<input
value={displayName}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('detail.general.agent')}</label>
<Combobox value={agentId} onChange={setAgentId} options={agentOptions} placeholder={t('detail.general.selectAgent')} />
</div>
<div className="flex items-center gap-2">
<Switch checked={enabled} onCheckedChange={setEnabled} />
<span className="text-xs text-text-secondary">{t('detail.general.enabled')}</span>
</div>
</section>
{/* Policies section */}
{essentialFields.length > 0 && (
<section className="space-y-3 rounded-lg border border-border p-4">
<h3 className="text-xs font-semibold text-text-secondary">{t('detail.policies')}</h3>
<ChannelFields
fields={essentialFields}
values={policyValues}
onChange={handlePolicyChange}
idPrefix="cg-pol"
contextValues={policyValues}
/>
</section>
)}
{/* Save button */}
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-1.5 text-xs bg-accent text-white rounded-lg font-medium hover:bg-accent-hover transition-colors disabled:opacity-50 cursor-pointer"
>
{saving ? t('detail.general.saving') : t('detail.general.saveChanges')}
</button>
</div>
)
}
@@ -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<string | null>(null)
const [deleteTarget, setDeleteTarget] = useState<ChannelInstanceData | null>(null)
const refresh = async () => { await fetchInstances(); await refreshStatus() }
// Detail view replaces list
if (detailId) {
const inst = instances.find((i) => i.id === detailId)
return (
<ChannelDetailPanel
instanceId={detailId}
status={inst ? (statusMap[inst.name] ?? null) : null}
onBack={() => { setDetailId(null); refresh() }}
onDelete={() => {
if (inst) setDeleteTarget(inst)
}}
/>
)
}
const agentNameMap = new Map(agents.map((a) => [a.id, a.display_name || a.agent_key]))
return (
<div className="space-y-5">
{/* Header */}
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-text-primary">{t('title')}</h2>
<p className="text-xs text-text-muted mt-0.5">{t('description')}</p>
</div>
<div className="flex items-center gap-2">
<RefreshButton onRefresh={refresh} />
<button
onClick={() => setFormOpen(true)}
disabled={atLimit}
className="px-3 py-1.5 text-xs bg-accent text-white rounded-lg font-medium hover:bg-accent-hover transition-colors disabled:opacity-50 cursor-pointer"
title={atLimit ? t('atLimit') : undefined}
>
{t('addChannel')}
</button>
</div>
</div>
{/* Limit warning */}
{atLimit && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2">
<p className="text-[11px] text-amber-600 dark:text-amber-400">{t('atLimit')}</p>
</div>
)}
{/* Channel cards */}
{loading && instances.length === 0 ? (
<div className="space-y-2">
{[1, 2].map((i) => <div key={i} className="h-16 rounded-xl bg-surface-tertiary/50 animate-pulse" />)}
</div>
) : instances.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-10">
<svg className="h-10 w-10 text-text-muted/40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
<path d="M22 8.35V20a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8.35A2 2 0 0 1 3.26 6.5l8-3.2a2 2 0 0 1 1.48 0l8 3.2A2 2 0 0 1 22 8.35Z" />
<path d="M6 18h12" /><path d="M6 14h12" />
</svg>
<p className="text-sm text-text-muted">{t('emptyTitle')}</p>
<p className="text-xs text-text-muted/70">{t('emptyDesc')}</p>
</div>
) : (
<div className="grid gap-2">
{instances.map((inst) => (
<ChannelCard
key={inst.id}
instance={inst}
status={statusMap[inst.name] ?? null}
agentName={agentNameMap.get(inst.agent_id) ?? inst.agent_id.slice(0, 8)}
onToggleEnabled={(enabled) => updateInstance(inst.id, { enabled })}
onClick={() => setDetailId(inst.id)}
/>
))}
</div>
)}
{/* Divider */}
<div className="border-t border-border" />
{/* Paired devices */}
<PairedDevicesSection />
{/* Create dialog */}
<ChannelFormDialog
open={formOpen}
onOpenChange={setFormOpen}
agents={agents}
telegramExists={telegramExists}
discordExists={discordExists}
onSubmit={createInstance}
/>
{/* Delete confirm */}
{deleteTarget && (
<ConfirmDialog
open
onOpenChange={() => 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)
}}
/>
)}
</div>
)
}
@@ -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<GroupManagerGroupInfo[]>
listManagers: (groupId: string) => Promise<GroupManagerData[]>
addManager: (groupId: string, userId: string, displayName?: string, username?: string) => Promise<void>
removeManager: (groupId: string, userId: string) => Promise<void>
listContacts: (search: string) => Promise<ChannelContact[]>
}
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<GroupManagerGroupInfo[]>([])
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
const [managersMap, setManagersMap] = useState<Record<string, GroupManagerData[]>>({})
const [loadingMap, setLoadingMap] = useState<Record<string, boolean>>({})
const [contactOptions, setContactOptions] = useState<{ value: string; label: string }[]>([])
// Per-group inline add: userId
const [inlineUserId, setInlineUserId] = useState<Record<string, string>>({})
// Standalone add form
const [newGroupId, setNewGroupId] = useState('')
const [newUserId, setNewUserId] = useState('')
const [addingMap, setAddingMap] = useState<Record<string, boolean>>({})
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 (
<div className="space-y-4">
{error && <p className="text-xs text-error">{error}</p>}
{groups.length === 0 ? (
<p className="text-sm text-text-muted py-6 text-center">{t('managers.empty')}</p>
) : (
<div className="space-y-2">
{groups.map((g) => (
<div key={g.group_id} className="border border-border rounded-lg overflow-hidden">
<button
onClick={() => handleToggle(g.group_id)}
className="w-full flex items-center justify-between px-4 py-3 bg-surface-secondary hover:bg-surface-tertiary transition-colors text-left cursor-pointer"
>
<span className="text-sm font-medium text-text-primary font-mono">{shortGroupId(g.group_id)}</span>
<div className="flex items-center gap-3">
<span className="text-xs text-text-muted">{g.writer_count} {t('managers.writers')}</span>
<svg className={`w-4 h-4 text-text-muted transition-transform ${expanded[g.group_id] ? 'rotate-180' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path d="m6 9 6 6 6-6" /></svg>
</div>
</button>
{expanded[g.group_id] && (
<div className="px-4 pb-3 pt-2 bg-surface-primary space-y-3">
{loadingMap[g.group_id] ? (
<p className="text-xs text-text-muted py-2">{t('common.loading')}</p>
) : (managersMap[g.group_id] ?? []).length === 0 ? (
<p className="text-xs text-text-muted py-2">{t('managers.noManagers')}</p>
) : (
<div className="divide-y divide-border">
{(managersMap[g.group_id] ?? []).map((m) => (
<div key={m.user_id} className="flex items-center justify-between py-2">
<div>
<span className="text-xs font-mono text-text-primary">{m.user_id}</span>
{m.display_name && <span className="text-xs text-text-muted ml-2">{m.display_name}</span>}
{m.username && <span className="text-xs text-text-muted ml-1">@{m.username}</span>}
</div>
<button onClick={() => handleRemove(g.group_id, m.user_id)} className="text-xs text-error hover:opacity-80 cursor-pointer transition-opacity">
{t('managers.remove')}
</button>
</div>
))}
</div>
)}
<div className="flex gap-2 pt-1">
<div className="flex-1">
<Combobox
value={inlineUserId[g.group_id] ?? ''}
onChange={(v) => { setInlineUserId((prev) => ({ ...prev, [g.group_id]: v })); handleContactSearch(v) }}
options={contactOptions}
placeholder={t('managers.userIdPlaceholder')}
/>
</div>
<button
onClick={() => handleInlineAdd(g.group_id)}
disabled={addingMap[g.group_id] || !inlineUserId[g.group_id]?.trim()}
className="px-3 py-1.5 bg-accent text-white text-xs rounded-lg disabled:opacity-50 cursor-pointer hover:opacity-90 transition-opacity"
>
{t('managers.add')}
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
{/* Standalone add form for new groups */}
<div className="border border-border rounded-lg p-4 space-y-3">
<p className="text-xs font-medium text-text-secondary">{t('managers.addToGroup')}</p>
<input
value={newGroupId}
onChange={(e) => setNewGroupId(e.target.value)}
placeholder={t('managers.groupIdPlaceholder')}
className={`w-full ${inputClass}`}
/>
<div className="flex gap-2">
<div className="flex-1">
<Combobox
value={newUserId}
onChange={(v) => { setNewUserId(v); handleContactSearch(v) }}
options={contactOptions}
placeholder={t('managers.userIdPlaceholder')}
/>
</div>
<button
onClick={handleStandaloneAdd}
disabled={addingMap['_new'] || !newGroupId.trim() || !newUserId.trim()}
className="px-3 py-1.5 bg-accent text-white text-xs rounded-lg disabled:opacity-50 cursor-pointer hover:opacity-90 transition-opacity"
>
{t('managers.add')}
</button>
</div>
</div>
</div>
)
}
@@ -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<PendingPairing | null>(null)
const [denyTarget, setDenyTarget] = useState<PendingPairing | null>(null)
const [revokeTarget, setRevokeTarget] = useState<PairedDevice | null>(null)
const isEmpty = pendingPairings.length === 0 && pairedDevices.length === 0
return (
<div className="space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold text-text-secondary">{t('pairing.title')}</h3>
<button onClick={refresh} disabled={loading} className="p-1 text-text-muted hover:text-text-primary transition-colors cursor-pointer">
<svg className={`h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
</button>
</div>
{isEmpty && !loading ? (
<div className="py-6 text-center">
<p className="text-xs text-text-muted">{t('pairing.empty')}</p>
<p className="text-[11px] text-text-muted/70 mt-1">{t('pairing.emptyDesc')}</p>
</div>
) : (
<div className="space-y-4">
{/* Pending */}
{pendingPairings.length > 0 && (
<div>
<p className="text-[11px] font-medium text-text-secondary mb-2">{t('pairing.pending', { count: pendingPairings.length })}</p>
<div className="space-y-2">
{pendingPairings.map((p) => (
<div key={p.code} className="flex items-center justify-between rounded-lg border border-border p-3">
<div>
<div className="flex items-center gap-2">
<span className="rounded-full px-1.5 py-0.5 text-[10px] bg-surface-tertiary text-text-secondary border border-border">{p.channel}</span>
<span className="font-mono text-xs font-medium text-text-primary">{p.code}</span>
</div>
<div className="mt-1 text-[11px] text-text-muted">
{t('pairing.sender')}{p.sender_id}
{p.chat_id && ` | ${t('pairing.chat')}${p.chat_id}`}
{' | '}{formatRelativeTime(p.created_at)}
</div>
</div>
<div className="flex gap-1.5 shrink-0">
<button onClick={() => setDenyTarget(p)} className="px-2 py-1 text-[11px] border border-border rounded-lg text-text-secondary hover:bg-surface-tertiary transition-colors cursor-pointer">
{t('pairing.deny')}
</button>
<button onClick={() => setApproveTarget(p)} className="px-2 py-1 text-[11px] bg-accent text-white rounded-lg font-medium hover:bg-accent-hover transition-colors cursor-pointer">
{t('pairing.approve')}
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Paired */}
{pairedDevices.length > 0 && (
<div>
<p className="text-[11px] font-medium text-text-secondary mb-2">{t('pairing.paired', { count: pairedDevices.length })}</p>
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-xs min-w-[500px]">
<thead>
<tr className="border-b border-border bg-surface-tertiary/40">
<th className="px-3 py-2 text-left text-[11px] font-medium text-text-muted">Channel</th>
<th className="px-3 py-2 text-left text-[11px] font-medium text-text-muted">Sender ID</th>
<th className="px-3 py-2 text-left text-[11px] font-medium text-text-muted">Paired</th>
<th className="px-3 py-2 text-left text-[11px] font-medium text-text-muted">By</th>
<th className="px-3 py-2 w-16" />
</tr>
</thead>
<tbody>
{pairedDevices.map((d) => (
<tr key={`${d.channel}-${d.sender_id}`} className="border-b border-border last:border-0 hover:bg-surface-tertiary/20">
<td className="px-3 py-2">
<span className="rounded-full px-1.5 py-0.5 text-[10px] bg-surface-tertiary text-text-secondary border border-border">{d.channel}</span>
</td>
<td className="px-3 py-2 font-mono text-text-primary">{d.sender_id}</td>
<td className="px-3 py-2 text-text-muted">{formatDate(d.paired_at)}</td>
<td className="px-3 py-2 text-text-muted">{d.paired_by}</td>
<td className="px-3 py-2 text-right">
<button onClick={() => setRevokeTarget(d)} className="text-[11px] text-text-muted hover:text-error transition-colors cursor-pointer">
{t('pairing.revoke')}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
{/* Confirm dialogs */}
{approveTarget && (
<ConfirmDialog
open
onOpenChange={() => 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 && (
<ConfirmDialog
open
onOpenChange={() => 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 && (
<ConfirmDialog
open
onOpenChange={() => 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) }}
/>
)}
</div>
)
}
@@ -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<string, unknown>
onChange: (key: string, value: unknown) => void
idPrefix: string
isEdit?: boolean
contextValues?: Record<string, unknown>
}
export function ChannelFields({ fields, values, onChange, idPrefix, isEdit, contextValues }: ChannelFieldsProps) {
const allValues = contextValues ? { ...contextValues, ...values } : values
return (
<div className="space-y-3">
{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 (
<FieldRenderer
key={field.key}
field={field}
value={values[field.key]}
onChange={(v) => onChange(field.key, v)}
id={`${idPrefix}-${field.key}`}
isEdit={isEdit}
disabled={disabled}
disabledHint={disabledHint}
/>
)
})}
</div>
)
}
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 (
<div className="space-y-1">
<label htmlFor={id} className="text-xs font-medium text-text-secondary">{label}{labelSuffix}{editHint}</label>
<input id={id} type={field.type} value={(value as string) ?? ''} onChange={(e) => onChange(e.target.value)} placeholder={field.placeholder} className={inputClass} />
{help && <p className="text-[11px] text-text-muted">{help}</p>}
</div>
)
case 'number':
return (
<div className="space-y-1">
<label htmlFor={id} className="text-xs font-medium text-text-secondary">{label}{labelSuffix}</label>
<input id={id} type="number" value={value !== undefined && value !== null ? String(value) : ''} onChange={(e) => onChange(e.target.value ? Number(e.target.value) : undefined)} placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : undefined} className={inputClass} />
{help && <p className="text-[11px] text-text-muted">{help}</p>}
</div>
)
case 'boolean':
return (
<div className={`flex items-center gap-2${disabled ? ' opacity-50' : ''}`}>
<Switch checked={(value as boolean) ?? (field.defaultValue as boolean) ?? false} onCheckedChange={(v) => onChange(v)} disabled={disabled} />
<label className="text-xs text-text-secondary">{label}</label>
{disabledHint && <span className="text-[11px] text-text-muted ml-1"> {disabledHint}</span>}
{!disabledHint && help && <span className="text-[11px] text-text-muted ml-1"> {help}</span>}
</div>
)
case 'select':
return (
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{label}{labelSuffix}</label>
<Combobox
value={(value as string) ?? (field.defaultValue as string) ?? ''}
onChange={(v) => onChange(v)}
options={field.options?.map((opt) => ({ value: opt.value, label: opt.label })) ?? []}
allowCustom={false}
/>
{help && <p className="text-[11px] text-text-muted">{help}</p>}
</div>
)
case 'tags':
return (
<div className="space-y-1">
<label htmlFor={id} className="text-xs font-medium text-text-secondary">{label}</label>
<textarea
id={id}
value={Array.isArray(value) ? (value as string[]).join('\n') : ''}
onChange={(e) => {
const lines = e.target.value.split(/[\n,]/).map((l) => l.trim()).filter(Boolean)
onChange(lines.length > 0 ? lines : undefined)
}}
placeholder={field.placeholder ?? 'One per line or comma-separated'}
rows={3}
className={`${inputClass} font-mono resize-y`}
/>
{help && <p className="text-[11px] text-text-muted">{help}</p>}
</div>
)
default:
return null
}
}
@@ -0,0 +1,103 @@
// Per-channel-type field definitions for credentials and config.
// Simplified from web UI — only telegram and discord supported in Lite edition.
export interface FieldDef {
key: string
label: string
type: 'text' | 'password' | 'number' | 'boolean' | 'select' | 'tags'
placeholder?: string
required?: boolean
defaultValue?: string | number | boolean | string[]
options?: { value: string; label: string }[]
help?: string
showWhen?: { key: string; value: string }
disabledWhen?: { key: string; value: string; hint?: string }
}
// --- Shared option lists ---
const dmPolicyOptions = [
{ value: 'pairing', label: 'Pairing (require code)' },
{ value: 'open', label: 'Open (accept all)' },
{ value: 'allowlist', label: 'Allowlist only' },
{ value: 'disabled', label: 'Disabled' },
]
const groupPolicyOptions = [
{ value: 'open', label: 'Open (accept all)' },
{ value: 'pairing', label: 'Pairing (require approval)' },
{ value: 'allowlist', label: 'Allowlist only' },
{ value: 'disabled', label: 'Disabled' },
]
const mentionModeOptions = [
{ value: 'strict', label: 'Default (follow @mention setting)' },
{ value: 'yield', label: 'Multi-bot (respond unless another bot is @mentioned)' },
]
const blockReplyOptions = [
{ value: 'inherit', label: 'Inherit from gateway' },
{ value: 'true', label: 'Enabled' },
{ value: 'false', label: 'Disabled' },
]
const reactionLevelOptions = [
{ value: 'off', label: 'Off' },
{ value: 'minimal', label: 'Minimal' },
{ value: 'full', label: 'Full' },
]
// --- Credentials schemas ---
export const credentialsSchema: Record<string, FieldDef[]> = {
telegram: [
{ key: 'token', label: 'Bot Token', type: 'password', required: true, placeholder: '123456:ABC-DEF...', help: 'From @BotFather' },
],
discord: [
{ key: 'token', label: 'Bot Token', type: 'password', required: true, placeholder: 'Discord bot token' },
],
}
// --- Config schemas ---
export const configSchema: Record<string, FieldDef[]> = {
telegram: [
{ key: 'api_server', label: 'API Server URL', type: 'text', placeholder: 'http://127.0.0.1:8081', help: 'Custom Bot API server for large file uploads. Leave empty for default.' },
{ key: 'proxy', label: 'HTTP Proxy', type: 'text', placeholder: 'http://proxy:8080', help: 'Route bot traffic through an HTTP proxy' },
{ key: 'dm_policy', label: 'DM Policy', type: 'select', options: dmPolicyOptions, defaultValue: 'pairing' },
{ key: 'group_policy', label: 'Group Policy', type: 'select', options: groupPolicyOptions, defaultValue: 'pairing' },
{ key: 'mention_mode', label: 'Group Response Behavior', type: 'select', options: mentionModeOptions, defaultValue: 'strict', help: 'How the bot decides when to respond in groups with multiple bots.' },
{ key: 'require_mention', label: 'Require @mention in groups', type: 'boolean', defaultValue: true, disabledWhen: { key: 'mention_mode', value: 'yield', hint: 'Disabled in multi-bot mode' } },
{ key: 'history_limit', label: 'Group History Limit', type: 'number', defaultValue: 50, help: 'Max pending group messages for context (0 = disabled)' },
{ key: 'dm_stream', label: 'DM Streaming', type: 'boolean', defaultValue: true, help: 'Stream response progressively in DMs' },
{ key: 'group_stream', label: 'Group Streaming', type: 'boolean', defaultValue: false, help: 'Stream response progressively in groups' },
{ key: 'draft_transport', label: 'Draft Preview', type: 'boolean', defaultValue: true, help: 'Stealth draft preview for streaming in DMs (requires DM Streaming)' },
{ key: 'reasoning_stream', label: 'Show Reasoning', type: 'boolean', defaultValue: true, help: 'Display AI thinking before the answer (requires streaming)' },
{ key: 'reaction_level', label: 'Reaction Level', type: 'select', options: reactionLevelOptions, defaultValue: 'full' },
{ key: 'media_max_mb', label: 'Max Media Size (MB)', type: 'number', defaultValue: 20, help: 'Default: 20 MB. Increase when using local Bot API server.' },
{ key: 'link_preview', label: 'Link Preview', type: 'boolean', defaultValue: true },
{ key: 'allow_from', label: 'Allowed Users', type: 'tags', help: 'User IDs or @usernames, one per line or comma-separated' },
{ key: 'block_reply', label: 'Block Reply', type: 'select', options: blockReplyOptions, defaultValue: 'inherit', help: 'Deliver intermediate text during tool iterations' },
],
discord: [
{ key: 'dm_policy', label: 'DM Policy', type: 'select', options: dmPolicyOptions, defaultValue: 'pairing' },
{ key: 'group_policy', label: 'Group Policy', type: 'select', options: groupPolicyOptions, defaultValue: 'pairing' },
{ key: 'require_mention', label: 'Require @mention in groups', type: 'boolean', defaultValue: true },
{ key: 'history_limit', label: 'Group History Limit', type: 'number', defaultValue: 50, help: 'Max pending group messages for context (0 = disabled)' },
{ key: 'allow_from', label: 'Allowed Users', type: 'tags', help: 'Discord user IDs' },
{ key: 'block_reply', label: 'Block Reply', type: 'select', options: blockReplyOptions, defaultValue: 'inherit', help: 'Deliver intermediate text during tool iterations' },
],
}
// Essential config keys shown in the General tab (policies section)
export const ESSENTIAL_CONFIG_KEYS: Record<string, string[]> = {
_default: ['dm_policy', 'group_policy', 'require_mention'],
telegram: ['dm_policy', 'group_policy', 'mention_mode', 'require_mention'],
}
// Advanced config grouping keys (for ChannelAdvancedDialog)
export const NETWORK_KEYS = new Set(['api_server', 'proxy'])
export const LIMITS_KEYS = new Set(['history_limit', 'media_max_mb'])
export const STREAMING_KEYS = new Set(['dm_stream', 'group_stream', 'draft_transport', 'reasoning_stream'])
export const BEHAVIOR_KEYS = new Set(['reaction_level', 'link_preview', 'block_reply'])
export const ACCESS_KEYS = new Set(['allow_from'])
@@ -2,7 +2,8 @@ import { useState, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Combobox } from '../../common/Combobox'
import { Switch } from '../../common/Switch'
import { PROVIDER_TYPES, slugify } from '../../../constants/providers'
import { PROVIDER_TYPES } from '../../../constants/providers'
import { slugify } from '../../../lib/slug'
import type { ProviderData, ProviderInput } from '../../../types/provider'
interface ProviderFormDialogProps {
@@ -1,7 +1,5 @@
import { useEffect, useState, useMemo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'
import { toast } from '../../../stores/toast-store'
import { fetchTraceDetail } from '../../../hooks/use-traces'
import { getApiClient, isApiClientReady } from '../../../lib/api'
@@ -13,37 +11,27 @@ interface Props {
onClose: () => void
}
function detectContent(text?: string): { lang: string; code: string } {
if (!text) return { lang: 'text', code: '' }
import { MarkdownRenderer } from '../../chat/MarkdownRenderer'
function TraceContentPreview({ text }: { text?: string }) {
if (!text) return null
const trimmed = text.trim()
// Auto-format JSON into a markdown code block
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
return { lang: 'json', code: JSON.stringify(JSON.parse(trimmed), null, 2) }
} catch { /* not valid JSON */ }
}
return { lang: 'text', code: text }
}
function CodePreview({ text }: { text?: string }) {
const { lang, code } = detectContent(text)
if (!code) return null
// Only use syntax highlighting for JSON; plain text gets a simple pre block
if (lang === 'json') {
return (
<SyntaxHighlighter
language="json"
style={oneDark}
customStyle={{ margin: 0, borderRadius: '0.5rem', fontSize: '0.7rem', maxHeight: '40vh', overflow: 'auto' }}
wrapLongLines
>
{code}
</SyntaxHighlighter>
)
const formatted = JSON.stringify(JSON.parse(trimmed), null, 2)
return (
<div className="max-h-[40vh] overflow-y-auto overflow-x-hidden">
<MarkdownRenderer content={'```json\n' + formatted + '\n```'} />
</div>
)
} catch { /* not valid JSON, fall through */ }
}
// Render as markdown (handles code blocks, headings, lists, etc.)
return (
<pre className="p-3 rounded-lg bg-surface-tertiary/50 border border-border text-xs text-text-primary overflow-auto max-h-[40vh] whitespace-pre-wrap break-words">
{code}
</pre>
<div className="max-h-[40vh] overflow-y-auto overflow-x-hidden">
<MarkdownRenderer content={text} />
</div>
)
}
@@ -182,13 +170,13 @@ function SpanRow({ node, expanded, onToggle }: { node: SpanNode; expanded: boole
{span.input_preview && (
<div>
<p className="text-[11px] font-medium text-text-secondary mb-1">{t('detail.input')}</p>
<CodePreview text={span.input_preview} />
<TraceContentPreview text={span.input_preview} />
</div>
)}
{span.output_preview && (
<div>
<p className="text-[11px] font-medium text-text-secondary mb-1">{t('detail.output')}</p>
<CodePreview text={span.output_preview} />
<TraceContentPreview text={span.output_preview} />
</div>
)}
</div>
@@ -363,7 +351,7 @@ export function TraceDetailDialog({ traceId, onClose }: Props) {
</svg>
{t('detail.input')}
</button>
{inputOpen && <div className="mt-1.5"><CodePreview text={trace.input_preview} /></div>}
{inputOpen && <div className="mt-1.5"><TraceContentPreview text={trace.input_preview} /></div>}
</div>
)}
{trace.output_preview && (
@@ -377,7 +365,7 @@ export function TraceDetailDialog({ traceId, onClose }: Props) {
</svg>
{t('detail.output')}
</button>
{outputOpen && <div className="mt-1.5"><CodePreview text={trace.output_preview} /></div>}
{outputOpen && <div className="mt-1.5"><TraceContentPreview text={trace.output_preview} /></div>}
</div>
)}
</div>
@@ -27,7 +27,3 @@ export const PROVIDER_TYPES: ProviderTypeInfo[] = [
{ value: 'claude_cli', label: 'Claude CLI (Local)', apiBase: '', needsKey: false },
{ value: 'acp', label: 'ACP Agent (Subprocess)', apiBase: '', needsKey: false },
]
export function slugify(text: string): string {
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
}
@@ -0,0 +1,62 @@
import { useState, useEffect, useCallback } from 'react'
import { getApiClient } from '../lib/api'
import { toast } from '../stores/toast-store'
import type { ChannelInstanceData, ChannelInstanceInput } from '../types/channel'
export function useChannelCrud() {
const [instances, setInstances] = useState<ChannelInstanceData[]>([])
const [loading, setLoading] = useState(true)
const fetchInstances = useCallback(async () => {
try {
const res = await getApiClient().get<{ instances: ChannelInstanceData[] | null }>('/v1/channels/instances')
setInstances(res.instances ?? [])
} catch (err) {
console.error('Failed to fetch channel instances:', err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchInstances() }, [fetchInstances])
const createInstance = useCallback(async (input: ChannelInstanceInput) => {
try {
const res = await getApiClient().post<{ id: string }>('/v1/channels/instances', input)
await fetchInstances()
toast.success('Channel created')
return res
} catch (err) {
toast.error('Failed to create channel', (err as Error).message)
throw err
}
}, [fetchInstances])
const updateInstance = useCallback(async (id: string, data: Record<string, unknown>) => {
try {
await getApiClient().put(`/v1/channels/instances/${id}`, data)
await fetchInstances()
toast.success('Channel updated')
} catch (err) {
toast.error('Failed to update channel', (err as Error).message)
throw err
}
}, [fetchInstances])
const deleteInstance = useCallback(async (id: string) => {
try {
await getApiClient().delete(`/v1/channels/instances/${id}`)
setInstances((prev) => prev.filter((i) => i.id !== id))
toast.success('Channel deleted')
} catch (err) {
toast.error('Failed to delete channel', (err as Error).message)
throw err
}
}, [])
const telegramExists = instances.some((i) => i.channel_type === 'telegram')
const discordExists = instances.some((i) => i.channel_type === 'discord')
const atLimit = instances.length >= 2
return { instances, loading, atLimit, telegramExists, discordExists, fetchInstances, createInstance, updateInstance, deleteInstance }
}
@@ -0,0 +1,75 @@
import { useState, useEffect, useCallback } from 'react'
import { getApiClient } from '../lib/api'
import { toast } from '../stores/toast-store'
import type { ChannelInstanceData, GroupManagerGroupInfo, GroupManagerData, ChannelContact } from '../types/channel'
export function useChannelDetail(instanceId: string | null) {
const [instance, setInstance] = useState<ChannelInstanceData | null>(null)
const [loading, setLoading] = useState(false)
const fetchInstance = useCallback(async () => {
if (!instanceId) return
setLoading(true)
try {
const res = await getApiClient().get<ChannelInstanceData>(`/v1/channels/instances/${instanceId}`)
setInstance(res)
} catch (err) {
console.error('Failed to fetch channel detail:', err)
} finally {
setLoading(false)
}
}, [instanceId])
useEffect(() => { fetchInstance() }, [fetchInstance])
const updateInstance = useCallback(async (updates: Record<string, unknown>) => {
if (!instanceId) return
try {
await getApiClient().put(`/v1/channels/instances/${instanceId}`, updates)
await fetchInstance()
toast.success('Channel updated')
} catch (err) {
toast.error('Failed to update channel', (err as Error).message)
throw err
}
}, [instanceId, fetchInstance])
const listManagerGroups = useCallback(async (): Promise<GroupManagerGroupInfo[]> => {
if (!instanceId) return []
const res = await getApiClient().get<{ groups: GroupManagerGroupInfo[] }>(`/v1/channels/instances/${instanceId}/writers/groups`)
return res.groups ?? []
}, [instanceId])
const listManagers = useCallback(async (groupId: string): Promise<GroupManagerData[]> => {
if (!instanceId) return []
const res = await getApiClient().get<{ writers: GroupManagerData[] }>(`/v1/channels/instances/${instanceId}/writers?group_id=${encodeURIComponent(groupId)}`)
return res.writers ?? []
}, [instanceId])
const addManager = useCallback(async (groupId: string, userId: string, displayName?: string, username?: string) => {
if (!instanceId) return
await getApiClient().post(`/v1/channels/instances/${instanceId}/writers`, {
group_id: groupId,
user_id: userId,
display_name: displayName ?? '',
username: username ?? '',
})
}, [instanceId])
const removeManager = useCallback(async (groupId: string, userId: string) => {
if (!instanceId) return
await getApiClient().delete(`/v1/channels/instances/${instanceId}/writers/${userId}?group_id=${encodeURIComponent(groupId)}`)
}, [instanceId])
const listContacts = useCallback(async (search: string): Promise<ChannelContact[]> => {
const qs = new URLSearchParams({ limit: '20' })
if (search) qs.set('search', search)
const res = await getApiClient().get<{ contacts: ChannelContact[] }>(`/v1/contacts?${qs}`)
return res.contacts ?? []
}, [])
return {
instance, loading, updateInstance, refresh: fetchInstance,
listManagerGroups, listManagers, addManager, removeManager, listContacts,
}
}
@@ -0,0 +1,24 @@
import { useState, useEffect, useCallback } from 'react'
import { getWsClient } from '../lib/ws'
import type { ChannelStatus } from '../types/channel'
export function useChannelStatus() {
const [statusMap, setStatusMap] = useState<Record<string, ChannelStatus>>({})
const [loading, setLoading] = useState(true)
const fetchStatus = useCallback(async () => {
try {
const ws = getWsClient()
const res = (await ws.call('channels.status')) as { channels: Record<string, ChannelStatus> }
setStatusMap(res.channels ?? {})
} catch {
// gateway may not be ready yet
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchStatus() }, [fetchStatus])
return { statusMap, loading, refreshStatus: fetchStatus }
}
@@ -0,0 +1,51 @@
import { useState, useEffect, useCallback } from 'react'
import { getWsClient } from '../lib/ws'
import type { PendingPairing, PairedDevice } from '../types/channel'
export function usePairedDevices() {
const [pendingPairings, setPendingPairings] = useState<PendingPairing[]>([])
const [pairedDevices, setPairedDevices] = useState<PairedDevice[]>([])
const [loading, setLoading] = useState(true)
const fetchDevices = useCallback(async () => {
try {
const ws = getWsClient()
const res = (await ws.call('device.pair.list')) as { pending: PendingPairing[]; paired: PairedDevice[] }
setPendingPairings(res.pending ?? [])
setPairedDevices(res.paired ?? [])
} catch {
// ignore — gateway may not be connected
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchDevices()
let unsub1: (() => void) | undefined
let unsub2: (() => void) | undefined
try {
const ws = getWsClient()
unsub1 = ws.on('device.pair.requested', () => { fetchDevices() })
unsub2 = ws.on('device.pair.resolved', () => { fetchDevices() })
} catch { /* ws not ready */ }
return () => { unsub1?.(); unsub2?.() }
}, [fetchDevices])
const approvePairing = useCallback(async (code: string) => {
await getWsClient().call('device.pair.approve', { code, approvedBy: 'desktop' })
fetchDevices()
}, [fetchDevices])
const denyPairing = useCallback(async (code: string) => {
await getWsClient().call('device.pair.deny', { code })
fetchDevices()
}, [fetchDevices])
const revokePairing = useCallback(async (senderId: string, channel: string) => {
await getWsClient().call('device.pair.revoke', { senderId, channel })
fetchDevices()
}, [fetchDevices])
return { pendingPairings, pairedDevices, loading, refresh: fetchDevices, approvePairing, denyPairing, revokePairing }
}
@@ -0,0 +1,30 @@
import { useState, useEffect, useCallback } from 'react'
import { getWsClient } from '../lib/ws'
export function usePendingPairingsCount() {
const [pendingCount, setPendingCount] = useState(0)
const fetchCount = useCallback(async () => {
try {
const ws = getWsClient()
const res = (await ws.call('device.pair.list')) as { pending: { code: string }[] }
setPendingCount(res.pending?.length ?? 0)
} catch {
// ignore
}
}, [])
useEffect(() => {
fetchCount()
let unsub1: (() => void) | undefined
let unsub2: (() => void) | undefined
try {
const ws = getWsClient()
unsub1 = ws.on('device.pair.requested', () => { fetchCount() })
unsub2 = ws.on('device.pair.resolved', () => { fetchCount() })
} catch { /* ws not ready */ }
return () => { unsub1?.(); unsub2?.() }
}, [fetchCount])
return { pendingCount }
}
+7 -4
View File
@@ -16,6 +16,7 @@ import enStorage from './locales/en/storage.json'
import enSessions from './locales/en/sessions.json'
import enDesktop from './locales/en/desktop.json'
import enTeams from './locales/en/teams.json'
import enChannels from './locales/en/channels.json'
// --- VI namespaces ---
import viCommon from './locales/vi/common.json'
@@ -32,6 +33,7 @@ import viStorage from './locales/vi/storage.json'
import viSessions from './locales/vi/sessions.json'
import viDesktop from './locales/vi/desktop.json'
import viTeams from './locales/vi/teams.json'
import viChannels from './locales/vi/channels.json'
// --- ZH namespaces ---
import zhCommon from './locales/zh/common.json'
@@ -48,6 +50,7 @@ import zhStorage from './locales/zh/storage.json'
import zhSessions from './locales/zh/sessions.json'
import zhDesktop from './locales/zh/desktop.json'
import zhTeams from './locales/zh/teams.json'
import zhChannels from './locales/zh/channels.json'
const STORAGE_KEY = 'goclaw:language'
@@ -66,22 +69,22 @@ i18n.use(initReactI18next).init({
common: enCommon, chat: enChat, agents: enAgents, providers: enProviders,
skills: enSkills, cron: enCron, mcp: enMcp, tools: enTools,
traces: enTraces, memory: enMemory, storage: enStorage, sessions: enSessions,
desktop: enDesktop, teams: enTeams,
desktop: enDesktop, teams: enTeams, channels: enChannels,
},
vi: {
common: viCommon, chat: viChat, agents: viAgents, providers: viProviders,
skills: viSkills, cron: viCron, mcp: viMcp, tools: viTools,
traces: viTraces, memory: viMemory, storage: viStorage, sessions: viSessions,
desktop: viDesktop, teams: viTeams,
desktop: viDesktop, teams: viTeams, channels: viChannels,
},
zh: {
common: zhCommon, chat: zhChat, agents: zhAgents, providers: zhProviders,
skills: zhSkills, cron: zhCron, mcp: zhMcp, tools: zhTools,
traces: zhTraces, memory: zhMemory, storage: zhStorage, sessions: zhSessions,
desktop: zhDesktop, teams: zhTeams,
desktop: zhDesktop, teams: zhTeams, channels: zhChannels,
},
},
ns: ['common', 'chat', 'agents', 'providers', 'skills', 'cron', 'mcp', 'tools', 'traces', 'memory', 'storage', 'sessions', 'desktop', 'teams'],
ns: ['common', 'chat', 'agents', 'providers', 'skills', 'cron', 'mcp', 'tools', 'traces', 'memory', 'storage', 'sessions', 'desktop', 'teams', 'channels'],
defaultNS: 'common',
lng: getInitialLanguage(),
fallbackLng: 'en',
@@ -0,0 +1,151 @@
{
"title": "Channels",
"description": "Connect messaging apps to your agents",
"addChannel": "Add Channel",
"atLimit": "Lite edition: max 1 Telegram + 1 Discord.",
"emptyTitle": "No channels",
"emptyDesc": "Connect Telegram or Discord to chat with your agents from messaging apps.",
"status": {
"running": "Running",
"stopped": "Stopped",
"disabled": "Disabled"
},
"channelTypes": {
"telegram": "Telegram",
"discord": "Discord"
},
"form": {
"createTitle": "Add Channel",
"displayName": "Display Name",
"displayNamePlaceholder": "My Telegram Bot",
"channelType": "Channel Type",
"selectType": "Select type",
"agent": "Agent",
"selectAgent": "Select agent",
"enabled": "Enabled",
"credentials": "Credentials",
"credentialsHint": "(leave blank to keep current)",
"configuration": "Configuration",
"cancel": "Cancel",
"save": "Save",
"create": "Create",
"saving": "Saving...",
"errors": {
"agentRequired": "Agent is required",
"typeRequired": "Channel type is required",
"tokenRequired": "Bot token is required"
}
},
"detail": {
"back": "Back",
"tabs": {
"general": "General",
"credentials": "Credentials",
"managers": "Managers"
},
"general": {
"identity": "Identity",
"name": "Name",
"nameHint": "Read-only slug identifier",
"channelType": "Channel Type",
"displayName": "Display Name",
"displayNamePlaceholder": "Enter display name",
"agent": "Agent",
"selectAgent": "Select agent",
"enabled": "Enabled",
"saveChanges": "Save Changes",
"saving": "Saving..."
},
"policies": "Policies",
"credentials": {
"hint": "Leave fields blank to keep current values. Credentials are encrypted at rest.",
"updateCredentials": "Update Credentials",
"saving": "Saving...",
"saved": "Credentials updated"
},
"managers": {
"description": "Assign users as managers for channel groups. Managers can moderate conversations.",
"groups": "Groups",
"noManagerGroups": "No manager groups yet",
"noManagerGroupsHint": "Groups appear here when users interact with the bot in group chats.",
"loadingManagers": "Loading managers...",
"noManagers": "No managers in this group",
"managersCount": "{{count}} manager",
"managersCountPlural": "{{count}} managers",
"columns": {
"userId": "User ID",
"name": "Name",
"username": "Username"
},
"addForm": {
"title": "Add Manager",
"hint": "Enter group and user details to add a manager",
"groupId": "Group ID",
"groupIdPlaceholder": "e.g. group:telegram:-100123456",
"userId": "User ID",
"userIdPlaceholder": "Search or enter user ID",
"addManager": "Add Manager",
"add": "Add",
"errors": {
"groupUserRequired": "Group ID and User ID are required",
"failedAdd": "Failed to add manager"
}
}
},
"advanced": "Advanced",
"advancedTitle": "Advanced Settings",
"network": "Network",
"networkDesc": "API server and proxy configuration",
"limits": "Limits",
"limitsDesc": "Message and media limits",
"streaming": "Streaming",
"streamingDesc": "Progressive response delivery",
"behavior": "Behavior",
"behaviorDesc": "Reactions, previews, and reply mode",
"accessControl": "Access Control",
"accessControlDesc": "User allowlist restrictions",
"saveConfig": "Save",
"noAdvanced": "No advanced settings available for this channel type."
},
"pairing": {
"title": "Paired Devices",
"pending": "Pending Requests ({{count}})",
"paired": "Paired Devices ({{count}})",
"empty": "No paired devices",
"emptyDesc": "Devices appear here when users message your bot and need approval.",
"sender": "Sender: ",
"chat": "Chat: ",
"code": "Code",
"approve": "Approve",
"deny": "Deny",
"revoke": "Revoke",
"confirmApprove": {
"title": "Approve Pairing",
"description": "Approve {{channel}}:{{senderId}} (code: {{code}})? This device will be able to interact with agents.",
"confirmLabel": "Approve"
},
"confirmDeny": {
"title": "Deny Pairing",
"description": "Deny {{channel}}:{{senderId}} (code: {{code}})? The request will be removed.",
"confirmLabel": "Deny"
},
"confirmRevoke": {
"title": "Revoke Device",
"description": "Revoke {{channel}}:{{senderId}}? The device will need to re-pair.",
"confirmLabel": "Revoke"
}
},
"toast": {
"created": "Channel created",
"updated": "Channel updated",
"deleted": "Channel deleted",
"failedCreate": "Failed to create channel",
"failedUpdate": "Failed to update channel",
"failedDelete": "Failed to delete channel"
},
"delete": {
"title": "Delete Channel",
"description": "Delete {{name}}? This cannot be undone.",
"confirmLabel": "Delete"
}
}
@@ -34,6 +34,7 @@
"appearance": "Appearance",
"providers": "Providers",
"agents": "Agents",
"channels": "Channels",
"mcp": "MCP",
"skills": "Skills",
"tools": "Tools",
@@ -0,0 +1,151 @@
{
"title": "Kênh",
"description": "Kết nối ứng dụng nhắn tin với agent",
"addChannel": "Thêm kênh",
"atLimit": "Phiên bản Lite: tối đa 1 Telegram + 1 Discord.",
"emptyTitle": "Chưa có kênh",
"emptyDesc": "Kết nối Telegram hoặc Discord để trò chuyện với agent từ ứng dụng nhắn tin.",
"status": {
"running": "Đang chạy",
"stopped": "Đã dừng",
"disabled": "Tắt"
},
"channelTypes": {
"telegram": "Telegram",
"discord": "Discord"
},
"form": {
"createTitle": "Thêm kênh",
"displayName": "Tên hiển thị",
"displayNamePlaceholder": "Bot Telegram của tôi",
"channelType": "Loại kênh",
"selectType": "Chọn loại",
"agent": "Agent",
"selectAgent": "Chọn agent",
"enabled": "Bật",
"credentials": "Thông tin xác thực",
"credentialsHint": "(để trống để giữ nguyên)",
"configuration": "Cấu hình",
"cancel": "Hủy",
"save": "Lưu",
"create": "Tạo",
"saving": "Đang lưu...",
"errors": {
"agentRequired": "Vui lòng chọn agent",
"typeRequired": "Vui lòng chọn loại kênh",
"tokenRequired": "Token bot là bắt buộc"
}
},
"detail": {
"back": "Quay lại",
"tabs": {
"general": "Chung",
"credentials": "Xác thực",
"managers": "Quản lý"
},
"general": {
"identity": "Thông tin",
"name": "Tên",
"nameHint": "Định danh slug (chỉ đọc)",
"channelType": "Loại kênh",
"displayName": "Tên hiển thị",
"displayNamePlaceholder": "Nhập tên hiển thị",
"agent": "Agent",
"selectAgent": "Chọn agent",
"enabled": "Bật",
"saveChanges": "Lưu thay đổi",
"saving": "Đang lưu..."
},
"policies": "Chính sách",
"credentials": {
"hint": "Để trống các trường để giữ giá trị hiện tại. Thông tin xác thực được mã hóa.",
"updateCredentials": "Cập nhật xác thực",
"saving": "Đang lưu...",
"saved": "Đã cập nhật xác thực"
},
"managers": {
"description": "Chỉ định người quản lý cho các nhóm kênh. Quản lý có thể điều phối cuộc trò chuyện.",
"groups": "Nhóm",
"noManagerGroups": "Chưa có nhóm quản lý",
"noManagerGroupsHint": "Nhóm sẽ xuất hiện khi người dùng tương tác với bot trong nhóm chat.",
"loadingManagers": "Đang tải...",
"noManagers": "Chưa có quản lý trong nhóm này",
"managersCount": "{{count}} quản lý",
"managersCountPlural": "{{count}} quản lý",
"columns": {
"userId": "User ID",
"name": "Tên",
"username": "Username"
},
"addForm": {
"title": "Thêm quản lý",
"hint": "Nhập thông tin nhóm và người dùng",
"groupId": "ID nhóm",
"groupIdPlaceholder": "VD: group:telegram:-100123456",
"userId": "User ID",
"userIdPlaceholder": "Tìm hoặc nhập user ID",
"addManager": "Thêm quản lý",
"add": "Thêm",
"errors": {
"groupUserRequired": "ID nhóm và User ID là bắt buộc",
"failedAdd": "Không thể thêm quản lý"
}
}
},
"advanced": "Nâng cao",
"advancedTitle": "Cài đặt nâng cao",
"network": "Mạng",
"networkDesc": "Cấu hình API server và proxy",
"limits": "Giới hạn",
"limitsDesc": "Giới hạn tin nhắn và media",
"streaming": "Streaming",
"streamingDesc": "Phản hồi từng phần",
"behavior": "Hành vi",
"behaviorDesc": "Phản ứng, xem trước và chế độ trả lời",
"accessControl": "Kiểm soát truy cập",
"accessControlDesc": "Giới hạn danh sách cho phép",
"saveConfig": "Lưu",
"noAdvanced": "Không có cài đặt nâng cao cho loại kênh này."
},
"pairing": {
"title": "Thiết bị đã ghép nối",
"pending": "Yêu cầu chờ ({{count}})",
"paired": "Thiết bị đã ghép ({{count}})",
"empty": "Chưa có thiết bị",
"emptyDesc": "Thiết bị sẽ xuất hiện khi người dùng nhắn tin cho bot và cần phê duyệt.",
"sender": "Người gửi: ",
"chat": "Chat: ",
"code": "Mã",
"approve": "Phê duyệt",
"deny": "Từ chối",
"revoke": "Thu hồi",
"confirmApprove": {
"title": "Phê duyệt ghép nối",
"description": "Phê duyệt {{channel}}:{{senderId}} (mã: {{code}})? Thiết bị này sẽ có thể tương tác với agent.",
"confirmLabel": "Phê duyệt"
},
"confirmDeny": {
"title": "Từ chối ghép nối",
"description": "Từ chối {{channel}}:{{senderId}} (mã: {{code}})? Yêu cầu sẽ bị xóa.",
"confirmLabel": "Từ chối"
},
"confirmRevoke": {
"title": "Thu hồi thiết bị",
"description": "Thu hồi {{channel}}:{{senderId}}? Thiết bị sẽ cần ghép nối lại.",
"confirmLabel": "Thu hồi"
}
},
"toast": {
"created": "Đã tạo kênh",
"updated": "Đã cập nhật kênh",
"deleted": "Đã xóa kênh",
"failedCreate": "Không thể tạo kênh",
"failedUpdate": "Không thể cập nhật kênh",
"failedDelete": "Không thể xóa kênh"
},
"delete": {
"title": "Xóa kênh",
"description": "Xóa {{name}}? Hành động này không thể hoàn tác.",
"confirmLabel": "Xóa"
}
}
@@ -34,6 +34,7 @@
"appearance": "Appearance",
"providers": "Providers",
"agents": "Agents",
"channels": "Kênh",
"mcp": "MCP",
"skills": "Skills",
"tools": "Tools",
@@ -0,0 +1,151 @@
{
"title": "频道",
"description": "将消息应用连接到您的代理",
"addChannel": "添加频道",
"atLimit": "Lite版:最多1个Telegram + 1个Discord。",
"emptyTitle": "暂无频道",
"emptyDesc": "连接Telegram或Discord,通过消息应用与代理聊天。",
"status": {
"running": "运行中",
"stopped": "已停止",
"disabled": "已禁用"
},
"channelTypes": {
"telegram": "Telegram",
"discord": "Discord"
},
"form": {
"createTitle": "添加频道",
"displayName": "显示名称",
"displayNamePlaceholder": "我的Telegram机器人",
"channelType": "频道类型",
"selectType": "选择类型",
"agent": "代理",
"selectAgent": "选择代理",
"enabled": "启用",
"credentials": "凭据",
"credentialsHint": "(留空保持不变)",
"configuration": "配置",
"cancel": "取消",
"save": "保存",
"create": "创建",
"saving": "保存中...",
"errors": {
"agentRequired": "请选择代理",
"typeRequired": "请选择频道类型",
"tokenRequired": "机器人令牌为必填项"
}
},
"detail": {
"back": "返回",
"tabs": {
"general": "常规",
"credentials": "凭据",
"managers": "管理员"
},
"general": {
"identity": "身份信息",
"name": "名称",
"nameHint": "只读标识符",
"channelType": "频道类型",
"displayName": "显示名称",
"displayNamePlaceholder": "输入显示名称",
"agent": "代理",
"selectAgent": "选择代理",
"enabled": "启用",
"saveChanges": "保存更改",
"saving": "保存中..."
},
"policies": "策略",
"credentials": {
"hint": "留空字段以保持当前值。凭据已加密存储。",
"updateCredentials": "更新凭据",
"saving": "保存中...",
"saved": "凭据已更新"
},
"managers": {
"description": "为频道群组分配管理员。管理员可以管理对话。",
"groups": "群组",
"noManagerGroups": "暂无管理组",
"noManagerGroupsHint": "当用户在群聊中与机器人互动时,群组将出现在这里。",
"loadingManagers": "加载中...",
"noManagers": "此群组暂无管理员",
"managersCount": "{{count}}位管理员",
"managersCountPlural": "{{count}}位管理员",
"columns": {
"userId": "用户ID",
"name": "名称",
"username": "用户名"
},
"addForm": {
"title": "添加管理员",
"hint": "输入群组和用户信息",
"groupId": "群组ID",
"groupIdPlaceholder": "如:group:telegram:-100123456",
"userId": "用户ID",
"userIdPlaceholder": "搜索或输入用户ID",
"addManager": "添加管理员",
"add": "添加",
"errors": {
"groupUserRequired": "群组ID和用户ID为必填项",
"failedAdd": "添加管理员失败"
}
}
},
"advanced": "高级",
"advancedTitle": "高级设置",
"network": "网络",
"networkDesc": "API服务器和代理配置",
"limits": "限制",
"limitsDesc": "消息和媒体限制",
"streaming": "流式传输",
"streamingDesc": "渐进式响应传输",
"behavior": "行为",
"behaviorDesc": "反应、预览和回复模式",
"accessControl": "访问控制",
"accessControlDesc": "用户白名单限制",
"saveConfig": "保存",
"noAdvanced": "此频道类型没有高级设置。"
},
"pairing": {
"title": "已配对设备",
"pending": "待处理请求({{count}}",
"paired": "已配对设备({{count}}",
"empty": "暂无配对设备",
"emptyDesc": "当用户向机器人发消息需要审批时,设备将出现在这里。",
"sender": "发送者:",
"chat": "聊天:",
"code": "配对码",
"approve": "批准",
"deny": "拒绝",
"revoke": "撤销",
"confirmApprove": {
"title": "批准配对",
"description": "批准 {{channel}}:{{senderId}}(配对码:{{code}})?该设备将能与代理交互。",
"confirmLabel": "批准"
},
"confirmDeny": {
"title": "拒绝配对",
"description": "拒绝 {{channel}}:{{senderId}}(配对码:{{code}})?请求将被删除。",
"confirmLabel": "拒绝"
},
"confirmRevoke": {
"title": "撤销设备",
"description": "撤销 {{channel}}:{{senderId}}?该设备需要重新配对。",
"confirmLabel": "撤销"
}
},
"toast": {
"created": "频道已创建",
"updated": "频道已更新",
"deleted": "频道已删除",
"failedCreate": "创建频道失败",
"failedUpdate": "更新频道失败",
"failedDelete": "删除频道失败"
},
"delete": {
"title": "删除频道",
"description": "删除 {{name}}?此操作无法撤销。",
"confirmLabel": "删除"
}
}
@@ -34,6 +34,7 @@
"appearance": "Appearance",
"providers": "Providers",
"agents": "Agents",
"channels": "频道",
"mcp": "MCP",
"skills": "Skills",
"tools": "Tools",
+11 -3
View File
@@ -21,10 +21,13 @@ class ApiClient {
}
private headers(extra?: Record<string, string>): Record<string, string> {
// Send locale for i18n error messages from backend
const lang = typeof localStorage !== 'undefined' ? localStorage.getItem('goclaw:language') : null
return {
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json',
'X-GoClaw-User-Id': 'system',
...(lang ? { 'Accept-Language': lang } : {}),
...extra,
}
}
@@ -41,9 +44,14 @@ class ApiClient {
let code: string | undefined
let message = res.statusText
try {
const json = (await res.json()) as { error?: { code?: string; message?: string } }
code = json.error?.code
message = json.error?.message ?? message
const json = await res.json()
// Backend sends either { error: "string" } or { error: { code, message } }
if (typeof json.error === 'string') {
message = json.error
} else if (json.error && typeof json.error === 'object') {
code = json.error.code
message = json.error.message ?? message
}
} catch {
// non-JSON error body
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Convert any string to a valid slug: lowercase, [a-z0-9-], no leading/trailing dashes.
* Handles Vietnamese and other diacritical characters by stripping accents first.
*/
export function slugify(input: string): string {
return input
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/đ/g, 'd')
.replace(/Đ/g, 'd')
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^-+/g, '')
.replace(/-+$/g, '')
}
/**
* Validate slug format: lowercase alphanumeric + hyphens, cannot start/end with hyphen.
*/
export function isValidSlug(slug: string): boolean {
return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(slug)
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export type AppView = 'chat' | 'settings' | 'team-board'
export type SettingsTab = 'appearance' | 'providers' | 'agents' | 'mcp' | 'skills' | 'tools' | 'cron' | 'traces' | 'storage' | 'about'
export type SettingsTab = 'appearance' | 'providers' | 'agents' | 'channels' | 'mcp' | 'skills' | 'tools' | 'cron' | 'traces' | 'storage' | 'about'
interface UiState {
theme: 'dark' | 'light'
+73
View File
@@ -0,0 +1,73 @@
// Channel instance data returned by GET /v1/channels/instances
export interface ChannelInstanceData {
id: string
name: string
display_name: string
channel_type: string // "telegram" | "discord"
agent_id: string
credentials: Record<string, string>
config: Record<string, unknown>
enabled: boolean
created_by: string
created_at: string
updated_at: string
}
// Input for creating/updating a channel instance
export interface ChannelInstanceInput {
name: string
displayName: string
channelType: string
agentId: string
credentials: Record<string, string>
config: Record<string, unknown>
enabled: boolean
}
// Live channel status from WS channels.status
export interface ChannelStatus {
enabled: boolean
running: boolean
}
// Pending pairing request from WS device.pair.list
export interface PendingPairing {
code: string
sender_id: string
channel: string
chat_id: string
account_id: string
created_at: number
expires_at: number
}
// Approved paired device from WS device.pair.list
export interface PairedDevice {
sender_id: string
channel: string
chat_id: string
paired_at: number
paired_by: string
}
// Manager group info from GET /v1/channels/instances/{id}/writers/groups
export interface GroupManagerGroupInfo {
group_id: string
writer_count: number
}
// Manager data from GET /v1/channels/instances/{id}/writers
export interface GroupManagerData {
user_id: string
display_name?: string
username?: string
}
// Contact from GET /v1/contacts
export interface ChannelContact {
id: string
sender_id: string
display_name?: string
username?: string
channel_type: string
}