mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-13 18:07:36 +08:00
fix ui and oauth
This commit is contained in:
@@ -37,6 +37,30 @@ type PanelFieldProps = FieldBlockProps & {
|
||||
dense?: boolean;
|
||||
};
|
||||
|
||||
type CheckboxCardFieldProps = {
|
||||
checked: boolean;
|
||||
className?: string;
|
||||
help?: React.ReactNode;
|
||||
label: React.ReactNode;
|
||||
onChange: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
type ToolbarCheckboxFieldProps = {
|
||||
checked: boolean;
|
||||
className?: string;
|
||||
help?: React.ReactNode;
|
||||
label: React.ReactNode;
|
||||
onChange: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
type InlineCheckboxFieldProps = {
|
||||
checked: boolean;
|
||||
className?: string;
|
||||
help?: React.ReactNode;
|
||||
label: React.ReactNode;
|
||||
onChange: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
export function TextField({ dense = false, monospace = false, className, ...props }: TextFieldProps) {
|
||||
return (
|
||||
<input
|
||||
@@ -84,6 +108,62 @@ export function CheckboxField({ className, ...props }: CheckboxFieldProps) {
|
||||
return <input {...props} type="checkbox" className={joinClasses('ui-checkbox', className)} />;
|
||||
}
|
||||
|
||||
export function CheckboxCardField({
|
||||
checked,
|
||||
className,
|
||||
help,
|
||||
label,
|
||||
onChange,
|
||||
}: CheckboxCardFieldProps) {
|
||||
return (
|
||||
<label className={joinClasses('ui-toggle-card ui-checkbox-field cursor-pointer', className)}>
|
||||
<div className="space-y-1">
|
||||
<div className="ui-text-primary text-sm font-semibold">{label}</div>
|
||||
{help ? <div className="ui-form-help">{help}</div> : null}
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<CheckboxField checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarCheckboxField({
|
||||
checked,
|
||||
className,
|
||||
help,
|
||||
label,
|
||||
onChange,
|
||||
}: ToolbarCheckboxFieldProps) {
|
||||
return (
|
||||
<label className={joinClasses('ui-toolbar-checkbox cursor-pointer', className)}>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="ui-text-primary text-xs font-semibold leading-tight">{label}</div>
|
||||
{help ? <div className="ui-form-help text-[10px] leading-tight">{help}</div> : null}
|
||||
</div>
|
||||
<CheckboxField checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function InlineCheckboxField({
|
||||
checked,
|
||||
className,
|
||||
help,
|
||||
label,
|
||||
onChange,
|
||||
}: InlineCheckboxFieldProps) {
|
||||
return (
|
||||
<label className={joinClasses('flex items-center justify-between gap-3 rounded-xl border border-zinc-800 bg-zinc-900/30 px-3 py-2.5 cursor-pointer', className)}>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="ui-text-primary text-sm font-semibold leading-tight">{label}</div>
|
||||
{help ? <div className="ui-form-help text-[10px] leading-tight">{help}</div> : null}
|
||||
</div>
|
||||
<CheckboxField checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function FieldBlock({ label, help, meta, className, children }: FieldBlockProps) {
|
||||
return (
|
||||
<div className={joinClasses('space-y-1', className)}>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useMemo, useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FixedButton } from './Button';
|
||||
import { CheckboxField, SelectField, TextField, TextareaField } from './FormControls';
|
||||
import { CheckboxCardField, SelectField, TextField, TextareaField } from './FormControls';
|
||||
|
||||
interface RecursiveConfigProps {
|
||||
data: any;
|
||||
@@ -175,25 +175,26 @@ 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="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">
|
||||
<CheckboxField
|
||||
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"
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<TextField
|
||||
type={typeof value === 'number' ? 'number' : 'text'}
|
||||
value={value === null || value === undefined ? '' : String(value)}
|
||||
onChange={(e) => onChange(currentPath, typeof value === 'number' ? Number(e.target.value) : e.target.value)}
|
||||
className="w-full transition-colors font-mono"
|
||||
<CheckboxCardField
|
||||
checked={value}
|
||||
help={<span className="font-mono text-[10px]">{currentPath}</span>}
|
||||
label={label}
|
||||
onChange={(checked) => onChange(currentPath, checked)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
<TextField
|
||||
type={typeof value === 'number' ? 'number' : 'text'}
|
||||
value={value === null || value === undefined ? '' : String(value)}
|
||||
onChange={(e) => onChange(currentPath, typeof value === 'number' ? Number(e.target.value) : e.target.value)}
|
||||
className="w-full transition-colors font-mono"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Check, ShieldCheck, Users, Wifi } from 'lucide-react';
|
||||
import { CheckboxField, FieldBlock, TextField } from '../FormControls';
|
||||
import { CheckboxCardField, FieldBlock, TextField } from '../FormControls';
|
||||
import type { ChannelField, ChannelKey } from './channelSchema';
|
||||
|
||||
type Translate = (key: string, options?: any) => string;
|
||||
@@ -125,39 +125,35 @@ const ChannelFieldRenderer: React.FC<ChannelFieldRendererProps> = ({
|
||||
if (isWhatsApp) {
|
||||
const Icon = getWhatsAppBooleanIcon(field.key);
|
||||
return (
|
||||
<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">
|
||||
<CheckboxCardField
|
||||
key={field.key}
|
||||
className="ui-boolean-card-detailed"
|
||||
checked={!!value}
|
||||
help={(
|
||||
<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="ui-text-primary text-sm font-semibold">{label}</div>
|
||||
<div className="ui-form-help mt-1">{helper}</div>
|
||||
<div className="ui-form-help">{helper}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CheckboxField
|
||||
checked={!!value}
|
||||
onChange={(e) => setDraft((prev) => ({ ...prev, [field.key]: e.target.checked }))}
|
||||
className="ui-checkbox mt-1"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
label={label}
|
||||
onChange={(checked) => setDraft((prev) => ({ ...prev, [field.key]: checked }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
{helper ? <div className="ui-form-help mt-1">{helper}</div> : null}
|
||||
</div>
|
||||
<CheckboxField
|
||||
checked={!!value}
|
||||
onChange={(e) => setDraft((prev) => ({ ...prev, [field.key]: e.target.checked }))}
|
||||
className="ui-checkbox"
|
||||
/>
|
||||
</label>
|
||||
<CheckboxCardField
|
||||
key={field.key}
|
||||
className="ui-boolean-card"
|
||||
checked={!!value}
|
||||
help={helper}
|
||||
label={label}
|
||||
onChange={(checked) => setDraft((prev) => ({ ...prev, [field.key]: checked }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const ChatComposer: React.FC<ChatComposerProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<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">
|
||||
<div className="ui-composer w-full relative flex min-h-[72px] items-center px-3">
|
||||
<input
|
||||
type="file"
|
||||
id="file"
|
||||
@@ -34,7 +34,7 @@ const ChatComposer: React.FC<ChatComposerProps> = ({
|
||||
/>
|
||||
<label
|
||||
htmlFor="file"
|
||||
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'}`}
|
||||
className={`absolute left-4 top-1/2 -translate-y-1/2 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>
|
||||
@@ -44,12 +44,12 @@ const ChatComposer: React.FC<ChatComposerProps> = ({
|
||||
onKeyDown={(e) => chatTab === 'main' && e.key === 'Enter' && onSend()}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="ui-composer-input w-full pl-14 pr-14 py-3.5 text-[15px] transition-all disabled:opacity-60"
|
||||
className="ui-composer-input w-full h-[72px] pl-16 pr-16 py-0 text-[15px] leading-none transition-all disabled:opacity-60"
|
||||
/>
|
||||
<button
|
||||
onClick={onSend}
|
||||
disabled={disabled || (!msg.trim() && !fileSelected)}
|
||||
className="absolute right-2 p-2.5 brand-button disabled:opacity-50 ui-text-primary rounded-full transition-colors"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/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,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { RefreshCw, Save } from 'lucide-react';
|
||||
import { Button, FixedButton } from '../Button';
|
||||
import { CheckboxField, TextField } from '../FormControls';
|
||||
import { TextField, ToolbarCheckboxField } from '../FormControls';
|
||||
import { ModalBackdrop, ModalCard, ModalHeader, ModalShell } from '../ModalFrame';
|
||||
|
||||
type Translate = (key: string, options?: any) => string;
|
||||
@@ -23,9 +23,10 @@ export function ConfigHeader({ onSave, onShowForm, onShowRaw, showRaw, t }: Conf
|
||||
<Button onClick={onShowForm} variant={!showRaw ? 'primary' : 'neutral'} size="sm" radius="lg">{t('form')}</Button>
|
||||
<Button onClick={onShowRaw} variant={showRaw ? 'primary' : 'neutral'} size="sm" radius="lg">{t('rawJson')}</Button>
|
||||
</div>
|
||||
<FixedButton onClick={onSave} variant="primary" label={t('saveChanges')}>
|
||||
<Button onClick={onSave} variant="primary" size="sm" radius="lg" gap="1">
|
||||
<Save className="w-4 h-4" />
|
||||
</FixedButton>
|
||||
{t('saveChanges')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -64,10 +65,12 @@ export function ConfigToolbar({
|
||||
<Button onClick={onToggleBasicMode} size="sm">
|
||||
{basicMode ? t('configBasicMode') : t('configAdvancedMode')}
|
||||
</Button>
|
||||
<label className="ui-text-primary flex items-center gap-2 text-sm">
|
||||
<CheckboxField checked={hotOnly} onChange={(e) => onHotOnlyChange(e.target.checked)} />
|
||||
{t('configHotOnly')}
|
||||
</label>
|
||||
<ToolbarCheckboxField
|
||||
checked={hotOnly}
|
||||
help={t('configHotOnlyHint', { defaultValue: 'Only show fields that support hot reload.' })}
|
||||
label={t('configHotOnly')}
|
||||
onChange={onHotOnlyChange}
|
||||
/>
|
||||
<TextField value={search} onChange={(e) => onSearchChange(e.target.value)} placeholder={t('configSearchPlaceholder')} className="min-w-[240px] flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Download, FolderOpen, LogIn, LogOut, Plus, RefreshCw, RotateCcw, ShieldCheck, Trash2, Upload, Wallet, X } from 'lucide-react';
|
||||
import { Button, FixedButton } from '../Button';
|
||||
import { CheckboxField, PanelField, SelectField, TextField } from '../FormControls';
|
||||
import { CheckboxField, InlineCheckboxField, PanelField, SelectField, TextField, ToolbarCheckboxField } from '../FormControls';
|
||||
|
||||
function joinClasses(...values: Array<string | undefined | false>) {
|
||||
return values.filter(Boolean).join(' ');
|
||||
@@ -112,21 +112,23 @@ export function ProviderRuntimeToolbar({
|
||||
t,
|
||||
}: ProviderRuntimeToolbarProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_auto] gap-3 items-start">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_auto] gap-3 items-center">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-semibold text-zinc-200">{t('configProxies')}</div>
|
||||
<div className="text-[11px] text-zinc-500">Runtime filters and provider creation are split so the status controls stay attached to each other.</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-stretch gap-2 xl:min-w-[760px]">
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 xl:min-w-[1040px]">
|
||||
<Button onClick={onRefreshRuntime} size="xs" radius="lg" variant="neutral" gap="2" noShrink>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
{t('providersRefreshRuntime')}
|
||||
</Button>
|
||||
<label className="flex shrink-0 items-center gap-2 rounded-xl border border-zinc-800 bg-zinc-900/30 px-2 py-1.5 text-[11px] whitespace-nowrap text-zinc-300">
|
||||
<CheckboxField checked={runtimeAutoRefresh} onChange={(e) => onRuntimeAutoRefreshChange(e.target.checked)} />
|
||||
{t('providersAutoRefresh')}
|
||||
</label>
|
||||
<ToolbarCheckboxField
|
||||
checked={runtimeAutoRefresh}
|
||||
className="shrink-0"
|
||||
help="Refresh runtime stats on the selected interval."
|
||||
label={t('providersAutoRefresh')}
|
||||
onChange={onRuntimeAutoRefreshChange}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-zinc-800 bg-zinc-950/25 px-2 py-2">
|
||||
<span className="px-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-zinc-500">Runtime</span>
|
||||
<SelectField dense value={String(runtimeRefreshSec)} onChange={(e) => onRuntimeRefreshSecChange(Number(e.target.value || 10))} className="min-w-[124px] bg-zinc-900/70 border-zinc-700">
|
||||
@@ -142,14 +144,11 @@ export function ProviderRuntimeToolbar({
|
||||
<option value="all">{t('providersRuntimeAll')}</option>
|
||||
</SelectField>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<TextField dense value={newProxyName} onChange={(e) => onNewProxyNameChange(e.target.value)} placeholder={t('configNewProviderName')} className="min-w-[220px] flex-1 bg-zinc-900/70 border-zinc-700 xl:max-w-[280px]" />
|
||||
<TextField dense value={newProxyName} onChange={(e) => onNewProxyNameChange(e.target.value)} placeholder={t('configNewProviderName')} className="min-w-[220px] bg-zinc-900/70 border-zinc-700 xl:w-[280px]" />
|
||||
<Button onClick={onAddProxy} variant="primary" size="xs" radius="lg" gap="2" noShrink>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('add')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -360,6 +359,7 @@ export function ProviderRuntimeDrawer({
|
||||
type ProviderProxyCardProps = {
|
||||
name: string;
|
||||
oauthAccounts: Array<any>;
|
||||
oauthAccountsLoading?: boolean;
|
||||
onClearOAuthCooldown: (credentialFile: string) => void;
|
||||
onDeleteOAuthAccount: (credentialFile: string) => void;
|
||||
onFieldChange: (field: string, value: any) => void;
|
||||
@@ -377,6 +377,7 @@ type ProviderProxyCardProps = {
|
||||
export function ProviderProxyCard({
|
||||
name,
|
||||
oauthAccounts,
|
||||
oauthAccountsLoading,
|
||||
onClearOAuthCooldown,
|
||||
onDeleteOAuthAccount,
|
||||
onFieldChange,
|
||||
@@ -402,6 +403,7 @@ export function ProviderProxyCard({
|
||||
const runtimeErrors = Array.isArray(runtimeItem?.recent_errors) ? runtimeItem.recent_errors : [];
|
||||
const lastQuotaError = runtimeErrors.find((item: any) => String(item?.reason || '').trim() === 'quota') || null;
|
||||
const connected = showOAuth && oauthAccountCount > 0;
|
||||
const primaryAccount = oauthAccounts[0] || null;
|
||||
const quotaState = !showOAuth
|
||||
? null
|
||||
: lastQuotaError
|
||||
@@ -435,6 +437,19 @@ export function ProviderProxyCard({
|
||||
tone: 'border-zinc-700 bg-zinc-900/50 text-zinc-300',
|
||||
detail: '还没有可用的 OAuth 账号。',
|
||||
};
|
||||
const quotaTone = quotaState?.tone || 'border-zinc-700 bg-zinc-900/50 text-zinc-300';
|
||||
const oauthStatusText = oauthAccountsLoading
|
||||
? t('providersOAuthLoading')
|
||||
: connected
|
||||
? `${t('providersOAuthAutoLoaded')} ${oauthAccountCount} ${t('providersOAuthAccountUnit')}`
|
||||
: t('providersNoOAuthAccounts');
|
||||
const oauthStatusDetail = oauthAccountsLoading
|
||||
? t('providersOAuthLoadingHelp')
|
||||
: connected
|
||||
? `${t('providersOAuthPrimaryAccount')} ${primaryAccount?.account_label || primaryAccount?.email || primaryAccount?.account_id || '-'}`
|
||||
: t('providersOAuthEmptyHelp');
|
||||
const primaryBalanceText = primaryAccount?.balance_label || primaryAccount?.plan_type || '';
|
||||
const primaryBalanceDetail = primaryAccount?.balance_detail || primaryAccount?.subscription_active_until || '';
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 rounded-2xl border border-zinc-800 bg-zinc-900/30 p-4 text-xs">
|
||||
@@ -562,6 +577,39 @@ export function ProviderProxyCard({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="rounded-2xl border border-zinc-800 bg-zinc-950/25 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-zinc-500">{t('providersOAuthLoginStatus')}</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-sm font-medium text-zinc-100">
|
||||
<ShieldCheck className={`h-4 w-4 ${connected ? 'text-emerald-300' : 'text-zinc-500'}`} />
|
||||
{oauthAccountsLoading ? t('providersOAuthLoading') : connected ? oauthStatusText : t('providersOAuthDisconnected')}
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] text-zinc-500">{oauthStatusDetail}</div>
|
||||
</div>
|
||||
<div className={`rounded-full border px-2.5 py-1 text-[11px] ${connected ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200' : 'border-zinc-700 bg-zinc-900/50 text-zinc-400'}`}>
|
||||
{oauthAccountsLoading ? t('providersLoading') : connected ? t('providersConnected') : t('providersDisconnected')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-zinc-800 bg-zinc-950/25 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-zinc-500">{t('providersQuotaStatus')}</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-sm font-medium text-zinc-100">
|
||||
<Wallet className="h-4 w-4 text-amber-300" />
|
||||
{primaryBalanceText || quotaState?.label || t('providersQuotaPending')}
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] text-zinc-500">{primaryBalanceDetail || quotaState?.detail || t('providersQuotaHelp')}</div>
|
||||
</div>
|
||||
<div className={`rounded-full border px-2.5 py-1 text-[11px] ${quotaTone}`}>
|
||||
{lastQuotaError ? t('providersQuotaBadge') : connected ? t('providersRuntimeBadge') : t('providersPending')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="rounded-2xl border border-zinc-800 bg-zinc-950/25 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
@@ -641,10 +689,23 @@ export function ProviderProxyCard({
|
||||
</div>
|
||||
</div>
|
||||
<FixedButton onClick={onLoadOAuthAccounts} variant="neutral" radius="lg" label={t('providersRefreshList')}>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<RefreshCw className={`w-4 h-4${oauthAccountsLoading ? ' animate-spin' : ''}`} />
|
||||
</FixedButton>
|
||||
</div>
|
||||
<div className={`rounded-xl border px-3 py-2 text-[11px] ${
|
||||
oauthAccountsLoading
|
||||
? 'border-sky-500/25 bg-sky-500/10 text-sky-100'
|
||||
: connected
|
||||
? 'border-emerald-500/25 bg-emerald-500/10 text-emerald-100'
|
||||
: 'border-zinc-800 bg-zinc-950/30 text-zinc-400'
|
||||
}`}>
|
||||
{oauthAccountsLoading
|
||||
? t('providersOAuthLoadingHelp')
|
||||
: connected
|
||||
? `${oauthStatusText}。${oauthStatusDetail}`
|
||||
: t('providersOAuthEmptyHelp')}
|
||||
</div>
|
||||
<div className={`hidden rounded-xl border px-3 py-2 text-[11px] ${
|
||||
connected
|
||||
? 'border-emerald-500/25 bg-emerald-500/10 text-emerald-100'
|
||||
: 'border-zinc-800 bg-zinc-950/30 text-zinc-400'
|
||||
@@ -653,7 +714,9 @@ export function ProviderProxyCard({
|
||||
? `已自动加载 ${oauthAccountCount} 个 OAuth 账号。当前主账号:${oauthAccounts[0]?.account_label || oauthAccounts[0]?.email || oauthAccounts[0]?.account_id || '-'}`
|
||||
: '当前没有可用账号。可以直接点击左侧 OAuth 登录,或者导入 auth.json。'}
|
||||
</div>
|
||||
{oauthAccounts.length === 0 ? (
|
||||
{oauthAccountsLoading ? (
|
||||
<div className="text-zinc-500">{t('providersOAuthLoading')}</div>
|
||||
) : oauthAccounts.length === 0 ? (
|
||||
<div className="text-zinc-500">{t('providersNoOAuthAccounts')}</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -674,9 +737,29 @@ export function ProviderProxyCard({
|
||||
</div>
|
||||
<div className="text-zinc-500 text-[11px]">label: {account?.account_label || account?.email || account?.account_id || '-'}</div>
|
||||
<div className="text-zinc-500 truncate text-[11px]">{account?.credential_file}</div>
|
||||
{(account?.balance_label || account?.plan_type) ? (
|
||||
<div className="text-zinc-500 text-[11px]">
|
||||
{t('providersBalanceDisplay')}: {account?.balance_label || account?.plan_type}
|
||||
{account?.balance_detail ? ` · ${account.balance_detail}` : ''}
|
||||
</div>
|
||||
) : null}
|
||||
{account?.subscription_active_until ? (
|
||||
<div className="text-zinc-500 text-[11px]">
|
||||
{t('providersSubscriptionUntil')}: {account?.subscription_active_until}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="text-zinc-500 text-[11px]">project: {account?.project_id || '-'} · device: {account?.device_id || '-'}</div>
|
||||
<div className="text-zinc-500 truncate text-[11px]">proxy: {account?.network_proxy || '-'}</div>
|
||||
<div className="text-zinc-500 text-[11px]">expire: {account?.expire || '-'} · cooldown: {account?.cooldown_until || '-'}</div>
|
||||
<div className="text-zinc-500 text-[11px]">
|
||||
{t('providersQuotaStatus')}: {String(account?.cooldown_until || '').trim()
|
||||
? `${t('providersQuotaCooldown')} · ${account?.cooldown_until || '-'}`
|
||||
: Number(account?.health_score || 100) < 60
|
||||
? `${t('providersQuotaHealthLow')} · ${t('providersQuotaLowestHealth')} ${Number(account?.health_score || 100)}`
|
||||
: lastQuotaError
|
||||
? `${t('providersQuotaLimited')} · ${String(lastQuotaError?.when || '-')}`
|
||||
: t('providersQuotaHealthy')}
|
||||
</div>
|
||||
<div className="text-zinc-500 text-[11px]">health: {Number(account?.health_score || 100)} · failures: {Number(account?.failure_count || 0)} · last failure: {account?.last_failure || '-'}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -710,9 +793,12 @@ export function ProviderProxyCard({
|
||||
{advancedOpen ? 'Hide' : 'Show'}
|
||||
</Button>
|
||||
</div>
|
||||
<PanelField label={t('providersRuntimePersist')} help={t('providersRuntimePersistHelp')} dense>
|
||||
<CheckboxField checked={Boolean(proxy?.runtime_persist)} onChange={(e) => onFieldChange('runtime_persist', e.target.checked)} />
|
||||
</PanelField>
|
||||
<InlineCheckboxField
|
||||
checked={Boolean(proxy?.runtime_persist)}
|
||||
help={t('providersRuntimePersistHelp')}
|
||||
label={t('providersRuntimePersist')}
|
||||
onChange={(checked) => onFieldChange('runtime_persist', checked)}
|
||||
/>
|
||||
{advancedOpen ? (
|
||||
<div className="space-y-3">
|
||||
<PanelField label={t('providersRuntimeHistoryFile')} dense>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Package, Wrench } from 'lucide-react';
|
||||
import { Button, FixedButton } from '../Button';
|
||||
import { CheckboxField, SelectField, TextField } from '../FormControls';
|
||||
import { CheckboxCardField, SelectField, TextField } from '../FormControls';
|
||||
import NoticePanel from '../NoticePanel';
|
||||
|
||||
type MCPDraftServer = {
|
||||
@@ -77,12 +77,15 @@ const MCPServerEditor: React.FC<MCPServerEditorProps> = ({
|
||||
<option value="sse">sse</option>
|
||||
</SelectField>
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-zinc-400">enabled</div>
|
||||
<div className="ui-toggle-card flex h-11 items-center rounded-xl px-3">
|
||||
<CheckboxField checked={draft.enabled} onChange={(e) => updateDraftField('enabled', e.target.checked)} />
|
||||
</div>
|
||||
</label>
|
||||
<CheckboxCardField
|
||||
checked={draft.enabled}
|
||||
className="min-h-[76px]"
|
||||
label={t('enable')}
|
||||
onChange={(checked) => updateDraftField('enabled', checked)}
|
||||
/>
|
||||
</div>
|
||||
{draft.transport === 'stdio' ? (
|
||||
<label className="space-y-2">
|
||||
<div className="text-xs text-zinc-400">permission</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { Save, Trash2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, FixedButton } from '../Button';
|
||||
import { CheckboxField, FieldBlock, SelectField, TextField, TextareaField } from '../FormControls';
|
||||
import { CheckboxCardField, FieldBlock, SelectField, TextField, TextareaField } from '../FormControls';
|
||||
import type { SubagentProfile, ToolAllowlistGroup } from './profileDraft';
|
||||
import { parseAllowlist } from './profileDraft';
|
||||
|
||||
@@ -108,15 +108,14 @@ const ProfileEditorPanel: React.FC<ProfileEditorPanelProps> = ({
|
||||
</FieldBlock>
|
||||
<FieldBlock
|
||||
label={statusLabel}
|
||||
help={statusEnabled ? '已启用,允许接收任务。' : '已停用,不会接收新任务。'}
|
||||
>
|
||||
<label className="flex min-h-[34px] items-center gap-3 rounded-lg border border-zinc-800 bg-zinc-950/40 px-3 py-2 text-sm">
|
||||
<CheckboxField
|
||||
checked={statusEnabled}
|
||||
onChange={(e) => onChange({ ...draft, status: e.target.checked ? 'active' : 'disabled' })}
|
||||
/>
|
||||
<span>{statusEnabled ? '启用' : '停用'}</span>
|
||||
</label>
|
||||
<CheckboxCardField
|
||||
checked={statusEnabled}
|
||||
className="min-h-[76px]"
|
||||
help={statusEnabled ? '已启用,允许接收任务。' : '已停用,不会接收新任务。'}
|
||||
label={statusEnabled ? '启用' : '停用'}
|
||||
onChange={(checked) => onChange({ ...draft, status: checked ? 'active' : 'disabled' })}
|
||||
/>
|
||||
</FieldBlock>
|
||||
<FieldBlock
|
||||
label="通知主代理"
|
||||
@@ -180,9 +179,10 @@ const ProfileEditorPanel: React.FC<ProfileEditorPanelProps> = ({
|
||||
placeholder={promptPlaceholder}
|
||||
/>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<FixedButton type="button" onClick={onSavePromptFile} disabled={!String(draft.system_prompt_file || '').trim() || promptPathInvalid} radius="lg" label={t('savePromptFile')}>
|
||||
<Button type="button" onClick={onSavePromptFile} disabled={!String(draft.system_prompt_file || '').trim() || promptPathInvalid} variant="primary" size="sm" radius="lg" gap="1">
|
||||
<Save className="w-4 h-4" />
|
||||
</FixedButton>
|
||||
{t('savePromptFile')}
|
||||
</Button>
|
||||
</div>
|
||||
</FieldBlock>
|
||||
<FieldBlock label={maxRetriesLabel}>
|
||||
@@ -227,9 +227,10 @@ const ProfileEditorPanel: React.FC<ProfileEditorPanelProps> = ({
|
||||
</FieldBlock>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FixedButton onClick={onSave} disabled={saving} variant="primary" label={isExisting ? t('update') : t('create')}>
|
||||
<Button onClick={onSave} disabled={saving} variant="primary" size="sm" radius="lg" gap="1">
|
||||
<Save className="w-4 h-4" />
|
||||
</FixedButton>
|
||||
{isExisting ? t('update') : t('create')}
|
||||
</Button>
|
||||
<FixedButton onClick={onDelete} disabled={!draft.agent_id} variant="danger" label={t('delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</FixedButton>
|
||||
|
||||
@@ -17,6 +17,13 @@ type TopologyControlsProps = {
|
||||
};
|
||||
|
||||
const FILTERS: TopologyFilter[] = ['all', 'running', 'failed', 'local', 'remote'];
|
||||
const FILTER_LABELS: Record<TopologyFilter, string> = {
|
||||
all: 'All',
|
||||
running: 'Running',
|
||||
failed: 'Failed',
|
||||
local: 'Local',
|
||||
remote: 'Remote',
|
||||
};
|
||||
|
||||
const TopologyControls: React.FC<TopologyControlsProps> = ({
|
||||
onClearFocus,
|
||||
@@ -39,7 +46,7 @@ const TopologyControls: React.FC<TopologyControlsProps> = ({
|
||||
onClick={() => onSelectFilter(filter)}
|
||||
className={`px-2 py-1 rounded-xl text-[11px] ${topologyFilter === filter ? 'control-chip-active' : 'control-chip'}`}
|
||||
>
|
||||
{t(`topologyFilter.${filter}`)}
|
||||
{t(`topologyFilter.${filter}`, { defaultValue: FILTER_LABELS[filter] })}
|
||||
</button>
|
||||
))}
|
||||
{selectedBranch ? (
|
||||
|
||||
@@ -126,6 +126,13 @@ const resources = {
|
||||
agentTopology: 'Agent Topology',
|
||||
agentTopologyHint: 'Unified graph for local agents, registered nodes, and mirrored remote agent branches.',
|
||||
runningTasks: 'running',
|
||||
topologyFilter: {
|
||||
all: 'All',
|
||||
running: 'Running',
|
||||
failed: 'Failed',
|
||||
local: 'Local',
|
||||
remote: 'Remote',
|
||||
},
|
||||
clearFocus: 'Clear Focus',
|
||||
zoomIn: 'Zoom In',
|
||||
zoomOut: 'Zoom Out',
|
||||
@@ -365,6 +372,33 @@ const resources = {
|
||||
providersOAuthAccounts: 'OAuth Accounts',
|
||||
providersRefreshList: 'Refresh List',
|
||||
providersNoOAuthAccounts: 'No imported OAuth accounts yet.',
|
||||
providersOAuthLoginStatus: 'login status',
|
||||
providersOAuthDisconnected: 'not logged in',
|
||||
providersConnected: 'connected',
|
||||
providersDisconnected: 'disconnected',
|
||||
providersLoading: 'loading',
|
||||
providersOAuthLoading: 'loading accounts',
|
||||
providersOAuthLoadingHelp: 'Loading imported OAuth accounts and runtime quota signals.',
|
||||
providersOAuthAutoLoaded: 'Auto-loaded',
|
||||
providersOAuthAccountUnit: 'OAuth account(s)',
|
||||
providersOAuthPrimaryAccount: 'Primary account:',
|
||||
providersOAuthEmptyHelp: 'No available account yet. You can click OAuth Login or import auth.json.',
|
||||
providersQuotaStatus: 'quota status',
|
||||
providersQuotaHelp: 'The backend does not expose a real balance API yet. This view shows quota, cooldown, and health signals.',
|
||||
providersQuotaLimited: 'quota limited',
|
||||
providersQuotaLastHit: 'Last quota hit:',
|
||||
providersQuotaCooldown: 'cooldown',
|
||||
providersQuotaCooldownUntil: 'Cooldown until:',
|
||||
providersQuotaHealthLow: 'health low',
|
||||
providersQuotaLowestHealth: 'Lowest health:',
|
||||
providersQuotaHealthy: 'healthy',
|
||||
providersQuotaHealthyHelp: 'Current account can still join OAuth rotation.',
|
||||
providersQuotaPending: 'pending',
|
||||
providersQuotaPendingHelp: 'No OAuth account has been loaded yet.',
|
||||
providersQuotaBadge: 'Quota',
|
||||
providersRuntimeBadge: 'Runtime',
|
||||
providersBalanceDisplay: 'balance',
|
||||
providersSubscriptionUntil: 'subscription until',
|
||||
system: 'System',
|
||||
pauseJob: 'Pause Job',
|
||||
startJob: 'Start Job',
|
||||
@@ -847,6 +881,13 @@ const resources = {
|
||||
agentTopology: 'Agent 拓扑',
|
||||
agentTopologyHint: '统一展示本地 agent、注册 node 以及远端镜像 agent 分支的关系图。',
|
||||
runningTasks: '运行中',
|
||||
topologyFilter: {
|
||||
all: '全部',
|
||||
running: '运行中',
|
||||
failed: '失败',
|
||||
local: '本地',
|
||||
remote: '远端',
|
||||
},
|
||||
clearFocus: '清除聚焦',
|
||||
zoomIn: '放大',
|
||||
zoomOut: '缩小',
|
||||
@@ -1083,6 +1124,33 @@ const resources = {
|
||||
providersOAuthAccounts: 'OAuth 账号',
|
||||
providersRefreshList: '刷新列表',
|
||||
providersNoOAuthAccounts: '当前还没有导入 OAuth 账号。',
|
||||
providersOAuthLoginStatus: '登录状态',
|
||||
providersOAuthDisconnected: '尚未登录',
|
||||
providersConnected: '已连接',
|
||||
providersDisconnected: '未连接',
|
||||
providersLoading: '加载中',
|
||||
providersOAuthLoading: '正在加载账号',
|
||||
providersOAuthLoadingHelp: '正在加载已导入的 OAuth 账号和运行时额度信号。',
|
||||
providersOAuthAutoLoaded: '已自动加载',
|
||||
providersOAuthAccountUnit: '个 OAuth 账号',
|
||||
providersOAuthPrimaryAccount: '当前主账号:',
|
||||
providersOAuthEmptyHelp: '当前没有可用账号。可以点击 OAuth 登录,或者导入 auth.json。',
|
||||
providersQuotaStatus: '额度状态',
|
||||
providersQuotaHelp: '后端暂时没有真实余额接口,这里展示 quota、cooldown 和 health 这些运行时信号。',
|
||||
providersQuotaLimited: '额度受限',
|
||||
providersQuotaLastHit: '最近一次限额命中:',
|
||||
providersQuotaCooldown: '冷却中',
|
||||
providersQuotaCooldownUntil: '冷却到:',
|
||||
providersQuotaHealthLow: '健康偏低',
|
||||
providersQuotaLowestHealth: '最低健康分:',
|
||||
providersQuotaHealthy: '可用',
|
||||
providersQuotaHealthyHelp: '当前账号仍可参与 OAuth 轮换。',
|
||||
providersQuotaPending: '待加载',
|
||||
providersQuotaPendingHelp: '当前还没有加载到 OAuth 账号。',
|
||||
providersQuotaBadge: '限额',
|
||||
providersRuntimeBadge: '运行态',
|
||||
providersBalanceDisplay: '额度显示',
|
||||
providersSubscriptionUntil: '订阅到期',
|
||||
system: '系统',
|
||||
pauseJob: '暂停任务',
|
||||
startJob: '启动任务',
|
||||
|
||||
@@ -966,19 +966,45 @@ html.theme-dark .brand-button {
|
||||
padding: 1.1rem 1.15rem;
|
||||
}
|
||||
|
||||
.ui-checkbox-field {
|
||||
display: flex;
|
||||
min-height: 92px;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
.ui-toolbar-checkbox {
|
||||
display: inline-flex;
|
||||
min-width: 150px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.85rem;
|
||||
border: 1px solid var(--color-zinc-800);
|
||||
border-radius: 0.9rem;
|
||||
background: rgb(255 255 255 / 0.78);
|
||||
box-shadow: inset 0 1px 0 var(--card-inner-highlight);
|
||||
padding: 0.55rem 0.75rem;
|
||||
transition: border-color 160ms ease, background-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.ui-toolbar-checkbox:hover {
|
||||
border-color: color-mix(in srgb, var(--color-indigo-400) 34%, var(--color-zinc-800));
|
||||
}
|
||||
|
||||
.ui-checkbox {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
width: 0.95rem;
|
||||
height: 0.95rem;
|
||||
flex-shrink: 0;
|
||||
border: 1.5px solid rgb(148 163 184 / 0.9);
|
||||
border-radius: 0.4rem;
|
||||
border-radius: 0.3rem;
|
||||
background: rgb(255 255 255 / 0.94);
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.95);
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: 0.72rem 0.72rem;
|
||||
background-size: 0.62rem 0.62rem;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
background-color 160ms ease,
|
||||
@@ -1000,9 +1026,8 @@ html.theme-dark .brand-button {
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 14 14' fill='none'%3E%3Cpath d='M3 7.2L5.7 10L11 4.6' stroke='white' stroke-width='2.1' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"),
|
||||
linear-gradient(135deg, var(--button-start) 0%, var(--button-end) 100%);
|
||||
box-shadow:
|
||||
0 0 0 3px rgb(249 115 22 / 0.14),
|
||||
0 8px 18px rgb(249 115 22 / 0.2);
|
||||
transform: translateY(-0.5px);
|
||||
0 0 0 2px rgb(249 115 22 / 0.12),
|
||||
0 4px 12px rgb(249 115 22 / 0.18);
|
||||
}
|
||||
|
||||
.ui-checkbox:focus-visible {
|
||||
@@ -1069,6 +1094,19 @@ html.theme-dark .ui-toggle-card {
|
||||
background: rgb(9 16 28 / 0.32);
|
||||
}
|
||||
|
||||
.ui-toolbar-checkbox {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
html.theme-dark .ui-toolbar-checkbox {
|
||||
border-color: var(--color-zinc-700);
|
||||
background: rgb(9 16 28 / 0.32);
|
||||
}
|
||||
|
||||
html.theme-dark .ui-toolbar-checkbox:hover {
|
||||
border-color: color-mix(in srgb, var(--color-indigo-400) 38%, var(--color-zinc-700));
|
||||
}
|
||||
|
||||
html.theme-dark .ui-toggle-card:hover {
|
||||
border-color: color-mix(in srgb, var(--color-indigo-400) 38%, var(--color-zinc-700));
|
||||
}
|
||||
@@ -1128,8 +1166,8 @@ html.theme-dark .ui-checkbox:checked {
|
||||
linear-gradient(135deg, rgb(249 115 22 / 0.96) 0%, rgb(245 158 11 / 0.96) 100%);
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(255 196 128 / 0.24),
|
||||
0 0 0 4px rgb(232 132 58 / 0.18),
|
||||
0 10px 22px rgb(232 132 58 / 0.22);
|
||||
0 0 0 3px rgb(232 132 58 / 0.16),
|
||||
0 6px 16px rgb(232 132 58 / 0.2);
|
||||
}
|
||||
|
||||
html.theme-dark .ui-checkbox:focus-visible {
|
||||
@@ -1169,6 +1207,7 @@ html.theme-dark .ui-select optgroup {
|
||||
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);
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background-color 160ms ease;
|
||||
}
|
||||
|
||||
.ui-composer-input {
|
||||
@@ -1178,10 +1217,25 @@ html.theme-dark .ui-select optgroup {
|
||||
background: transparent;
|
||||
color: rgb(51 65 85 / 0.98);
|
||||
outline: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
caret-color: rgb(234 88 12 / 0.92);
|
||||
}
|
||||
|
||||
.ui-composer:focus-within {
|
||||
border-color: rgb(249 115 22 / 0.72);
|
||||
box-shadow:
|
||||
0 0 0 3px rgb(249 115 22 / 0.12),
|
||||
inset 0 1px 0 var(--card-inner-highlight);
|
||||
}
|
||||
|
||||
.ui-composer-input::placeholder {
|
||||
color: rgb(100 116 139 / 0.9);
|
||||
color: rgb(113 128 150 / 0.86);
|
||||
}
|
||||
|
||||
.ui-composer-input::selection {
|
||||
background: rgb(251 191 36 / 0.32);
|
||||
color: rgb(30 41 59 / 0.98);
|
||||
}
|
||||
|
||||
.ui-composer-input:focus {
|
||||
@@ -1220,10 +1274,23 @@ html.theme-dark .ui-composer {
|
||||
|
||||
html.theme-dark .ui-composer-input {
|
||||
color: rgb(226 232 240 / 0.96);
|
||||
caret-color: rgb(251 146 60 / 0.96);
|
||||
}
|
||||
|
||||
html.theme-dark .ui-composer:focus-within {
|
||||
border-color: rgb(251 146 60 / 0.8);
|
||||
box-shadow:
|
||||
0 0 0 3px rgb(251 146 60 / 0.14),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.05);
|
||||
}
|
||||
|
||||
html.theme-dark .ui-composer-input::placeholder {
|
||||
color: rgb(111 131 155 / 0.9);
|
||||
color: rgb(126 146 171 / 0.86);
|
||||
}
|
||||
|
||||
html.theme-dark .ui-composer-input::selection {
|
||||
background: rgb(251 146 60 / 0.28);
|
||||
color: rgb(248 250 252 / 0.98);
|
||||
}
|
||||
|
||||
html.theme-dark .ui-code-panel {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { RefreshCw, Save } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import { useUI } from '../context/UIContext';
|
||||
import { FixedButton } from '../components/Button';
|
||||
import { Button, FixedButton } from '../components/Button';
|
||||
import ChannelSectionCard from '../components/channel/ChannelSectionCard';
|
||||
import ChannelFieldRenderer from '../components/channel/ChannelFieldRenderer';
|
||||
import {
|
||||
@@ -69,6 +69,15 @@ const ChannelSettings: React.FC = () => {
|
||||
const [draft, setDraft] = useState<Record<string, any>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [waStatus, setWaStatus] = useState<WhatsAppStatusPayload | null>(null);
|
||||
const draftRef = React.useRef<Record<string, any>>({});
|
||||
|
||||
const updateDraft: React.Dispatch<React.SetStateAction<Record<string, any>>> = React.useCallback((next) => {
|
||||
setDraft((prev) => {
|
||||
const resolved = typeof next === 'function' ? (next as (value: Record<string, any>) => Record<string, any>)(prev) : next;
|
||||
draftRef.current = resolved;
|
||||
return resolved;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fallbackChannel) {
|
||||
@@ -80,6 +89,7 @@ const ChannelSettings: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
const next = cloneJSON(((cfg as any)?.channels?.[definition.id] || {}) as Record<string, any>);
|
||||
draftRef.current = next;
|
||||
setDraft(next);
|
||||
}, [availableChannelKeys, cfg, definition, fallbackChannel, key, navigate]);
|
||||
|
||||
@@ -112,13 +122,17 @@ const ChannelSettings: React.FC = () => {
|
||||
if (!definition || !availableChannelKeys.includes(key)) return null;
|
||||
|
||||
const saveChannel = async () => {
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const nextCfg = cloneJSON(cfg || {});
|
||||
if (!nextCfg.channels || typeof nextCfg.channels !== 'object') {
|
||||
(nextCfg as any).channels = {};
|
||||
}
|
||||
(nextCfg as any).channels[definition.id] = cloneJSON(draft);
|
||||
(nextCfg as any).channels[definition.id] = cloneJSON(draftRef.current || {});
|
||||
const submit = async (confirmRisky: boolean) => {
|
||||
const body = confirmRisky ? { ...nextCfg, confirm_risky: true } : nextCfg;
|
||||
return ui.withLoading(async () => {
|
||||
@@ -202,9 +216,10 @@ const ChannelSettings: React.FC = () => {
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</FixedButton>
|
||||
)}
|
||||
<FixedButton onClick={saveChannel} disabled={saving} variant="primary" label={saving ? t('loading') : t('saveChanges')}>
|
||||
<Button onClick={saveChannel} disabled={saving} variant="primary" size="sm" radius="lg" gap="1">
|
||||
<Save className="h-4 w-4" />
|
||||
</FixedButton>
|
||||
{saving ? t('loading') : t('saveChanges')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -229,7 +244,7 @@ const ChannelSettings: React.FC = () => {
|
||||
field={field}
|
||||
getDescription={getChannelFieldDescription}
|
||||
parseList={parseChannelList}
|
||||
setDraft={setDraft}
|
||||
setDraft={updateDraft}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useAppContext } from '../context/AppContext';
|
||||
import { useUI } from '../context/UIContext';
|
||||
import { Button, FixedButton } from '../components/Button';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
import { CheckboxField, FieldBlock, SelectField, TextField, TextareaField } from '../components/FormControls';
|
||||
import { CheckboxCardField, FieldBlock, SelectField, TextField, TextareaField } from '../components/FormControls';
|
||||
import { ModalBackdrop, ModalBody, ModalCard, ModalFooter, ModalHeader, ModalShell } from '../components/ModalFrame';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import { CronJob } from '../types';
|
||||
@@ -343,24 +343,20 @@ const Cron: React.FC = () => {
|
||||
</FieldBlock>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 pt-2">
|
||||
<label className="flex items-center gap-3 cursor-pointer group">
|
||||
<CheckboxField
|
||||
<div className="grid grid-cols-1 gap-3 pt-2 md:grid-cols-2">
|
||||
<CheckboxCardField
|
||||
checked={cronForm.deliver}
|
||||
onChange={(e) => setCronForm({ ...cronForm, deliver: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-zinc-700 text-indigo-500 focus:ring-indigo-500 focus:ring-offset-zinc-900 bg-zinc-950"
|
||||
help={t('cronDeliverHint', { defaultValue: 'Send the message through the selected channel.' })}
|
||||
label={t('deliver')}
|
||||
onChange={(checked) => setCronForm({ ...cronForm, deliver: checked })}
|
||||
/>
|
||||
<span className="text-sm font-medium text-zinc-400 group-hover:text-zinc-200 transition-colors">{t('deliver')}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 cursor-pointer group">
|
||||
<CheckboxField
|
||||
<CheckboxCardField
|
||||
checked={cronForm.enabled}
|
||||
onChange={(e) => setCronForm({ ...cronForm, enabled: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-zinc-700 text-indigo-500 focus:ring-indigo-500 focus:ring-offset-zinc-900 bg-zinc-950"
|
||||
help={t('cronEnabledHint', { defaultValue: 'Enable this cron job immediately after saving.' })}
|
||||
label={t('active')}
|
||||
onChange={(checked) => setCronForm({ ...cronForm, enabled: checked })}
|
||||
/>
|
||||
<span className="text-sm font-medium text-zinc-400 group-hover:text-zinc-200 transition-colors">{t('active')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter className="bg-zinc-900/20">
|
||||
|
||||
@@ -456,10 +456,11 @@ const MCP: React.FC = () => {
|
||||
</FixedButton>
|
||||
)}
|
||||
<Button onClick={closeModal} size="sm">{t('cancel')}</Button>
|
||||
<FixedButton onClick={saveServer} variant="primary" label={t('saveChanges')}>
|
||||
<Save className="w-4 h-4" />
|
||||
</FixedButton>
|
||||
</div>
|
||||
<Button onClick={saveServer} variant="primary" size="sm" radius="lg" gap="1">
|
||||
<Save className="w-4 h-4" />
|
||||
{t('saveChanges')}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalCard>
|
||||
</motion.div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Save, Trash2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import { useUI } from '../context/UIContext';
|
||||
import { FixedButton } from '../components/Button';
|
||||
import { Button, FixedButton } from '../components/Button';
|
||||
import { TextareaField } from '../components/FormControls';
|
||||
import FileListItem from '../components/FileListItem';
|
||||
|
||||
@@ -153,10 +153,11 @@ 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>
|
||||
<FixedButton onClick={saveFile} variant="primary" radius="xl" label={t('save')}>
|
||||
<Save className="w-4 h-4" />
|
||||
</FixedButton>
|
||||
</div>
|
||||
<Button onClick={saveFile} variant="primary" size="sm" radius="xl" gap="1">
|
||||
<Save className="w-4 h-4" />
|
||||
{t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
<TextareaField value={content} onChange={(e) => setContent(e.target.value)} className="w-full h-[50vh] lg:h-[80vh] rounded-[24px] p-4" />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RefreshCw, Save } from 'lucide-react';
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
@@ -29,6 +29,8 @@ const Providers: React.FC = () => {
|
||||
const latestProviderRuntimeRef = useRef<any[]>([]);
|
||||
const [displayedProviderRuntimeItems, setDisplayedProviderRuntimeItems] = useState<any[]>([]);
|
||||
const [oauthAccounts, setOAuthAccounts] = useState<Record<string, Array<any>>>({});
|
||||
const [oauthAccountsLoading, setOAuthAccountsLoading] = useState<Record<string, boolean>>({});
|
||||
const [oauthAccountsLoaded, setOAuthAccountsLoaded] = useState<Record<string, boolean>>({});
|
||||
|
||||
const providerEntries = useMemo(() => {
|
||||
const providers = (((cfg as any)?.models || {}) as any)?.providers || {};
|
||||
@@ -77,14 +79,6 @@ const Providers: React.FC = () => {
|
||||
}
|
||||
}, [activeProviderName, providerEntries]);
|
||||
|
||||
useEffect(() => {
|
||||
providerEntries.forEach(([name, p]) => {
|
||||
if (['oauth', 'hybrid'].includes(String(p?.auth || ''))) {
|
||||
loadOAuthAccounts(name);
|
||||
}
|
||||
});
|
||||
}, [providerEntries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (baseline == null && cfg && Object.keys(cfg).length > 0) {
|
||||
setBaseline(cloneJSON(cfg));
|
||||
@@ -131,6 +125,49 @@ const Providers: React.FC = () => {
|
||||
ui,
|
||||
});
|
||||
|
||||
const loadOAuthAccountsNow = useCallback(async (name: string) => {
|
||||
if (!name) return;
|
||||
setOAuthAccountsLoading((prev) => ({ ...prev, [name]: true }));
|
||||
try {
|
||||
await loadOAuthAccounts(name);
|
||||
setOAuthAccountsLoaded((prev) => ({ ...prev, [name]: true }));
|
||||
} finally {
|
||||
setOAuthAccountsLoading((prev) => ({ ...prev, [name]: false }));
|
||||
}
|
||||
}, [loadOAuthAccounts]);
|
||||
|
||||
useEffect(() => {
|
||||
providerEntries.forEach(([name, p]) => {
|
||||
if (!['oauth', 'hybrid'].includes(String(p?.auth || ''))) return;
|
||||
if (oauthAccountsLoaded[name] || oauthAccountsLoading[name]) return;
|
||||
void loadOAuthAccountsNow(name);
|
||||
});
|
||||
}, [loadOAuthAccountsNow, oauthAccountsLoaded, oauthAccountsLoading, providerEntries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeProviderEntry) return;
|
||||
const [name, provider] = activeProviderEntry;
|
||||
if (!['oauth', 'hybrid'].includes(String(provider?.auth || ''))) return;
|
||||
if (oauthAccountsLoaded[name] || oauthAccountsLoading[name]) return;
|
||||
void loadOAuthAccountsNow(name);
|
||||
}, [activeProviderEntry, loadOAuthAccountsNow, oauthAccountsLoaded, oauthAccountsLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const oauthProviderNames = new Set(
|
||||
providerEntries
|
||||
.filter(([, provider]) => ['oauth', 'hybrid'].includes(String(provider?.auth || '')))
|
||||
.map(([name]) => name),
|
||||
);
|
||||
setOAuthAccountsLoaded((prev) => {
|
||||
const next = Object.fromEntries(Object.entries(prev).filter(([name]) => oauthProviderNames.has(name)));
|
||||
return Object.keys(next).length === Object.keys(prev).length ? prev : next;
|
||||
});
|
||||
setOAuthAccountsLoading((prev) => {
|
||||
const next = Object.fromEntries(Object.entries(prev).filter(([name]) => oauthProviderNames.has(name)));
|
||||
return Object.keys(next).length === Object.keys(prev).length ? prev : next;
|
||||
});
|
||||
}, [providerEntries]);
|
||||
|
||||
const { saveConfig } = useConfigSaveAction({
|
||||
cfg,
|
||||
cfgRaw,
|
||||
@@ -161,12 +198,13 @@ const Providers: React.FC = () => {
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</FixedButton>
|
||||
<Button onClick={() => setShowDiff(true)} size="sm">{t('configDiffPreview')}</Button>
|
||||
<FixedButton onClick={saveConfig} variant="primary" label={t('saveChanges')}>
|
||||
<Save className="w-4 h-4" />
|
||||
</FixedButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Button onClick={saveConfig} variant="primary" size="sm" radius="lg" gap="1">
|
||||
<Save className="w-4 h-4" />
|
||||
{t('saveChanges')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="brand-card ui-border-subtle border rounded-[30px] p-4 md:p-6 space-y-4">
|
||||
<ProviderRuntimeToolbar
|
||||
@@ -217,10 +255,11 @@ const Providers: React.FC = () => {
|
||||
key={activeProviderEntry[0]}
|
||||
name={activeProviderEntry[0]}
|
||||
oauthAccounts={oauthAccounts[activeProviderEntry[0]] || []}
|
||||
oauthAccountsLoading={!!oauthAccountsLoading[activeProviderEntry[0]]}
|
||||
onClearOAuthCooldown={(credentialFile) => clearOAuthCooldown(activeProviderEntry[0], credentialFile)}
|
||||
onDeleteOAuthAccount={(credentialFile) => deleteOAuthAccount(activeProviderEntry[0], credentialFile)}
|
||||
onFieldChange={(field, value) => updateProxyField(activeProviderEntry[0], field, value)}
|
||||
onLoadOAuthAccounts={() => loadOAuthAccounts(activeProviderEntry[0])}
|
||||
onLoadOAuthAccounts={() => loadOAuthAccountsNow(activeProviderEntry[0])}
|
||||
onRefreshOAuthAccount={(credentialFile) => refreshOAuthAccount(activeProviderEntry[0], credentialFile)}
|
||||
onRemove={() => removeProxy(activeProviderEntry[0])}
|
||||
onStartOAuthLogin={() => startOAuthLogin(activeProviderEntry[0], activeProviderEntry[1])}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import { useUI } from '../context/UIContext';
|
||||
import { Button, FixedButton } from '../components/Button';
|
||||
import { CheckboxField, TextField, TextareaField } from '../components/FormControls';
|
||||
import { TextField, TextareaField, ToolbarCheckboxField } from '../components/FormControls';
|
||||
import { ModalBackdrop, ModalBody, ModalCard, ModalHeader, ModalShell } from '../components/ModalFrame';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import ToolbarRow from '../components/ToolbarRow';
|
||||
@@ -231,14 +231,13 @@ const Skills: React.FC = () => {
|
||||
>
|
||||
{installingSkill ? t('loading') : t('install')}
|
||||
</Button>
|
||||
<label className="flex shrink-0 items-center gap-2 whitespace-nowrap text-xs text-zinc-400">
|
||||
<CheckboxField
|
||||
checked={ignoreSuspicious}
|
||||
disabled={installingSkill}
|
||||
onChange={(e) => setIgnoreSuspicious(e.target.checked)}
|
||||
/>
|
||||
{t('skillsIgnoreSuspicious')}
|
||||
</label>
|
||||
<ToolbarCheckboxField
|
||||
checked={ignoreSuspicious}
|
||||
className={installingSkill ? 'pointer-events-none opacity-60' : 'shrink-0'}
|
||||
help={t('skillsIgnoreSuspiciousHint', { defaultValue: 'Use --force to ignore suspicious package warnings.' })}
|
||||
label={t('skillsIgnoreSuspicious')}
|
||||
onChange={setIgnoreSuspicious}
|
||||
/>
|
||||
</ToolbarRow>
|
||||
|
||||
{!clawhubInstalled && (
|
||||
@@ -319,9 +318,10 @@ const Skills: React.FC = () => {
|
||||
className="px-4 py-3"
|
||||
actions={
|
||||
<>
|
||||
<FixedButton onClick={saveFile} variant="success" radius="lg" label={t('save')}>
|
||||
<Button onClick={saveFile} variant="success" size="sm" radius="lg" gap="1">
|
||||
<Save className="w-4 h-4" />
|
||||
</FixedButton>
|
||||
{t('save')}
|
||||
</Button>
|
||||
<FixedButton onClick={() => setIsFileModalOpen(false)} radius="full" label={t('close')}>
|
||||
<X className="w-4 h-4" />
|
||||
</FixedButton>
|
||||
|
||||
Reference in New Issue
Block a user