mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-12 12:57:31 +08:00
polish webui and add desktop gateway service support
This commit is contained in:
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -578,6 +579,12 @@ func applyMaximumPermissionPolicy(cfg *config.Config) {
|
||||
}
|
||||
|
||||
func gatewayInstallServiceCmd() error {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return gatewayInstallLaunchdService()
|
||||
case "windows":
|
||||
return gatewayInstallWindowsTask()
|
||||
}
|
||||
scope, unitPath, err := detectGatewayServiceScopeAndPath()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -615,6 +622,12 @@ func gatewayInstallServiceCmd() error {
|
||||
}
|
||||
|
||||
func gatewayServiceControlCmd(action string) error {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return gatewayLaunchdControl(action)
|
||||
case "windows":
|
||||
return gatewayWindowsTaskControl(action)
|
||||
}
|
||||
scope, _, err := detectInstalledGatewayService()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -631,8 +644,10 @@ func gatewayScopePreference() string {
|
||||
}
|
||||
|
||||
func detectGatewayServiceScopeAndPath() (string, string, error) {
|
||||
if runtime.GOOS != "linux" {
|
||||
return "", "", fmt.Errorf("gateway service registration currently supports Linux systemd only")
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
default:
|
||||
return "", "", fmt.Errorf("unsupported service manager for %s", runtime.GOOS)
|
||||
}
|
||||
switch gatewayScopePreference() {
|
||||
case "user":
|
||||
@@ -655,6 +670,12 @@ func userGatewayUnitPath() (string, string, error) {
|
||||
}
|
||||
|
||||
func detectInstalledGatewayService() (string, string, error) {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return detectInstalledLaunchdService()
|
||||
case "windows":
|
||||
return detectInstalledWindowsTask()
|
||||
}
|
||||
systemPath := "/etc/systemd/system/" + gatewayServiceName
|
||||
userScope, userPath, err := userGatewayUnitPath()
|
||||
if err != nil {
|
||||
@@ -754,6 +775,271 @@ func runSystemctl(scope string, args ...string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func gatewayLaunchdLabel() string { return "ai.clawgo.gateway" }
|
||||
|
||||
func gatewayWindowsTaskName() string { return "ClawGo Gateway" }
|
||||
|
||||
func detectLaunchdScopeAndPath() (string, string, error) {
|
||||
label := gatewayLaunchdLabel() + ".plist"
|
||||
switch gatewayScopePreference() {
|
||||
case "system":
|
||||
return "system", filepath.Join("/Library/LaunchDaemons", label), nil
|
||||
case "user":
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("resolve user home failed: %w", err)
|
||||
}
|
||||
return "user", filepath.Join(home, "Library", "LaunchAgents", label), nil
|
||||
}
|
||||
if os.Geteuid() == 0 {
|
||||
return "system", filepath.Join("/Library/LaunchDaemons", label), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("resolve user home failed: %w", err)
|
||||
}
|
||||
return "user", filepath.Join(home, "Library", "LaunchAgents", label), nil
|
||||
}
|
||||
|
||||
func detectInstalledLaunchdService() (string, string, error) {
|
||||
userScope, userPath, err := detectLaunchdScopeAndPath()
|
||||
if err != nil && gatewayScopePreference() == "user" {
|
||||
return "", "", err
|
||||
}
|
||||
systemPath := filepath.Join("/Library/LaunchDaemons", gatewayLaunchdLabel()+".plist")
|
||||
systemExists := fileExists(systemPath)
|
||||
userExists := fileExists(userPath)
|
||||
|
||||
switch gatewayScopePreference() {
|
||||
case "system":
|
||||
if systemExists {
|
||||
return "system", systemPath, nil
|
||||
}
|
||||
return "", "", fmt.Errorf("launchd plist not found in system scope: %s", systemPath)
|
||||
case "user":
|
||||
if userExists {
|
||||
return userScope, userPath, nil
|
||||
}
|
||||
return "", "", fmt.Errorf("launchd plist not found in user scope: %s", userPath)
|
||||
}
|
||||
|
||||
if os.Geteuid() == 0 {
|
||||
if systemExists {
|
||||
return "system", systemPath, nil
|
||||
}
|
||||
if userExists {
|
||||
return userScope, userPath, nil
|
||||
}
|
||||
} else {
|
||||
if userExists {
|
||||
return userScope, userPath, nil
|
||||
}
|
||||
if systemExists {
|
||||
return "system", systemPath, nil
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("gateway service not registered. Run: clawgo gateway")
|
||||
}
|
||||
|
||||
func gatewayInstallLaunchdService() error {
|
||||
scope, plistPath, err := detectLaunchdScopeAndPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve executable path failed: %w", err)
|
||||
}
|
||||
exePath, _ = filepath.Abs(exePath)
|
||||
configPath := getConfigPath()
|
||||
workDir := filepath.Dir(exePath)
|
||||
if err := os.MkdirAll(filepath.Dir(plistPath), 0755); err != nil {
|
||||
return fmt.Errorf("create launchd directory failed: %w", err)
|
||||
}
|
||||
content := buildGatewayLaunchdPlist(exePath, configPath, workDir)
|
||||
if err := os.WriteFile(plistPath, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf("write launchd plist failed: %w", err)
|
||||
}
|
||||
_ = runLaunchctl(scope, "bootout", launchdDomainTarget(scope), plistPath)
|
||||
if err := runLaunchctl(scope, "bootstrap", launchdDomainTarget(scope), plistPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runLaunchctl(scope, "kickstart", "-k", launchdServiceTarget(scope)); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✓ Gateway service registered: %s (%s)\n", gatewayLaunchdLabel(), scope)
|
||||
fmt.Printf(" Launchd plist: %s\n", plistPath)
|
||||
fmt.Println(" Start service: clawgo gateway start")
|
||||
fmt.Println(" Restart service: clawgo gateway restart")
|
||||
fmt.Println(" Stop service: clawgo gateway stop")
|
||||
return nil
|
||||
}
|
||||
|
||||
func gatewayLaunchdControl(action string) error {
|
||||
scope, plistPath, err := detectInstalledLaunchdService()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch action {
|
||||
case "start":
|
||||
_ = runLaunchctl(scope, "bootstrap", launchdDomainTarget(scope), plistPath)
|
||||
return runLaunchctl(scope, "kickstart", "-k", launchdServiceTarget(scope))
|
||||
case "stop":
|
||||
return runLaunchctl(scope, "bootout", launchdDomainTarget(scope), plistPath)
|
||||
case "restart":
|
||||
_ = runLaunchctl(scope, "bootout", launchdDomainTarget(scope), plistPath)
|
||||
if err := runLaunchctl(scope, "bootstrap", launchdDomainTarget(scope), plistPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return runLaunchctl(scope, "kickstart", "-k", launchdServiceTarget(scope))
|
||||
case "status":
|
||||
return runLaunchctl(scope, "print", launchdServiceTarget(scope))
|
||||
default:
|
||||
return fmt.Errorf("unsupported action: %s", action)
|
||||
}
|
||||
}
|
||||
|
||||
func buildGatewayLaunchdPlist(exePath, configPath, workDir string) string {
|
||||
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>%s</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>%s</string>
|
||||
<string>gateway</string>
|
||||
<string>run</string>
|
||||
<string>--config</string>
|
||||
<string>%s</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>%s</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>%s</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>%s</string>
|
||||
</dict>
|
||||
</plist>
|
||||
`, gatewayLaunchdLabel(), exePath, configPath, workDir, filepath.Join(filepath.Dir(configPath), "gateway.launchd.out.log"), filepath.Join(filepath.Dir(configPath), "gateway.launchd.err.log"))
|
||||
}
|
||||
|
||||
func launchdDomainTarget(scope string) string {
|
||||
if scope == "system" {
|
||||
return "system"
|
||||
}
|
||||
return fmt.Sprintf("gui/%d", os.Getuid())
|
||||
}
|
||||
|
||||
func launchdServiceTarget(scope string) string {
|
||||
return launchdDomainTarget(scope) + "/" + gatewayLaunchdLabel()
|
||||
}
|
||||
|
||||
func runLaunchctl(scope string, args ...string) error {
|
||||
cmd := exec.Command("launchctl", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("launchctl %s failed: %w", strings.Join(args, " "), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func gatewayInstallWindowsTask() error {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve executable path failed: %w", err)
|
||||
}
|
||||
exePath, _ = filepath.Abs(exePath)
|
||||
configPath := getConfigPath()
|
||||
taskName := gatewayWindowsTaskName()
|
||||
command := fmt.Sprintf(`"%s" gateway run --config "%s"`, exePath, configPath)
|
||||
_ = runSCHTASKS("/Delete", "/TN", taskName, "/F")
|
||||
if err := runSCHTASKS("/Create", "/TN", taskName, "/SC", "ONLOGON", "/TR", command, "/F"); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✓ Gateway service registered: %s (windows task)\n", taskName)
|
||||
fmt.Println(" Start service: clawgo gateway start")
|
||||
fmt.Println(" Restart service: clawgo gateway restart")
|
||||
fmt.Println(" Stop service: clawgo gateway stop")
|
||||
return nil
|
||||
}
|
||||
|
||||
func gatewayWindowsTaskControl(action string) error {
|
||||
_, _, err := detectInstalledWindowsTask()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
taskName := gatewayWindowsTaskName()
|
||||
switch action {
|
||||
case "start":
|
||||
return runSCHTASKS("/Run", "/TN", taskName)
|
||||
case "stop":
|
||||
return stopGatewayProcessByPIDFile()
|
||||
case "restart":
|
||||
_ = stopGatewayProcessByPIDFile()
|
||||
return runSCHTASKS("/Run", "/TN", taskName)
|
||||
case "status":
|
||||
return runSCHTASKS("/Query", "/TN", taskName, "/V", "/FO", "LIST")
|
||||
default:
|
||||
return fmt.Errorf("unsupported action: %s", action)
|
||||
}
|
||||
}
|
||||
|
||||
func detectInstalledWindowsTask() (string, string, error) {
|
||||
taskName := gatewayWindowsTaskName()
|
||||
if err := runSCHTASKSQuiet("/Query", "/TN", taskName); err != nil {
|
||||
return "", "", fmt.Errorf("gateway service not registered. Run: clawgo gateway")
|
||||
}
|
||||
return "user", taskName, nil
|
||||
}
|
||||
|
||||
func runSCHTASKS(args ...string) error {
|
||||
cmd := exec.Command("schtasks", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("schtasks %s failed: %w", strings.Join(args, " "), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSCHTASKSQuiet(args ...string) error {
|
||||
cmd := exec.Command("schtasks", args...)
|
||||
cmd.Stdout = io.Discard
|
||||
cmd.Stderr = io.Discard
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func stopGatewayProcessByPIDFile() error {
|
||||
pidPath := filepath.Join(filepath.Dir(getConfigPath()), "gateway.pid")
|
||||
data, err := os.ReadFile(pidPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gateway pid file not found: %w", err)
|
||||
}
|
||||
pid := strings.TrimSpace(string(data))
|
||||
if pid == "" {
|
||||
return fmt.Errorf("gateway pid file is empty")
|
||||
}
|
||||
cmd := exec.Command("taskkill", "/PID", pid, "/T", "/F")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("taskkill /PID %s failed: %w", pid, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
func buildGatewayRuntime(ctx context.Context, cfg *config.Config, msgBus *bus.MessageBus, cronService *cron.CronService) (*agent.AgentLoop, *channels.Manager, error) {
|
||||
provider, err := providers.CreateProvider(cfg)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func uninstallCmd() {
|
||||
@@ -52,6 +53,28 @@ func uninstallCmd() {
|
||||
}
|
||||
|
||||
func uninstallGatewayService() error {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
scope, plistPath, err := detectInstalledLaunchdService()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
_ = runLaunchctl(scope, "bootout", launchdDomainTarget(scope), plistPath)
|
||||
if err := os.Remove(plistPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove launchd plist failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
case "windows":
|
||||
_, taskName, err := detectInstalledWindowsTask()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
_ = stopGatewayProcessByPIDFile()
|
||||
if err := runSCHTASKS("/Delete", "/TN", taskName, "/F"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
scope, unitPath, err := detectInstalledGatewayService()
|
||||
if err != nil {
|
||||
return nil
|
||||
|
||||
208
webui/src/components/Button.tsx
Normal file
208
webui/src/components/Button.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import React from 'react';
|
||||
|
||||
type ButtonVariant = 'neutral' | 'primary' | 'accent' | 'success' | 'warning' | 'danger';
|
||||
type FixedButtonShape = 'icon' | 'square';
|
||||
type ButtonSize = 'sm' | 'md' | 'xs' | 'xs_tall' | 'md_tall' | 'md_wide';
|
||||
type ButtonRadius = 'default' | 'lg' | 'xl' | 'full';
|
||||
type ButtonGap = 'none' | '1' | '2';
|
||||
type NativeButtonProps = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'className'>;
|
||||
type NativeAnchorProps = Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'className'>;
|
||||
|
||||
type ButtonStyleProps = {
|
||||
radius?: ButtonRadius;
|
||||
gap?: ButtonGap;
|
||||
shadow?: boolean;
|
||||
noShrink?: boolean;
|
||||
};
|
||||
|
||||
type ButtonProps = NativeButtonProps & ButtonStyleProps & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
fullWidth?: boolean;
|
||||
grow?: boolean;
|
||||
};
|
||||
|
||||
type LinkButtonProps = NativeAnchorProps & ButtonStyleProps & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
fullWidth?: boolean;
|
||||
grow?: boolean;
|
||||
};
|
||||
|
||||
type FixedButtonProps = NativeButtonProps & ButtonStyleProps & {
|
||||
label: string;
|
||||
shape?: FixedButtonShape;
|
||||
variant?: ButtonVariant;
|
||||
};
|
||||
|
||||
type FixedLinkButtonProps = NativeAnchorProps & ButtonStyleProps & {
|
||||
label: string;
|
||||
shape?: FixedButtonShape;
|
||||
variant?: ButtonVariant;
|
||||
};
|
||||
|
||||
function joinClasses(...values: Array<string | undefined | false>) {
|
||||
return values.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function buttonSizeClass(size: ButtonSize) {
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return 'px-2.5 py-1 text-xs';
|
||||
case 'xs_tall':
|
||||
return 'px-2.5 py-2 text-xs';
|
||||
case 'sm':
|
||||
return 'px-3 py-1.5 text-sm';
|
||||
case 'md_tall':
|
||||
return 'px-4 py-2.5 text-sm font-medium';
|
||||
case 'md_wide':
|
||||
return 'px-6 py-2 text-sm font-medium';
|
||||
case 'md':
|
||||
default:
|
||||
return 'px-4 py-2 text-sm font-medium';
|
||||
}
|
||||
}
|
||||
|
||||
function buttonRadiusClass(radius: ButtonRadius) {
|
||||
switch (radius) {
|
||||
case 'lg':
|
||||
return 'rounded-lg';
|
||||
case 'xl':
|
||||
return 'rounded-xl';
|
||||
case 'full':
|
||||
return 'rounded-full';
|
||||
case 'default':
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function buttonGapClass(gap: ButtonGap) {
|
||||
switch (gap) {
|
||||
case '1':
|
||||
return 'gap-1';
|
||||
case '2':
|
||||
return 'gap-2';
|
||||
case 'none':
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function buttonClass(
|
||||
variant: ButtonVariant,
|
||||
size: ButtonSize,
|
||||
fullWidth: boolean,
|
||||
grow: boolean,
|
||||
radius: ButtonRadius,
|
||||
gap: ButtonGap,
|
||||
shadow: boolean,
|
||||
noShrink: boolean,
|
||||
) {
|
||||
return joinClasses(
|
||||
'ui-button',
|
||||
`ui-button-${variant}`,
|
||||
buttonSizeClass(size),
|
||||
fullWidth && 'w-full',
|
||||
grow && 'flex-1',
|
||||
buttonRadiusClass(radius),
|
||||
buttonGapClass(gap),
|
||||
shadow && 'shadow-sm',
|
||||
noShrink && 'shrink-0',
|
||||
);
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'neutral',
|
||||
size = 'md',
|
||||
fullWidth = false,
|
||||
grow = false,
|
||||
radius = 'default',
|
||||
gap = 'none',
|
||||
shadow = false,
|
||||
noShrink = false,
|
||||
type = 'button',
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return <button {...props} type={type} className={buttonClass(variant, size, fullWidth, grow, radius, gap, shadow, noShrink)} />;
|
||||
}
|
||||
|
||||
export function LinkButton({
|
||||
variant = 'neutral',
|
||||
size = 'md',
|
||||
fullWidth = false,
|
||||
grow = false,
|
||||
radius = 'default',
|
||||
gap = 'none',
|
||||
shadow = false,
|
||||
noShrink = false,
|
||||
...props
|
||||
}: LinkButtonProps) {
|
||||
return <a {...props} className={buttonClass(variant, size, fullWidth, grow, radius, gap, shadow, noShrink)} />;
|
||||
}
|
||||
|
||||
export function FixedButton({
|
||||
label,
|
||||
shape = 'icon',
|
||||
variant = 'neutral',
|
||||
radius = 'default',
|
||||
gap = 'none',
|
||||
shadow = false,
|
||||
noShrink = false,
|
||||
type = 'button',
|
||||
title,
|
||||
children,
|
||||
...props
|
||||
}: FixedButtonProps) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
type={type}
|
||||
title={title || label}
|
||||
aria-label={props['aria-label'] || label}
|
||||
className={joinClasses(
|
||||
'ui-button',
|
||||
`ui-button-${variant}`,
|
||||
shape === 'square' ? 'ui-button-square' : 'ui-button-icon',
|
||||
buttonRadiusClass(radius),
|
||||
buttonGapClass(gap),
|
||||
shadow && 'shadow-sm',
|
||||
noShrink && 'shrink-0',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function FixedLinkButton({
|
||||
label,
|
||||
shape = 'icon',
|
||||
variant = 'neutral',
|
||||
radius = 'default',
|
||||
gap = 'none',
|
||||
shadow = false,
|
||||
noShrink = false,
|
||||
title,
|
||||
children,
|
||||
...props
|
||||
}: FixedLinkButtonProps) {
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
title={title || label}
|
||||
aria-label={props['aria-label'] || label}
|
||||
className={joinClasses(
|
||||
'ui-button',
|
||||
`ui-button-${variant}`,
|
||||
shape === 'square' ? 'ui-button-square' : 'ui-button-icon',
|
||||
buttonRadiusClass(radius),
|
||||
buttonGapClass(gap),
|
||||
shadow && 'shadow-sm',
|
||||
noShrink && 'shrink-0',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from './Button';
|
||||
|
||||
type DialogOptions = {
|
||||
title?: string;
|
||||
@@ -61,11 +62,11 @@ export const GlobalDialog: React.FC<{
|
||||
</div>
|
||||
<div className="px-5 pb-5 flex items-center justify-end gap-2 relative z-[1]">
|
||||
{(kind === 'confirm' || kind === 'prompt') && (
|
||||
<button onClick={onCancel} className="ui-button ui-button-neutral px-3 py-1.5 text-sm">{options.cancelText || t('cancel')}</button>
|
||||
<Button onClick={onCancel} size="sm">{options.cancelText || t('cancel')}</Button>
|
||||
)}
|
||||
<button onClick={() => onConfirm(kind === 'prompt' ? value : undefined)} className={`ui-button px-3 py-1.5 text-sm ${options.danger ? 'ui-button-danger' : 'ui-button-primary'}`}>
|
||||
<Button onClick={() => onConfirm(kind === 'prompt' ? value : undefined)} variant={options.danger ? 'danger' : 'primary'} size="sm">
|
||||
{options.confirmText || t('dialogOk')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Github, Moon, RefreshCw, SunMedium, Terminal } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import { useUI } from '../context/UIContext';
|
||||
import { FixedButton, FixedLinkButton } from './Button';
|
||||
|
||||
const REPO_URL = 'https://github.com/YspCoder/clawgo';
|
||||
|
||||
@@ -88,41 +89,21 @@ const Header: React.FC = () => {
|
||||
|
||||
<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="ui-button ui-button-neutral ui-button-icon text-sm font-medium"
|
||||
title={t('githubRepo')}
|
||||
>
|
||||
<FixedLinkButton href={REPO_URL} target="_blank" rel="noreferrer" label={t('githubRepo')}>
|
||||
<Github className="w-4 h-4" />
|
||||
</a>
|
||||
</FixedLinkButton>
|
||||
|
||||
<button
|
||||
onClick={checkVersion}
|
||||
disabled={checkingVersion}
|
||||
className="ui-button ui-button-neutral ui-button-icon text-sm font-medium disabled:opacity-60"
|
||||
title={t('checkVersion')}
|
||||
>
|
||||
<FixedButton onClick={checkVersion} disabled={checkingVersion} label={t('checkVersion')}>
|
||||
<RefreshCw className={`w-4 h-4 ${checkingVersion ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</FixedButton>
|
||||
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="ui-button ui-button-neutral ui-button-icon text-sm font-medium"
|
||||
title={theme === 'dark' ? t('themeLight') : t('themeDark')}
|
||||
>
|
||||
<FixedButton onClick={toggleTheme} label={theme === 'dark' ? t('themeLight') : t('themeDark')}>
|
||||
{theme === 'dark' ? <SunMedium className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||
</button>
|
||||
</FixedButton>
|
||||
|
||||
<button
|
||||
onClick={toggleLang}
|
||||
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')}
|
||||
>
|
||||
<FixedButton onClick={toggleLang} shape="square" label={i18n.language === 'en' ? t('languageZh') : t('languageEn')}>
|
||||
{i18n.language === 'en' ? '中' : 'EN'}
|
||||
</button>
|
||||
</FixedButton>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FixedButton } from './Button';
|
||||
|
||||
interface RecursiveConfigProps {
|
||||
data: any;
|
||||
@@ -79,17 +80,16 @@ const PrimitiveArrayEditor: React.FC<{
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
<button
|
||||
<FixedButton
|
||||
onClick={() => {
|
||||
addValue(draft);
|
||||
setDraft('');
|
||||
}}
|
||||
className="ui-button ui-button-neutral ui-button-icon rounded-xl"
|
||||
title={t('add')}
|
||||
aria-label={t('add')}
|
||||
radius="xl"
|
||||
label={t('add')}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</FixedButton>
|
||||
|
||||
<select
|
||||
value={selected}
|
||||
|
||||
@@ -801,6 +801,8 @@ html.theme-dark .brand-button {
|
||||
transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
/* Fixed-width buttons must opt in explicitly via ui-button-icon / ui-button-square. */
|
||||
|
||||
.ui-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
@@ -826,6 +828,16 @@ html.theme-dark .brand-button {
|
||||
background: var(--button-primary-bg-hover);
|
||||
}
|
||||
|
||||
.ui-button-accent {
|
||||
background: var(--pill-accent-bg);
|
||||
border-color: var(--pill-accent-border);
|
||||
color: var(--pill-accent-text);
|
||||
}
|
||||
|
||||
.ui-button-accent:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--pill-accent-bg) 78%, white);
|
||||
}
|
||||
|
||||
.ui-button-success {
|
||||
background: var(--button-success-bg);
|
||||
border-color: var(--button-success-border);
|
||||
@@ -860,17 +872,16 @@ html.theme-dark .brand-button {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
width: 40px;
|
||||
border-radius: var(--radius-button);
|
||||
}
|
||||
|
||||
.ui-button:has(> svg:only-child) {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
flex: 0 0 40px;
|
||||
padding-inline: 0;
|
||||
border-radius: var(--radius-button);
|
||||
}
|
||||
|
||||
.ui-button-square {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
flex: 0 0 40px;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Check, KeyRound, ListFilter, LogOut, QrCode, RefreshCw, ShieldCheck, Sm
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import { useUI } from '../context/UIContext';
|
||||
import { Button, FixedButton } from '../components/Button';
|
||||
|
||||
type ChannelKey = 'telegram' | 'whatsapp' | 'discord' | 'feishu' | 'qq' | 'dingtalk' | 'maixcam';
|
||||
|
||||
@@ -600,18 +601,13 @@ const ChannelSettings: React.FC = () => {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{key === 'whatsapp' && (
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="ui-button ui-button-neutral ui-button-icon"
|
||||
title={t('refresh')}
|
||||
aria-label={t('refresh')}
|
||||
>
|
||||
<FixedButton onClick={() => window.location.reload()} label={t('refresh')}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
</FixedButton>
|
||||
)}
|
||||
<button onClick={saveChannel} disabled={saving} className="ui-button ui-button-primary px-4 py-2 text-sm font-medium">
|
||||
<Button onClick={saveChannel} disabled={saving} variant="primary">
|
||||
{saving ? t('loading') : t('saveChanges')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -651,10 +647,10 @@ const ChannelSettings: React.FC = () => {
|
||||
<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">
|
||||
<Button onClick={handleLogout} variant="danger" gap="2">
|
||||
<LogOut className="h-4 w-4" />
|
||||
{t('logout')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { motion } from 'motion/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import { useUI } from '../context/UIContext';
|
||||
import { Button, FixedButton } from '../components/Button';
|
||||
import { ChatItem } from '../types';
|
||||
|
||||
type StreamItem = {
|
||||
@@ -569,25 +570,15 @@ const Chat: React.FC = () => {
|
||||
<div className="flex-1 flex flex-col brand-card ui-panel rounded-[30px] overflow-hidden">
|
||||
<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'}`}
|
||||
>
|
||||
{t('mainChat')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setChatTab('subagents')}
|
||||
className={`ui-button px-3 py-1.5 text-xs ${chatTab === 'subagents' ? 'ui-button-primary' : 'ui-button-neutral'}`}
|
||||
>
|
||||
{t('subagentGroup')}
|
||||
</button>
|
||||
<Button onClick={() => setChatTab('main')} variant={chatTab === 'main' ? 'primary' : 'neutral'} size="xs">{t('mainChat')}</Button>
|
||||
<Button onClick={() => setChatTab('subagents')} variant={chatTab === 'subagents' ? 'primary' : 'neutral'} size="xs">{t('subagentGroup')}</Button>
|
||||
</div>
|
||||
{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
|
||||
<FixedButton
|
||||
onClick={() => {
|
||||
if (chatTab === 'main') {
|
||||
void loadHistory();
|
||||
@@ -595,30 +586,26 @@ const Chat: React.FC = () => {
|
||||
void loadSubagentGroup();
|
||||
}
|
||||
}}
|
||||
className="ui-button ui-button-neutral ui-button-icon ml-auto shrink-0"
|
||||
title={t('reloadHistory')}
|
||||
aria-label={t('reloadHistory')}
|
||||
noShrink
|
||||
label={t('reloadHistory')}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
</FixedButton>
|
||||
</div>
|
||||
|
||||
{chatTab === 'subagents' && (
|
||||
<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'}`}
|
||||
>
|
||||
{t('allAgents')}
|
||||
</button>
|
||||
<Button onClick={() => setSelectedStreamAgents([])} variant={selectedStreamAgents.length === 0 ? 'primary' : 'neutral'} size="xs" radius="full">{t('allAgents')}</Button>
|
||||
{streamActors.map((agent) => (
|
||||
<button
|
||||
<Button
|
||||
key={agent}
|
||||
onClick={() => toggleStreamAgent(agent)}
|
||||
className={`ui-button px-2.5 py-1 rounded-full text-xs ${selectedStreamAgents.includes(agent) ? 'ui-button-primary' : 'ui-button-neutral'}`}
|
||||
variant={selectedStreamAgents.includes(agent) ? 'primary' : 'neutral'}
|
||||
size="xs"
|
||||
radius="full"
|
||||
>
|
||||
{formatAgentName(agent, t)}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -654,13 +641,9 @@ const Chat: React.FC = () => {
|
||||
placeholder={t('subagentLabelPlaceholder')}
|
||||
className="ui-input w-full rounded-2xl px-3 py-2.5 text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={dispatchSubagentTask}
|
||||
disabled={!dispatchAgentID.trim() || !dispatchTask.trim()}
|
||||
className="ui-button ui-button-primary w-full px-3 py-2.5 text-sm font-medium"
|
||||
>
|
||||
<Button onClick={dispatchSubagentTask} disabled={!dispatchAgentID.trim() || !dispatchTask.trim()} variant="primary" size="md_tall" fullWidth>
|
||||
{t('dispatchToSubagent')}
|
||||
</button>
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Plus, RefreshCw, Save } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import { useUI } from '../context/UIContext';
|
||||
import { Button, FixedButton } from '../components/Button';
|
||||
import RecursiveConfig from '../components/RecursiveConfig';
|
||||
|
||||
function setPath(obj: any, path: string, value: any) {
|
||||
@@ -283,29 +284,27 @@ const Config: React.FC = () => {
|
||||
<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>
|
||||
<Button onClick={() => setShowRaw(false)} variant={!showRaw ? 'primary' : 'neutral'} size="sm" radius="lg">{t('form')}</Button>
|
||||
<Button onClick={() => setShowRaw(true)} variant={showRaw ? 'primary' : 'neutral'} size="sm" radius="lg">{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">
|
||||
<Button onClick={saveConfig} variant="primary" gap="2">
|
||||
<Save className="w-4 h-4" /> {t('saveChanges')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
<FixedButton
|
||||
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')}
|
||||
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">
|
||||
</FixedButton>
|
||||
<Button onClick={() => setShowDiff(true)} size="sm">{t('configDiffPreview')}</Button>
|
||||
<Button onClick={() => setBasicMode(v => !v)} size="sm">
|
||||
{basicMode ? t('configBasicMode') : t('configAdvancedMode')}
|
||||
</button>
|
||||
</Button>
|
||||
<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')}
|
||||
@@ -352,14 +351,9 @@ 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 ui-button-icon"
|
||||
title={t('add')}
|
||||
aria-label={t('add')}
|
||||
>
|
||||
<FixedButton onClick={addProxy} variant="primary" label={t('add')}>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</FixedButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -369,7 +363,7 @@ const Config: React.FC = () => {
|
||||
<input value={String(p?.api_base || '')} onChange={(e)=>updateProxyField(name, 'api_base', e.target.value)} placeholder={t('configLabels.api_base')} className="md:col-span-2 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" />
|
||||
<input value={String(p?.api_key || '')} onChange={(e)=>updateProxyField(name, 'api_key', e.target.value)} placeholder={t('configLabels.api_key')} className="md:col-span-2 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" />
|
||||
<input value={Array.isArray(p?.models) ? p.models.join(',') : ''} onChange={(e)=>updateProxyField(name, 'models', e.target.value.split(',').map(s=>s.trim()).filter(Boolean))} placeholder={`${t('configLabels.models')}${t('configCommaSeparatedHint')}`} className="md:col-span-1 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" />
|
||||
<button onClick={()=>removeProxy(name)} className="ui-button ui-button-danger md:col-span-1 px-2 py-1 rounded text-xs">{t('delete')}</button>
|
||||
<Button onClick={()=>removeProxy(name)} variant="danger" size="xs" radius="lg">{t('delete')}</Button>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(((cfg as any)?.providers?.proxies || {}) as Record<string, any>).length === 0 && (
|
||||
@@ -417,14 +411,9 @@ 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 ui-button-icon"
|
||||
title={t('add')}
|
||||
aria-label={t('add')}
|
||||
>
|
||||
<FixedButton onClick={addGatewayIceServer} variant="primary" label={t('add')}>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</FixedButton>
|
||||
</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) => (
|
||||
@@ -447,7 +436,7 @@ const Config: React.FC = () => {
|
||||
placeholder={t('configNodeP2PIceCredential')}
|
||||
className="md:col-span-2 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800"
|
||||
/>
|
||||
<button onClick={() => removeGatewayIceServer(index)} className="ui-button ui-button-danger md:col-span-1 px-2 py-1 rounded text-xs">{t('delete')}</button>
|
||||
<Button onClick={() => removeGatewayIceServer(index)} variant="danger" size="xs" radius="lg">{t('delete')}</Button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
@@ -623,7 +612,7 @@ const Config: React.FC = () => {
|
||||
<div className="w-full max-w-4xl max-h-[85vh] brand-card border border-zinc-800 rounded-[30px] overflow-hidden flex flex-col">
|
||||
<div className="px-4 py-3 border-b border-zinc-800 flex items-center justify-between">
|
||||
<div className="font-semibold">{t('configDiffPreviewCount', { count: diffRows.length })}</div>
|
||||
<button className="px-3 py-1 rounded-xl bg-zinc-800" onClick={() => setShowDiff(false)}>{t('close')}</button>
|
||||
<Button onClick={() => setShowDiff(false)} size="xs" radius="xl">{t('close')}</Button>
|
||||
</div>
|
||||
<div className="overflow-auto text-xs">
|
||||
<table className="w-full">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { motion, AnimatePresence } from 'motion/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import { useUI } from '../context/UIContext';
|
||||
import { Button, FixedButton } from '../components/Button';
|
||||
import { CronJob } from '../types';
|
||||
import { formatLocalDateTime } from '../utils/time';
|
||||
|
||||
@@ -172,22 +173,12 @@ 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 ui-button-icon"
|
||||
title={t('refresh')}
|
||||
aria-label={t('refresh')}
|
||||
>
|
||||
<FixedButton onClick={() => refreshCron()} label={t('refresh')}>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openCronModal()}
|
||||
className="ui-button ui-button-primary ui-button-icon"
|
||||
title={t('addJob')}
|
||||
aria-label={t('addJob')}
|
||||
>
|
||||
</FixedButton>
|
||||
<FixedButton onClick={() => openCronModal()} variant="primary" label={t('addJob')}>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</FixedButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -223,26 +214,20 @@ const Cron: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-4 border-t border-zinc-800/50">
|
||||
<button
|
||||
onClick={() => openCronModal(j)}
|
||||
className="ui-button ui-button-neutral flex-1 flex items-center justify-center gap-2 py-2 text-xs font-medium"
|
||||
>
|
||||
<Button onClick={() => openCronModal(j)} size="xs_tall" gap="2" grow>
|
||||
<Edit2 className="w-3.5 h-3.5" /> {t('editJob')}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<FixedButton
|
||||
onClick={() => cronAction(j.enabled ? 'disable' : 'enable', j.id)}
|
||||
className={`ui-button p-2 rounded-lg ${j.enabled ? 'ui-button-warning' : 'ui-button-success'}`}
|
||||
title={j.enabled ? t('pauseJob') : t('startJob')}
|
||||
variant={j.enabled ? 'warning' : 'success'}
|
||||
radius="lg"
|
||||
label={j.enabled ? t('pauseJob') : t('startJob')}
|
||||
>
|
||||
{j.enabled ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => cronAction('delete', j.id)}
|
||||
className="ui-button ui-button-danger p-2 rounded-lg"
|
||||
title={t('deleteJob')}
|
||||
>
|
||||
</FixedButton>
|
||||
<FixedButton onClick={() => cronAction('delete', j.id)} variant="danger" radius="lg" label={t('deleteJob')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</FixedButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -273,9 +258,9 @@ const Cron: React.FC = () => {
|
||||
>
|
||||
<div className="p-6 border-b border-zinc-800 dark:border-zinc-700 flex items-center justify-between bg-zinc-900/20 relative z-[1]">
|
||||
<h2 className="text-xl font-semibold text-zinc-100">{editingCron ? t('editJob') : t('addJob')}</h2>
|
||||
<button onClick={() => setIsCronModalOpen(false)} className="ui-button ui-button-neutral p-2 rounded-full">
|
||||
<FixedButton onClick={() => setIsCronModalOpen(false)} radius="full" label={t('close')}>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</FixedButton>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4 max-h-[70vh] overflow-y-auto relative z-[1]">
|
||||
@@ -376,18 +361,10 @@ const Cron: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-zinc-800 dark:border-zinc-700 bg-zinc-900/20 flex items-center justify-end gap-3 relative z-[1]">
|
||||
<button
|
||||
onClick={() => setIsCronModalOpen(false)}
|
||||
className="ui-button ui-button-neutral px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCronSubmit}
|
||||
className="ui-button ui-button-primary px-6 py-2 text-sm font-medium"
|
||||
>
|
||||
<Button onClick={() => setIsCronModalOpen(false)}>{t('cancel')}</Button>
|
||||
<Button onClick={handleCronSubmit} variant="primary" size="md_wide">
|
||||
{editingCron ? t('update') : t('create')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { RefreshCw, Activity, MessageSquare, Wrench, Sparkles, AlertTriangle, Wo
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import StatCard from '../components/StatCard';
|
||||
import { FixedButton } from '../components/Button';
|
||||
|
||||
function formatRuntimeTime(value: unknown) {
|
||||
const raw = String(value || '').trim();
|
||||
@@ -122,14 +123,9 @@ const Dashboard: React.FC = () => {
|
||||
{t('webui')}: <span className="font-mono text-zinc-300">{webuiVersion}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={refreshAll}
|
||||
className="ui-button ui-button-primary ui-button-icon shrink-0"
|
||||
title={t('refreshAll')}
|
||||
aria-label={t('refreshAll')}
|
||||
>
|
||||
<FixedButton onClick={refreshAll} variant="primary" noShrink label={t('refreshAll')}>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
</FixedButton>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-6 gap-4">
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, RefreshCw, Route, ServerCrash, Workflow } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import { FixedButton } from '../components/Button';
|
||||
|
||||
type EKGKV = { key?: string; score?: number; count?: number };
|
||||
|
||||
@@ -164,14 +165,14 @@ const EKG: React.FC = () => {
|
||||
<option value="24h">24h</option>
|
||||
<option value="7d">7d</option>
|
||||
</select>
|
||||
<button
|
||||
<FixedButton
|
||||
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')}
|
||||
variant="primary"
|
||||
radius="xl"
|
||||
label={loading ? t('loading') : t('refresh')}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
</FixedButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAppContext } from '../context/AppContext';
|
||||
import { useUI } from '../context/UIContext';
|
||||
import { LogEntry } from '../types';
|
||||
import { formatLocalTime } from '../utils/time';
|
||||
import { Button } from '../components/Button';
|
||||
|
||||
const Logs: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -174,23 +175,15 @@ const Logs: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setShowRaw(!showRaw)}
|
||||
className="ui-button ui-button-neutral flex items-center gap-2 px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
<Button onClick={() => setShowRaw(!showRaw)} gap="2">
|
||||
{showRaw ? t('pretty') : t('raw')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsStreaming(!isStreaming)}
|
||||
className={`ui-button flex items-center gap-2 px-4 py-2 text-sm font-medium ${
|
||||
isStreaming ? 'ui-button-neutral' : 'ui-button-primary'
|
||||
}`}
|
||||
>
|
||||
</Button>
|
||||
<Button onClick={() => setIsStreaming(!isStreaming)} variant={isStreaming ? 'neutral' : 'primary'} gap="2">
|
||||
{isStreaming ? <><Square className="w-4 h-4" /> {t('pause')}</> : <><Play className="w-4 h-4" /> {t('resume')}</>}
|
||||
</button>
|
||||
<button onClick={clearLogs} className="ui-button ui-button-neutral flex items-center gap-2 px-4 py-2 text-sm font-medium">
|
||||
</Button>
|
||||
<Button onClick={clearLogs} gap="2">
|
||||
<Trash2 className="w-4 h-4" /> {t('clear')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Package, Pencil, Plus, RefreshCw, Save, Trash2, Wrench, X } from 'lucid
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import { useUI } from '../context/UIContext';
|
||||
import { Button, FixedButton } from '../components/Button';
|
||||
|
||||
type MCPDraftServer = {
|
||||
enabled: boolean;
|
||||
@@ -356,22 +357,12 @@ const MCP: React.FC = () => {
|
||||
<p className="text-sm text-zinc-500 mt-1">{t('mcpServicesHint')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
onClick={async () => { await loadConfig(true); await refreshMCPTools(); }}
|
||||
className="ui-button ui-button-neutral ui-button-icon"
|
||||
title={t('reload')}
|
||||
aria-label={t('reload')}
|
||||
>
|
||||
<FixedButton onClick={async () => { await loadConfig(true); await refreshMCPTools(); }} label={t('reload')}>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="ui-button ui-button-primary ui-button-icon"
|
||||
title={t('add')}
|
||||
aria-label={t('add')}
|
||||
>
|
||||
</FixedButton>
|
||||
<FixedButton onClick={openCreateModal} variant="primary" label={t('add')}>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</FixedButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -405,12 +396,12 @@ const MCP: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button onClick={() => openEditModal(name, server)} className="ui-button ui-button-neutral p-2 rounded-xl" title={t('edit')}>
|
||||
<FixedButton onClick={() => openEditModal(name, server)} radius="xl" label={t('edit')}>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => removeServer(name)} className="ui-button ui-button-danger p-2 rounded-xl" title={t('delete')}>
|
||||
</FixedButton>
|
||||
<FixedButton onClick={() => removeServer(name)} variant="danger" radius="xl" label={t('delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</FixedButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -476,9 +467,9 @@ const MCP: React.FC = () => {
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500 mt-1">{t('mcpServicesHint')}</div>
|
||||
</div>
|
||||
<button onClick={closeModal} className="ui-button ui-button-neutral p-2 rounded-xl">
|
||||
<FixedButton onClick={closeModal} radius="xl" label={t('close')}>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</FixedButton>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[80vh] overflow-y-auto px-6 py-5 space-y-5">
|
||||
@@ -551,9 +542,9 @@ const MCP: React.FC = () => {
|
||||
<div className="text-sm font-medium text-zinc-200">Args</div>
|
||||
<div className="text-xs text-zinc-500">{t('configMCPArgsEnterHint')}</div>
|
||||
</div>
|
||||
<button onClick={installDraftPackage} className="ui-button ui-button-success flex items-center gap-2 px-3 py-2 text-xs">
|
||||
<Button onClick={installDraftPackage} variant="success" size="xs_tall" gap="2">
|
||||
<Package className="w-4 h-4" /> {t('install')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex max-h-28 flex-wrap gap-2 overflow-y-auto pr-1">
|
||||
@@ -587,9 +578,9 @@ const MCP: React.FC = () => {
|
||||
<div className="text-amber-300/80">{t('configMCPInstallSuggested', { pkg: activeCheck.package })}</div>
|
||||
)}
|
||||
{activeCheck.installable && (
|
||||
<button onClick={() => installCheckPackage(activeCheck)} className="ui-button ui-button-warning px-3 py-2 text-xs">
|
||||
<Button onClick={() => installCheckPackage(activeCheck)} variant="warning" size="xs_tall">
|
||||
<Wrench className="w-4 h-4" /> {t('install')}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -601,14 +592,14 @@ const MCP: React.FC = () => {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{editingName && (
|
||||
<button onClick={() => removeServer(editingName)} className="ui-button ui-button-danger flex items-center gap-2 px-3 py-2 text-sm">
|
||||
<Button onClick={() => removeServer(editingName)} variant="danger" gap="2">
|
||||
<Trash2 className="w-4 h-4" /> {t('delete')}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<button onClick={closeModal} className="ui-button ui-button-neutral px-3 py-2 text-sm">{t('cancel')}</button>
|
||||
<button onClick={saveServer} className="ui-button ui-button-primary flex items-center gap-2 px-4 py-2 text-sm">
|
||||
<Button onClick={closeModal} size="sm">{t('cancel')}</Button>
|
||||
<Button onClick={saveServer} variant="primary" gap="2">
|
||||
<Save className="w-4 h-4" /> {t('saveChanges')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Trash2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import { useUI } from '../context/UIContext';
|
||||
import { Button, FixedButton } from '../components/Button';
|
||||
|
||||
const Memory: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -118,7 +119,9 @@ const Memory: React.FC = () => {
|
||||
<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>
|
||||
<FixedButton onClick={createFile} variant="primary" shape="square" radius="xl" label={t('add')}>
|
||||
+
|
||||
</FixedButton>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{files.map((f) => (
|
||||
@@ -141,7 +144,7 @@ const Memory: React.FC = () => {
|
||||
<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>
|
||||
<Button onClick={saveFile} variant="primary" size="sm" radius="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>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { RefreshCw } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import { Button, FixedButton, LinkButton } from '../components/Button';
|
||||
import { formatLocalDateTime } from '../utils/time';
|
||||
|
||||
function dataUrlForArtifact(artifact: any) {
|
||||
@@ -142,17 +143,10 @@ const NodeArtifacts: React.FC = () => {
|
||||
<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 ui-button-icon"
|
||||
title={loading ? t('loading') : t('refresh')}
|
||||
aria-label={loading ? t('loading') : t('refresh')}
|
||||
>
|
||||
<LinkButton href={exportURL()} size="sm">{t('export')}</LinkButton>
|
||||
<FixedButton onClick={loadArtifacts} variant="primary" label={loading ? t('loading') : t('refresh')}>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</FixedButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -188,13 +182,9 @@ const NodeArtifacts: React.FC = () => {
|
||||
placeholder={t('nodeArtifactsKeepLatest')}
|
||||
className="ui-input rounded-xl px-3 py-2 text-xs"
|
||||
/>
|
||||
<button
|
||||
onClick={pruneArtifacts}
|
||||
disabled={prunePending}
|
||||
className="ui-button ui-button-warning px-3 py-2 text-xs"
|
||||
>
|
||||
<Button onClick={pruneArtifacts} disabled={prunePending} variant="warning" size="xs_tall">
|
||||
{prunePending ? t('loading') : t('nodeArtifactsPrune')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto min-h-0">
|
||||
@@ -230,12 +220,8 @@ const NodeArtifacts: React.FC = () => {
|
||||
<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">
|
||||
{t('download')}
|
||||
</a>
|
||||
<button onClick={() => deleteArtifact(String(selected?.id || ''))} className="ui-button ui-button-danger px-3 py-1.5 text-xs">
|
||||
{t('delete')}
|
||||
</button>
|
||||
<LinkButton href={downloadURL(String(selected?.id || ''))} size="xs">{t('download')}</LinkButton>
|
||||
<Button onClick={() => deleteArtifact(String(selected?.id || ''))} variant="danger" size="xs">{t('delete')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
|
||||
import { Check, RefreshCw } from 'lucide-react';
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import { formatLocalDateTime } from '../utils/time';
|
||||
import { Button, FixedButton, LinkButton } from '../components/Button';
|
||||
|
||||
function dataUrlForArtifact(artifact: any) {
|
||||
const mime = String(artifact?.mime_type || '').trim() || 'application/octet-stream';
|
||||
@@ -221,14 +222,13 @@ 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
|
||||
<FixedButton
|
||||
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')}
|
||||
variant="primary"
|
||||
label={loading ? t('loading') : t('refresh')}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</FixedButton>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[300px_1fr_1.1fr] gap-4 flex-1 min-h-0">
|
||||
@@ -298,18 +298,12 @@ const Nodes: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Link
|
||||
to={`/node-artifacts?node=${encodeURIComponent(String(selectedNode.id || ''))}`}
|
||||
className="ui-button ui-button-neutral px-3 py-1.5 text-xs"
|
||||
>
|
||||
{t('nodeArtifacts')}
|
||||
<Link to={`/node-artifacts?node=${encodeURIComponent(String(selectedNode.id || ''))}`} className="contents">
|
||||
<Button size="xs">{t('nodeArtifacts')}</Button>
|
||||
</Link>
|
||||
<a
|
||||
href={`/webui/api/node_artifacts/export${q ? `${q}&node=${encodeURIComponent(String(selectedNode.id || ''))}` : `?node=${encodeURIComponent(String(selectedNode.id || ''))}`}`}
|
||||
className="ui-button ui-button-neutral px-3 py-1.5 text-xs"
|
||||
>
|
||||
<LinkButton href={`/webui/api/node_artifacts/export${q ? `${q}&node=${encodeURIComponent(String(selectedNode.id || ''))}` : `?node=${encodeURIComponent(String(selectedNode.id || ''))}`}`} size="xs">
|
||||
{t('export')}
|
||||
</a>
|
||||
</LinkButton>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -455,20 +449,10 @@ const Nodes: React.FC = () => {
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium text-zinc-200">{t('nodeDispatchDetail')}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={resetReplayDraft}
|
||||
disabled={replayPending}
|
||||
className="ui-button ui-button-neutral px-3 py-1.5 text-xs"
|
||||
>
|
||||
{t('resetReplayDraft')}
|
||||
</button>
|
||||
<button
|
||||
onClick={replayDispatch}
|
||||
disabled={replayPending}
|
||||
className="ui-button ui-button-primary px-3 py-1.5 text-xs"
|
||||
>
|
||||
<Button onClick={resetReplayDraft} disabled={replayPending} size="xs">{t('resetReplayDraft')}</Button>
|
||||
<Button onClick={replayDispatch} disabled={replayPending} variant="primary" size="xs">
|
||||
{replayPending ? t('replaying') : t('replayDispatch')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { motion, AnimatePresence } from 'motion/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import { useUI } from '../context/UIContext';
|
||||
import { Button, FixedButton } from '../components/Button';
|
||||
|
||||
const Skills: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -190,7 +191,7 @@ const Skills: React.FC = () => {
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('skills')}</h1>
|
||||
<div className="flex items-center gap-2 flex-wrap w-full xl:w-auto">
|
||||
<input disabled={installingSkill} value={installName} onChange={(e) => setInstallName(e.target.value)} placeholder={t('skillsNamePlaceholder')} className="w-full sm:w-72 px-3 py-2 bg-zinc-950/70 border border-zinc-800 rounded-xl text-sm disabled:opacity-60" />
|
||||
<button disabled={installingSkill} onClick={installSkill} className="ui-button ui-button-success px-3 py-2 text-sm font-medium">{installingSkill ? t('loading') : t('install')}</button>
|
||||
<Button disabled={installingSkill} onClick={installSkill} variant="success">{installingSkill ? t('loading') : t('install')}</Button>
|
||||
<label className="flex items-center gap-2 text-xs text-zinc-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -206,29 +207,16 @@ const Skills: React.FC = () => {
|
||||
{t('skillsClawhubStatus')}: {clawhubInstalled ? t('installed') : t('notInstalled')}
|
||||
</div>
|
||||
{!clawhubInstalled && (
|
||||
<button
|
||||
onClick={installClawHubIfNeeded}
|
||||
className="ui-button ui-button-primary flex items-center gap-2 px-4 py-2 text-sm font-medium shadow-sm"
|
||||
>
|
||||
<Button onClick={installClawHubIfNeeded} variant="primary" gap="2" shadow>
|
||||
<Zap className="w-4 h-4" /> {t('skillsInstallNow')}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => refreshSkills()}
|
||||
className="ui-button ui-button-neutral ui-button-icon"
|
||||
title={t('refresh')}
|
||||
aria-label={t('refresh')}
|
||||
>
|
||||
<FixedButton onClick={() => refreshSkills()} label={t('refresh')}>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onAddSkillClick}
|
||||
className="ui-button ui-button-primary ui-button-icon shadow-sm"
|
||||
title={t('skillsAdd')}
|
||||
aria-label={t('skillsAdd')}
|
||||
>
|
||||
</FixedButton>
|
||||
<FixedButton onClick={onAddSkillClick} variant="primary" shadow label={t('skillsAdd')}>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</FixedButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -276,19 +264,12 @@ const Skills: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-4 border-t border-zinc-800/50 mt-auto">
|
||||
<button
|
||||
onClick={() => openFileManager(s.id)}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-2 bg-indigo-500/10 text-indigo-300 hover:bg-indigo-500/20 rounded-lg text-xs font-medium transition-colors"
|
||||
title={t('files')}
|
||||
>
|
||||
<Button onClick={() => openFileManager(s.id)} variant="accent" size="xs_tall" radius="lg" gap="2" grow title={t('files')}>
|
||||
<FileText className="w-4 h-4" /> {t('skillsFileEdit')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteSkill(s.id)}
|
||||
className="ui-pill ui-pill-danger p-2 rounded-lg transition-colors"
|
||||
>
|
||||
</Button>
|
||||
<FixedButton onClick={() => deleteSkill(s.id)} variant="danger" radius="lg" label={t('delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</FixedButton>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -311,8 +292,10 @@ const Skills: React.FC = () => {
|
||||
<div className="px-4 py-3 border-b border-zinc-800 flex items-center justify-between">
|
||||
<div className="text-sm text-zinc-300 font-mono truncate">{activeFile || t('noFileSelected')}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={saveFile} className="ui-button ui-button-success px-3 py-1.5 rounded text-xs flex items-center gap-1"><Save className="w-3 h-3"/>{t('save')}</button>
|
||||
<button onClick={() => setIsFileModalOpen(false)} className="p-2 hover:bg-zinc-800 rounded-full transition-colors text-zinc-400"><X className="w-4 h-4" /></button>
|
||||
<Button onClick={saveFile} variant="success" size="xs" radius="lg" gap="1"><Save className="w-3 h-3"/>{t('save')}</Button>
|
||||
<FixedButton onClick={() => setIsFileModalOpen(false)} radius="full" label={t('close')}>
|
||||
<X className="w-4 h-4" />
|
||||
</FixedButton>
|
||||
</div>
|
||||
</div>
|
||||
<textarea value={fileContent} onChange={(e)=>setFileContent(e.target.value)} className="flex-1 bg-zinc-950 text-zinc-200 font-mono text-sm p-4 resize-none outline-none" />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Check, Plus, RefreshCw } from 'lucide-react';
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import { useUI } from '../context/UIContext';
|
||||
import { Button, FixedButton } from '../components/Button';
|
||||
|
||||
type SubagentProfile = {
|
||||
agent_id: string;
|
||||
@@ -272,22 +273,12 @@ 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 ui-button-icon"
|
||||
title={t('refresh')}
|
||||
aria-label={t('refresh')}
|
||||
>
|
||||
<FixedButton onClick={() => load()} label={t('refresh')}>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onNew}
|
||||
className="ui-button ui-button-success ui-button-icon"
|
||||
title={t('newProfile')}
|
||||
aria-label={t('newProfile')}
|
||||
>
|
||||
</FixedButton>
|
||||
<FixedButton onClick={onNew} variant="success" label={t('newProfile')}>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</FixedButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -411,15 +402,9 @@ const SubagentProfiles: React.FC = () => {
|
||||
{groups.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{groups.map((g) => (
|
||||
<button
|
||||
key={g.name}
|
||||
type="button"
|
||||
onClick={() => addAllowlistToken(`group:${g.name}`)}
|
||||
className="ui-button ui-button-neutral px-2 py-1 text-[11px] rounded"
|
||||
title={g.description || g.name}
|
||||
>
|
||||
<Button key={g.name} type="button" onClick={() => addAllowlistToken(`group:${g.name}`)} size="xs" radius="lg" title={g.description || g.name}>
|
||||
{`group:${g.name}`}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -436,14 +421,9 @@ const SubagentProfiles: React.FC = () => {
|
||||
placeholder={t('agentPromptContentPlaceholder')}
|
||||
/>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={savePromptFile}
|
||||
disabled={!String(draft.system_prompt_file || '').trim()}
|
||||
className="ui-button ui-button-neutral px-3 py-1.5 text-xs rounded disabled:opacity-50"
|
||||
>
|
||||
<Button type="button" onClick={savePromptFile} disabled={!String(draft.system_prompt_file || '').trim()} size="xs" radius="lg">
|
||||
{t('savePromptFile')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -489,34 +469,18 @@ const SubagentProfiles: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
className="ui-button ui-button-primary px-3 py-1.5 text-xs"
|
||||
>
|
||||
<Button onClick={save} disabled={saving} variant="primary" size="xs">
|
||||
{selected ? t('update') : t('create')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatus('active')}
|
||||
disabled={!draft.agent_id}
|
||||
className="ui-button ui-button-success px-3 py-1.5 text-xs"
|
||||
>
|
||||
</Button>
|
||||
<Button onClick={() => setStatus('active')} disabled={!draft.agent_id} variant="success" size="xs">
|
||||
{t('enable')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatus('disabled')}
|
||||
disabled={!draft.agent_id}
|
||||
className="ui-button ui-button-warning px-3 py-1.5 text-xs"
|
||||
>
|
||||
</Button>
|
||||
<Button onClick={() => setStatus('disabled')} disabled={!draft.agent_id} variant="warning" size="xs">
|
||||
{t('disable')}
|
||||
</button>
|
||||
<button
|
||||
onClick={remove}
|
||||
disabled={!draft.agent_id}
|
||||
className="ui-button ui-button-danger px-3 py-1.5 text-xs"
|
||||
>
|
||||
</Button>
|
||||
<Button onClick={remove} disabled={!draft.agent_id} variant="danger" size="xs">
|
||||
{t('delete')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useAppContext } from '../context/AppContext';
|
||||
import { useUI } from '../context/UIContext';
|
||||
import { Activity, Server, Cpu, Network, RefreshCw } from 'lucide-react';
|
||||
import { SpaceParticles } from '../components/SpaceParticles';
|
||||
import { FixedButton } from '../components/Button';
|
||||
|
||||
type SubagentTask = {
|
||||
id: string;
|
||||
@@ -1158,14 +1159,9 @@ 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="ui-button ui-button-primary ui-button-icon"
|
||||
title={t('refresh')}
|
||||
aria-label={t('refresh')}
|
||||
>
|
||||
<FixedButton onClick={() => load()} variant="primary" label={t('refresh')}>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
</FixedButton>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 brand-card border border-zinc-800 p-4 flex flex-col gap-3">
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Check, RefreshCw } from 'lucide-react';
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import { FixedButton } from '../components/Button';
|
||||
import { formatLocalDateTime } from '../utils/time';
|
||||
|
||||
type TaskAuditItem = {
|
||||
@@ -128,14 +129,9 @@ 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 ui-button-icon"
|
||||
title={loading ? t('loading') : t('refresh')}
|
||||
aria-label={loading ? t('loading') : t('refresh')}
|
||||
>
|
||||
<FixedButton onClick={fetchData} variant="primary" label={loading ? t('loading') : t('refresh')}>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</FixedButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user