mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-08-05 08:22:53 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user