refine whatsapp bridge defaults and webui polish

This commit is contained in:
lpf
2026-03-10 12:07:56 +08:00
parent 8a6a2755de
commit c7b159d2ed
29 changed files with 1389 additions and 432 deletions

View File

@@ -1,4 +1,4 @@
FROM golang:1.25.5-bookworm AS builder
FROM golang:1.25.7-bookworm AS builder
WORKDIR /src

View File

@@ -1,4 +1,4 @@
.PHONY: all build build-linux-slim build-all build-webui package-all install install-win uninstall clean help test install-bootstrap-docs sync-embed-workspace sync-embed-workspace-base sync-embed-webui cleanup-embed-workspace test-only clean-test-artifacts dev
.PHONY: all build build-linux-slim build-all build-webui package-all install install-win uninstall clean help test test-docker install-bootstrap-docs sync-embed-workspace sync-embed-workspace-base sync-embed-webui cleanup-embed-workspace test-only clean-test-artifacts dev
# Build variables
BINARY_NAME=clawgo
@@ -324,12 +324,15 @@ clean:
@rm -rf $(BUILD_DIR)
@echo "Clean complete"
## test-only: Run tests without leaving build artifacts (cleans embed workspace and test cache)
test-only: sync-embed-workspace
## test: Run Go tests without leaving build artifacts (cleans embed workspace and test cache)
test: sync-embed-workspace
@echo "Running tests..."
@set -e; trap '$(MAKE) cleanup-embed-workspace clean-test-artifacts' EXIT; \
$(GO) test ./...
## test-only: Backward-compatible alias for Go tests
test-only: test
## clean-test-artifacts: Remove test caches/artifacts generated by go test
clean-test-artifacts:
@$(GO) clean -testcache
@@ -364,8 +367,8 @@ dev: sync-embed-workspace
echo " Args: $(DEV_ARGS)"; \
CLAWGO_CONFIG="$(DEV_CONFIG)" $(GO) run $(GOFLAGS) ./$(CMD_DIR) $(DEV_ARGS)
## test: Build and compile-check in Docker (Dockerfile.test)
test: sync-embed-workspace
## test-docker: Build and compile-check in Docker (Dockerfile.test)
test-docker: sync-embed-workspace
@echo "Running Docker compile test..."
@set -e; trap '$(MAKE) cleanup-embed-workspace' EXIT; \
docker build -f Dockerfile.test -t clawgo:test .

View File

@@ -447,14 +447,14 @@ func gatewayCmd() {
return
}
newWhatsAppBridge, _ := setupEmbeddedWhatsAppBridge(ctx, newCfg)
channelManager.StopAll(ctx)
agentLoop.Stop()
if whatsAppBridge != nil {
whatsAppBridge.Stop()
}
newWhatsAppBridge, _ := setupEmbeddedWhatsAppBridge(ctx, newCfg)
channelManager = newChannelManager
agentLoop = newAgentLoop
cfg = newCfg

View File

@@ -1218,12 +1218,12 @@ func (s *Server) handleWebUIWhatsAppLogout(w http.ResponseWriter, r *http.Reques
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
waCfg, err := s.loadWhatsAppConfig()
cfg, err := s.loadConfig()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
logoutURL, err := channels.BridgeLogoutURL(strings.TrimSpace(waCfg.BridgeURL))
logoutURL, err := channels.BridgeLogoutURL(s.resolveWhatsAppBridgeURL(cfg))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@@ -1272,14 +1272,15 @@ func (s *Server) handleWebUIWhatsAppQR(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) webUIWhatsAppStatusPayload(ctx context.Context) (map[string]interface{}, int) {
waCfg, err := s.loadWhatsAppConfig()
cfg, err := s.loadConfig()
if err != nil {
return map[string]interface{}{
"ok": false,
"error": err.Error(),
}, http.StatusInternalServerError
}
bridgeURL := strings.TrimSpace(waCfg.BridgeURL)
waCfg := cfg.Channels.WhatsApp
bridgeURL := s.resolveWhatsAppBridgeURL(cfg)
statusURL, err := channels.BridgeStatusURL(bridgeURL)
if err != nil {
return map[string]interface{}{
@@ -1344,15 +1345,77 @@ func (s *Server) webUIWhatsAppStatusPayload(ctx context.Context) (map[string]int
}
func (s *Server) loadWhatsAppConfig() (cfgpkg.WhatsAppConfig, error) {
cfg, err := s.loadConfig()
if err != nil {
return cfgpkg.WhatsAppConfig{}, err
}
return cfg.Channels.WhatsApp, nil
}
func (s *Server) loadConfig() (*cfgpkg.Config, error) {
configPath := strings.TrimSpace(s.configPath)
if configPath == "" {
configPath = filepath.Join(cfgpkg.GetConfigDir(), "config.json")
}
cfg, err := cfgpkg.LoadConfig(configPath)
if err != nil {
return cfgpkg.WhatsAppConfig{}, err
return nil, err
}
return cfg.Channels.WhatsApp, nil
return cfg, nil
}
func (s *Server) resolveWhatsAppBridgeURL(cfg *cfgpkg.Config) string {
if cfg == nil {
return ""
}
raw := strings.TrimSpace(cfg.Channels.WhatsApp.BridgeURL)
if raw == "" {
return embeddedWhatsAppBridgeURL(cfg.Gateway.Host, cfg.Gateway.Port)
}
hostPort := comparableBridgeHostPort(raw)
if hostPort == "" {
return raw
}
if hostPort == "127.0.0.1:3001" || hostPort == "localhost:3001" {
return embeddedWhatsAppBridgeURL(cfg.Gateway.Host, cfg.Gateway.Port)
}
if hostPort == comparableGatewayHostPort(cfg.Gateway.Host, cfg.Gateway.Port) {
return embeddedWhatsAppBridgeURL(cfg.Gateway.Host, cfg.Gateway.Port)
}
return raw
}
func embeddedWhatsAppBridgeURL(host string, port int) string {
host = strings.TrimSpace(host)
switch host {
case "", "0.0.0.0", "::", "[::]":
host = "127.0.0.1"
}
return fmt.Sprintf("ws://%s:%d/whatsapp/ws", host, port)
}
func comparableBridgeHostPort(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if !strings.Contains(raw, "://") {
return strings.ToLower(raw)
}
u, err := url.Parse(raw)
if err != nil {
return ""
}
return strings.ToLower(strings.TrimSpace(u.Host))
}
func comparableGatewayHostPort(host string, port int) string {
host = strings.TrimSpace(strings.ToLower(host))
switch host {
case "", "0.0.0.0", "::", "[::]":
host = "127.0.0.1"
}
return fmt.Sprintf("%s:%d", host, port)
}
func renderQRCodeSVG(code *qr.Code, scale, quietZone int) string {

View File

@@ -7,10 +7,13 @@ import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
@@ -167,6 +170,76 @@ func TestHandleWebUIWhatsAppStatusWithNestedBridgePath(t *testing.T) {
}
}
func TestHandleWebUIWhatsAppStatusMapsLegacyBridgeURLToEmbeddedPath(t *testing.T) {
t.Parallel()
bridge := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/whatsapp/status":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"state": "connected",
"connected": true,
"logged_in": true,
"bridge_addr": "127.0.0.1:7788",
"user_jid": "8613012345678@s.whatsapp.net",
"qr_available": false,
"last_event": "connected",
"updated_at": "2026-03-09T12:00:00+08:00",
})
default:
http.NotFound(w, r)
}
}))
defer bridge.Close()
u, err := url.Parse(bridge.URL)
if err != nil {
t.Fatalf("parse bridge url: %v", err)
}
host, portRaw, err := net.SplitHostPort(u.Host)
if err != nil {
t.Fatalf("split host port: %v", err)
}
port, err := strconv.Atoi(portRaw)
if err != nil {
t.Fatalf("atoi port: %v", err)
}
tmp := t.TempDir()
cfgPath := filepath.Join(tmp, "config.json")
cfg := cfgpkg.DefaultConfig()
cfg.Logging.Enabled = false
cfg.Gateway.Host = host
cfg.Gateway.Port = port
cfg.Channels.WhatsApp.Enabled = true
cfg.Channels.WhatsApp.BridgeURL = "ws://localhost:3001"
if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil {
t.Fatalf("save config: %v", err)
}
srv := NewServer("127.0.0.1", 0, "", nil)
srv.SetConfigPath(cfgPath)
req := httptest.NewRequest(http.MethodGet, "/webui/api/whatsapp/status", nil)
rec := httptest.NewRecorder()
srv.handleWebUIWhatsAppStatus(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"bridge_running":true`) {
t.Fatalf("expected bridge_running=true, got: %s", rec.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
bridgeURL, _ := payload["bridge_url"].(string)
if !strings.HasSuffix(bridgeURL, "/whatsapp/ws") {
t.Fatalf("expected embedded whatsapp bridge url, got: %s", rec.Body.String())
}
}
func TestHandleWebUIConfigRequiresConfirmForProviderAPIBaseChange(t *testing.T) {
t.Parallel()

View File

@@ -450,7 +450,7 @@ func (s *WhatsAppBridgeService) ServeWS(w http.ResponseWriter, r *http.Request)
func (s *WhatsAppBridgeService) wrapHandler(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if s.localOnly && !isLoopbackRequest(r) {
if s.localOnly && !isLocalRequest(r) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
@@ -954,14 +954,43 @@ func joinBridgeRoute(basePath, endpoint string) string {
return basePath + "/" + strings.TrimPrefix(endpoint, "/")
}
func isLoopbackRequest(r *http.Request) bool {
func isLocalRequest(r *http.Request) bool {
if r == nil {
return false
}
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
addrs, err := net.InterfaceAddrs()
if err != nil {
host = strings.TrimSpace(r.RemoteAddr)
return false
}
return isLocalRemoteAddr(strings.TrimSpace(r.RemoteAddr), addrs)
}
func isLocalRemoteAddr(remoteAddr string, localAddrs []net.Addr) bool {
host, _, err := net.SplitHostPort(strings.TrimSpace(remoteAddr))
if err != nil {
host = strings.TrimSpace(remoteAddr)
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
if ip == nil {
return false
}
if ip.IsLoopback() {
return true
}
for _, addr := range localAddrs {
if addr == nil {
continue
}
switch v := addr.(type) {
case *net.IPNet:
if v.IP != nil && v.IP.Equal(ip) {
return true
}
case *net.IPAddr:
if v.IP != nil && v.IP.Equal(ip) {
return true
}
}
}
return false
}

View File

@@ -3,6 +3,7 @@ package channels
import (
"context"
"encoding/json"
"net"
"testing"
"time"
@@ -63,6 +64,32 @@ func TestBridgeStatusURLWithNestedPath(t *testing.T) {
}
}
func TestIsLocalRemoteAddr(t *testing.T) {
ipv4Net := &net.IPNet{IP: net.ParseIP("192.168.1.10"), Mask: net.CIDRMask(24, 32)}
ipv6Net := &net.IPNet{IP: net.ParseIP("fe80::1"), Mask: net.CIDRMask(64, 128)}
tests := []struct {
name string
remoteAddr string
want bool
}{
{name: "loopback", remoteAddr: "127.0.0.1:4321", want: true},
{name: "local interface ipv4", remoteAddr: "192.168.1.10:4321", want: true},
{name: "local interface ipv6", remoteAddr: "[fe80::1]:4321", want: true},
{name: "non local ip", remoteAddr: "192.168.1.11:4321", want: false},
{name: "invalid host", remoteAddr: "not-an-ip", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isLocalRemoteAddr(tt.remoteAddr, []net.Addr{ipv4Net, ipv6Net})
if got != tt.want {
t.Fatalf("got %v want %v", got, tt.want)
}
})
}
}
func TestNormalizeWhatsAppRecipientJID(t *testing.T) {
tests := []struct {
input string

View File

@@ -528,7 +528,7 @@ func DefaultConfig() *Config {
OutboundDedupeWindowSeconds: 12,
WhatsApp: WhatsAppConfig{
Enabled: false,
BridgeURL: "ws://localhost:3001",
BridgeURL: "",
AllowFrom: []string{},
EnableGroups: true,
RequireMentionInGroups: true,

View File

@@ -223,9 +223,6 @@ func Validate(cfg *Config) []error {
if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token == "" {
errs = append(errs, fmt.Errorf("channels.discord.token is required when channels.discord.enabled=true"))
}
if cfg.Channels.WhatsApp.Enabled && cfg.Channels.WhatsApp.BridgeURL == "" {
errs = append(errs, fmt.Errorf("channels.whatsapp.bridge_url is required when channels.whatsapp.enabled=true"))
}
if cfg.Channels.DingTalk.Enabled {
if cfg.Channels.DingTalk.ClientID == "" {
errs = append(errs, fmt.Errorf("channels.dingtalk.client_id is required when channels.dingtalk.enabled=true"))

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Terminal, Globe, Github, Menu, Moon, RefreshCw, SunMedium } from 'lucide-react';
import { Github, Moon, RefreshCw, SunMedium, Terminal } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
import { useUI } from '../context/UIContext';
@@ -12,7 +12,7 @@ function normalizeVersion(value: string) {
const Header: React.FC = () => {
const { t, i18n } = useTranslation();
const { isGatewayOnline, setSidebarOpen, sidebarCollapsed, gatewayVersion, webuiVersion } = useAppContext();
const { isGatewayOnline, sidebarCollapsed, gatewayVersion, webuiVersion } = useAppContext();
const { theme, toggleTheme, notify } = useUI();
const [checkingVersion, setCheckingVersion] = React.useState(false);
@@ -56,28 +56,23 @@ const Header: React.FC = () => {
};
return (
<header className="app-header h-14 md:h-16 border-b border-zinc-800 flex items-center justify-between px-3 md:px-6 shrink-0 z-10">
<header className="app-header ui-border-subtle h-14 md:h-16 border-b flex items-center justify-between px-3 md:px-6 shrink-0 z-10">
<div className="flex items-center gap-2 md:gap-3 min-w-0">
<button className="md:hidden p-2 rounded-lg hover:bg-zinc-800 text-zinc-300" onClick={() => setSidebarOpen(true)}>
<Menu className="w-5 h-5" />
</button>
<div className="hidden md:flex items-center gap-3 rounded-xl px-2 py-1.5 min-w-0">
<div className="brand-badge w-9 h-9 rounded-xl flex items-center justify-center shadow-lg shrink-0">
<Terminal className="w-5 h-5 text-zinc-950" />
</div>
{!sidebarCollapsed && (
<span className="font-semibold text-lg md:text-xl tracking-tight truncate">{t('appName')}</span>
)}
<div className="brand-badge hidden md:flex h-9 w-9 items-center justify-center rounded-xl shadow-lg shrink-0">
<Terminal className="h-5 w-5 text-white" />
</div>
<div className="brand-badge md:hidden w-8 h-8 rounded-xl flex items-center justify-center shadow-lg shrink-0">
<Terminal className="w-4 h-4 text-zinc-950" />
<div className="brand-badge flex h-8 w-8 items-center justify-center rounded-xl shadow-lg shrink-0 md:hidden">
<Terminal className="h-4 w-4 text-white" />
</div>
{!sidebarCollapsed && (
<span className="hidden md:inline font-semibold text-lg md:text-xl tracking-tight truncate">{t('appName')}</span>
)}
<span className="md:hidden font-semibold text-lg tracking-tight truncate">{t('appName')}</span>
</div>
<div className="flex items-center gap-2 md:gap-6">
<div className="flex items-center gap-1.5 md:gap-2.5 bg-zinc-900 border border-zinc-800 px-2 md:px-3 py-1 rounded-lg max-w-[140px] md:max-w-none overflow-hidden">
<span className="hidden md:inline text-sm font-medium text-zinc-400">{t('gatewayStatus')}:</span>
<div className="ui-toolbar-chip flex items-center gap-1.5 md:gap-2.5 px-2 md:px-3 py-1 rounded-lg max-w-[140px] md:max-w-none overflow-hidden">
<span className="ui-text-muted hidden md:inline text-sm font-medium">{t('gatewayStatus')}:</span>
{isGatewayOnline ? (
<div className="status-pill-online flex items-center gap-1.5 px-2.5 py-0.5 rounded-md text-xs font-semibold border">
<div className="status-dot-online w-1.5 h-1.5 rounded-full" />
@@ -91,13 +86,13 @@ const Header: React.FC = () => {
)}
</div>
<div className="hidden md:block h-5 w-px bg-zinc-800" />
<div className="ui-border-subtle hidden md:block h-5 w-px bg-transparent border-l" />
<a
href={REPO_URL}
target="_blank"
rel="noreferrer"
className="inline-flex h-9 w-9 items-center justify-center text-sm font-medium text-zinc-400 hover:text-zinc-200 transition-colors bg-zinc-900 hover:bg-zinc-800 border border-zinc-800 rounded-lg"
className="ui-button ui-button-neutral ui-button-icon text-sm font-medium"
title={t('githubRepo')}
>
<Github className="w-4 h-4" />
@@ -106,7 +101,7 @@ const Header: React.FC = () => {
<button
onClick={checkVersion}
disabled={checkingVersion}
className="inline-flex h-9 w-9 items-center justify-center text-sm font-medium text-zinc-400 hover:text-zinc-200 transition-colors bg-zinc-900 hover:bg-zinc-800 border border-zinc-800 rounded-lg disabled:opacity-60"
className="ui-button ui-button-neutral ui-button-icon text-sm font-medium disabled:opacity-60"
title={t('checkVersion')}
>
<RefreshCw className={`w-4 h-4 ${checkingVersion ? 'animate-spin' : ''}`} />
@@ -114,18 +109,19 @@ const Header: React.FC = () => {
<button
onClick={toggleTheme}
className="inline-flex h-9 w-9 items-center justify-center text-sm font-medium text-zinc-400 hover:text-zinc-200 transition-colors bg-zinc-900 hover:bg-zinc-800 border border-zinc-800 rounded-lg"
className="ui-button ui-button-neutral ui-button-icon text-sm font-medium"
title={theme === 'dark' ? t('themeLight') : t('themeDark')}
>
{theme === 'dark' ? <SunMedium className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button>
<button
<button
onClick={toggleLang}
className="flex items-center gap-2 text-sm font-medium text-zinc-400 hover:text-zinc-200 transition-colors bg-zinc-900 hover:bg-zinc-800 border border-zinc-800 px-3 py-1.5 rounded-lg"
className="ui-button ui-button-neutral ui-button-square text-sm font-semibold"
title={i18n.language === 'en' ? t('languageZh') : t('languageEn')}
aria-label={i18n.language === 'en' ? t('languageZh') : t('languageEn')}
>
<Globe className="w-4 h-4" />
{i18n.language === 'en' ? t('languageZh') : t('languageEn')}
{i18n.language === 'en' ? '中' : 'EN'}
</button>
</div>
</header>

View File

@@ -1,4 +1,5 @@
import React, { useMemo, useState } from 'react';
import { Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface RecursiveConfigProps {
@@ -55,11 +56,11 @@ const PrimitiveArrayEditor: React.FC<{
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{value.length === 0 && <span className="text-xs text-zinc-500 italic">{t('empty')}</span>}
{value.length === 0 && <span className="ui-text-muted text-xs italic">{t('empty')}</span>}
{value.map((item, idx) => (
<span key={`${item}-${idx}`} className="inline-flex items-center gap-1 px-2 py-1 rounded-xl ui-soft-panel text-xs font-mono text-zinc-700 dark:text-zinc-200">
<span key={`${item}-${idx}`} className="ui-text-secondary inline-flex items-center gap-1 px-2 py-1 rounded-xl ui-soft-panel text-xs font-mono">
{String(item)}
<button onClick={() => removeAt(idx)} className="ui-text-danger-hover text-zinc-400">×</button>
<button onClick={() => removeAt(idx)} className="ui-text-danger-hover ui-text-subtle">×</button>
</span>
))}
</div>
@@ -83,9 +84,11 @@ const PrimitiveArrayEditor: React.FC<{
addValue(draft);
setDraft('');
}}
className="ui-button ui-button-neutral px-3 py-2 text-xs rounded-xl"
className="ui-button ui-button-neutral ui-button-icon rounded-xl"
title={t('add')}
aria-label={t('add')}
>
{t('add')}
<Plus className="w-4 h-4" />
</button>
<select
@@ -126,8 +129,8 @@ const RecursiveConfig: React.FC<RecursiveConfigProps> = ({ data, labels, path =
return (
<div key={currentPath} className="space-y-2 col-span-full">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-zinc-300 block capitalize">{label}</span>
<span className="text-[10px] text-zinc-600 font-mono">{currentPath}</span>
<span className="ui-text-secondary text-sm font-medium block capitalize">{label}</span>
<span className="ui-text-subtle text-[10px] font-mono">{currentPath}</span>
</div>
<div className="ui-soft-panel p-3">
{allPrimitive ? (
@@ -158,11 +161,11 @@ const RecursiveConfig: React.FC<RecursiveConfigProps> = ({ data, labels, path =
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return (
<div key={currentPath} className="space-y-6 col-span-full">
<h3 className="text-sm font-semibold text-zinc-400 uppercase tracking-wider flex items-center gap-2">
<span className="w-1.5 h-4 bg-indigo-500 rounded-full" />
<h3 className="ui-text-subtle text-sm font-semibold uppercase tracking-wider flex items-center gap-2">
<span className="ui-icon-info w-1.5 h-4 rounded-full" />
{label}
</h3>
<div className="pl-6 border-l border-zinc-800/50 dark:border-zinc-700/50">
<div className="ui-border-subtle pl-6 border-l">
<RecursiveConfig data={value} labels={labels} path={currentPath} onChange={onChange} hotPaths={hotPaths} onlyHot={onlyHot} />
</div>
</div>
@@ -172,8 +175,8 @@ const RecursiveConfig: React.FC<RecursiveConfigProps> = ({ data, labels, path =
return (
<div key={currentPath} className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-zinc-300 block capitalize">{label}</span>
<span className="text-[10px] text-zinc-600 font-mono">{currentPath}</span>
<span className="ui-text-secondary text-sm font-medium block capitalize">{label}</span>
<span className="ui-text-subtle text-[10px] font-mono">{currentPath}</span>
</div>
{typeof value === 'boolean' ? (
<label className="ui-toggle-card flex items-center gap-3 p-3 cursor-pointer transition-colors group">
@@ -181,9 +184,9 @@ const RecursiveConfig: React.FC<RecursiveConfigProps> = ({ data, labels, path =
type="checkbox"
checked={value}
onChange={(e) => onChange(currentPath, e.target.checked)}
className="w-4 h-4 rounded border-zinc-700 text-indigo-500 focus:ring-indigo-500 focus:ring-offset-zinc-950 bg-zinc-900"
className="w-4 h-4 rounded border-zinc-700 text-indigo-500 focus:ring-indigo-500"
/>
<span className="text-sm text-zinc-400 group-hover:text-zinc-300 transition-colors">
<span className="ui-text-subtle group-hover:ui-text-secondary text-sm transition-colors">
{value ? (labels['enabled_true'] || t('enabled_true')) : (labels['enabled_false'] || t('enabled_false'))}
</span>
</label>

View File

@@ -17,27 +17,73 @@ const resources = {
qqChannelHint: 'Configure QQ bot credentials for the QQ channel.',
dingtalkChannelHint: 'Configure DingTalk app credentials for the DingTalk channel.',
maixcamChannelHint: 'Configure host, port, and allowed senders for the MaixCam channel.',
channelSectionConnection: 'Connection',
channelSectionConnectionHint: 'Enable the channel and configure the credentials or bridge endpoint required to connect.',
channelSectionAccess: 'Access Control',
channelSectionAccessHint: 'Restrict which senders or conversations are allowed to reach this channel.',
channelSectionGroupPolicy: 'Group Policy',
channelSectionGroupPolicyHint: 'Control whether group chats are accepted and when mentions are required.',
channelSectionNetwork: 'Network',
channelSectionNetworkHint: 'Configure the host and port this channel should use to communicate.',
channelFieldTelegramEnabledHint: 'Turn the built-in Telegram bot integration on or off.',
channelFieldTelegramTokenHint: 'Telegram bot token from BotFather, used to receive and send messages.',
channelFieldTelegramStreamingHint: 'Stream partial replies back to Telegram instead of waiting for the full response.',
channelFieldTelegramAllowFromHint: 'Only these Telegram user IDs can message the bot when set.',
channelFieldTelegramAllowChatsHint: 'Optional chat allowlist, for example private chats or specific groups.',
channelFieldDiscordEnabledHint: 'Turn the Discord bot channel on or off.',
channelFieldDiscordTokenHint: 'Discord bot token used to connect to the gateway bot account.',
channelFieldDiscordAllowFromHint: 'Only these Discord user IDs can invoke the bot when set.',
channelFieldFeishuEnabledHint: 'Turn the Feishu channel on or off.',
channelFieldFeishuAppIDHint: 'Feishu app ID for the bot or custom app integration.',
channelFieldFeishuAppSecretHint: 'Secret paired with the Feishu app ID.',
channelFieldFeishuEncryptKeyHint: 'Optional event encryption key provided by Feishu.',
channelFieldFeishuVerificationTokenHint: 'Verification token used to validate callback requests.',
channelFieldFeishuAllowFromHint: 'Only these Feishu senders can invoke the channel when set.',
channelFieldFeishuAllowChatsHint: 'Optional allowlist of Feishu chat IDs.',
channelFieldQQEnabledHint: 'Turn the QQ channel on or off.',
channelFieldQQAppIDHint: 'QQ bot application ID.',
channelFieldQQAppSecretHint: 'QQ bot application secret.',
channelFieldQQAllowFromHint: 'Only these QQ senders can invoke the channel when set.',
channelFieldDingTalkEnabledHint: 'Turn the DingTalk channel on or off.',
channelFieldDingTalkClientIDHint: 'DingTalk application client ID.',
channelFieldDingTalkClientSecretHint: 'Secret paired with the DingTalk client ID.',
channelFieldDingTalkAllowFromHint: 'Only these DingTalk senders can invoke the channel when set.',
channelFieldMaixCamEnabledHint: 'Turn the MaixCam channel on or off.',
channelFieldMaixCamHostHint: 'Hostname or IP address of the MaixCam device.',
channelFieldMaixCamPortHint: 'Port used by the MaixCam service.',
channelFieldMaixCamAllowFromHint: 'Only these device or sender IDs can invoke the channel when set.',
channelFieldEnableGroupsHint: 'Allow messages coming from group chats.',
channelFieldRequireMentionHint: 'When enabled, group messages must mention the bot before they are accepted.',
whatsappBridgeRunning: 'Bridge Running',
whatsappBridgeStopped: 'Bridge Stopped',
whatsappBridgeURL: 'Bridge URL',
whatsappBridgeAccount: 'Linked Account',
whatsappBridgeLastEvent: 'Last Event',
whatsappInbound: 'Inbound',
whatsappOutbound: 'Outbound',
whatsappReadReceipts: 'Read Receipts',
whatsappLastRead: 'Last Read',
whatsappLastInbound: 'Last Inbound',
whatsappLastOutbound: 'Last Outbound',
whatsappBridgeQRCode: 'QR Login',
whatsappQRCodeReady: 'Scan with WhatsApp',
whatsappQRCodeUnavailable: 'QR not available',
whatsappQRCodeHint: 'If you are already linked, no QR is shown.\nIf the bridge is not running, start `clawgo channel whatsapp bridge run` first.',
whatsappQRCodeHint: 'If you are already linked, no QR is shown.\nIf the QR code does not appear, confirm the gateway is running and the WhatsApp channel is enabled.',
whatsappStateAwaitingScan: 'Awaiting Scan',
whatsappStateDisconnected: 'Disconnected',
whatsappBridgeDevHintTitle: 'How to use',
whatsappBridgeDevHint: '1. Start the bridge with `clawgo channel whatsapp bridge run`.\n2. Scan the QR code here with WhatsApp.\n3. Keep `make dev` running to receive WhatsApp messages in ClawGo.',
whatsappBridgeDevHint: '1. Start the gateway and enable the WhatsApp channel.\n2. Scan the QR code here with WhatsApp.\n3. Keep ClawGo running to receive WhatsApp messages.',
whatsappFieldEnabledHint: 'Master switch for receiving WhatsApp messages through the bridge.',
whatsappFieldBridgeURLHint: 'WebSocket address of the local WhatsApp companion bridge service.',
whatsappFieldBridgeURLHint: 'Optional. Leave empty to use the gateway embedded WhatsApp bridge at /whatsapp/ws.',
whatsappFieldAllowFromHint: 'One sender JID per line. Only these senders can trigger ClawGo.',
whatsappFieldEnableGroupsHint: 'Allow messages from WhatsApp groups to enter the channel.',
whatsappFieldRequireMentionHint: 'When enabled, group messages must mention the bot before being handled.',
whatsappFieldAllowFromFootnote: 'Supports one JID per line, and also accepts comma-separated values.',
whatsappLogoutTitle: 'Logout WhatsApp Session',
whatsappLogoutMessage: 'Unlink the current WhatsApp companion session and request a new QR code?',
unknownAgent: 'Unknown Agent',
mainAgent: 'Main Agent',
ekgWindowLabel: '{{window}} window',
mcpServices: 'MCP',
mcpServicesHint: 'Manage MCP servers and install service packages.',
cronJobs: 'Cron Jobs',
@@ -494,7 +540,7 @@ const resources = {
proxy: 'Proxy',
timeout_sec: 'Timeout (Seconds)',
shell: 'Shell',
enabled: 'Enabled',
enabled: 'Enable',
logging: 'Logging',
level: 'Log Level',
format: 'Log Format',
@@ -672,27 +718,73 @@ const resources = {
qqChannelHint: '配置 QQ 通道的机器人凭证。',
dingtalkChannelHint: '配置钉钉通道的应用凭证。',
maixcamChannelHint: '配置 MaixCam 通道的 host、port 和允许发送者。',
channelSectionConnection: '接入配置',
channelSectionConnectionHint: '启用通道,并配置连接所需的凭证或 bridge 地址。',
channelSectionAccess: '访问控制',
channelSectionAccessHint: '限制允许访问该通道的发送者或会话范围。',
channelSectionGroupPolicy: '群组策略',
channelSectionGroupPolicyHint: '控制是否接收群组消息,以及何时必须 @ 提及。',
channelSectionNetwork: '网络配置',
channelSectionNetworkHint: '配置该通道使用的 host 和 port。',
channelFieldTelegramEnabledHint: '开启或关闭内置 Telegram 机器人通道。',
channelFieldTelegramTokenHint: '来自 BotFather 的 Telegram bot token用于收发消息。',
channelFieldTelegramStreamingHint: '将回复分段流式返回到 Telegram而不是等待整条结果完成。',
channelFieldTelegramAllowFromHint: '设置后,仅允许这些 Telegram 用户 ID 调用该机器人。',
channelFieldTelegramAllowChatsHint: '可选的会话白名单,例如私聊或指定群组。',
channelFieldDiscordEnabledHint: '开启或关闭 Discord 机器人通道。',
channelFieldDiscordTokenHint: '用于连接 Discord 机器人的 bot token。',
channelFieldDiscordAllowFromHint: '设置后,仅允许这些 Discord 用户 ID 调用该机器人。',
channelFieldFeishuEnabledHint: '开启或关闭飞书通道。',
channelFieldFeishuAppIDHint: '飞书机器人或应用集成使用的 App ID。',
channelFieldFeishuAppSecretHint: '与飞书 App ID 配套的密钥。',
channelFieldFeishuEncryptKeyHint: '飞书事件订阅可选的加密 Key。',
channelFieldFeishuVerificationTokenHint: '用于校验飞书回调请求的 verification token。',
channelFieldFeishuAllowFromHint: '设置后,仅允许这些飞书发送者调用该通道。',
channelFieldFeishuAllowChatsHint: '可选的飞书会话 ID 白名单。',
channelFieldQQEnabledHint: '开启或关闭 QQ 通道。',
channelFieldQQAppIDHint: 'QQ 机器人应用 ID。',
channelFieldQQAppSecretHint: 'QQ 机器人应用密钥。',
channelFieldQQAllowFromHint: '设置后,仅允许这些 QQ 发送者调用该通道。',
channelFieldDingTalkEnabledHint: '开启或关闭钉钉通道。',
channelFieldDingTalkClientIDHint: '钉钉应用的 Client ID。',
channelFieldDingTalkClientSecretHint: '与钉钉 Client ID 配套的密钥。',
channelFieldDingTalkAllowFromHint: '设置后,仅允许这些钉钉发送者调用该通道。',
channelFieldMaixCamEnabledHint: '开启或关闭 MaixCam 通道。',
channelFieldMaixCamHostHint: 'MaixCam 设备的主机名或 IP 地址。',
channelFieldMaixCamPortHint: 'MaixCam 服务使用的端口。',
channelFieldMaixCamAllowFromHint: '设置后,仅允许这些设备或发送者 ID 调用该通道。',
channelFieldEnableGroupsHint: '允许接收来自群聊的消息。',
channelFieldRequireMentionHint: '开启后,群聊消息必须先 @ 机器人才会被接收。',
whatsappBridgeRunning: 'Bridge 运行中',
whatsappBridgeStopped: 'Bridge 未运行',
whatsappBridgeURL: 'Bridge 地址',
whatsappBridgeAccount: '关联账号',
whatsappBridgeLastEvent: '最近事件',
whatsappInbound: '入站',
whatsappOutbound: '出站',
whatsappReadReceipts: '已读回执',
whatsappLastRead: '最近已读',
whatsappLastInbound: '最近入站',
whatsappLastOutbound: '最近出站',
whatsappBridgeQRCode: '二维码登录',
whatsappQRCodeReady: '请用 WhatsApp 扫码',
whatsappQRCodeUnavailable: '当前没有二维码',
whatsappQRCodeHint: '如果已经关联成功,就不会显示二维码。\n如果 bridge 还没启动,请先执行 `clawgo channel whatsapp bridge run`。',
whatsappQRCodeHint: '如果已经关联成功,就不会显示二维码。\n如果二维码没有出现,请确认 gateway 已启动且 WhatsApp 通道已启用。',
whatsappStateAwaitingScan: '等待扫码',
whatsappStateDisconnected: '已断开',
whatsappBridgeDevHintTitle: '使用方式',
whatsappBridgeDevHint: '1. 先执行 `clawgo channel whatsapp bridge run` 启动 bridge。\n2. 在这里用 WhatsApp 扫描二维码。\n3. 保持 `make dev` 运行,让 ClawGo 接收 WhatsApp 消息。',
whatsappBridgeDevHint: '1. 先启动 gateway并启用 WhatsApp 通道。\n2. 在这里用 WhatsApp 扫描二维码。\n3. 保持 ClawGo 运行以接收 WhatsApp 消息。',
whatsappFieldEnabledHint: '总开关,控制是否通过 bridge 接收 WhatsApp 消息。',
whatsappFieldBridgeURLHint: '本地 WhatsApp companion bridge 服务的 WebSocket 地址。',
whatsappFieldBridgeURLHint: '可选。留空时自动使用当前 Gateway 内嵌的 /whatsapp/ws 地址。',
whatsappFieldAllowFromHint: '每行一个发送者 JID只有这些发送者可以触发 ClawGo。',
whatsappFieldEnableGroupsHint: '允许来自 WhatsApp 群组的消息进入该通道。',
whatsappFieldRequireMentionHint: '开启后,群消息必须显式 @ 提及机器人才会被处理。',
whatsappFieldAllowFromFootnote: '支持每行一个 JID也支持逗号分隔后自动拆分。',
whatsappLogoutTitle: '退出 WhatsApp 会话',
whatsappLogoutMessage: '是否解除当前 WhatsApp companion 会话,并重新申请新的二维码?',
unknownAgent: '未知代理',
mainAgent: '主代理',
ekgWindowLabel: '{{window}} 窗口',
mcpServices: 'MCP',
mcpServicesHint: '管理 MCP 服务并安装服务包。',
cronJobs: '定时任务',
@@ -1149,7 +1241,7 @@ const resources = {
proxy: '代理',
timeout_sec: '超时时间 (秒)',
shell: 'Shell',
enabled: '启用',
enabled: '启用',
logging: '日志',
level: '日志级别',
format: '日志格式',

View File

@@ -71,6 +71,10 @@ html {
--card-shadow: rgb(15 23 42 / 0.045);
--card-subtle-a: rgb(255 255 255 / 0.6);
--card-subtle-b: rgb(248 250 252 / 0.4);
--text-primary: rgb(15 23 42 / 0.96);
--text-secondary: rgb(51 65 85 / 0.94);
--text-muted: rgb(71 85 105 / 0.9);
--text-subtle: rgb(100 116 139 / 0.9);
--button-start: #fb923c;
--button-end: #f97316;
--button-shadow: rgb(249 115 22 / 0.16);
@@ -172,13 +176,13 @@ html {
--chip-active-text: rgb(154 52 18 / 0.98);
--chip-group-bg: rgb(255 255 255 / 0.55);
--chip-group-border: rgb(226 232 240 / 0.95);
--radius-card: 18px;
--radius-subtle: 12px;
--radius-panel: 16px;
--radius-canvas: 12px;
--radius-card: 16px;
--radius-subtle: 10px;
--radius-panel: 14px;
--radius-canvas: 10px;
--radius-button: 14px;
--radius-chip: 14px;
--radius-section: 20px;
--radius-section: 18px;
--radius-badge: 14px;
}
@@ -229,6 +233,10 @@ html.theme-dark {
--card-shadow: rgb(0 0 0 / 0.24);
--card-subtle-a: rgb(22 32 50 / 0.62);
--card-subtle-b: rgb(13 21 35 / 0.48);
--text-primary: rgb(241 245 249 / 0.96);
--text-secondary: rgb(215 227 241 / 0.94);
--text-muted: rgb(159 176 196 / 0.92);
--text-subtle: rgb(129 146 167 / 0.9);
--button-start: #ee9852;
--button-end: #d96b25;
--button-shadow: rgb(217 107 37 / 0.18);
@@ -590,6 +598,66 @@ html.theme-dark .brand-card-subtle {
color: var(--notice-danger-text);
}
.ui-text-primary {
color: var(--text-primary);
}
.ui-text-secondary {
color: var(--text-secondary);
}
.ui-text-muted {
color: var(--text-muted);
}
.ui-text-subtle {
color: var(--text-subtle);
}
.ui-text-danger {
color: var(--color-red-500);
}
.ui-icon-success {
color: var(--color-emerald-500);
}
.ui-icon-warning {
color: var(--color-amber-500);
}
.ui-icon-info {
color: var(--color-sky-500);
}
.ui-icon-muted {
color: var(--color-zinc-500);
}
.ui-bg-accent {
background: var(--color-indigo-500);
}
.ui-surface-muted {
background: var(--card-subtle-a);
}
.ui-surface-strong {
background: color-mix(in srgb, var(--color-zinc-100) 84%, transparent);
}
.ui-border-subtle {
border-color: var(--color-zinc-800);
}
html.theme-dark .ui-surface-muted {
background: var(--card-subtle-a);
}
html.theme-dark .ui-surface-strong {
background: color-mix(in srgb, var(--color-zinc-900) 72%, transparent);
}
.ui-pill {
border: 1px solid transparent;
}
@@ -723,8 +791,13 @@ html.theme-dark .brand-button {
}
.ui-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 40px;
border-radius: var(--radius-button);
border: 1px solid transparent;
line-height: 1;
transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
@@ -783,6 +856,38 @@ html.theme-dark .brand-button {
background: var(--button-danger-bg-hover);
}
.ui-button-icon {
height: 40px;
min-height: 40px;
width: 40px;
border-radius: var(--radius-button);
}
.ui-button:has(> svg:only-child) {
width: 40px;
padding-inline: 0;
}
.ui-button-square {
width: 40px;
min-width: 40px;
padding-inline: 0;
}
.ui-toolbar-chip {
background: var(--card-subtle-a);
border: 1px solid var(--color-zinc-800);
color: var(--color-zinc-500);
}
.ui-row-hover {
transition: background-color 160ms ease;
}
.ui-row-hover:hover {
background: color-mix(in srgb, var(--card-subtle-a) 82%, transparent);
}
.ui-panel {
border: 1px solid var(--color-zinc-800);
border-radius: var(--radius-card);
@@ -798,7 +903,89 @@ html.theme-dark .brand-button {
.ui-toggle-card {
border: 1px solid var(--color-zinc-800);
border-radius: var(--radius-subtle);
background: rgb(255 255 255 / 0.8);
background: rgb(255 255 255 / 0.78);
box-shadow: inset 0 1px 0 var(--card-inner-highlight);
transition: border-color 160ms ease, background-color 160ms ease, box-shadow 160ms ease;
}
.ui-toggle-card:hover {
border-color: color-mix(in srgb, var(--color-indigo-400) 34%, var(--color-zinc-800));
}
.ui-form-field {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.ui-form-label {
color: var(--color-zinc-700);
font-size: 0.875rem;
font-weight: 600;
}
.ui-form-help {
color: var(--color-zinc-500);
font-size: 0.75rem;
line-height: 1.45;
}
.ui-section-header {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
column-gap: 1rem;
align-items: center;
}
.ui-boolean-head {
display: grid;
grid-template-columns: 40px minmax(0, 1fr);
column-gap: 0.85rem;
align-items: center;
}
.ui-boolean-card {
min-height: 92px;
padding: 1rem 1.1rem;
}
.ui-boolean-card-detailed {
min-height: 132px;
padding: 1.1rem 1.15rem;
}
.ui-checkbox {
appearance: none;
-webkit-appearance: none;
width: 1.1rem;
height: 1.1rem;
flex-shrink: 0;
border: 1.5px solid rgb(148 163 184 / 0.9);
border-radius: 0.4rem;
background: rgb(255 255 255 / 0.94);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.95);
transition: border-color 160ms ease, background-color 160ms ease, box-shadow 160ms ease;
cursor: pointer;
}
.ui-checkbox:hover {
border-color: rgb(249 115 22 / 0.72);
}
.ui-checkbox:checked {
border-color: var(--color-indigo-500);
background:
linear-gradient(135deg, var(--button-start) 0%, var(--button-end) 100%);
box-shadow:
inset 0 0 0 2px rgb(255 255 255 / 0.96),
0 0 0 3px rgb(249 115 22 / 0.12);
}
.ui-checkbox:focus-visible {
outline: none;
box-shadow:
0 0 0 3px rgb(249 115 22 / 0.14),
inset 0 0 0 2px rgb(255 255 255 / 0.96);
}
.ui-input,
@@ -813,6 +1000,34 @@ html.theme-dark .brand-button {
transition: border-color 160ms ease, box-shadow 160ms ease, background-color 160ms ease;
}
.ui-input,
.ui-select {
min-height: 40px;
}
.ui-select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
cursor: pointer;
padding-right: 2.75rem;
background-image:
linear-gradient(180deg, rgb(255 255 255 / 0.08), rgb(255 255 255 / 0)),
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='M4 6.5L8 10L12 6.5' stroke='%2364758b' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat, no-repeat;
background-position: 0 0, right 0.95rem center;
background-size: auto, 16px 16px;
}
.ui-select:hover {
border-color: color-mix(in srgb, var(--color-indigo-400) 34%, var(--color-zinc-800));
background-color: rgb(255 255 255 / 0.96);
}
.ui-select::-ms-expand {
display: none;
}
.ui-input::placeholder,
.ui-textarea::placeholder {
color: rgb(100 116 139 / 0.9);
@@ -830,6 +1045,18 @@ html.theme-dark .ui-toggle-card {
background: rgb(9 16 28 / 0.32);
}
html.theme-dark .ui-toggle-card:hover {
border-color: color-mix(in srgb, var(--color-indigo-400) 38%, var(--color-zinc-700));
}
html.theme-dark .ui-form-label {
color: rgb(215 227 241 / 0.94);
}
html.theme-dark .ui-form-help {
color: rgb(129 146 167 / 0.92);
}
html.theme-dark .ui-input,
html.theme-dark .ui-textarea,
html.theme-dark .ui-select {
@@ -838,17 +1065,75 @@ html.theme-dark .ui-select {
color: rgb(226 232 240 / 0.96);
}
html.theme-dark .ui-select {
background-image:
linear-gradient(180deg, rgb(255 255 255 / 0.03), rgb(255 255 255 / 0)),
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='M4 6.5L8 10L12 6.5' stroke='%2390a4bc' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
}
html.theme-dark .ui-select:hover {
border-color: color-mix(in srgb, var(--color-indigo-400) 38%, var(--color-zinc-700));
background-color: rgb(9 16 28 / 0.72);
}
html.theme-dark .ui-input::placeholder,
html.theme-dark .ui-textarea::placeholder {
color: rgb(111 131 155 / 0.9);
}
html.theme-dark .ui-checkbox {
border-color: rgb(111 131 155 / 0.85);
background: rgb(9 16 28 / 0.76);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.04);
}
html.theme-dark .ui-checkbox:hover {
border-color: rgb(241 165 97 / 0.78);
}
html.theme-dark .ui-checkbox:checked {
border-color: rgb(241 165 97 / 0.78);
box-shadow:
inset 0 0 0 2px rgb(9 16 28 / 0.95),
0 0 0 3px rgb(232 132 58 / 0.16);
}
html.theme-dark .ui-checkbox:focus-visible {
box-shadow:
0 0 0 3px rgb(232 132 58 / 0.18),
inset 0 0 0 2px rgb(9 16 28 / 0.95);
}
.ui-soft-panel {
border: 1px solid var(--color-zinc-800);
border-radius: var(--radius-subtle);
background: rgb(255 255 255 / 0.72);
}
.ui-composer {
border: 1px solid var(--color-zinc-800);
border-radius: 9999px;
background: linear-gradient(180deg, var(--card-subtle-a), var(--card-subtle-b));
box-shadow: inset 0 1px 0 var(--card-inner-highlight);
}
.ui-composer-input {
width: 100%;
border: 0;
border-radius: 9999px;
background: transparent;
color: rgb(51 65 85 / 0.98);
outline: none;
}
.ui-composer-input::placeholder {
color: rgb(100 116 139 / 0.9);
}
.ui-composer-input:focus {
outline: none;
}
.ui-code-panel {
border: 1px solid var(--color-zinc-800);
border-radius: var(--radius-subtle);
@@ -856,17 +1141,49 @@ html.theme-dark .ui-textarea::placeholder {
color: rgb(51 65 85 / 0.96);
}
.ui-code-badge {
display: inline-flex;
min-width: 3.5rem;
align-items: center;
justify-content: center;
border: 1px solid rgb(125 211 252 / 0.9);
border-radius: 9999px;
background: rgb(224 242 254 / 0.92);
color: rgb(3 105 161 / 0.98);
font-weight: 700;
line-height: 1;
}
html.theme-dark .ui-soft-panel {
border-color: var(--color-zinc-700);
background: rgb(9 16 28 / 0.34);
}
html.theme-dark .ui-composer {
border-color: var(--color-zinc-700);
background: linear-gradient(180deg, rgb(9 16 28 / 0.68), rgb(9 16 28 / 0.52));
}
html.theme-dark .ui-composer-input {
color: rgb(226 232 240 / 0.96);
}
html.theme-dark .ui-composer-input::placeholder {
color: rgb(111 131 155 / 0.9);
}
html.theme-dark .ui-code-panel {
border-color: var(--color-zinc-700);
background: rgb(9 16 28 / 0.6);
color: rgb(226 232 240 / 0.96);
}
html.theme-dark .ui-code-badge {
border-color: rgb(14 165 233 / 0.32);
background: rgb(8 47 73 / 0.52);
color: rgb(186 230 253 / 0.98);
}
.chat-bubble-user {
background: linear-gradient(135deg, var(--button-start) 0%, var(--button-end) 100%);
color: white;

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Check, LogOut, QrCode, RefreshCw, ShieldCheck, Smartphone, Users, Wifi, WifiOff } from 'lucide-react';
import { Check, KeyRound, ListFilter, LogOut, QrCode, RefreshCw, ShieldCheck, Smartphone, Users, Wifi, WifiOff } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
import { useUI } from '../context/UIContext';
@@ -16,7 +16,13 @@ type ChannelDefinition = {
id: ChannelKey;
titleKey: string;
hintKey: string;
fields: ChannelField[];
sections: Array<{
id: string;
titleKey: string;
hintKey: string;
fields: ChannelField[];
columns?: 1 | 2;
}>;
};
type WhatsAppStatusPayload = {
@@ -56,85 +62,221 @@ const channelDefinitions: Record<ChannelKey, ChannelDefinition> = {
id: 'telegram',
titleKey: 'telegram',
hintKey: 'telegramChannelHint',
fields: [
{ key: 'enabled', type: 'boolean' },
{ key: 'token', type: 'password' },
{ key: 'streaming', type: 'boolean' },
{ key: 'allow_from', type: 'list', placeholder: '123456789' },
{ key: 'allow_chats', type: 'list', placeholder: 'telegram:123456789' },
{ key: 'enable_groups', type: 'boolean' },
{ key: 'require_mention_in_groups', type: 'boolean' },
sections: [
{
id: 'connection',
titleKey: 'channelSectionConnection',
hintKey: 'channelSectionConnectionHint',
columns: 2,
fields: [
{ key: 'enabled', type: 'boolean' },
{ key: 'token', type: 'password' },
{ key: 'streaming', type: 'boolean' },
],
},
{
id: 'access',
titleKey: 'channelSectionAccess',
hintKey: 'channelSectionAccessHint',
columns: 2,
fields: [
{ key: 'allow_from', type: 'list', placeholder: '123456789' },
{ key: 'allow_chats', type: 'list', placeholder: 'telegram:123456789' },
],
},
{
id: 'groups',
titleKey: 'channelSectionGroupPolicy',
hintKey: 'channelSectionGroupPolicyHint',
columns: 2,
fields: [
{ key: 'enable_groups', type: 'boolean' },
{ key: 'require_mention_in_groups', type: 'boolean' },
],
},
],
},
whatsapp: {
id: 'whatsapp',
titleKey: 'whatsappBridge',
hintKey: 'whatsappBridgeHint',
fields: [
{ key: 'enabled', type: 'boolean' },
{ key: 'bridge_url', type: 'text', placeholder: 'ws://127.0.0.1:3001' },
{ key: 'allow_from', type: 'list', placeholder: '8613012345678@s.whatsapp.net' },
{ key: 'enable_groups', type: 'boolean' },
{ key: 'require_mention_in_groups', type: 'boolean' },
sections: [
{
id: 'connection',
titleKey: 'channelSectionConnection',
hintKey: 'channelSectionConnectionHint',
columns: 2,
fields: [
{ key: 'enabled', type: 'boolean' },
{ key: 'bridge_url', type: 'text' },
],
},
{
id: 'access',
titleKey: 'channelSectionAccess',
hintKey: 'channelSectionAccessHint',
columns: 1,
fields: [
{ key: 'allow_from', type: 'list', placeholder: '8613012345678@s.whatsapp.net' },
],
},
{
id: 'groups',
titleKey: 'channelSectionGroupPolicy',
hintKey: 'channelSectionGroupPolicyHint',
columns: 2,
fields: [
{ key: 'enable_groups', type: 'boolean' },
{ key: 'require_mention_in_groups', type: 'boolean' },
],
},
],
},
discord: {
id: 'discord',
titleKey: 'discord',
hintKey: 'discordChannelHint',
fields: [
{ key: 'enabled', type: 'boolean' },
{ key: 'token', type: 'password' },
{ key: 'allow_from', type: 'list', placeholder: 'discord-user-id' },
sections: [
{
id: 'connection',
titleKey: 'channelSectionConnection',
hintKey: 'channelSectionConnectionHint',
columns: 2,
fields: [
{ key: 'enabled', type: 'boolean' },
{ key: 'token', type: 'password' },
],
},
{
id: 'access',
titleKey: 'channelSectionAccess',
hintKey: 'channelSectionAccessHint',
columns: 1,
fields: [
{ key: 'allow_from', type: 'list', placeholder: 'discord-user-id' },
],
},
],
},
feishu: {
id: 'feishu',
titleKey: 'feishu',
hintKey: 'feishuChannelHint',
fields: [
{ key: 'enabled', type: 'boolean' },
{ key: 'app_id', type: 'text' },
{ key: 'app_secret', type: 'password' },
{ key: 'encrypt_key', type: 'password' },
{ key: 'verification_token', type: 'password' },
{ key: 'allow_from', type: 'list' },
{ key: 'allow_chats', type: 'list' },
{ key: 'enable_groups', type: 'boolean' },
{ key: 'require_mention_in_groups', type: 'boolean' },
sections: [
{
id: 'connection',
titleKey: 'channelSectionConnection',
hintKey: 'channelSectionConnectionHint',
columns: 2,
fields: [
{ key: 'enabled', type: 'boolean' },
{ key: 'app_id', type: 'text' },
{ key: 'app_secret', type: 'password' },
{ key: 'encrypt_key', type: 'password' },
{ key: 'verification_token', type: 'password' },
],
},
{
id: 'access',
titleKey: 'channelSectionAccess',
hintKey: 'channelSectionAccessHint',
columns: 2,
fields: [
{ key: 'allow_from', type: 'list' },
{ key: 'allow_chats', type: 'list' },
],
},
{
id: 'groups',
titleKey: 'channelSectionGroupPolicy',
hintKey: 'channelSectionGroupPolicyHint',
columns: 2,
fields: [
{ key: 'enable_groups', type: 'boolean' },
{ key: 'require_mention_in_groups', type: 'boolean' },
],
},
],
},
qq: {
id: 'qq',
titleKey: 'qq',
hintKey: 'qqChannelHint',
fields: [
{ key: 'enabled', type: 'boolean' },
{ key: 'app_id', type: 'text' },
{ key: 'app_secret', type: 'password' },
{ key: 'allow_from', type: 'list' },
sections: [
{
id: 'connection',
titleKey: 'channelSectionConnection',
hintKey: 'channelSectionConnectionHint',
columns: 2,
fields: [
{ key: 'enabled', type: 'boolean' },
{ key: 'app_id', type: 'text' },
{ key: 'app_secret', type: 'password' },
],
},
{
id: 'access',
titleKey: 'channelSectionAccess',
hintKey: 'channelSectionAccessHint',
columns: 1,
fields: [
{ key: 'allow_from', type: 'list' },
],
},
],
},
dingtalk: {
id: 'dingtalk',
titleKey: 'dingtalk',
hintKey: 'dingtalkChannelHint',
fields: [
{ key: 'enabled', type: 'boolean' },
{ key: 'client_id', type: 'text' },
{ key: 'client_secret', type: 'password' },
{ key: 'allow_from', type: 'list' },
sections: [
{
id: 'connection',
titleKey: 'channelSectionConnection',
hintKey: 'channelSectionConnectionHint',
columns: 2,
fields: [
{ key: 'enabled', type: 'boolean' },
{ key: 'client_id', type: 'text' },
{ key: 'client_secret', type: 'password' },
],
},
{
id: 'access',
titleKey: 'channelSectionAccess',
hintKey: 'channelSectionAccessHint',
columns: 1,
fields: [
{ key: 'allow_from', type: 'list' },
],
},
],
},
maixcam: {
id: 'maixcam',
titleKey: 'maixcam',
hintKey: 'maixcamChannelHint',
fields: [
{ key: 'enabled', type: 'boolean' },
{ key: 'host', type: 'text' },
{ key: 'port', type: 'number' },
{ key: 'allow_from', type: 'list' },
sections: [
{
id: 'network',
titleKey: 'channelSectionNetwork',
hintKey: 'channelSectionNetworkHint',
columns: 2,
fields: [
{ key: 'enabled', type: 'boolean' },
{ key: 'host', type: 'text' },
{ key: 'port', type: 'number' },
],
},
{
id: 'access',
titleKey: 'channelSectionAccess',
hintKey: 'channelSectionAccessHint',
columns: 1,
fields: [
{ key: 'allow_from', type: 'list' },
],
},
],
},
};
@@ -187,6 +329,72 @@ function getWhatsAppBooleanIcon(fieldKey: string) {
}
}
function getSectionIcon(sectionID: string) {
switch (sectionID) {
case 'connection':
return KeyRound;
case 'access':
return ListFilter;
case 'groups':
return Users;
case 'network':
return Smartphone;
default:
return Check;
}
}
function getChannelFieldDescription(t: (key: string) => string, channelKey: ChannelKey, fieldKey: string) {
if (channelKey === 'whatsapp') return getWhatsAppFieldDescription(t, fieldKey);
const map: Partial<Record<ChannelKey, Partial<Record<string, string>>>> = {
telegram: {
enabled: 'channelFieldTelegramEnabledHint',
token: 'channelFieldTelegramTokenHint',
streaming: 'channelFieldTelegramStreamingHint',
allow_from: 'channelFieldTelegramAllowFromHint',
allow_chats: 'channelFieldTelegramAllowChatsHint',
enable_groups: 'channelFieldEnableGroupsHint',
require_mention_in_groups: 'channelFieldRequireMentionHint',
},
discord: {
enabled: 'channelFieldDiscordEnabledHint',
token: 'channelFieldDiscordTokenHint',
allow_from: 'channelFieldDiscordAllowFromHint',
},
feishu: {
enabled: 'channelFieldFeishuEnabledHint',
app_id: 'channelFieldFeishuAppIDHint',
app_secret: 'channelFieldFeishuAppSecretHint',
encrypt_key: 'channelFieldFeishuEncryptKeyHint',
verification_token: 'channelFieldFeishuVerificationTokenHint',
allow_from: 'channelFieldFeishuAllowFromHint',
allow_chats: 'channelFieldFeishuAllowChatsHint',
enable_groups: 'channelFieldEnableGroupsHint',
require_mention_in_groups: 'channelFieldRequireMentionHint',
},
qq: {
enabled: 'channelFieldQQEnabledHint',
app_id: 'channelFieldQQAppIDHint',
app_secret: 'channelFieldQQAppSecretHint',
allow_from: 'channelFieldQQAllowFromHint',
},
dingtalk: {
enabled: 'channelFieldDingTalkEnabledHint',
client_id: 'channelFieldDingTalkClientIDHint',
client_secret: 'channelFieldDingTalkClientSecretHint',
allow_from: 'channelFieldDingTalkAllowFromHint',
},
maixcam: {
enabled: 'channelFieldMaixCamEnabledHint',
host: 'channelFieldMaixCamHostHint',
port: 'channelFieldMaixCamPortHint',
allow_from: 'channelFieldMaixCamAllowFromHint',
},
};
const key = map[channelKey]?.[fieldKey];
return key ? t(key) : '';
}
const ChannelSettings: React.FC = () => {
const { channelId } = useParams();
const navigate = useNavigate();
@@ -272,7 +480,15 @@ const ChannelSettings: React.FC = () => {
});
if (!ok) return;
await ui.withLoading(async () => {
await fetch(`/webui/api/whatsapp/logout${q}`, { method: 'POST' });
const res = await fetch(`/webui/api/whatsapp/logout${q}`, { method: 'POST' });
if (!res.ok) {
const message = (await res.text()) || `HTTP ${res.status}`;
throw new Error(message);
}
const json = await res.json().catch(() => null);
if (json && typeof json === 'object') {
setWaStatus((prev) => ({ ...(prev || {}), ok: true, status: json as any }));
}
}, t('loading'));
};
@@ -280,20 +496,20 @@ const ChannelSettings: React.FC = () => {
const label = t(`configLabels.${field.key}`);
const value = draft[field.key];
const isWhatsApp = key === 'whatsapp';
const helper = isWhatsApp ? getWhatsAppFieldDescription(t, field.key) : '';
const helper = getChannelFieldDescription(t, key, field.key);
if (field.type === 'boolean') {
if (isWhatsApp) {
const Icon = getWhatsAppBooleanIcon(field.key);
return (
<label key={field.key} className="ui-toggle-card rounded-[24px] p-5 flex items-start justify-between gap-4 min-h-[148px] cursor-pointer transition-colors hover:border-zinc-700">
<div className="min-w-0 pr-3">
<div className="flex items-center gap-3">
<label key={field.key} className="ui-toggle-card ui-boolean-card-detailed flex items-start justify-between gap-4 cursor-pointer">
<div className="min-w-0 flex-1 pr-3">
<div className="ui-boolean-head">
<div className={`ui-pill ${value ? 'ui-pill-success' : 'ui-pill-neutral'} flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border`}>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">{label}</div>
<div className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">{helper}</div>
<div className="ui-text-primary text-sm font-semibold">{label}</div>
<div className="ui-form-help mt-1">{helper}</div>
</div>
</div>
<div className={`ui-pill mt-4 inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium ${value ? 'ui-pill-success' : 'ui-pill-neutral'}`}>
@@ -304,52 +520,54 @@ const ChannelSettings: React.FC = () => {
type="checkbox"
checked={!!value}
onChange={(e) => setDraft((prev) => ({ ...prev, [field.key]: e.target.checked }))}
className="mt-1 h-4 w-4 rounded border-zinc-700/60 text-indigo-500 focus:ring-indigo-500 dark:border-zinc-700"
className="ui-checkbox mt-1"
/>
</label>
);
}
return (
<label key={field.key} className="ui-toggle-card p-4 flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{label}</div>
<div className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">{t(value ? 'enabled_true' : 'enabled_false')}</div>
<label key={field.key} className="ui-toggle-card ui-boolean-card flex items-center justify-between gap-4 cursor-pointer">
<div className="min-w-0 flex-1 pr-3">
<div className="ui-text-primary text-sm font-semibold">{label}</div>
<div className={`ui-pill mt-3 inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium ${value ? 'ui-pill-success' : 'ui-pill-neutral'}`}>
{t(value ? 'enabled_true' : 'enabled_false')}
</div>
</div>
<input
type="checkbox"
checked={!!value}
onChange={(e) => setDraft((prev) => ({ ...prev, [field.key]: e.target.checked }))}
className="h-4 w-4 rounded border-zinc-700/60 text-indigo-500 focus:ring-indigo-500 dark:border-zinc-700"
className="ui-checkbox"
/>
</label>
);
}
if (field.type === 'list') {
return (
<div key={field.key} className={`space-y-2 ${isWhatsApp ? 'lg:col-span-2' : ''}`}>
<div key={field.key} className={`ui-form-field ${isWhatsApp ? 'lg:col-span-2' : ''}`}>
<div className="flex items-center justify-between gap-3">
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-200">{label}</label>
<label className="ui-form-label">{label}</label>
{isWhatsApp && Array.isArray(value) && value.length > 0 && (
<span className="ui-pill ui-pill-neutral inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium">
{t('entries')}: {value.length}
</span>
)}
</div>
{helper && <div className="text-xs text-zinc-500 dark:text-zinc-400">{helper}</div>}
{helper && <div className="ui-form-help">{helper}</div>}
<textarea
value={formatList(value)}
onChange={(e) => setDraft((prev) => ({ ...prev, [field.key]: parseList(e.target.value) }))}
placeholder={field.placeholder || ''}
className={`ui-textarea px-4 py-3 text-sm ${isWhatsApp ? 'min-h-36 font-mono' : 'min-h-28'}`}
className={`ui-textarea px-4 py-3 text-sm ${isWhatsApp ? 'min-h-36 font-mono' : 'min-h-32'}`}
/>
{isWhatsApp && <div className="text-[11px] text-zinc-500 dark:text-zinc-400">{t('whatsappFieldAllowFromFootnote')}</div>}
{isWhatsApp && <div className="ui-form-help text-[11px]">{t('whatsappFieldAllowFromFootnote')}</div>}
</div>
);
}
return (
<div key={field.key} className={`space-y-2 ${isWhatsApp && field.key === 'bridge_url' ? 'lg:col-span-2' : ''}`}>
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-200">{label}</label>
{helper && <div className="text-xs text-zinc-500 dark:text-zinc-400">{helper}</div>}
<div key={field.key} className={`ui-form-field ${isWhatsApp && field.key === 'bridge_url' ? 'lg:col-span-2' : ''}`}>
<label className="ui-form-label">{label}</label>
{helper && <div className="ui-form-help">{helper}</div>}
<input
type={field.type}
value={value === null || value === undefined ? '' : String(value)}
@@ -368,14 +586,18 @@ const ChannelSettings: React.FC = () => {
<div className="space-y-6 px-5 py-5 md:px-7 md:py-6 xl:px-8">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight text-zinc-950 dark:text-zinc-50">{t(definition.titleKey)}</h1>
<p className="mt-1 text-sm text-zinc-500">{t(definition.hintKey)}</p>
<h1 className="ui-text-primary text-3xl font-bold tracking-tight">{t(definition.titleKey)}</h1>
<p className="ui-text-muted mt-1 text-sm">{t(definition.hintKey)}</p>
</div>
<div className="flex items-center gap-2">
{key === 'whatsapp' && (
<button onClick={() => window.location.reload()} className="ui-button ui-button-neutral flex items-center gap-2 px-4 py-2 text-sm font-medium">
<button
onClick={() => window.location.reload()}
className="ui-button ui-button-neutral ui-button-icon"
title={t('refresh')}
aria-label={t('refresh')}
>
<RefreshCw className="h-4 w-4" />
{t('refresh')}
</button>
)}
<button onClick={saveChannel} disabled={saving} className="ui-button ui-button-primary px-4 py-2 text-sm font-medium">
@@ -385,10 +607,26 @@ const ChannelSettings: React.FC = () => {
</div>
<div className={`grid gap-6 ${key === 'whatsapp' ? 'xl:grid-cols-[1fr_0.92fr]' : ''}`}>
<div className="brand-card ui-panel rounded-[30px] p-6 space-y-5">
<div className="grid gap-4 lg:grid-cols-2">
{definition.fields.map(renderField)}
</div>
<div className="space-y-4">
{definition.sections.map((section) => {
const Icon = getSectionIcon(section.id);
return (
<section key={section.id} className="brand-card ui-panel rounded-[30px] p-6 space-y-5">
<div className="ui-section-header">
<div className="ui-subpanel flex h-11 w-11 shrink-0 items-center justify-center">
<Icon className="ui-icon-muted h-[18px] w-[18px]" />
</div>
<div className="min-w-0">
<h2 className="ui-text-primary text-lg font-semibold">{t(section.titleKey)}</h2>
<p className="ui-text-muted mt-1 text-sm">{t(section.hintKey)}</p>
</div>
</div>
<div className={`grid gap-4 ${section.columns === 1 ? 'grid-cols-1' : 'lg:grid-cols-2'}`}>
{section.fields.map(renderField)}
</div>
</section>
);
})}
</div>
{key === 'whatsapp' && (
@@ -397,11 +635,11 @@ const ChannelSettings: React.FC = () => {
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="ui-subpanel flex h-12 w-12 items-center justify-center">
{wa?.connected ? <Wifi className="h-5 w-5 text-emerald-500" /> : <WifiOff className="h-5 w-5 text-amber-500" />}
{wa?.connected ? <Wifi className="ui-icon-success h-5 w-5" /> : <WifiOff className="ui-icon-warning h-5 w-5" />}
</div>
<div>
<div className="text-xs uppercase tracking-[0.28em] text-zinc-500">{t('gatewayStatus')}</div>
<div className="mt-1 text-2xl font-semibold text-zinc-950 dark:text-zinc-50">{stateLabel}</div>
<div className="ui-text-muted text-xs uppercase tracking-[0.28em]">{t('gatewayStatus')}</div>
<div className="ui-text-primary mt-1 text-2xl font-semibold">{stateLabel}</div>
</div>
</div>
<button onClick={handleLogout} className="ui-button ui-button-danger flex items-center gap-2 px-4 py-2 text-sm font-medium">
@@ -412,51 +650,51 @@ const ChannelSettings: React.FC = () => {
<div className="grid gap-4 sm:grid-cols-2">
<div className="ui-subpanel p-4">
<div className="text-xs uppercase tracking-[0.25em] text-zinc-500">{t('whatsappBridgeURL')}</div>
<div className="mt-2 break-all text-sm text-zinc-700 dark:text-zinc-200">{waStatus?.bridge_url || draft.bridge_url || '-'}</div>
<div className="ui-text-muted text-xs uppercase tracking-[0.25em]">{t('whatsappBridgeURL')}</div>
<div className="ui-text-secondary mt-2 break-all text-sm">{waStatus?.bridge_url || draft.bridge_url || '-'}</div>
</div>
<div className="ui-subpanel p-4">
<div className="text-xs uppercase tracking-[0.25em] text-zinc-500">{t('whatsappBridgeAccount')}</div>
<div className="mt-2 break-all text-sm text-zinc-700 dark:text-zinc-200">{wa?.user_jid || '-'}</div>
<div className="ui-text-muted text-xs uppercase tracking-[0.25em]">{t('whatsappBridgeAccount')}</div>
<div className="ui-text-secondary mt-2 break-all text-sm">{wa?.user_jid || '-'}</div>
</div>
<div className="ui-subpanel p-4">
<div className="text-xs uppercase tracking-[0.25em] text-zinc-500">{t('whatsappBridgeLastEvent')}</div>
<div className="mt-2 text-sm text-zinc-700 dark:text-zinc-200">{wa?.last_event || '-'}</div>
<div className="ui-text-muted text-xs uppercase tracking-[0.25em]">{t('whatsappBridgeLastEvent')}</div>
<div className="ui-text-secondary mt-2 text-sm">{wa?.last_event || '-'}</div>
</div>
<div className="ui-subpanel p-4">
<div className="text-xs uppercase tracking-[0.25em] text-zinc-500">{t('time')}</div>
<div className="mt-2 text-sm text-zinc-700 dark:text-zinc-200">{wa?.updated_at || '-'}</div>
<div className="ui-text-muted text-xs uppercase tracking-[0.25em]">{t('time')}</div>
<div className="ui-text-secondary mt-2 text-sm">{wa?.updated_at || '-'}</div>
</div>
<div className="ui-subpanel p-4">
<div className="text-xs uppercase tracking-[0.25em] text-zinc-500">Inbound</div>
<div className="mt-2 text-sm text-zinc-700 dark:text-zinc-200">{wa?.inbound_count ?? 0}</div>
<div className="ui-text-muted text-xs uppercase tracking-[0.25em]">{t('whatsappInbound')}</div>
<div className="ui-text-secondary mt-2 text-sm">{wa?.inbound_count ?? 0}</div>
</div>
<div className="ui-subpanel p-4">
<div className="text-xs uppercase tracking-[0.25em] text-zinc-500">Outbound</div>
<div className="mt-2 text-sm text-zinc-700 dark:text-zinc-200">{wa?.outbound_count ?? 0}</div>
<div className="ui-text-muted text-xs uppercase tracking-[0.25em]">{t('whatsappOutbound')}</div>
<div className="ui-text-secondary mt-2 text-sm">{wa?.outbound_count ?? 0}</div>
</div>
<div className="ui-subpanel p-4">
<div className="text-xs uppercase tracking-[0.25em] text-zinc-500">Read Receipts</div>
<div className="mt-2 text-sm text-zinc-700 dark:text-zinc-200">{wa?.read_receipt_count ?? 0}</div>
<div className="ui-text-muted text-xs uppercase tracking-[0.25em]">{t('whatsappReadReceipts')}</div>
<div className="ui-text-secondary mt-2 text-sm">{wa?.read_receipt_count ?? 0}</div>
</div>
<div className="ui-subpanel p-4">
<div className="text-xs uppercase tracking-[0.25em] text-zinc-500">Last Read</div>
<div className="mt-2 text-sm text-zinc-700 dark:text-zinc-200">{wa?.last_read_at || '-'}</div>
<div className="ui-text-muted text-xs uppercase tracking-[0.25em]">{t('whatsappLastRead')}</div>
<div className="ui-text-secondary mt-2 text-sm">{wa?.last_read_at || '-'}</div>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="ui-subpanel p-4">
<div className="text-xs uppercase tracking-[0.25em] text-zinc-500">Last Inbound</div>
<div className="mt-2 text-sm text-zinc-700 dark:text-zinc-200">{wa?.last_inbound_at || '-'}</div>
<div className="mt-2 text-xs text-zinc-500 break-all">{wa?.last_inbound_from || '-'}</div>
<div className="mt-2 text-xs text-zinc-600 dark:text-zinc-300 whitespace-pre-wrap">{wa?.last_inbound_text || '-'}</div>
<div className="ui-text-muted text-xs uppercase tracking-[0.25em]">{t('whatsappLastInbound')}</div>
<div className="ui-text-secondary mt-2 text-sm">{wa?.last_inbound_at || '-'}</div>
<div className="ui-text-muted mt-2 break-all text-xs">{wa?.last_inbound_from || '-'}</div>
<div className="ui-text-subtle mt-2 whitespace-pre-wrap text-xs">{wa?.last_inbound_text || '-'}</div>
</div>
<div className="ui-subpanel p-4">
<div className="text-xs uppercase tracking-[0.25em] text-zinc-500">Last Outbound</div>
<div className="mt-2 text-sm text-zinc-700 dark:text-zinc-200">{wa?.last_outbound_at || '-'}</div>
<div className="mt-2 text-xs text-zinc-500 break-all">{wa?.last_outbound_to || '-'}</div>
<div className="mt-2 text-xs text-zinc-600 dark:text-zinc-300 whitespace-pre-wrap">{wa?.last_outbound_text || '-'}</div>
<div className="ui-text-muted text-xs uppercase tracking-[0.25em]">{t('whatsappLastOutbound')}</div>
<div className="ui-text-secondary mt-2 text-sm">{wa?.last_outbound_at || '-'}</div>
<div className="ui-text-muted mt-2 break-all text-xs">{wa?.last_outbound_to || '-'}</div>
<div className="ui-text-subtle mt-2 whitespace-pre-wrap text-xs">{wa?.last_outbound_text || '-'}</div>
</div>
</div>
@@ -475,11 +713,11 @@ const ChannelSettings: React.FC = () => {
<div className="brand-card ui-panel rounded-[30px] p-6 space-y-5">
<div className="flex items-center gap-3">
<div className="ui-subpanel flex h-12 w-12 items-center justify-center">
<QrCode className="h-5 w-5 text-sky-500" />
<QrCode className="ui-icon-info h-5 w-5" />
</div>
<div>
<div className="text-xs uppercase tracking-[0.28em] text-zinc-500">{t('whatsappBridgeQRCode')}</div>
<div className="mt-1 text-2xl font-semibold text-zinc-950 dark:text-zinc-50">{wa?.qr_available ? t('whatsappQRCodeReady') : t('whatsappQRCodeUnavailable')}</div>
<div className="ui-text-muted text-xs uppercase tracking-[0.28em]">{t('whatsappBridgeQRCode')}</div>
<div className="ui-text-primary mt-1 text-2xl font-semibold">{wa?.qr_available ? t('whatsappQRCodeReady') : t('whatsappQRCodeUnavailable')}</div>
</div>
</div>
@@ -489,11 +727,11 @@ const ChannelSettings: React.FC = () => {
</div>
) : (
<div className="ui-soft-panel rounded-[28px] border-dashed p-8 text-center">
<div className="ui-subpanel mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-zinc-100/80">
<Smartphone className="h-7 w-7 text-zinc-500" />
<div className="ui-subpanel ui-surface-strong mx-auto flex h-16 w-16 items-center justify-center rounded-3xl">
<Smartphone className="ui-icon-muted h-7 w-7" />
</div>
<div className="mt-4 text-base font-medium text-zinc-900 dark:text-zinc-100">{t('whatsappQRCodeUnavailable')}</div>
<div className="mt-2 whitespace-pre-line text-sm text-zinc-500">{t('whatsappQRCodeHint')}</div>
<div className="ui-text-primary mt-4 text-base font-medium">{t('whatsappQRCodeUnavailable')}</div>
<div className="ui-text-muted mt-2 whitespace-pre-line text-sm">{t('whatsappQRCodeHint')}</div>
</div>
)}
</div>

View File

@@ -55,10 +55,10 @@ type AgentRuntimeBadge = {
text: string;
};
function formatAgentName(agentID?: string): string {
function formatAgentName(agentID: string | undefined, t: (key: string) => string): string {
const normalized = String(agentID || '').trim();
if (!normalized) return 'Unknown Agent';
if (normalized === 'main') return 'Main Agent';
if (!normalized) return t('unknownAgent');
if (normalized === 'main') return t('mainAgent');
return normalized
.split(/[-_.:]+/)
.filter(Boolean)
@@ -396,7 +396,7 @@ const Chat: React.FC = () => {
label: t('system'),
actorName: t('system'),
avatarText: 'S',
avatarClassName: 'bg-zinc-700 text-zinc-100',
avatarClassName: 'avatar-system',
}]);
}
}
@@ -436,7 +436,7 @@ const Chat: React.FC = () => {
const messageID = String(item.message_id || '').trim();
if (!messageID) return;
replyIndex.set(messageID, {
actor: formatAgentName(item.from_agent || item.agent_id),
actor: formatAgentName(item.from_agent || item.agent_id, t),
messageType: String(item.message_type || 'message'),
});
});
@@ -451,7 +451,7 @@ const Chat: React.FC = () => {
})
.map((item, index) => {
const actorKey = messageActorKey(item);
const actorName = formatAgentName(actorKey);
const actorName = formatAgentName(actorKey, t);
let metaLine = '';
if (item.kind === 'message') {
@@ -461,7 +461,7 @@ const Chat: React.FC = () => {
} else if (item.from_agent && item.to_agent && item.from_agent === item.to_agent) {
metaLine = t('selfRefresh');
} else if (item.to_agent) {
metaLine = `${t('toAgent')}: ${formatAgentName(item.to_agent)}`;
metaLine = `${t('toAgent')}: ${formatAgentName(item.to_agent, t)}`;
}
if (item.message_type) {
metaLine = metaLine ? `${metaLine} · ${item.message_type}` : String(item.message_type);
@@ -565,10 +565,10 @@ const Chat: React.FC = () => {
};
return (
<div className="flex h-full min-w-0">
<div className="flex h-full min-w-0 p-4 md:p-5 xl:p-6">
<div className="flex-1 flex flex-col brand-card ui-panel rounded-[30px] overflow-hidden">
<div className="px-4 py-3 border-b border-zinc-800 dark:border-zinc-700 flex items-center justify-between gap-3 flex-wrap bg-zinc-900/15">
<div className="flex items-center gap-2 flex-wrap min-w-0">
<div className="ui-surface-muted ui-border-subtle px-4 py-3 border-b flex items-center gap-2 min-w-0 overflow-x-auto">
<div className="flex items-center gap-2 min-w-0 shrink-0">
<button
onClick={() => setChatTab('main')}
className={`ui-button px-3 py-1.5 text-xs ${chatTab === 'main' ? 'ui-button-primary' : 'ui-button-neutral'}`}
@@ -581,17 +581,30 @@ const Chat: React.FC = () => {
>
{t('subagentGroup')}
</button>
{chatTab === 'main' && (
<select value={sessionKey} onChange={(e) => setSessionKey(e.target.value)} className="ui-select max-w-full rounded-xl px-2.5 py-1.5 text-xs">
{userSessions.map((s: any) => <option key={s.key} value={s.key}>{s.title || s.key}</option>)}
</select>
)}
</div>
<button onClick={() => { if (chatTab === 'main') { void loadHistory(); } else { void loadSubagentGroup(); } }} className="ui-button ui-button-neutral flex items-center gap-1 px-2.5 py-1.5 text-xs"><RefreshCw className="w-3 h-3" />{t('reloadHistory')}</button>
{chatTab === 'main' && (
<select value={sessionKey} onChange={(e) => setSessionKey(e.target.value)} className="ui-select min-w-[220px] flex-1 rounded-xl px-2.5 py-1.5 text-xs">
{userSessions.map((s: any) => <option key={s.key} value={s.key}>{s.title || s.key}</option>)}
</select>
)}
<button
onClick={() => {
if (chatTab === 'main') {
void loadHistory();
} else {
void loadSubagentGroup();
}
}}
className="ui-button ui-button-neutral ui-button-icon ml-auto shrink-0"
title={t('reloadHistory')}
aria-label={t('reloadHistory')}
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
{chatTab === 'subagents' && (
<div className="px-4 py-3 border-b border-zinc-800 dark:border-zinc-700 bg-zinc-950/20 flex flex-wrap gap-2">
<div className="ui-surface-strong ui-border-subtle px-4 py-3 border-b flex flex-wrap gap-2">
<button
onClick={() => setSelectedStreamAgents([])}
className={`ui-button px-2.5 py-1 rounded-full text-xs ${selectedStreamAgents.length === 0 ? 'ui-button-primary' : 'ui-button-neutral'}`}
@@ -604,7 +617,7 @@ const Chat: React.FC = () => {
onClick={() => toggleStreamAgent(agent)}
className={`ui-button px-2.5 py-1 rounded-full text-xs ${selectedStreamAgents.includes(agent) ? 'ui-button-primary' : 'ui-button-neutral'}`}
>
{formatAgentName(agent)}
{formatAgentName(agent, t)}
</button>
))}
</div>
@@ -612,10 +625,10 @@ const Chat: React.FC = () => {
<div className="flex-1 min-h-0 flex flex-col xl:flex-row">
{chatTab === 'subagents' && (
<div className="w-full xl:w-[320px] xl:shrink-0 border-b xl:border-b-0 xl:border-r border-zinc-800 dark:border-zinc-700 bg-zinc-950/28 p-4 flex flex-col gap-4 max-h-[46vh] xl:max-h-none overflow-y-auto">
<div className="ui-surface-strong ui-border-subtle w-full xl:w-[320px] xl:shrink-0 border-b xl:border-b-0 xl:border-r p-4 flex flex-col gap-4 max-h-[46vh] xl:max-h-none overflow-y-auto">
<div>
<div className="text-xs uppercase tracking-wider text-zinc-500 mb-1">{t('subagentDispatch')}</div>
<div className="text-sm text-zinc-300">{t('subagentDispatchHint')}</div>
<div className="ui-text-muted text-xs uppercase tracking-wider mb-1">{t('subagentDispatch')}</div>
<div className="ui-text-secondary text-sm">{t('subagentDispatchHint')}</div>
</div>
<div className="space-y-3">
<select
@@ -625,7 +638,7 @@ const Chat: React.FC = () => {
>
{registryAgents.map((agent) => (
<option key={agent.agent_id} value={agent.agent_id}>
{formatAgentName(agent.display_name || agent.agent_id)} · {agent.role || '-'}
{formatAgentName(agent.display_name || agent.agent_id, t)} · {agent.role || '-'}
</option>
))}
</select>
@@ -649,8 +662,8 @@ const Chat: React.FC = () => {
{t('dispatchToSubagent')}
</button>
</div>
<div className="border-t border-zinc-800 dark:border-zinc-700 pt-4 min-h-0 flex flex-col">
<div className="text-xs uppercase tracking-wider text-zinc-500 mb-2">{t('agents')}</div>
<div className="ui-border-subtle border-t pt-4 min-h-0 flex flex-col">
<div className="ui-text-muted text-xs uppercase tracking-wider mb-2">{t('agents')}</div>
<div className="overflow-y-auto space-y-2 min-h-0">
{registryAgents.map((agent) => {
const active = dispatchAgentID === agent.agent_id;
@@ -668,13 +681,13 @@ const Chat: React.FC = () => {
<button
key={agent.agent_id}
onClick={() => setDispatchAgentID(String(agent.agent_id || ''))}
className={`w-full text-left rounded-2xl border px-3 py-2.5 ${active ? 'ui-card-active-warning' : 'border-zinc-800 bg-zinc-900/50 hover:bg-zinc-900/70'}`}
className={`w-full text-left rounded-2xl border px-3 py-2.5 ${active ? 'ui-card-active-warning' : 'ui-border-subtle ui-surface-muted ui-row-hover'}`}
>
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium text-zinc-100">{formatAgentName(agent.display_name || agent.agent_id)}</div>
<div className="ui-text-primary text-sm font-medium">{formatAgentName(agent.display_name || agent.agent_id, t)}</div>
<span className={`ui-pill inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] ${badgeClass}`}>{badge?.text || t('idle')}</span>
</div>
<div className="text-xs text-zinc-500">{agent.agent_id} · {agent.role || '-'}</div>
<div className="ui-text-muted text-xs">{agent.agent_id} · {agent.role || '-'}</div>
</button>
);
})}
@@ -690,9 +703,9 @@ const Chat: React.FC = () => {
className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-4 sm:space-y-6 min-w-0"
>
{displayedChat.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-zinc-500 space-y-4">
<div className="w-16 h-16 rounded-[24px] brand-card-subtle flex items-center justify-center border border-zinc-800">
<MessageSquare className="w-8 h-8 text-zinc-600" />
<div className="ui-text-muted h-full flex flex-col items-center justify-center space-y-4">
<div className="ui-border-subtle w-16 h-16 rounded-[24px] brand-card-subtle flex items-center justify-center border">
<MessageSquare className="ui-icon-muted w-8 h-8" />
</div>
<p className="text-sm font-medium">{chatTab === 'main' ? t('startConversation') : t('noSubagentStream')}</p>
</div>
@@ -714,12 +727,12 @@ const Chat: React.FC = () => {
? 'chat-meta-user'
: isExec
? 'chat-meta-tool'
: 'text-zinc-500 dark:text-zinc-400';
: 'ui-text-muted';
const subLabelClass = isUser
? 'chat-submeta-user'
: isExec
? 'chat-submeta-tool'
: 'text-zinc-500 dark:text-zinc-400';
: 'ui-text-muted';
return (
<motion.div
@@ -749,8 +762,8 @@ const Chat: React.FC = () => {
</div>
</div>
<div className="p-3 sm:p-4 bg-zinc-950/20 border-t border-zinc-800 dark:border-zinc-700">
<div className="w-full relative flex items-center">
<div className="ui-soft-panel ui-border-subtle p-3 sm:p-4 border-t">
<div className="ui-composer w-full relative flex items-center px-2">
<input
type="file"
id="file"
@@ -759,7 +772,7 @@ const Chat: React.FC = () => {
/>
<label
htmlFor="file"
className={`absolute left-3 p-2 rounded-full cursor-pointer transition-colors ${fileSelected ? 'text-indigo-400 bg-indigo-500/10' : 'text-zinc-400 hover:bg-zinc-800/70 hover:text-zinc-200'}`}
className={`absolute left-3 p-2 rounded-full cursor-pointer transition-colors ${fileSelected ? 'ui-icon-info ui-surface-muted' : 'ui-text-muted ui-row-hover'}`}
>
<Paperclip className="w-5 h-5" />
</label>
@@ -769,12 +782,12 @@ const Chat: React.FC = () => {
onKeyDown={(e) => chatTab === 'main' && e.key === 'Enter' && send()}
placeholder={chatTab === 'main' ? t('typeMessage') : t('subagentGroupReadonly')}
disabled={chatTab !== 'main'}
className="ui-input w-full rounded-full pl-14 pr-14 py-3.5 text-[15px] transition-all shadow-sm disabled:opacity-60"
className="ui-composer-input w-full pl-14 pr-14 py-3.5 text-[15px] transition-all disabled:opacity-60"
/>
<button
onClick={send}
disabled={chatTab !== 'main' || (!msg.trim() && !fileSelected)}
className="absolute right-2 p-2.5 brand-button disabled:opacity-50 text-zinc-950 rounded-full transition-colors"
className="absolute right-2 p-2.5 brand-button disabled:opacity-50 ui-text-primary rounded-full transition-colors"
>
<Send className="w-4 h-4 ml-0.5" />
</button>

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react';
import { RefreshCw, Save } from 'lucide-react';
import { Plus, RefreshCw, Save } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
import { useUI } from '../context/UIContext';
@@ -278,46 +278,53 @@ const Config: React.FC = () => {
}
return (
<div className="p-4 md:p-8 w-full space-y-6 flex flex-col min-h-full">
<div className="p-4 md:p-6 xl:p-8 w-full space-y-4 flex flex-col min-h-full">
<div className="flex items-center justify-between gap-3 flex-wrap">
<h1 className="text-2xl font-semibold tracking-tight">{t('configuration')}</h1>
<div className="flex items-center gap-1 bg-zinc-900/60 p-1 rounded-xl border border-zinc-800">
<button onClick={() => setShowRaw(false)} className={`ui-button px-4 py-1.5 text-sm font-medium rounded-lg ${!showRaw ? 'ui-button-primary' : 'ui-button-neutral'}`}>{t('form')}</button>
<button onClick={() => setShowRaw(true)} className={`ui-button px-4 py-1.5 text-sm font-medium rounded-lg ${showRaw ? 'ui-button-primary' : 'ui-button-neutral'}`}>{t('rawJson')}</button>
<h1 className="ui-text-primary text-2xl font-semibold tracking-tight">{t('configuration')}</h1>
<div className="flex items-center gap-2 flex-wrap justify-end">
<div className="ui-toolbar-chip flex items-center gap-1 p-1 rounded-xl">
<button onClick={() => setShowRaw(false)} className={`ui-button px-4 py-1.5 text-sm font-medium rounded-lg ${!showRaw ? 'ui-button-primary' : 'ui-button-neutral'}`}>{t('form')}</button>
<button onClick={() => setShowRaw(true)} className={`ui-button px-4 py-1.5 text-sm font-medium rounded-lg ${showRaw ? 'ui-button-primary' : 'ui-button-neutral'}`}>{t('rawJson')}</button>
</div>
<button onClick={saveConfig} className="ui-button ui-button-primary flex items-center gap-2 px-4 py-2 text-sm font-medium">
<Save className="w-4 h-4" /> {t('saveChanges')}
</button>
</div>
</div>
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-3 flex-wrap">
<button onClick={async () => { await loadConfig(true); setTimeout(() => setBaseline(JSON.parse(JSON.stringify(cfg))), 0); }} className="ui-button ui-button-neutral flex items-center gap-2 px-4 py-2 text-sm font-medium">
<RefreshCw className="w-4 h-4" /> {t('reload')}
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={async () => { await loadConfig(true); setTimeout(() => setBaseline(JSON.parse(JSON.stringify(cfg))), 0); }}
className="ui-button ui-button-neutral ui-button-icon"
title={t('reload')}
aria-label={t('reload')}
>
<RefreshCw className="w-4 h-4" />
</button>
<button onClick={() => setShowDiff(true)} className="ui-button ui-button-neutral px-3 py-2 text-sm">{t('configDiffPreview')}</button>
<button onClick={() => setBasicMode(v => !v)} className="ui-button ui-button-neutral px-3 py-2 text-sm">
{basicMode ? t('configBasicMode') : t('configAdvancedMode')}
</button>
<label className="flex items-center gap-2 text-sm text-zinc-300">
<label className="ui-text-primary flex items-center gap-2 text-sm">
<input type="checkbox" checked={hotOnly} onChange={(e) => setHotOnly(e.target.checked)} />
{t('configHotOnly')}
</label>
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder={t('configSearchPlaceholder')} className="px-3 py-2 bg-zinc-950/70 border border-zinc-800 rounded-xl text-sm" />
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder={t('configSearchPlaceholder')} className="ui-input min-w-[240px] flex-1 px-3 py-2 rounded-xl text-sm" />
</div>
<button onClick={saveConfig} className="ui-button ui-button-primary flex items-center gap-2 px-4 py-2 text-sm font-medium">
<Save className="w-4 h-4" /> {t('saveChanges')}
</button>
</div>
<div className="flex-1 brand-card border border-zinc-800/80 rounded-[30px] overflow-hidden flex flex-col shadow-sm min-h-[420px]">
<div className="flex-1 brand-card ui-border-subtle border rounded-[30px] overflow-hidden flex flex-col shadow-sm min-h-[420px]">
{!showRaw ? (
<div className="flex-1 flex min-h-0">
<aside className="w-44 md:w-56 border-r border-zinc-800 bg-zinc-950/20 p-2 md:p-3 overflow-y-auto shrink-0">
<div className="text-xs text-zinc-500 uppercase tracking-widest mb-2 px-2">{t('configTopLevel')}</div>
<aside className="sidebar-section ui-border-subtle w-44 md:w-56 border-r p-2 md:p-3 overflow-y-auto shrink-0">
<div className="ui-text-secondary text-xs uppercase tracking-widest mb-2 px-2">{t('configTopLevel')}</div>
<div className="space-y-1">
{filteredTopKeys.map((k) => (
<button
key={k}
onClick={() => setSelectedTop(k)}
className={`w-full text-left px-3 py-2 rounded-xl text-sm transition-colors ${activeTop === k ? 'nav-item-active text-indigo-700 border border-indigo-500/30' : 'text-zinc-300 hover:bg-zinc-800/30'}`}
className={`w-full text-left px-3 py-2 rounded-xl text-sm transition-colors ${activeTop === k ? 'nav-item-active ui-text-primary' : 'ui-text-primary ui-row-hover'}`}
>
{k === hotReloadTabKey ? t('configHotFieldsFull') : (configLabels[k] || k)}
</button>
@@ -328,12 +335,12 @@ const Config: React.FC = () => {
<div className="flex-1 p-4 md:p-6 overflow-y-auto space-y-4">
{activeTop === hotReloadTabKey && (
<div className="space-y-3">
<div className="text-sm font-semibold text-zinc-300">{t('configHotFieldsFull')}</div>
<div className="ui-text-primary text-sm font-semibold">{t('configHotFieldsFull')}</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-xs">
{hotReloadFieldDetails.map((it) => (
<div key={it.path} className="p-2 rounded-xl bg-zinc-950/70 border border-zinc-800">
<div className="font-mono text-zinc-200">{it.path}</div>
<div className="text-zinc-400">{it.name || ''}{it.description ? ` · ${it.description}` : ''}</div>
<div key={it.path} className="ui-soft-panel ui-border-subtle p-2 rounded-xl border">
<div className="ui-text-primary font-mono">{it.path}</div>
<div className="ui-text-secondary">{it.name || ''}{it.description ? ` · ${it.description}` : ''}</div>
</div>
))}
</div>
@@ -345,7 +352,14 @@ const Config: React.FC = () => {
<div className="text-sm font-semibold text-zinc-200">{t('configProxies')}</div>
<div className="flex items-center gap-2">
<input value={newProxyName} onChange={(e)=>setNewProxyName(e.target.value)} placeholder={t('configNewProviderName')} className="px-2 py-1 rounded-lg bg-zinc-900/70 border border-zinc-700 text-xs" />
<button onClick={addProxy} className="ui-button ui-button-primary px-2 py-1 rounded-lg text-xs">{t('add')}</button>
<button
onClick={addProxy}
className="ui-button ui-button-primary ui-button-icon"
title={t('add')}
aria-label={t('add')}
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
<div className="space-y-2">
@@ -384,7 +398,7 @@ const Config: React.FC = () => {
<select
value={String((cfg as any)?.gateway?.nodes?.p2p?.transport || 'websocket_tunnel')}
onChange={(e) => updateGatewayP2PField('transport', e.target.value)}
className="w-full px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800"
className="ui-select w-full min-h-0 rounded-lg px-2 py-1 text-xs"
>
<option value="websocket_tunnel">websocket_tunnel</option>
<option value="webrtc">webrtc</option>
@@ -403,7 +417,14 @@ const Config: React.FC = () => {
<div className="space-y-2">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="text-sm font-medium text-zinc-200">{t('configNodeP2PIceServers')}</div>
<button onClick={addGatewayIceServer} className="ui-button ui-button-primary px-2 py-1 rounded-lg text-xs">{t('add')}</button>
<button
onClick={addGatewayIceServer}
className="ui-button ui-button-primary ui-button-icon"
title={t('add')}
aria-label={t('add')}
>
<Plus className="w-4 h-4" />
</button>
</div>
{Array.isArray((cfg as any)?.gateway?.nodes?.p2p?.ice_servers) && (cfg as any).gateway.nodes.p2p.ice_servers.length > 0 ? (
((cfg as any).gateway.nodes.p2p.ice_servers as Array<any>).map((server, index) => (

View File

@@ -172,11 +172,21 @@ const Cron: React.FC = () => {
<div className="flex items-center justify-between flex-wrap gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{t('cronJobs')}</h1>
<div className="flex items-center gap-3 flex-wrap">
<button onClick={() => refreshCron()} className="ui-button ui-button-neutral flex items-center gap-2 px-4 py-2 text-sm font-medium">
<RefreshCw className="w-4 h-4" /> {t('refresh')}
<button
onClick={() => refreshCron()}
className="ui-button ui-button-neutral ui-button-icon"
title={t('refresh')}
aria-label={t('refresh')}
>
<RefreshCw className="w-4 h-4" />
</button>
<button onClick={() => openCronModal()} className="ui-button ui-button-primary flex items-center gap-2 px-4 py-2 text-sm font-medium">
<Plus className="w-4 h-4" /> {t('addJob')}
<button
onClick={() => openCronModal()}
className="ui-button ui-button-primary ui-button-icon"
title={t('addJob')}
aria-label={t('addJob')}
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>

View File

@@ -122,44 +122,49 @@ const Dashboard: React.FC = () => {
{t('webui')}: <span className="font-mono text-zinc-300">{webuiVersion}</span>
</div>
</div>
<button onClick={refreshAll} className="brand-button flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-colors shrink-0 text-zinc-950">
<RefreshCw className="w-4 h-4" /> {t('refreshAll')}
<button
onClick={refreshAll}
className="ui-button ui-button-primary ui-button-icon shrink-0"
title={t('refreshAll')}
aria-label={t('refreshAll')}
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-6 gap-4">
<StatCard title={t('gatewayStatus')} value={isGatewayOnline ? t('online') : t('offline')} icon={<Activity className={`w-6 h-6 ${isGatewayOnline ? 'text-emerald-400' : 'text-red-400'}`} />} />
<StatCard title={t('activeSessions')} value={sessions.length} icon={<MessageSquare className="w-6 h-6 text-sky-400" />} />
<StatCard title={t('skills')} value={skills.length} icon={<Sparkles className="w-6 h-6 text-rose-300" />} />
<StatCard title={t('subagentsRuntime')} value={subagentCount} icon={<Wrench className="w-6 h-6 text-sky-300" />} />
<StatCard title={t('taskAudit')} value={recentTasks.length} icon={<Activity className="w-6 h-6 text-amber-400" />} />
<StatCard title={t('nodeP2P')} value={p2pEnabled ? `${p2pSessions} · ${p2pTransport}` : t('disabled')} icon={<Workflow className="w-6 h-6 text-violet-400" />} />
<StatCard title={t('gatewayStatus')} value={isGatewayOnline ? t('online') : t('offline')} icon={<Activity className={`w-6 h-6 ${isGatewayOnline ? 'ui-icon-success' : 'ui-text-danger'}`} />} />
<StatCard title={t('activeSessions')} value={sessions.length} icon={<MessageSquare className="ui-icon-info w-6 h-6" />} />
<StatCard title={t('skills')} value={skills.length} icon={<Sparkles className="ui-pill-danger w-6 h-6 rounded-full p-1" />} />
<StatCard title={t('subagentsRuntime')} value={subagentCount} icon={<Wrench className="ui-icon-info w-6 h-6" />} />
<StatCard title={t('taskAudit')} value={recentTasks.length} icon={<Activity className="ui-icon-warning w-6 h-6" />} />
<StatCard title={t('nodeP2P')} value={p2pEnabled ? `${p2pSessions} · ${p2pTransport}` : t('disabled')} icon={<Workflow className="ui-pill-accent w-6 h-6 rounded-full p-1" />} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="brand-card rounded-[28px] border border-zinc-800 p-5 min-h-[148px]">
<div className="flex items-center gap-2 text-zinc-200 mb-2">
<AlertTriangle className="w-4 h-4 text-amber-400" />
<div className="brand-card ui-border-subtle rounded-[28px] border p-5 min-h-[148px]">
<div className="ui-text-secondary flex items-center gap-2 mb-2">
<AlertTriangle className="ui-icon-warning w-4 h-4" />
<div className="text-sm font-medium">{t('ekgEscalations')}</div>
</div>
<div className="text-3xl font-semibold text-zinc-100">{ekgEscalationCount}</div>
<div className="mt-2 text-xs text-zinc-500">{t('dashboardTopErrorSignature')}: {ekgTopErrSig}</div>
<div className="ui-text-primary text-3xl font-semibold">{ekgEscalationCount}</div>
<div className="ui-text-muted mt-2 text-xs">{t('dashboardTopErrorSignature')}: {ekgTopErrSig}</div>
</div>
<div className="brand-card rounded-[28px] border border-zinc-800 p-5 min-h-[148px]">
<div className="flex items-center gap-2 text-zinc-200 mb-2">
<Sparkles className="w-4 h-4 text-sky-400" />
<div className="brand-card ui-border-subtle rounded-[28px] border p-5 min-h-[148px]">
<div className="ui-text-secondary flex items-center gap-2 mb-2">
<Sparkles className="ui-icon-info w-4 h-4" />
<div className="text-sm font-medium">{t('ekgTopProvidersWorkload')}</div>
</div>
<div className="text-2xl font-semibold text-zinc-100 truncate">{ekgTopProvider}</div>
<div className="mt-2 text-xs text-zinc-500">{t('dashboardWorkloadSnapshot')}</div>
<div className="ui-text-primary text-2xl font-semibold truncate">{ekgTopProvider}</div>
<div className="ui-text-muted mt-2 text-xs">{t('dashboardWorkloadSnapshot')}</div>
</div>
<div className="brand-card rounded-[28px] border border-zinc-800 p-5 min-h-[148px]">
<div className="flex items-center gap-2 text-zinc-200 mb-2">
<Activity className="w-4 h-4 text-rose-400" />
<div className="brand-card ui-border-subtle rounded-[28px] border p-5 min-h-[148px]">
<div className="ui-text-secondary flex items-center gap-2 mb-2">
<Activity className="ui-text-danger w-4 h-4" />
<div className="text-sm font-medium">{t('taskAudit')}</div>
</div>
<div className="text-3xl font-semibold text-zinc-100">{recentFailures.length}</div>
<div className="mt-2 text-xs text-zinc-500">{t('dashboardRecentFailedTasks')}</div>
<div className="ui-text-primary text-3xl font-semibold">{recentFailures.length}</div>
<div className="ui-text-muted mt-2 text-xs">{t('dashboardRecentFailedTasks')}</div>
</div>
</div>

View File

@@ -19,12 +19,12 @@ function StatCard({
icon: React.ReactNode;
}) {
return (
<div className="brand-card rounded-[28px] border border-zinc-800 p-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] uppercase tracking-widest text-zinc-500">{title}</div>
<div className="mt-2 text-3xl font-semibold text-zinc-100">{value}</div>
{subtitle && <div className="mt-1 text-xs text-zinc-500">{subtitle}</div>}
<div className="brand-card ui-border-subtle rounded-[28px] border p-5 min-h-[148px]">
<div className="flex h-full items-start justify-between gap-3">
<div className="flex min-h-full flex-1 flex-col">
<div className="ui-text-muted text-[11px] uppercase tracking-widest">{title}</div>
<div className="ui-text-primary mt-2 text-3xl font-semibold">{value}</div>
{subtitle && <div className="ui-text-muted mt-auto pt-4 text-xs">{subtitle}</div>}
</div>
<div className={`flex h-10 w-10 items-center justify-center rounded-xl ${accent}`}>{icon}</div>
</div>
@@ -45,18 +45,18 @@ function KVDistributionCard({
const maxValue = entries.length > 0 ? Math.max(...entries.map(([, value]) => value)) : 0;
return (
<div className="brand-card rounded-[28px] border border-zinc-800 p-5">
<div className="mb-4 text-sm font-medium text-zinc-200">{title}</div>
<div className="brand-card ui-border-subtle rounded-[28px] border p-5">
<div className="ui-text-secondary mb-4 text-sm font-medium">{title}</div>
<div className="space-y-3">
{entries.length === 0 ? (
<div className="text-sm text-zinc-500">-</div>
<div className="ui-text-muted text-sm">-</div>
) : entries.map(([key, value]) => (
<div key={key} className="space-y-1">
<div className="flex items-center justify-between gap-3 text-xs">
<div className="truncate text-zinc-300">{key}</div>
<div className="shrink-0 font-mono text-zinc-500">{value}</div>
<div className="ui-text-secondary truncate">{key}</div>
<div className="ui-text-muted shrink-0 font-mono">{value}</div>
</div>
<div className="h-2 rounded-full bg-zinc-800 overflow-hidden">
<div className="ui-surface-muted h-2 rounded-full overflow-hidden">
<div
className="ekg-bar-fill h-full rounded-full"
style={{ width: `${maxValue > 0 ? (value / maxValue) * 100 : 0}%` }}
@@ -79,19 +79,19 @@ function RankingCard({
valueMode: 'score' | 'count';
}) {
return (
<div className="brand-card rounded-[28px] border border-zinc-800 p-5">
<div className="mb-4 text-sm font-medium text-zinc-200">{title}</div>
<div className="brand-card ui-border-subtle rounded-[28px] border p-5">
<div className="ui-text-secondary mb-4 text-sm font-medium">{title}</div>
<div className="space-y-2">
{items.length === 0 ? (
<div className="text-sm text-zinc-500">-</div>
<div className="ui-text-muted text-sm">-</div>
) : items.map((item, index) => (
<div key={`${item.key || '-'}-${index}`} className="flex items-start gap-3 rounded-xl border border-zinc-800 bg-zinc-950/60 px-3 py-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-zinc-800 text-[11px] font-semibold text-zinc-300">
<div key={`${item.key || '-'}-${index}`} className="ui-border-subtle ui-surface-strong flex items-start gap-3 rounded-xl border px-3 py-2">
<div className="ui-surface-muted ui-text-secondary flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold">
{index + 1}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-zinc-200">{item.key || '-'}</div>
<div className="text-xs text-zinc-500">
<div className="ui-text-secondary truncate text-sm">{item.key || '-'}</div>
<div className="ui-text-muted text-xs">
{valueMode === 'score'
? Number(item.score || 0).toFixed(2)
: `x${item.count || 0}`}
@@ -155,24 +155,28 @@ const EKG: React.FC = () => {
<div className="h-full w-full p-4 md:p-6 xl:p-8 flex flex-col gap-6">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t('ekg')}</h1>
<div className="mt-1 text-sm text-zinc-500">{t('ekgOverviewHint')}</div>
<h1 className="ui-text-primary text-2xl font-semibold tracking-tight">{t('ekg')}</h1>
<div className="ui-text-muted mt-1 text-sm">{t('ekgOverviewHint')}</div>
</div>
<div className="flex items-center gap-2">
<select value={ekgWindow} onChange={(e) => setEkgWindow(e.target.value as '6h' | '24h' | '7d')} className="bg-zinc-900/70 border border-zinc-700 rounded-xl px-3 py-2 text-sm">
<select value={ekgWindow} onChange={(e) => setEkgWindow(e.target.value as '6h' | '24h' | '7d')} className="ui-select h-12 min-w-[96px] rounded-xl px-3 text-sm">
<option value="6h">6h</option>
<option value="24h">24h</option>
<option value="7d">7d</option>
</select>
<button onClick={fetchData} className="brand-button inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm text-zinc-950">
<button
onClick={fetchData}
className="ui-button ui-button-primary ui-button-icon h-10 w-10 rounded-xl"
title={loading ? t('loading') : t('refresh')}
aria-label={loading ? t('loading') : t('refresh')}
>
<RefreshCw className="w-4 h-4" />
{loading ? t('loading') : t('refresh')}
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<StatCard title={t('ekgEscalations')} value={escalationCount} subtitle={`${ekgWindow} window`} accent="ui-pill ui-pill-warning border" icon={<AlertTriangle className="w-5 h-5" />} />
<StatCard title={t('ekgEscalations')} value={escalationCount} subtitle={t('ekgWindowLabel', { window: ekgWindow })} accent="ui-pill ui-pill-warning border" icon={<AlertTriangle className="w-5 h-5" />} />
<StatCard title={t('ekgSourceStats')} value={sourceCount} subtitle={t('ekgActiveSources')} accent="ui-pill ui-pill-info border" icon={<Workflow className="w-5 h-5" />} />
<StatCard title={t('ekgChannelStats')} value={channelCount} subtitle={t('ekgActiveChannels')} accent="ui-pill ui-pill-accent border" icon={<Route className="w-5 h-5" />} />
<StatCard title={t('ekgTopProvidersWorkload')} value={topWorkloadProvider} subtitle={`${t('ekgErrorsCount')} ${totalErrorHits}`} accent="ui-pill ui-pill-danger border" icon={<ServerCrash className="w-5 h-5" />} />

View File

@@ -42,33 +42,35 @@ const LogCodes: React.FC = () => {
return (
<div className="p-4 md:p-6 xl:p-8 w-full space-y-6">
<div className="flex items-center justify-between gap-3 flex-wrap">
<h1 className="text-2xl font-semibold tracking-tight">{t('logCodes')}</h1>
<h1 className="ui-text-secondary text-2xl font-semibold tracking-tight">{t('logCodes')}</h1>
<input
value={kw}
onChange={(e) => setKw(e.target.value)}
placeholder={t('logCodesSearchPlaceholder')}
className="w-full sm:w-80 bg-zinc-900/70 border border-zinc-800 rounded-xl px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20"
className="ui-input w-full sm:w-80 rounded-xl px-3 py-2 text-sm"
/>
</div>
<div className="brand-card border border-zinc-800 rounded-[30px] overflow-hidden">
<div className="brand-card ui-border-subtle border rounded-[30px] overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-zinc-900/70 border-b border-zinc-800">
<tr className="text-zinc-400">
<thead className="ui-soft-panel ui-border-subtle border-b">
<tr className="ui-text-secondary">
<th className="text-left p-3 font-medium w-40">{t('code')}</th>
<th className="text-left p-3 font-medium">{t('template')}</th>
</tr>
</thead>
<tbody>
{filtered.map((it) => (
<tr key={it.code} className="border-b border-zinc-900/70 hover:bg-zinc-900/20">
<td className="p-3 font-mono text-indigo-300">{it.code}</td>
<td className="p-3 text-zinc-200 break-all">{it.text}</td>
<tr key={it.code} className="ui-border-subtle ui-row-hover border-b">
<td className="p-3">
<span className="ui-code-badge px-3 py-1 font-mono text-sm">{it.code}</span>
</td>
<td className="ui-text-secondary p-3 break-all">{it.text}</td>
</tr>
))}
{filtered.length === 0 && (
<tr>
<td className="p-6 text-zinc-500" colSpan={2}>{t('logCodesNoCodes')}</td>
<td className="ui-text-muted p-6" colSpan={2}>{t('logCodesNoCodes')}</td>
</tr>
)}
</tbody>

View File

@@ -154,18 +154,18 @@ const Logs: React.FC = () => {
const getLevelColor = (level: string) => {
switch ((level || 'INFO').toUpperCase()) {
case 'ERROR': return 'text-red-400';
case 'WARN': return 'text-amber-400';
case 'DEBUG': return 'text-sky-400';
default: return 'text-emerald-400';
case 'ERROR': return 'ui-text-danger';
case 'WARN': return 'ui-code-warning';
case 'DEBUG': return 'ui-icon-info';
default: return 'ui-icon-success';
}
};
return (
<div className="p-4 md:p-6 xl:p-8 w-full space-y-6 h-full flex flex-col">
<div className="p-4 md:p-5 xl:p-6 w-full space-y-4 h-full flex flex-col">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{t('logs')}</h1>
<h1 className="ui-text-primary text-2xl font-semibold tracking-tight">{t('logs')}</h1>
<div className={`ui-pill flex items-center gap-1.5 px-2.5 py-0.5 rounded-md text-[10px] font-bold uppercase tracking-wider border ${
isStreaming ? 'ui-pill-success' : 'ui-pill-neutral'
}`}>
@@ -194,36 +194,36 @@ const Logs: React.FC = () => {
</div>
</div>
<div className="flex-1 brand-card border border-zinc-800 rounded-[30px] overflow-hidden flex flex-col shadow-2xl">
<div className="bg-zinc-900/20 px-4 py-2 border-b border-zinc-800 flex items-center justify-between">
<div className="flex-1 brand-card ui-border-subtle border rounded-[30px] overflow-hidden flex flex-col shadow-2xl">
<div className="ui-soft-panel ui-border-subtle px-4 py-2 border-b flex items-center justify-between">
<div className="flex items-center gap-2">
<Terminal className="w-4 h-4 text-zinc-500" />
<span className="text-xs font-mono text-zinc-500">{t('systemLog')}</span>
<Terminal className="ui-icon-muted w-4 h-4" />
<span className="ui-text-primary text-xs font-mono">{t('systemLog')}</span>
</div>
<span className="text-[10px] font-mono text-zinc-600 uppercase tracking-widest">{logs.length} {t('entries')}</span>
<span className="ui-text-secondary text-[10px] font-mono uppercase tracking-widest">{logs.length} {t('entries')}</span>
</div>
<div className="flex-1 overflow-auto selection:bg-indigo-500/30">
{logs.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-zinc-700 space-y-2 p-4">
<div className="ui-text-primary h-full flex flex-col items-center justify-center space-y-2 p-4">
<Terminal className="w-8 h-8 opacity-10" />
<p>{t('waitingForLogs')}</p>
</div>
) : showRaw ? (
<div className="p-3 font-mono text-xs space-y-1">
{logs.map((log, i) => (
<div key={i} className="border-b border-zinc-900/70 py-1 text-zinc-300 break-all">{log.__raw || JSON.stringify(log)}</div>
<div key={i} className="ui-border-subtle ui-text-primary border-b py-1 break-all">{log.__raw || JSON.stringify(log)}</div>
))}
<div ref={logEndRef} />
</div>
) : (
<table className="w-full text-xs">
<thead className="sticky top-0 bg-zinc-900/85 border-b border-zinc-800">
<tr className="text-zinc-400">
<th className="text-left p-2 font-medium">{t('time')}</th>
<th className="text-left p-2 font-medium">{t('level')}</th>
<th className="text-left p-2 font-medium">{t('message')}</th>
<th className="text-left p-2 font-medium">{t('error')}</th>
<th className="text-left p-2 font-medium">{t('codeCaller')}</th>
<thead className="ui-soft-panel ui-border-subtle sticky top-0 border-b">
<tr className="ui-text-primary">
<th className="text-left px-2 py-2 font-semibold">{t('time')}</th>
<th className="text-left px-2 py-2 font-semibold">{t('level')}</th>
<th className="text-left px-2 py-2 font-semibold">{t('message')}</th>
<th className="text-left px-2 py-2 font-semibold">{t('error')}</th>
<th className="text-left px-2 py-2 font-semibold">{t('codeCaller')}</th>
</tr>
</thead>
<tbody>
@@ -236,12 +236,12 @@ const Logs: React.FC = () => {
const caller = (log as any).caller || (log as any).source || '';
const code = toCode(rawCode);
return (
<tr key={i} className="border-b border-zinc-900/70 hover:bg-zinc-900/25 align-top">
<td className="p-2 text-zinc-500 whitespace-nowrap">{formatLocalTime(log.time)}</td>
<td className={`p-2 font-semibold whitespace-nowrap ${getLevelColor(lvl)}`}>{lvl}</td>
<td className="p-2 text-zinc-200 break-all">{message}</td>
<td className="p-2 text-red-300 break-all">{errText}</td>
<td className="p-2 text-zinc-500 break-all">{code ? `${code} | ${caller}` : caller}</td>
<tr key={i} className="ui-border-subtle ui-row-hover border-b align-top">
<td className="ui-text-secondary px-2 py-1.5 whitespace-nowrap">{formatLocalTime(log.time)}</td>
<td className={`px-2 py-1.5 font-semibold whitespace-nowrap ${getLevelColor(lvl)}`}>{lvl}</td>
<td className="ui-text-primary px-2 py-1.5 break-all">{message}</td>
<td className="ui-text-danger px-2 py-1.5 break-all">{errText}</td>
<td className="ui-text-secondary px-2 py-1.5 break-all">{code ? `${code} | ${caller}` : caller}</td>
</tr>
);
})}

View File

@@ -358,15 +358,19 @@ const MCP: React.FC = () => {
<div className="flex items-center gap-3 flex-wrap">
<button
onClick={async () => { await loadConfig(true); await refreshMCPTools(); }}
className="ui-button ui-button-neutral flex items-center gap-2 px-4 py-2 text-sm font-medium"
className="ui-button ui-button-neutral ui-button-icon"
title={t('reload')}
aria-label={t('reload')}
>
<RefreshCw className="w-4 h-4" /> {t('reload')}
<RefreshCw className="w-4 h-4" />
</button>
<button
onClick={openCreateModal}
className="ui-button ui-button-primary flex items-center gap-2 px-4 py-2 text-sm font-medium"
className="ui-button ui-button-primary ui-button-icon"
title={t('add')}
aria-label={t('add')}
>
<Plus className="w-4 h-4" /> {t('add')}
<Plus className="w-4 h-4" />
</button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
import { useUI } from '../context/UIContext';
@@ -111,26 +112,41 @@ const Memory: React.FC = () => {
}, [q]);
return (
<div className="flex h-full flex-col lg:flex-row brand-card rounded-[30px] border border-zinc-800 overflow-hidden">
<aside className="w-full lg:w-72 border-b lg:border-b-0 lg:border-r border-zinc-800 p-4 space-y-2 overflow-y-auto bg-zinc-950/20">
<div className="flex items-center justify-between">
<h2 className="font-semibold">{t('memoryFiles')}</h2>
<button onClick={createFile} className="ui-button ui-button-primary px-2.5 py-1 rounded-xl">+</button>
</div>
{files.map((f) => (
<div key={f} className={`flex items-center justify-between p-2.5 rounded-2xl ${active === f ? 'nav-item-active' : 'hover:bg-zinc-900/30'}`}>
<button className="text-left flex-1" onClick={() => openFile(f)}>{f}</button>
<button className="ui-button ui-button-danger px-2 py-1 text-xs rounded-lg" onClick={() => removeFile(f)}>x</button>
<div className="h-full p-4 md:p-5 xl:p-6">
<div className="flex h-full flex-col overflow-hidden rounded-[30px] border brand-card ui-border-subtle lg:flex-row">
<aside className="ui-border-subtle w-full overflow-y-auto border-b p-2 md:p-3 lg:w-72 lg:border-r lg:border-b-0">
<div className="sidebar-section rounded-[24px] p-2 md:p-2.5 space-y-1">
<div className="flex items-center justify-between">
<h2 className="ui-text-primary font-semibold">{t('memoryFiles')}</h2>
<button onClick={createFile} className="ui-button ui-button-primary ui-button-square rounded-xl">+</button>
</div>
<div className="space-y-1">
{files.map((f) => (
<div key={f} className={`flex items-center justify-between px-2.5 py-2 rounded-2xl ${active === f ? 'nav-item-active' : 'ui-row-hover'}`}>
<button className={`text-left flex-1 min-w-0 break-all pr-2 ${active === f ? 'ui-text-primary font-medium' : 'ui-text-primary'}`} onClick={() => openFile(f)}>{f}</button>
<button
className="ui-text-danger ui-text-danger-hover shrink-0 p-1"
onClick={() => removeFile(f)}
aria-label={t('delete')}
title={t('delete')}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
</div>
))}
</aside>
<main className="flex-1 p-4 md:p-6 space-y-3 min-h-0">
<div className="flex items-center justify-between">
<h2 className="font-semibold">{active || t('noFileSelected')}</h2>
<button onClick={saveFile} className="ui-button ui-button-primary px-3 py-1.5 rounded-xl">{t('save')}</button>
</div>
<textarea value={content} onChange={(e) => setContent(e.target.value)} className="w-full h-[50vh] lg:h-[80vh] bg-zinc-900/70 border border-zinc-800 rounded-[24px] p-4 focus:outline-none focus:ring-2 focus:ring-indigo-500/20" />
</main>
</aside>
<main className="flex-1 overflow-y-auto p-4 md:p-5">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="ui-text-primary font-semibold">{active || t('noFileSelected')}</h2>
<button onClick={saveFile} className="ui-button ui-button-primary px-3 py-1.5 rounded-xl">{t('save')}</button>
</div>
<textarea value={content} onChange={(e) => setContent(e.target.value)} className="ui-textarea w-full h-[50vh] lg:h-[80vh] rounded-[24px] p-4" />
</div>
</main>
</div>
</div>
);
};

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react';
import { RefreshCw } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
import { useAppContext } from '../context/AppContext';
@@ -137,24 +138,29 @@ const NodeArtifacts: React.FC = () => {
<div className="h-full p-4 md:p-6 xl:p-8 flex flex-col gap-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="text-xl md:text-2xl font-semibold">{t('nodeArtifacts')}</h1>
<div className="text-sm text-zinc-500 mt-1">{t('nodeArtifactsHint')}</div>
<h1 className="ui-text-primary text-xl md:text-2xl font-semibold">{t('nodeArtifacts')}</h1>
<div className="ui-text-muted text-sm mt-1">{t('nodeArtifactsHint')}</div>
</div>
<div className="flex items-center gap-2">
<a href={exportURL()} className="ui-button ui-button-neutral px-3 py-1.5 text-sm">
{t('export')}
</a>
<button onClick={loadArtifacts} className="ui-button ui-button-primary px-3 py-1.5 text-sm">
{loading ? t('loading') : t('refresh')}
<button
onClick={loadArtifacts}
className="ui-button ui-button-primary ui-button-icon"
title={loading ? t('loading') : t('refresh')}
aria-label={loading ? t('loading') : t('refresh')}
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-[320px_1fr] gap-4 flex-1 min-h-0">
<div className="brand-card ui-panel rounded-[28px] overflow-hidden flex flex-col min-h-0">
<div className="p-3 border-b border-zinc-800 dark:border-zinc-700 space-y-2">
<div className="ui-code-panel p-3 text-xs text-zinc-700 dark:text-zinc-300 space-y-1">
<div className="font-medium text-zinc-100">{t('nodeArtifactsRetention')}</div>
<div className="ui-border-subtle p-3 border-b space-y-2">
<div className="ui-code-panel ui-text-secondary p-3 text-xs space-y-1">
<div className="ui-text-primary font-medium">{t('nodeArtifactsRetention')}</div>
<div>{t('nodeArtifactsRetentionKeepLatest')}: {Number(retentionSummary?.keep_latest || 0) || '-'}</div>
<div>{t('nodeArtifactsRetentionRetainDays')}: {Number(retentionSummary?.retain_days || 0)}</div>
<div>{t('nodeArtifactsRetentionPruned')}: {Number(retentionSummary?.pruned || retentionSummary?.manual_pruned || 0)}</div>
@@ -193,18 +199,18 @@ const NodeArtifacts: React.FC = () => {
</div>
<div className="overflow-y-auto min-h-0">
{filteredItems.length === 0 ? (
<div className="p-4 text-sm text-zinc-500">{t('nodeArtifactsEmpty')}</div>
<div className="ui-text-muted p-4 text-sm">{t('nodeArtifactsEmpty')}</div>
) : filteredItems.map((item, index) => {
const active = String(selected?.id || '') === String(item?.id || '');
return (
<button
key={String(item?.id || index)}
onClick={() => setSelectedID(String(item?.id || ''))}
className={`w-full text-left px-3 py-3 border-b border-zinc-800/60 hover:bg-zinc-800/20 ${active ? 'bg-indigo-500/15' : ''}`}
className={`ui-border-subtle w-full text-left px-3 py-3 border-b ${active ? 'ui-card-active-warning' : 'ui-row-hover'}`}
>
<div className="text-sm font-medium text-zinc-100 truncate">{String(item?.name || item?.source_path || `artifact-${index + 1}`)}</div>
<div className="text-xs text-zinc-400 truncate">{String(item?.node || '-')} · {String(item?.action || '-')} · {String(item?.kind || '-')}</div>
<div className="text-[11px] text-zinc-500 truncate">{formatLocalDateTime(item?.time)}</div>
<div className="ui-text-primary text-sm font-medium truncate">{String(item?.name || item?.source_path || `artifact-${index + 1}`)}</div>
<div className="ui-text-subtle text-xs truncate">{String(item?.node || '-')} · {String(item?.action || '-')} · {String(item?.kind || '-')}</div>
<div className="ui-text-muted text-[11px] truncate">{formatLocalDateTime(item?.time)}</div>
</button>
);
})}
@@ -212,16 +218,16 @@ const NodeArtifacts: React.FC = () => {
</div>
<div className="brand-card ui-panel rounded-[28px] overflow-hidden flex flex-col min-h-0">
<div className="px-3 py-2 border-b border-zinc-800 dark:border-zinc-700 text-xs text-zinc-400 uppercase tracking-wider">{t('nodeArtifactDetail')}</div>
<div className="ui-border-subtle ui-text-subtle px-3 py-2 border-b text-xs uppercase tracking-wider">{t('nodeArtifactDetail')}</div>
<div className="p-4 overflow-y-auto min-h-0 space-y-4 text-sm">
{!selected ? (
<div className="text-zinc-500">{t('nodeArtifactsEmpty')}</div>
<div className="ui-text-muted">{t('nodeArtifactsEmpty')}</div>
) : (
<>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-lg font-medium text-zinc-100">{String(selected?.name || selected?.source_path || 'artifact')}</div>
<div className="text-xs text-zinc-500 mt-1">{String(selected?.node || '-')} · {String(selected?.action || '-')} · {formatLocalDateTime(selected?.time)}</div>
<div className="ui-text-primary text-lg font-medium">{String(selected?.name || selected?.source_path || 'artifact')}</div>
<div className="ui-text-muted text-xs mt-1">{String(selected?.node || '-')} · {String(selected?.action || '-')} · {formatLocalDateTime(selected?.time)}</div>
</div>
<div className="flex items-center gap-2">
<a href={downloadURL(String(selected?.id || ''))} className="ui-button ui-button-neutral px-3 py-1.5 text-xs">
@@ -234,13 +240,13 @@ const NodeArtifacts: React.FC = () => {
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div><div className="text-zinc-500 text-xs">{t('node')}</div><div>{String(selected?.node || '-')}</div></div>
<div><div className="text-zinc-500 text-xs">{t('action')}</div><div>{String(selected?.action || '-')}</div></div>
<div><div className="text-zinc-500 text-xs">{t('kind')}</div><div>{String(selected?.kind || '-')}</div></div>
<div><div className="text-zinc-500 text-xs">{t('size')}</div><div>{formatBytes(selected?.size_bytes)}</div></div>
<div><div className="ui-text-muted text-xs">{t('node')}</div><div className="ui-text-secondary">{String(selected?.node || '-')}</div></div>
<div><div className="ui-text-muted text-xs">{t('action')}</div><div className="ui-text-secondary">{String(selected?.action || '-')}</div></div>
<div><div className="ui-text-muted text-xs">{t('kind')}</div><div className="ui-text-secondary">{String(selected?.kind || '-')}</div></div>
<div><div className="ui-text-muted text-xs">{t('size')}</div><div className="ui-text-secondary">{formatBytes(selected?.size_bytes)}</div></div>
</div>
<div className="text-xs text-zinc-500 break-all">
<div className="ui-text-muted text-xs break-all">
{String(selected?.source_path || selected?.path || selected?.url || '-')}
</div>
@@ -259,7 +265,7 @@ const NodeArtifacts: React.FC = () => {
if (String(selected?.content_text || '').trim() !== '') {
return <pre className="ui-code-panel p-3 text-[12px] whitespace-pre-wrap overflow-auto max-h-[420px]">{String(selected?.content_text || '')}</pre>;
}
return <div className="text-zinc-500">{t('nodeArtifactPreviewUnavailable')}</div>;
return <div className="ui-text-muted">{t('nodeArtifactPreviewUnavailable')}</div>;
})()}
<pre className="ui-code-panel p-3 text-xs overflow-auto">{JSON.stringify(selected, null, 2)}</pre>

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Check } from 'lucide-react';
import { Check, RefreshCw } from 'lucide-react';
import { useAppContext } from '../context/AppContext';
import { formatLocalDateTime } from '../utils/time';
@@ -221,15 +221,19 @@ const Nodes: React.FC = () => {
<h1 className="text-xl md:text-2xl font-semibold">{t('nodes')}</h1>
<div className="text-sm text-zinc-500 mt-1">{t('nodesDetailHint')}</div>
</div>
<button onClick={() => { refreshNodes(); setReloadTick((value) => value + 1); }} className="ui-button ui-button-primary px-3 py-1.5 text-sm">
{loading ? t('loading') : t('refresh')}
<button
onClick={() => { refreshNodes(); setReloadTick((value) => value + 1); }}
className="ui-button ui-button-primary ui-button-icon"
title={loading ? t('loading') : t('refresh')}
aria-label={loading ? t('loading') : t('refresh')}
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
<div className="grid grid-cols-1 xl:grid-cols-[300px_1fr_1.1fr] gap-4 flex-1 min-h-0">
<div className="brand-card ui-panel rounded-[28px] overflow-hidden flex flex-col min-h-0">
<div className="px-3 py-2 border-b border-zinc-800 dark:border-zinc-700 space-y-2">
<div className="text-xs text-zinc-400 uppercase tracking-wider">{t('nodes')}</div>
<input
value={nodeFilter}
onChange={(e) => setNodeFilter(e.target.value)}

View File

@@ -213,11 +213,21 @@ const Skills: React.FC = () => {
<Zap className="w-4 h-4" /> {t('skillsInstallNow')}
</button>
)}
<button onClick={() => refreshSkills()} className="ui-button ui-button-neutral flex items-center gap-2 px-4 py-2 text-sm font-medium">
<RefreshCw className="w-4 h-4" /> {t('refresh')}
<button
onClick={() => refreshSkills()}
className="ui-button ui-button-neutral ui-button-icon"
title={t('refresh')}
aria-label={t('refresh')}
>
<RefreshCw className="w-4 h-4" />
</button>
<button onClick={onAddSkillClick} className="ui-button ui-button-primary flex items-center gap-2 px-4 py-2 text-sm font-medium shadow-sm">
<Plus className="w-4 h-4" /> {t('skillsAdd')}
<button
onClick={onAddSkillClick}
className="ui-button ui-button-primary ui-button-icon shadow-sm"
title={t('skillsAdd')}
aria-label={t('skillsAdd')}
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Check } from 'lucide-react';
import { Check, Plus, RefreshCw } from 'lucide-react';
import { useAppContext } from '../context/AppContext';
import { useUI } from '../context/UIContext';
@@ -272,18 +272,28 @@ const SubagentProfiles: React.FC = () => {
<div className="flex items-center justify-between">
<h1 className="text-xl md:text-2xl font-semibold">{t('subagentProfiles')}</h1>
<div className="flex items-center gap-2">
<button onClick={() => load()} className="ui-button ui-button-neutral px-3 py-1.5 text-sm">
{t('refresh')}
<button
onClick={() => load()}
className="ui-button ui-button-neutral ui-button-icon"
title={t('refresh')}
aria-label={t('refresh')}
>
<RefreshCw className="w-4 h-4" />
</button>
<button onClick={onNew} className="ui-button ui-button-success px-3 py-1.5 text-sm">
{t('newProfile')}
<button
onClick={onNew}
className="ui-button ui-button-success ui-button-icon"
title={t('newProfile')}
aria-label={t('newProfile')}
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-[360px_1fr] gap-4">
<div className="brand-card rounded-[28px] border border-zinc-800 overflow-hidden">
<div className="px-3 py-2 border-b border-zinc-800 text-xs text-zinc-400 uppercase tracking-wider">
<div className="brand-card ui-border-subtle rounded-[28px] border overflow-hidden">
<div className="ui-border-subtle ui-text-subtle px-3 py-2 border-b text-xs uppercase tracking-wider">
{t('subagentProfiles')}
</div>
<div className="overflow-y-auto max-h-[70vh]">
@@ -291,17 +301,17 @@ const SubagentProfiles: React.FC = () => {
<button
key={it.agent_id}
onClick={() => onSelect(it)}
className="w-full text-left px-3 py-2 border-b border-zinc-800/50 transition-colors"
className="ui-border-subtle ui-row-hover w-full text-left px-3 py-2 border-b transition-colors"
>
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<div className="text-sm text-zinc-100 truncate">{it.agent_id || '-'}</div>
<div className="text-xs text-zinc-400 truncate">
<div className="ui-text-primary text-sm truncate">{it.agent_id || '-'}</div>
<div className="ui-text-subtle text-xs truncate">
{(it.status || 'active')} · {it.role || '-'} · {(it.memory_namespace || '-')}
</div>
</div>
{selectedId === it.agent_id && (
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-indigo-500/15 text-indigo-300 self-center">
<span className="ui-pill ui-pill-info inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full self-center">
<Check className="w-3.5 h-3.5" />
</span>
)}
@@ -309,58 +319,58 @@ const SubagentProfiles: React.FC = () => {
</button>
))}
{items.length === 0 && (
<div className="px-3 py-4 text-sm text-zinc-500">No subagent profiles.</div>
<div className="ui-text-muted px-3 py-4 text-sm">No subagent profiles.</div>
)}
</div>
</div>
<div className="brand-card rounded-[28px] border border-zinc-800 p-4 space-y-3">
<div className="brand-card ui-border-subtle rounded-[28px] border p-4 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<div className="text-xs text-zinc-400 mb-1">{t('id')}</div>
<div className="ui-text-subtle text-xs mb-1">{t('id')}</div>
<input
value={draft.agent_id || ''}
disabled={!!selected}
onChange={(e) => setDraft({ ...draft, agent_id: e.target.value })}
className="w-full px-2 py-1.5 text-xs bg-zinc-900/70 border border-zinc-700 rounded-xl disabled:opacity-60"
className="ui-input w-full px-2 py-1.5 text-xs rounded-xl disabled:opacity-60"
placeholder="coder"
/>
</div>
<div>
<div className="text-xs text-zinc-400 mb-1">{t('name')}</div>
<div className="ui-text-subtle text-xs mb-1">{t('name')}</div>
<input
value={draft.name || ''}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded"
className="ui-input w-full px-2 py-1 text-xs rounded"
placeholder="Code Agent"
/>
</div>
<div>
<div className="text-xs text-zinc-400 mb-1">Role</div>
<div className="ui-text-subtle text-xs mb-1">Role</div>
<input
value={draft.role || ''}
onChange={(e) => setDraft({ ...draft, role: e.target.value })}
className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded"
className="ui-input w-full px-2 py-1 text-xs rounded"
placeholder="coding"
/>
</div>
<div>
<div className="text-xs text-zinc-400 mb-1">{t('status')}</div>
<div className="ui-text-subtle text-xs mb-1">{t('status')}</div>
<select
value={draft.status || 'active'}
onChange={(e) => setDraft({ ...draft, status: e.target.value })}
className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded"
className="ui-select w-full px-2 py-1 text-xs rounded"
>
<option value="active">active</option>
<option value="disabled">disabled</option>
</select>
</div>
<div>
<div className="text-xs text-zinc-400 mb-1">notify_main_policy</div>
<div className="ui-text-subtle text-xs mb-1">notify_main_policy</div>
<select
value={draft.notify_main_policy || 'final_only'}
onChange={(e) => setDraft({ ...draft, notify_main_policy: e.target.value })}
className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded"
className="ui-select w-full px-2 py-1 text-xs rounded"
>
<option value="final_only">final_only</option>
<option value="internal_only">internal_only</option>
@@ -370,33 +380,33 @@ const SubagentProfiles: React.FC = () => {
</select>
</div>
<div className="md:col-span-2">
<div className="text-xs text-zinc-400 mb-1">system_prompt_file</div>
<div className="ui-text-subtle text-xs mb-1">system_prompt_file</div>
<input
value={draft.system_prompt_file || ''}
onChange={(e) => setDraft({ ...draft, system_prompt_file: e.target.value })}
className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded"
className="ui-input w-full px-2 py-1 text-xs rounded"
placeholder="agents/coder/AGENT.md"
/>
</div>
<div className="md:col-span-2">
<div className="text-xs text-zinc-400 mb-1">{t('memoryNamespace')}</div>
<div className="ui-text-subtle text-xs mb-1">{t('memoryNamespace')}</div>
<input
value={draft.memory_namespace || ''}
onChange={(e) => setDraft({ ...draft, memory_namespace: e.target.value })}
className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded"
className="ui-input w-full px-2 py-1 text-xs rounded"
placeholder="coder"
/>
</div>
<div className="md:col-span-2">
<div className="text-xs text-zinc-400 mb-1">{t('toolAllowlist')}</div>
<div className="ui-text-subtle text-xs mb-1">{t('toolAllowlist')}</div>
<input
value={allowlistText}
onChange={(e) => setDraft({ ...draft, tool_allowlist: parseAllowlist(e.target.value) })}
className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded"
className="ui-input w-full px-2 py-1 text-xs rounded"
placeholder="read_file, list_files, memory_search"
/>
<div className="mt-1 text-[11px] text-zinc-500">
<span className="font-mono text-zinc-400">skill_exec</span> is inherited automatically and does not need to be listed here.
<div className="ui-text-muted mt-1 text-[11px]">
<span className="ui-text-subtle font-mono">skill_exec</span> is inherited automatically and does not need to be listed here.
</div>
{groups.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
@@ -416,13 +426,13 @@ const SubagentProfiles: React.FC = () => {
</div>
<div className="md:col-span-2">
<div className="flex items-center justify-between mb-1 gap-3">
<div className="text-xs text-zinc-400">system_prompt_file content</div>
<div className="text-[11px] text-zinc-500">{promptFileFound ? t('promptFileReady') : t('promptFileMissing')}</div>
<div className="ui-text-subtle text-xs">system_prompt_file content</div>
<div className="ui-text-muted text-[11px]">{promptFileFound ? t('promptFileReady') : t('promptFileMissing')}</div>
</div>
<textarea
value={promptFileContent}
onChange={(e) => setPromptFileContent(e.target.value)}
className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded min-h-[220px]"
className="ui-textarea w-full px-2 py-1 text-xs rounded min-h-[220px]"
placeholder={t('agentPromptContentPlaceholder')}
/>
<div className="mt-2 flex items-center gap-2">
@@ -437,43 +447,43 @@ const SubagentProfiles: React.FC = () => {
</div>
</div>
<div>
<div className="text-xs text-zinc-400 mb-1">{t('maxRetries')}</div>
<div className="ui-text-subtle text-xs mb-1">{t('maxRetries')}</div>
<input
type="number"
min={0}
value={Number(draft.max_retries || 0)}
onChange={(e) => setDraft({ ...draft, max_retries: Number(e.target.value) || 0 })}
className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded"
className="ui-input w-full px-2 py-1 text-xs rounded"
/>
</div>
<div>
<div className="text-xs text-zinc-400 mb-1">{t('retryBackoffMs')}</div>
<div className="ui-text-subtle text-xs mb-1">{t('retryBackoffMs')}</div>
<input
type="number"
min={0}
value={Number(draft.retry_backoff_ms || 0)}
onChange={(e) => setDraft({ ...draft, retry_backoff_ms: Number(e.target.value) || 0 })}
className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded"
className="ui-input w-full px-2 py-1 text-xs rounded"
/>
</div>
<div>
<div className="text-xs text-zinc-400 mb-1">Max Task Chars</div>
<div className="ui-text-subtle text-xs mb-1">Max Task Chars</div>
<input
type="number"
min={0}
value={Number(draft.max_task_chars || 0)}
onChange={(e) => setDraft({ ...draft, max_task_chars: Number(e.target.value) || 0 })}
className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded"
className="ui-input w-full px-2 py-1 text-xs rounded"
/>
</div>
<div className="md:col-span-2">
<div className="text-xs text-zinc-400 mb-1">Max Result Chars</div>
<div className="ui-text-subtle text-xs mb-1">Max Result Chars</div>
<input
type="number"
min={0}
value={Number(draft.max_result_chars || 0)}
onChange={(e) => setDraft({ ...draft, max_result_chars: Number(e.target.value) || 0 })}
className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded"
className="ui-input w-full px-2 py-1 text-xs rounded"
/>
</div>
</div>

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
import { useUI } from '../context/UIContext';
import { Activity, Server, Cpu, Network } from 'lucide-react';
import { Activity, Server, Cpu, Network, RefreshCw } from 'lucide-react';
import { SpaceParticles } from '../components/SpaceParticles';
type SubagentTask = {
@@ -1158,7 +1158,14 @@ const Subagents: React.FC = () => {
<div className="h-full p-4 md:p-6 xl:p-8 flex flex-col gap-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<h1 className="text-xl md:text-2xl font-semibold">{t('subagentsRuntime')}</h1>
<button onClick={() => load()} className="brand-button px-3 py-1.5 rounded-xl text-sm text-zinc-950">{t('refresh')}</button>
<button
onClick={() => load()}
className="ui-button ui-button-primary ui-button-icon"
title={t('refresh')}
aria-label={t('refresh')}
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
<div className="flex-1 min-h-0 brand-card border border-zinc-800 p-4 flex flex-col gap-3">

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Check } from 'lucide-react';
import { Check, RefreshCw } from 'lucide-react';
import { useAppContext } from '../context/AppContext';
import { formatLocalDateTime } from '../utils/time';
@@ -128,7 +128,14 @@ const TaskAudit: React.FC = () => {
<option value="error">{t('statusError')}</option>
<option value="suppressed">{t('statusSuppressed')}</option>
</select>
<button onClick={fetchData} className="ui-button ui-button-primary px-3 py-1.5 text-sm">{loading ? t('loading') : t('refresh')}</button>
<button
onClick={fetchData}
className="ui-button ui-button-primary ui-button-icon"
title={loading ? t('loading') : t('refresh')}
aria-label={loading ? t('loading') : t('refresh')}
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>