mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-17 19:17:29 +08:00
feat: refine webui config workflows
This commit is contained in:
135
.github/workflows/release.yml
vendored
135
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '未知代理',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]]}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user