feat: refine webui config workflows

This commit is contained in:
LPF
2026-03-11 23:12:11 +08:00
parent 045927f6d1
commit 5e0c371bb9
12 changed files with 474 additions and 181 deletions

View File

@@ -23,8 +23,57 @@ permissions:
contents: write
jobs:
build-and-package:
prepare-release:
runs-on: ubuntu-latest
outputs:
raw_tag: ${{ steps.prepare.outputs.raw_tag }}
version: ${{ steps.prepare.outputs.version }}
targets_json: ${{ steps.prepare.outputs.targets_json }}
channel_variants: ${{ steps.prepare.outputs.channel_variants }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve release inputs
id: prepare
shell: bash
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.tag }}" ]; then
raw_tag="${{ inputs.tag }}"
elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then
raw_tag="${GITHUB_REF_NAME}"
else
raw_tag="$(git describe --tags --always --dirty 2>/dev/null || echo dev)"
fi
build_targets="${{ inputs.build_targets || '' }}"
channel_variants="${{ inputs.channel_variants || '' }}"
if [ -z "$build_targets" ]; then
build_targets="linux/amd64 linux/arm64 linux/riscv64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64"
fi
if [ -z "$channel_variants" ]; then
channel_variants="full none telegram discord feishu maixcam qq dingtalk whatsapp"
fi
version="${raw_tag#v}"
targets_json="$(python3 -c 'import json, sys; print(json.dumps(sys.argv[1].split()))' "$build_targets")"
echo "raw_tag=$raw_tag" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "targets_json=$targets_json" >> "$GITHUB_OUTPUT"
echo "channel_variants=$channel_variants" >> "$GITHUB_OUTPUT"
echo "Build version: $version (from $raw_tag)"
echo "Targets: $build_targets"
echo "Variants: $channel_variants"
build-and-package:
needs: prepare-release
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
target: ${{ fromJson(needs.prepare-release.outputs.targets_json) }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -47,74 +96,74 @@ jobs:
sudo apt-get update
sudo apt-get install -y zip
- name: Resolve build version
id: ver
- name: Resolve matrix target
id: target
shell: bash
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.tag }}" ]; then
raw="${{ inputs.tag }}"
elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then
raw="${GITHUB_REF_NAME}"
else
raw="$(git describe --tags --always --dirty 2>/dev/null || echo dev)"
fi
version="${raw#v}"
echo "raw=$raw" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "Build version: $version (from $raw)"
set -euo pipefail
target="${{ matrix.target }}"
goos="${target%/*}"
goarch="${target#*/}"
echo "goos=$goos" >> "$GITHUB_OUTPUT"
echo "goarch=$goarch" >> "$GITHUB_OUTPUT"
echo "artifact_name=release-$goos-$goarch" >> "$GITHUB_OUTPUT"
- name: Build and package release artifacts
- name: Build and package matrix artifacts
shell: bash
run: |
set -euo pipefail
make clean
build_targets="${{ inputs.build_targets || '' }}"
channel_variants="${{ inputs.channel_variants || '' }}"
if [ -z "$build_targets" ]; then
build_targets="linux/amd64 linux/arm64 linux/riscv64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64"
fi
if [ -z "$channel_variants" ]; then
channel_variants="full none telegram discord feishu maixcam qq dingtalk whatsapp"
fi
echo "BUILD_TARGETS=$build_targets"
echo "CHANNEL_PACKAGE_VARIANTS=$channel_variants"
make package-all VERSION="${{ steps.ver.outputs.version }}" BUILD_TARGETS="$build_targets" CHANNEL_PACKAGE_VARIANTS="$channel_variants"
make package-all \
VERSION="${{ needs.prepare-release.outputs.version }}" \
BUILD_TARGETS="${{ matrix.target }}" \
CHANNEL_PACKAGE_VARIANTS="${{ needs.prepare-release.outputs.channel_variants }}"
rm -f build/checksums.txt
- name: Upload artifacts
- name: Upload matrix artifacts
uses: actions/upload-artifact@v4
with:
name: release-artifacts
path: build
name: ${{ steps.target.outputs.artifact_name }}
path: |
build/*.tar.gz
build/*.zip
if-no-files-found: error
publish-release:
needs: build-and-package
needs:
- prepare-release
- build-and-package
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
steps:
- name: Download artifacts
- name: Download packaged artifacts
uses: actions/download-artifact@v4
with:
name: release-artifacts
pattern: release-*
merge-multiple: true
path: build
- name: List downloaded artifacts
run: find build -maxdepth 4 -type f | sort
run: find build -maxdepth 2 -type f | sort
- name: Resolve tag
id: tag
- name: Generate checksums
shell: bash
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.tag }}" ]; then
echo "name=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
set -euo pipefail
cd build
if command -v sha256sum >/dev/null 2>&1; then
sha256sum *.tar.gz *.zip > checksums.txt
else
echo "name=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
shasum -a 256 *.tar.gz *.zip > checksums.txt
fi
cat checksums.txt
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.tag.outputs.name }}
name: ${{ steps.tag.outputs.name }}
tag_name: ${{ needs.prepare-release.outputs.raw_tag }}
name: ${{ needs.prepare-release.outputs.raw_tag }}
generate_release_notes: true
files: |
build/**/*.tar.gz
build/**/*.zip
build/**/checksums.txt
build/*.tar.gz
build/*.zip
build/checksums.txt

View File

@@ -186,9 +186,6 @@ const RecursiveConfig: React.FC<RecursiveConfigProps> = ({ data, labels, path =
onChange={(e) => onChange(currentPath, e.target.checked)}
className="w-4 h-4 rounded border-zinc-700 text-indigo-500 focus:ring-indigo-500"
/>
<span className="ui-text-subtle group-hover:ui-text-secondary text-sm transition-colors">
{value ? (labels['enabled_true'] || t('enabled_true')) : (labels['enabled_false'] || t('enabled_false'))}
</span>
</label>
) : (
<TextField

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Check, ShieldCheck, Users, Wifi } from 'lucide-react';
import { CheckboxField, FieldBlock, TextField, TextareaField } from '../FormControls';
import { CheckboxField, FieldBlock, TextField } from '../FormControls';
import type { ChannelField, ChannelKey } from './channelSchema';
type Translate = (key: string, options?: any) => string;
@@ -28,8 +28,83 @@ function getWhatsAppBooleanIcon(fieldKey: string) {
}
}
function formatList(value: unknown) {
return Array.isArray(value) ? value.join('\n') : '';
type TagListFieldProps = {
isWhatsApp: boolean;
onChange: (values: string[]) => void;
placeholder?: string;
value: unknown;
};
function TagListField({ isWhatsApp, onChange, placeholder, value }: TagListFieldProps) {
const values = Array.isArray(value) ? value.map((item) => String(item || '').trim()).filter(Boolean) : [];
const [draft, setDraft] = React.useState('');
React.useEffect(() => {
setDraft('');
}, [value]);
function commit(raw: string) {
const items = String(raw || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
if (items.length === 0) {
setDraft('');
return;
}
const next = [...values];
items.forEach((item) => {
if (!next.includes(item)) next.push(item);
});
onChange(next);
setDraft('');
}
function remove(item: string) {
onChange(values.filter((value) => value !== item));
}
return (
<div className="space-y-2">
{values.length > 0 ? (
<div className="flex flex-wrap gap-2">
{values.map((item) => (
<button
key={item}
type="button"
onClick={() => remove(item)}
className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] transition ${
isWhatsApp
? 'border-zinc-700 bg-zinc-950/70 text-zinc-200 hover:border-zinc-500'
: 'border-zinc-700 bg-zinc-900/60 text-zinc-200 hover:border-zinc-500'
}`}
title={item}
>
<span className="font-mono">{item}</span>
</button>
))}
</div>
) : null}
<TextField
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
commit(draft);
} else if (e.key === 'Backspace' && !draft && values.length > 0) {
e.preventDefault();
remove(values[values.length - 1]);
}
}}
onBlur={() => {
if (draft.trim()) commit(draft);
}}
placeholder={placeholder || ''}
monospace={isWhatsApp}
/>
</div>
);
}
const ChannelFieldRenderer: React.FC<ChannelFieldRendererProps> = ({
@@ -61,9 +136,6 @@ const ChannelFieldRenderer: React.FC<ChannelFieldRendererProps> = ({
<div className="ui-form-help mt-1">{helper}</div>
</div>
</div>
<div className={`ui-pill mt-4 inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium ${value ? 'ui-pill-success' : 'ui-pill-neutral'}`}>
{t(value ? 'enabled_true' : 'enabled_false')}
</div>
</div>
<CheckboxField
checked={!!value}
@@ -78,9 +150,7 @@ const ChannelFieldRenderer: React.FC<ChannelFieldRendererProps> = ({
<label key={field.key} className="ui-toggle-card ui-boolean-card flex items-center justify-between gap-4 cursor-pointer">
<div className="min-w-0 flex-1 pr-3">
<div className="ui-text-primary text-sm font-semibold">{label}</div>
<div className={`ui-pill mt-3 inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium ${value ? 'ui-pill-success' : 'ui-pill-neutral'}`}>
{t(value ? 'enabled_true' : 'enabled_false')}
</div>
{helper ? <div className="ui-form-help mt-1">{helper}</div> : null}
</div>
<CheckboxField
checked={!!value}
@@ -100,14 +170,13 @@ const ChannelFieldRenderer: React.FC<ChannelFieldRendererProps> = ({
help={helper}
meta={isWhatsApp && Array.isArray(value) && value.length > 0 ? `${t('entries')}: ${value.length}` : undefined}
>
<TextareaField
value={formatList(value)}
onChange={(e) => setDraft((prev) => ({ ...prev, [field.key]: parseList(e.target.value) }))}
<TagListField
isWhatsApp={isWhatsApp}
onChange={(items) => setDraft((prev) => ({ ...prev, [field.key]: parseList(items.join('\n')) }))}
placeholder={field.placeholder || ''}
monospace={isWhatsApp}
className={isWhatsApp ? 'min-h-36 px-4 py-3' : 'min-h-32 px-4 py-3'}
value={value}
/>
{isWhatsApp ? <div className="ui-form-help text-[11px]">{t('whatsappFieldAllowFromFootnote')}</div> : null}
<div className="ui-form-help text-[11px]">{t('channelListInputFootnote')}</div>
</FieldBlock>
);
}

View File

@@ -44,7 +44,6 @@ export const channelDefinitions: Record<ChannelKey, ChannelDefinition> = {
columns: 2,
fields: [
{ key: 'allow_from', type: 'list', placeholder: '123456789' },
{ key: 'allow_chats', type: 'list', placeholder: 'telegram:123456789' },
],
},
{
@@ -146,7 +145,6 @@ export const channelDefinitions: Record<ChannelKey, ChannelDefinition> = {
columns: 2,
fields: [
{ key: 'allow_from', type: 'list' },
{ key: 'allow_chats', type: 'list' },
],
},
{

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Download, FolderOpen, LogIn, Plus, RefreshCw, RotateCcw, Trash2, Upload, X } from 'lucide-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';
@@ -112,34 +112,44 @@ export function ProviderRuntimeToolbar({
t,
}: ProviderRuntimeToolbarProps) {
return (
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="text-sm font-semibold text-zinc-200">{t('configProxies')}</div>
<div className="flex items-center gap-2 flex-wrap justify-end">
<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>
<SelectField dense value={String(runtimeRefreshSec)} onChange={(e) => onRuntimeRefreshSecChange(Number(e.target.value || 10))} className="min-w-[136px] bg-zinc-900/70 border-zinc-700">
<option value="2">2s</option>
<option value="5">5s</option>
<option value="10">10s</option>
<option value="30">30s</option>
</SelectField>
<SelectField dense value={runtimeWindow} onChange={(e) => onRuntimeWindowChange(e.target.value as 'all' | '1h' | '24h' | '7d')} className="min-w-[156px] bg-zinc-900/70 border-zinc-700">
<option value="1h">{t('providersRuntime1h')}</option>
<option value="24h">{t('providersRuntime24h')}</option>
<option value="7d">{t('providersRuntime7d')}</option>
<option value="all">{t('providersRuntimeAll')}</option>
</SelectField>
<TextField dense value={newProxyName} onChange={(e) => onNewProxyNameChange(e.target.value)} placeholder={t('configNewProviderName')} className="min-w-[168px] bg-zinc-900/70 border-zinc-700" />
<Button onClick={onAddProxy} variant="primary" size="xs" radius="lg" gap="2" noShrink>
<Plus className="w-4 h-4" />
{t('add')}
</Button>
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_auto] gap-3 items-start">
<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">
<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>
<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">
<option value="2">2s</option>
<option value="5">5s</option>
<option value="10">10s</option>
<option value="30">30s</option>
</SelectField>
<SelectField dense value={runtimeWindow} onChange={(e) => onRuntimeWindowChange(e.target.value as 'all' | '1h' | '24h' | '7d')} className="min-w-[148px] bg-zinc-900/70 border-zinc-700">
<option value="1h">{t('providersRuntime1h')}</option>
<option value="24h">{t('providersRuntime24h')}</option>
<option value="7d">{t('providersRuntime7d')}</option>
<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]" />
<Button onClick={onAddProxy} variant="primary" size="xs" radius="lg" gap="2" noShrink>
<Plus className="w-4 h-4" />
{t('add')}
</Button>
</div>
</div>
</div>
);
@@ -359,6 +369,7 @@ type ProviderProxyCardProps = {
onStartOAuthLogin: () => void;
onTriggerOAuthImport: () => void;
proxy: any;
runtimeItem?: any;
runtimeSummary?: React.ReactNode;
t: (key: string) => string;
};
@@ -375,6 +386,7 @@ export function ProviderProxyCard({
onStartOAuthLogin,
onTriggerOAuthImport,
proxy,
runtimeItem,
runtimeSummary,
t,
}: ProviderProxyCardProps) {
@@ -387,6 +399,42 @@ export function ProviderProxyCard({
const [runtimeOpen, setRuntimeOpen] = React.useState(false);
const [advancedOpen, setAdvancedOpen] = React.useState(false);
const oauthAccountCount = Array.isArray(oauthAccounts) ? oauthAccounts.length : 0;
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 quotaState = !showOAuth
? null
: lastQuotaError
? {
label: '额度受限',
tone: 'border-amber-500/30 bg-amber-500/10 text-amber-200',
detail: `最近一次限额命中:${String(lastQuotaError?.when || '-')}`,
}
: oauthAccounts.some((account) => String(account?.cooldown_until || '').trim())
? {
label: '冷却中',
tone: 'border-orange-500/30 bg-orange-500/10 text-orange-200',
detail: oauthAccounts
.map((account) => String(account?.cooldown_until || '').trim())
.find(Boolean) || '-',
}
: oauthAccounts.some((account) => Number(account?.health_score || 100) < 60)
? {
label: '健康偏低',
tone: 'border-rose-500/30 bg-rose-500/10 text-rose-200',
detail: `最低健康分 ${Math.min(...oauthAccounts.map((account) => Number(account?.health_score || 100)))}`,
}
: connected
? {
label: '可用',
tone: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200',
detail: '当前账号可参与 OAuth 轮换。',
}
: {
label: '未登录',
tone: 'border-zinc-700 bg-zinc-900/50 text-zinc-300',
detail: '还没有可用的 OAuth 账号。',
};
return (
<div className="grid grid-cols-1 gap-4 rounded-2xl border border-zinc-800 bg-zinc-900/30 p-4 text-xs">
@@ -512,6 +560,43 @@ export function ProviderProxyCard({
<span className="font-mono text-zinc-300">{t('providersOAuthLoginButton')}</span>
{t('providersOAuthGuideAfter')}
</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"></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'}`} />
{connected ? `已登录 ${oauthAccountCount} 个账号` : '尚未登录'}
</div>
<div className="mt-2 text-[11px] text-zinc-500">
{connected
? (oauthAccounts[0]?.account_label || oauthAccounts[0]?.email || oauthAccounts[0]?.account_id || '主账号已加载')
: '点击 OAuth 登录或导入授权文件后,这里会自动显示账号。'}
</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'}`}>
{connected ? 'Connected' : 'Disconnected'}
</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"></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" />
{quotaState?.label || '-'}
</div>
<div className="mt-2 text-[11px] text-zinc-500">{quotaState?.detail || '后端暂未提供真实余额接口,这里展示现有的 quota/cooldown/health 信号。'}</div>
</div>
<div className={`rounded-full border px-2.5 py-1 text-[11px] ${quotaState?.tone || 'border-zinc-700 bg-zinc-900/50 text-zinc-300'}`}>
{lastQuotaError ? 'Quota' : connected ? 'Runtime' : 'Pending'}
</div>
</div>
</div>
</div>
</div>
)}
</div>
@@ -559,6 +644,15 @@ export function ProviderProxyCard({
<RefreshCw className="w-4 h-4" />
</FixedButton>
</div>
<div className={`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'
}`}>
{connected
? `已自动加载 ${oauthAccountCount} 个 OAuth 账号。当前主账号:${oauthAccounts[0]?.account_label || oauthAccounts[0]?.email || oauthAccounts[0]?.account_id || '-'}`
: '当前没有可用账号。可以直接点击左侧 OAuth 登录,或者导入 auth.json。'}
</div>
{oauthAccounts.length === 0 ? (
<div className="text-zinc-500">{t('providersNoOAuthAccounts')}</div>
) : (
@@ -566,12 +660,24 @@ export function ProviderProxyCard({
{oauthAccounts.map((account, idx) => (
<div key={`${account?.credential_file || idx}`} className="rounded-xl border border-zinc-800 bg-zinc-900/40 px-3 py-3 space-y-2">
<div className="min-w-0">
<div className="text-zinc-200 truncate">{account?.email || account?.account_id || account?.credential_file}</div>
<div className="flex items-center justify-between gap-2">
<div className="text-zinc-200 truncate">{account?.email || account?.account_id || account?.credential_file}</div>
<div className={`shrink-0 rounded-full border px-2 py-0.5 text-[10px] ${
String(account?.cooldown_until || '').trim()
? 'border-orange-500/30 bg-orange-500/10 text-orange-200'
: Number(account?.health_score || 100) < 60
? 'border-rose-500/30 bg-rose-500/10 text-rose-200'
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200'
}`}>
{String(account?.cooldown_until || '').trim() ? '冷却中' : Number(account?.health_score || 100) < 60 ? '受限' : '在线'}
</div>
</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>
<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]">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">
<FixedButton onClick={() => onRefreshOAuthAccount(String(account?.credential_file || ''))} variant="neutral" radius="lg" label="Refresh">
@@ -580,8 +686,8 @@ export function ProviderProxyCard({
<FixedButton onClick={() => onClearOAuthCooldown(String(account?.credential_file || ''))} variant="neutral" radius="lg" label="Clear Cooldown">
<RotateCcw className="w-4 h-4" />
</FixedButton>
<FixedButton onClick={() => onDeleteOAuthAccount(String(account?.credential_file || ''))} variant="danger" radius="lg" label={t('delete')}>
<Trash2 className="w-4 h-4" />
<FixedButton onClick={() => onDeleteOAuthAccount(String(account?.credential_file || ''))} variant="danger" radius="lg" label="Logout">
<LogOut className="w-4 h-4" />
</FixedButton>
</div>
</div>

View File

@@ -1,8 +1,8 @@
import React from 'react';
import { Pause, Play, Save, Trash2 } from 'lucide-react';
import { Save, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, FixedButton } from '../Button';
import { FieldBlock, SelectField, TextField, TextareaField } from '../FormControls';
import { CheckboxField, FieldBlock, SelectField, TextField, TextareaField } from '../FormControls';
import type { SubagentProfile, ToolAllowlistGroup } from './profileDraft';
import { parseAllowlist } from './profileDraft';
@@ -16,14 +16,14 @@ type ProfileEditorPanelProps = {
onAddAllowlistToken: (token: string) => void;
onChange: (next: SubagentProfile) => void;
onDelete: () => void;
onDisable: () => void;
onEnable: () => void;
onPromptContentChange: (value: string) => void;
onSave: () => void;
onSavePromptFile: () => void;
promptContent: string;
promptMeta: string;
promptPlaceholder: string;
promptPathHint: string;
promptPathInvalid: boolean;
roleLabel: string;
saving: boolean;
statusLabel: string;
@@ -45,14 +45,14 @@ const ProfileEditorPanel: React.FC<ProfileEditorPanelProps> = ({
onAddAllowlistToken,
onChange,
onDelete,
onDisable,
onEnable,
onPromptContentChange,
onSave,
onSavePromptFile,
promptContent,
promptMeta,
promptPlaceholder,
promptPathHint,
promptPathInvalid,
roleLabel,
saving,
statusLabel,
@@ -65,6 +65,15 @@ const ProfileEditorPanel: React.FC<ProfileEditorPanelProps> = ({
}) => {
const { t } = useTranslation();
const allowlistText = (draft.tool_allowlist || []).join(', ');
const statusEnabled = (draft.status || 'active') === 'active';
const notifyPolicyOptions = [
{ value: 'final_only', label: '仅最终结果', help: '只在任务完成后通知主代理。' },
{ value: 'internal_only', label: '仅内部事件', help: '只回传中间过程,不单独强调最终结果。' },
{ value: 'milestone', label: '关键节点', help: '到达关键阶段时通知主代理。' },
{ value: 'on_blocked', label: '遇阻才通知', help: '只有卡住、需要介入时才通知主代理。' },
{ value: 'always', label: '始终通知', help: '过程和结果都会尽量通知主代理。' },
];
const notifyPolicy = notifyPolicyOptions.find((option) => option.value === (draft.notify_main_policy || 'final_only')) || notifyPolicyOptions[0];
return (
<div className="brand-card ui-border-subtle rounded-[28px] border p-4 space-y-3">
@@ -97,39 +106,42 @@ const ProfileEditorPanel: React.FC<ProfileEditorPanelProps> = ({
placeholder="coding"
/>
</FieldBlock>
<FieldBlock label={statusLabel}>
<SelectField
value={draft.status || 'active'}
onChange={(e) => onChange({ ...draft, status: e.target.value })}
dense
className="w-full"
>
<option value="active">active</option>
<option value="disabled">disabled</option>
</SelectField>
<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>
</FieldBlock>
<FieldBlock label="notify_main_policy">
<FieldBlock
label="通知主代理"
help={notifyPolicy.help}
>
<SelectField
value={draft.notify_main_policy || 'final_only'}
onChange={(e) => onChange({ ...draft, notify_main_policy: e.target.value })}
dense
className="w-full"
>
<option value="final_only">final_only</option>
<option value="internal_only">internal_only</option>
<option value="milestone">milestone</option>
<option value="on_blocked">on_blocked</option>
<option value="always">always</option>
{notifyPolicyOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</SelectField>
</FieldBlock>
<FieldBlock className="md:col-span-2" label="system_prompt_file">
<TextField
value={draft.system_prompt_file || ''}
onChange={(e) => onChange({ ...draft, system_prompt_file: e.target.value })}
onChange={(e) => onChange({ ...draft, system_prompt_file: e.target.value.replace(/\\/g, '/') })}
dense
className="w-full"
className={`w-full ${promptPathInvalid ? 'border-rose-400/70 focus:border-rose-300' : ''}`}
placeholder="agents/coder/AGENT.md"
/>
<div className={`mt-1 text-[11px] ${promptPathInvalid ? 'text-rose-300' : 'ui-text-muted'}`}>{promptPathHint}</div>
</FieldBlock>
<FieldBlock className="md:col-span-2" label={memoryNamespaceLabel}>
<TextField
@@ -168,7 +180,7 @@ 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()} radius="lg" label={t('savePromptFile')}>
<FixedButton type="button" onClick={onSavePromptFile} disabled={!String(draft.system_prompt_file || '').trim() || promptPathInvalid} radius="lg" label={t('savePromptFile')}>
<Save className="w-4 h-4" />
</FixedButton>
</div>
@@ -218,12 +230,6 @@ const ProfileEditorPanel: React.FC<ProfileEditorPanelProps> = ({
<FixedButton onClick={onSave} disabled={saving} variant="primary" label={isExisting ? t('update') : t('create')}>
<Save className="w-4 h-4" />
</FixedButton>
<FixedButton onClick={onEnable} disabled={!draft.agent_id} variant="success" label={t('enable')}>
<Play className="w-4 h-4" />
</FixedButton>
<FixedButton onClick={onDisable} disabled={!draft.agent_id} variant="warning" label={t('disable')}>
<Pause className="w-4 h-4" />
</FixedButton>
<FixedButton onClick={onDelete} disabled={!draft.agent_id} variant="danger" label={t('delete')}>
<Trash2 className="w-4 h-4" />
</FixedButton>

View File

@@ -75,6 +75,7 @@ const resources = {
whatsappFieldEnableGroupsHint: 'Allow messages from WhatsApp groups to enter the channel.',
whatsappFieldRequireMentionHint: 'When enabled, group messages must mention the bot before being handled.',
whatsappFieldAllowFromFootnote: 'Supports one JID per line, and also accepts comma-separated values.',
channelListInputFootnote: 'Press Enter to add one item. Comma-separated input is also supported.',
whatsappLogoutTitle: 'Logout WhatsApp Session',
whatsappLogoutMessage: 'Unlink the current WhatsApp companion session and request a new QR code?',
unknownAgent: 'Unknown Agent',
@@ -135,6 +136,9 @@ const resources = {
promptFileSaved: 'Prompt file saved.',
promptFileReady: 'AGENT.md ready',
promptFileMissing: 'AGENT.md missing',
promptFileRelativePathHint: 'Use a workspace-relative path such as agents/coder/AGENT.md.',
promptFileRelativePathOnly: 'system_prompt_file must be a workspace-relative path, not an absolute path.',
promptFileWorkspaceOnly: 'system_prompt_file must stay within the workspace.',
newProfile: 'New Profile',
spawn: 'Spawn',
dispatch: 'Dispatch',
@@ -792,6 +796,7 @@ const resources = {
whatsappFieldEnableGroupsHint: '允许来自 WhatsApp 群组的消息进入该通道。',
whatsappFieldRequireMentionHint: '开启后,群消息必须显式 @ 提及机器人才会被处理。',
whatsappFieldAllowFromFootnote: '支持每行一个 JID也支持逗号分隔后自动拆分。',
channelListInputFootnote: '按回车添加一项,也支持输入逗号后自动拆分。',
whatsappLogoutTitle: '退出 WhatsApp 会话',
whatsappLogoutMessage: '是否解除当前 WhatsApp companion 会话,并重新申请新的二维码?',
unknownAgent: '未知代理',

View File

@@ -119,19 +119,47 @@ const ChannelSettings: React.FC = () => {
(nextCfg as any).channels = {};
}
(nextCfg as any).channels[definition.id] = cloneJSON(draft);
const res = await fetch(`/webui/api/config${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(nextCfg),
});
if (!res.ok) {
throw new Error(await res.text());
const submit = async (confirmRisky: boolean) => {
const body = confirmRisky ? { ...nextCfg, confirm_risky: true } : nextCfg;
return ui.withLoading(async () => {
const res = await fetch(`/webui/api/config${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const text = await res.text();
let data: any = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
return { ok: res.ok, text, data };
}, t('saving'));
};
let result = await submit(false);
if (!result.ok && result.data?.requires_confirm) {
const changedFields = Array.isArray(result.data?.changed_fields) ? result.data.changed_fields.join(', ') : '';
const ok = await ui.confirmDialog({
title: t('configRiskyChangeConfirmTitle'),
message: t('configRiskyChangeConfirmMessage', { fields: changedFields || '-' }),
danger: true,
confirmText: t('saveChanges'),
});
if (!ok) return;
result = await submit(true);
}
if (!result.ok) {
throw new Error(result.data?.error || result.text || 'save failed');
}
setCfg(nextCfg);
await loadConfig(true);
await ui.notify(t('configSaved'));
await ui.notify({ title: t('saved'), message: t('configSaved') });
} catch (err: any) {
await ui.notify(String(err?.message || err || 'save failed'));
await ui.notify({ title: t('requestFailed'), message: `${t('saveConfigFailed')}: ${String(err?.message || err || 'save failed')}` });
} finally {
setSaving(false);
}

View File

@@ -226,6 +226,7 @@ const Providers: React.FC = () => {
onStartOAuthLogin={() => startOAuthLogin(activeProviderEntry[0], activeProviderEntry[1])}
onTriggerOAuthImport={() => triggerOAuthImport(activeProviderEntry[0], activeProviderEntry[1])}
proxy={activeProviderEntry[1]}
runtimeItem={providerRuntimeMap[activeProviderEntry[0]]}
runtimeSummary={providerRuntimeMap[activeProviderEntry[0]] ? (
<ProviderRuntimeSummary
item={providerRuntimeMap[activeProviderEntry[0]]}

View File

@@ -214,12 +214,24 @@ const Skills: React.FC = () => {
}
/>
<ToolbarRow className="w-full">
<TextField disabled={installingSkill} value={installName} onChange={(e) => setInstallName(e.target.value)} placeholder={t('skillsNamePlaceholder')} className="w-full sm:w-72 disabled:opacity-60" />
<FixedButton disabled={installingSkill} onClick={installSkill} variant="success" label={installingSkill ? t('loading') : t('install')}>
<Zap className="w-4 h-4" />
</FixedButton>
<label className="flex items-center gap-2 text-xs text-zinc-400">
<ToolbarRow className="w-full flex-nowrap items-center">
<TextField
disabled={installingSkill}
value={installName}
onChange={(e) => setInstallName(e.target.value)}
placeholder={t('skillsNamePlaceholder')}
className="min-w-0 flex-1 disabled:opacity-60"
/>
<Button
disabled={installingSkill}
onClick={installSkill}
variant="success"
size="md"
noShrink
>
{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}

View File

@@ -9,6 +9,26 @@ import ProfileEditorPanel from '../components/subagentProfiles/ProfileEditorPane
import ProfileListPanel from '../components/subagentProfiles/ProfileListPanel';
import { emptyDraft, toProfileDraft, type SubagentProfile, type ToolAllowlistGroup } from '../components/subagentProfiles/profileDraft';
function validatePromptFilePath(pathValue: string): string | null {
const path = String(pathValue || '').trim();
if (!path) return null;
const normalized = path.replace(/\\/g, '/');
if (/^[a-zA-Z]:\//.test(normalized) || normalized.startsWith('/')) {
return 'promptFileRelativePathOnly';
}
const segments = normalized.split('/').filter(Boolean);
if (segments.some((segment) => segment === '..')) {
return 'promptFileWorkspaceOnly';
}
return null;
}
const promptPathMessages: Record<string, string> = {
promptFileRelativePathHint: 'Use a workspace-relative path such as agents/coder/AGENT.md.',
promptFileRelativePathOnly: 'system_prompt_file must be a workspace-relative path, not an absolute path.',
promptFileWorkspaceOnly: 'system_prompt_file must stay within the workspace.',
};
const SubagentProfiles: React.FC = () => {
const { t } = useTranslation();
const { q } = useAppContext();
@@ -21,6 +41,11 @@ const SubagentProfiles: React.FC = () => {
const [groups, setGroups] = useState<ToolAllowlistGroup[]>([]);
const [promptFileContent, setPromptFileContent] = useState('');
const [promptFileFound, setPromptFileFound] = useState(false);
const promptPathErrorKey = validatePromptFilePath(String(draft.system_prompt_file || ''));
const promptPathHint = t('promptFileRelativePathHint', { defaultValue: promptPathMessages.promptFileRelativePathHint });
const promptPathError = promptPathErrorKey
? t(promptPathErrorKey, { defaultValue: promptPathMessages[promptPathErrorKey] || promptPathMessages.promptFileWorkspaceOnly })
: '';
const selected = useMemo(
() => items.find((p) => p.agent_id === selectedId) || null,
@@ -66,6 +91,11 @@ const SubagentProfiles: React.FC = () => {
setPromptFileFound(false);
return;
}
if (promptPathErrorKey) {
setPromptFileFound(false);
setPromptFileContent('');
return;
}
fetch(`/webui/api/subagents_runtime${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -84,7 +114,7 @@ const SubagentProfiles: React.FC = () => {
setPromptFileFound(false);
setPromptFileContent('');
});
}, [draft.system_prompt_file, q]);
}, [draft.system_prompt_file, promptPathErrorKey, q]);
const onSelect = (p: SubagentProfile) => {
setSelectedId(p.agent_id || '');
@@ -110,6 +140,10 @@ const SubagentProfiles: React.FC = () => {
await ui.notify({ title: t('requestFailed'), message: 'agent_id is required' });
return;
}
if (promptPathErrorKey) {
await ui.notify({ title: t('requestFailed'), message: promptPathError });
return;
}
setSaving(true);
try {
@@ -144,22 +178,6 @@ const SubagentProfiles: React.FC = () => {
}
};
const setStatus = async (status: 'active' | 'disabled') => {
const agentId = String(draft.agent_id || '').trim();
if (!agentId) return;
const action = status === 'active' ? 'enable' : 'disable';
const r = await fetch(`/webui/api/subagent_profiles${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, agent_id: agentId }),
});
if (!r.ok) {
await ui.notify({ title: t('requestFailed'), message: await r.text() });
return;
}
await load();
};
const remove = async () => {
const agentId = String(draft.agent_id || '').trim();
if (!agentId) return;
@@ -185,6 +203,10 @@ const SubagentProfiles: React.FC = () => {
await ui.notify({ title: t('requestFailed'), message: 'system_prompt_file is required' });
return;
}
if (promptPathErrorKey) {
await ui.notify({ title: t('requestFailed'), message: promptPathError });
return;
}
const r = await fetch(`/webui/api/subagents_runtime${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -233,14 +255,14 @@ const SubagentProfiles: React.FC = () => {
onAddAllowlistToken={addAllowlistToken}
onChange={setDraft}
onDelete={remove}
onDisable={() => setStatus('disabled')}
onEnable={() => setStatus('active')}
onPromptContentChange={setPromptFileContent}
onSave={save}
onSavePromptFile={savePromptFile}
promptContent={promptFileContent}
promptMeta={promptFileFound ? t('promptFileReady') : t('promptFileMissing')}
promptMeta={promptPathErrorKey ? promptPathError : (promptFileFound ? t('promptFileReady') : t('promptFileMissing'))}
promptPlaceholder={t('agentPromptContentPlaceholder')}
promptPathHint={promptPathHint}
promptPathInvalid={!!promptPathErrorKey}
roleLabel="Role"
saving={saving}
statusLabel={t('status')}

View File

@@ -111,26 +111,26 @@ const TaskAudit: React.FC = () => {
title={t('taskAudit')}
titleClassName="text-xl md:text-2xl font-semibold"
actions={(
<ToolbarRow>
<SelectField dense value={sourceFilter} onChange={(e)=>setSourceFilter(e.target.value)}>
<option value="all">{t('allSources')}</option>
<option value="direct">{t('sourceDirect')}</option>
<option value="memory_todo">{t('sourceMemoryTodo')}</option>
<option value="task_watchdog">task_watchdog</option>
<option value="-">-</option>
</SelectField>
<SelectField dense value={statusFilter} onChange={(e)=>setStatusFilter(e.target.value)}>
<option value="all">{t('allStatus')}</option>
<option value="running">{t('statusRunning')}</option>
<option value="waiting">{t('statusWaiting')}</option>
<option value="blocked">{t('statusBlocked')}</option>
<option value="success">{t('statusSuccess')}</option>
<option value="error">{t('statusError')}</option>
<option value="suppressed">{t('statusSuppressed')}</option>
</SelectField>
<FixedButton onClick={fetchData} variant="primary" label={loading ? t('loading') : t('refresh')}>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</FixedButton>
<ToolbarRow className="flex-nowrap">
<SelectField dense className="w-[152px] shrink-0" value={sourceFilter} onChange={(e) => setSourceFilter(e.target.value)}>
<option value="all">{t('allSources')}</option>
<option value="direct">{t('sourceDirect')}</option>
<option value="memory_todo">{t('sourceMemoryTodo')}</option>
<option value="task_watchdog">task_watchdog</option>
<option value="-">-</option>
</SelectField>
<SelectField dense className="w-[152px] shrink-0" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="all">{t('allStatus')}</option>
<option value="running">{t('statusRunning')}</option>
<option value="waiting">{t('statusWaiting')}</option>
<option value="blocked">{t('statusBlocked')}</option>
<option value="success">{t('statusSuccess')}</option>
<option value="error">{t('statusError')}</option>
<option value="suppressed">{t('statusSuppressed')}</option>
</SelectField>
<FixedButton onClick={fetchData} variant="primary" label={loading ? t('loading') : t('refresh')}>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</FixedButton>
</ToolbarRow>
)}
/>