diff --git a/internal/bootstrap/seed_store.go b/internal/bootstrap/seed_store.go
index bbc3d002..f7302384 100644
--- a/internal/bootstrap/seed_store.go
+++ b/internal/bootstrap/seed_store.go
@@ -4,12 +4,32 @@ import (
"context"
"log/slog"
"path/filepath"
+ "strings"
+ "time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
+// retryOnBusy retries fn up to 3 times on SQLITE_BUSY errors with 500ms delay.
+func retryOnBusy(fn func() error) error {
+ for attempt := 0; attempt < 3; attempt++ {
+ err := fn()
+ if err == nil {
+ return nil
+ }
+ if !strings.Contains(err.Error(), "SQLITE_BUSY") && !strings.Contains(err.Error(), "database is locked") {
+ return err
+ }
+ if attempt < 2 {
+ slog.Warn("bootstrap: retrying after SQLITE_BUSY", "attempt", attempt+1)
+ time.Sleep(500 * time.Millisecond)
+ }
+ }
+ return nil // unreachable, but satisfies compiler
+}
+
// SeedToStore seeds embedded templates into agent_context_files (agent-level).
// Used for predefined agents only — open agents get per-user files via SeedUserFiles.
// Only writes files that don't already have content.
@@ -55,7 +75,7 @@ func SeedToStore(ctx context.Context, agentStore store.AgentStore, agentID uuid.
continue
}
- if err := agentStore.SetAgentContextFile(ctx, agentID, name, string(content)); err != nil {
+ if err := retryOnBusy(func() error { return agentStore.SetAgentContextFile(ctx, agentID, name, string(content)) }); err != nil {
return seeded, err
}
seeded = append(seeded, name)
@@ -66,7 +86,7 @@ func SeedToStore(ctx context.Context, agentStore store.AgentStore, agentID uuid.
if !hasContent[UserPredefinedFile] {
content, err := templateFS.ReadFile(filepath.Join("templates", UserPredefinedFile))
if err == nil {
- if err := agentStore.SetAgentContextFile(ctx, agentID, UserPredefinedFile, string(content)); err != nil {
+ if err := retryOnBusy(func() error { return agentStore.SetAgentContextFile(ctx, agentID, UserPredefinedFile, string(content)) }); err != nil {
return seeded, err
}
seeded = append(seeded, UserPredefinedFile)
@@ -167,7 +187,7 @@ func SeedUserFiles(ctx context.Context, agentStore store.AgentStore, agentID uui
// This propagates wizard/dashboard-configured owner profile to the first user.
if agentType == store.AgentTypePredefined && name == UserFile {
if agentContent, ok := agentLevelFiles[name]; ok {
- if err := agentStore.SetUserContextFile(ctx, agentID, userID, name, agentContent); err != nil {
+ if err := retryOnBusy(func() error { return agentStore.SetUserContextFile(ctx, agentID, userID, name, agentContent) }); err != nil {
return seeded, err
}
seeded = append(seeded, name)
@@ -188,7 +208,7 @@ func SeedUserFiles(ctx context.Context, agentStore store.AgentStore, agentID uui
continue
}
- if err := agentStore.SetUserContextFile(ctx, agentID, userID, name, string(content)); err != nil {
+ if err := retryOnBusy(func() error { return agentStore.SetUserContextFile(ctx, agentID, userID, name, string(content)) }); err != nil {
return seeded, err
}
seeded = append(seeded, name)
diff --git a/internal/http/agents.go b/internal/http/agents.go
index 3d2d97fb..a5e07812 100644
--- a/internal/http/agents.go
+++ b/internal/http/agents.go
@@ -190,7 +190,8 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "23505") {
writeJSON(w, http.StatusConflict, map[string]string{"error": i18n.T(locale, i18n.MsgAlreadyExists, "agent", req.AgentKey)})
} else {
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ slog.Error("agents.create", "agent_key", req.AgentKey, "error", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToCreate, "agent", "internal error")})
}
return
}
diff --git a/internal/http/channel_instances.go b/internal/http/channel_instances.go
index ef050498..17fe0f35 100644
--- a/internal/http/channel_instances.go
+++ b/internal/http/channel_instances.go
@@ -168,7 +168,7 @@ func (h *ChannelInstancesHandler) handleCreate(w http.ResponseWriter, r *http.Re
if err := h.store.Create(r.Context(), inst); err != nil {
slog.Error("channel_instances.create", "error", err)
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToCreate, "channel instance", "internal error")})
return
}
@@ -213,7 +213,7 @@ func (h *ChannelInstancesHandler) handleUpdate(w http.ResponseWriter, r *http.Re
if err := h.store.Update(r.Context(), id, updates); err != nil {
slog.Error("channel_instances.update", "error", err)
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToUpdate, "channel instance", "internal error")})
return
}
@@ -243,7 +243,7 @@ func (h *ChannelInstancesHandler) handleDelete(w http.ResponseWriter, r *http.Re
if err := h.store.Delete(r.Context(), id); err != nil {
slog.Error("channel_instances.delete", "error", err)
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToDelete, "channel instance", "internal error")})
return
}
diff --git a/internal/store/sqlitestore/pool.go b/internal/store/sqlitestore/pool.go
index 84af8280..3b7316f5 100644
--- a/internal/store/sqlitestore/pool.go
+++ b/internal/store/sqlitestore/pool.go
@@ -26,8 +26,9 @@ func OpenDB(path string) (*sql.DB, error) {
}
// SQLite is single-writer; WAL allows concurrent readers.
- // Keep pool small to reduce lock contention.
- db.SetMaxOpenConns(2)
+ // 4 connections: up to 3 readers + 1 writer can proceed in parallel,
+ // reducing connection pool starvation during concurrent operations.
+ db.SetMaxOpenConns(4)
// Set PRAGMAs explicitly — DSN params may not be applied by modernc.org/sqlite.
pragmas := []string{
diff --git a/ui/desktop/frontend/src/components/common/EditionCompareModal.tsx b/ui/desktop/frontend/src/components/common/EditionCompareModal.tsx
index 18159d40..89139d82 100644
--- a/ui/desktop/frontend/src/components/common/EditionCompareModal.tsx
+++ b/ui/desktop/frontend/src/components/common/EditionCompareModal.tsx
@@ -26,6 +26,7 @@ const FEATURES: FeatureGroup[] = [
{ key: 'teams', lite: 'Max 1', standard: true },
{ key: 'teamMembers', lite: 'Max 5', standard: true },
{ key: 'sessions', lite: 'Max 50', standard: true },
+ { key: 'channels', lite: '1 Telegram + 1 Discord', standard: true },
],
},
{
@@ -44,7 +45,6 @@ const FEATURES: FeatureGroup[] = [
group: 'standardOnly',
rows: [
{ key: 'taskActions', lite: 'Core lifecycle', standard: 'Full + review/approve' },
- { key: 'channels', lite: false, standard: true },
{ key: 'heartbeat', lite: false, standard: true },
{ key: 'storage', lite: false, standard: true },
{ key: 'skillManage', lite: false, standard: true },
diff --git a/ui/desktop/frontend/src/components/layout/sidebar/SidebarFooter.tsx b/ui/desktop/frontend/src/components/layout/sidebar/SidebarFooter.tsx
index ab50768c..60c76dc4 100644
--- a/ui/desktop/frontend/src/components/layout/sidebar/SidebarFooter.tsx
+++ b/ui/desktop/frontend/src/components/layout/sidebar/SidebarFooter.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { useSessions } from '../../../hooks/use-sessions'
import { useUiStore } from '../../../stores/ui-store'
import { getWsClient } from '../../../lib/ws'
+import { usePendingPairingsCount } from '../../../hooks/use-pending-pairings-count'
export function SidebarFooter() {
const { t } = useTranslation('desktop')
@@ -12,6 +13,8 @@ export function SidebarFooter() {
const toggleTheme = useUiStore((s) => s.toggleTheme)
const theme = useUiStore((s) => s.theme)
+ const { pendingCount } = usePendingPairingsCount()
+
const [connected, setConnected] = useState(() => {
try { return getWsClient().isConnected } catch { return false }
})
@@ -54,6 +57,23 @@ export function SidebarFooter() {
)}
+ {/* Pairing notification */}
+ {pendingCount > 0 && (
+
+ )}
+
{/* Settings */}
- {outputOpen &&
}
+ {outputOpen &&
}
)}
diff --git a/ui/desktop/frontend/src/constants/providers.ts b/ui/desktop/frontend/src/constants/providers.ts
index 61ff1732..1928938a 100644
--- a/ui/desktop/frontend/src/constants/providers.ts
+++ b/ui/desktop/frontend/src/constants/providers.ts
@@ -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, '')
-}
diff --git a/ui/desktop/frontend/src/hooks/use-channel-crud.ts b/ui/desktop/frontend/src/hooks/use-channel-crud.ts
new file mode 100644
index 00000000..a2c6f393
--- /dev/null
+++ b/ui/desktop/frontend/src/hooks/use-channel-crud.ts
@@ -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([])
+ 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) => {
+ 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 }
+}
diff --git a/ui/desktop/frontend/src/hooks/use-channel-detail.ts b/ui/desktop/frontend/src/hooks/use-channel-detail.ts
new file mode 100644
index 00000000..958d1388
--- /dev/null
+++ b/ui/desktop/frontend/src/hooks/use-channel-detail.ts
@@ -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(null)
+ const [loading, setLoading] = useState(false)
+
+ const fetchInstance = useCallback(async () => {
+ if (!instanceId) return
+ setLoading(true)
+ try {
+ const res = await getApiClient().get(`/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) => {
+ 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 => {
+ 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 => {
+ 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 => {
+ 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,
+ }
+}
diff --git a/ui/desktop/frontend/src/hooks/use-channel-status.ts b/ui/desktop/frontend/src/hooks/use-channel-status.ts
new file mode 100644
index 00000000..25481175
--- /dev/null
+++ b/ui/desktop/frontend/src/hooks/use-channel-status.ts
@@ -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>({})
+ const [loading, setLoading] = useState(true)
+
+ const fetchStatus = useCallback(async () => {
+ try {
+ const ws = getWsClient()
+ const res = (await ws.call('channels.status')) as { channels: Record }
+ setStatusMap(res.channels ?? {})
+ } catch {
+ // gateway may not be ready yet
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => { fetchStatus() }, [fetchStatus])
+
+ return { statusMap, loading, refreshStatus: fetchStatus }
+}
diff --git a/ui/desktop/frontend/src/hooks/use-paired-devices.ts b/ui/desktop/frontend/src/hooks/use-paired-devices.ts
new file mode 100644
index 00000000..3edd2d9e
--- /dev/null
+++ b/ui/desktop/frontend/src/hooks/use-paired-devices.ts
@@ -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([])
+ const [pairedDevices, setPairedDevices] = useState([])
+ 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 }
+}
diff --git a/ui/desktop/frontend/src/hooks/use-pending-pairings-count.ts b/ui/desktop/frontend/src/hooks/use-pending-pairings-count.ts
new file mode 100644
index 00000000..e12bc345
--- /dev/null
+++ b/ui/desktop/frontend/src/hooks/use-pending-pairings-count.ts
@@ -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 }
+}
diff --git a/ui/desktop/frontend/src/i18n/index.ts b/ui/desktop/frontend/src/i18n/index.ts
index 3aa5e152..20939c6e 100644
--- a/ui/desktop/frontend/src/i18n/index.ts
+++ b/ui/desktop/frontend/src/i18n/index.ts
@@ -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',
diff --git a/ui/desktop/frontend/src/i18n/locales/en/channels.json b/ui/desktop/frontend/src/i18n/locales/en/channels.json
new file mode 100644
index 00000000..daa80c9d
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/en/channels.json
@@ -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"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/en/desktop.json b/ui/desktop/frontend/src/i18n/locales/en/desktop.json
index 0402de46..256b447e 100644
--- a/ui/desktop/frontend/src/i18n/locales/en/desktop.json
+++ b/ui/desktop/frontend/src/i18n/locales/en/desktop.json
@@ -34,6 +34,7 @@
"appearance": "Appearance",
"providers": "Providers",
"agents": "Agents",
+ "channels": "Channels",
"mcp": "MCP",
"skills": "Skills",
"tools": "Tools",
diff --git a/ui/desktop/frontend/src/i18n/locales/vi/channels.json b/ui/desktop/frontend/src/i18n/locales/vi/channels.json
new file mode 100644
index 00000000..e44b73b5
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/vi/channels.json
@@ -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"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/vi/desktop.json b/ui/desktop/frontend/src/i18n/locales/vi/desktop.json
index 328eb9a0..26e2793a 100644
--- a/ui/desktop/frontend/src/i18n/locales/vi/desktop.json
+++ b/ui/desktop/frontend/src/i18n/locales/vi/desktop.json
@@ -34,6 +34,7 @@
"appearance": "Appearance",
"providers": "Providers",
"agents": "Agents",
+ "channels": "Kênh",
"mcp": "MCP",
"skills": "Skills",
"tools": "Tools",
diff --git a/ui/desktop/frontend/src/i18n/locales/zh/channels.json b/ui/desktop/frontend/src/i18n/locales/zh/channels.json
new file mode 100644
index 00000000..4d447d02
--- /dev/null
+++ b/ui/desktop/frontend/src/i18n/locales/zh/channels.json
@@ -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": "删除"
+ }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/zh/desktop.json b/ui/desktop/frontend/src/i18n/locales/zh/desktop.json
index 6b161cdc..d6e24ea7 100644
--- a/ui/desktop/frontend/src/i18n/locales/zh/desktop.json
+++ b/ui/desktop/frontend/src/i18n/locales/zh/desktop.json
@@ -34,6 +34,7 @@
"appearance": "Appearance",
"providers": "Providers",
"agents": "Agents",
+ "channels": "频道",
"mcp": "MCP",
"skills": "Skills",
"tools": "Tools",
diff --git a/ui/desktop/frontend/src/lib/api.ts b/ui/desktop/frontend/src/lib/api.ts
index e27ae4b8..5e59683d 100644
--- a/ui/desktop/frontend/src/lib/api.ts
+++ b/ui/desktop/frontend/src/lib/api.ts
@@ -21,10 +21,13 @@ class ApiClient {
}
private headers(extra?: Record): Record {
+ // 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
}
diff --git a/ui/desktop/frontend/src/lib/slug.ts b/ui/desktop/frontend/src/lib/slug.ts
new file mode 100644
index 00000000..b1016cc9
--- /dev/null
+++ b/ui/desktop/frontend/src/lib/slug.ts
@@ -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)
+}
diff --git a/ui/desktop/frontend/src/stores/ui-store.ts b/ui/desktop/frontend/src/stores/ui-store.ts
index 3e94395d..3a4da2fa 100644
--- a/ui/desktop/frontend/src/stores/ui-store.ts
+++ b/ui/desktop/frontend/src/stores/ui-store.ts
@@ -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'
diff --git a/ui/desktop/frontend/src/types/channel.ts b/ui/desktop/frontend/src/types/channel.ts
new file mode 100644
index 00000000..309c6e4d
--- /dev/null
+++ b/ui/desktop/frontend/src/types/channel.ts
@@ -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
+ config: Record
+ 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
+ config: Record
+ 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
+}