mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-19 02:04:57 +08:00
refine whatsapp bridge defaults and webui polish
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.25.5-bookworm AS builder
|
||||
FROM golang:1.25.7-bookworm AS builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
|
||||
13
Makefile
13
Makefile
@@ -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 .
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -528,7 +528,7 @@ func DefaultConfig() *Config {
|
||||
OutboundDedupeWindowSeconds: 12,
|
||||
WhatsApp: WhatsAppConfig{
|
||||
Enabled: false,
|
||||
BridgeURL: "ws://localhost:3001",
|
||||
BridgeURL: "",
|
||||
AllowFrom: []string{},
|
||||
EnableGroups: true,
|
||||
RequireMentionInGroups: true,
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '日志格式',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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" />} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user