Files
beaver_project/app-instance/frontend/app/(app)/status/page.tsx
steven_li 3b0af173cc refactor(beaver): 移除Hermes相关引用和迁移代码,完善Beaver后端主线实现
移除了所有Hermes相关的命名引用,包括:
- 从.gitignore中清理相关构建缓存文件
- 将README中的beaver-home路径配置更新
- 完善backend/README.md文档说明Beaver后端主线实现
- 移除Hermes风格的相关注释和兼容性代码
- 清理nanobot环境变量兼容性处理
- 删除技能迁移和服务迁移相关功能代码
- 更新测试用例中相关命名和函数名

BREAKING CHANGE: 移除了Hermes迁移相关API和CLI命令,不再支持nanobot环境变量兼容性
2026-05-14 17:20:32 +08:00

420 lines
16 KiB
TypeScript

'use client';
import React, { useEffect, useState } from 'react';
import Link from 'next/link';
import {
CheckCircle2,
XCircle,
AlertCircle,
RefreshCw,
Server,
Cpu,
Radio,
Key,
Loader2,
Settings2,
ScrollText,
} from 'lucide-react';
import { getStatus, updateProviderConfig } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import type { ProviderStatus, SystemStatus } from '@/types';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
type ProviderFormState = {
enabled: boolean;
model: string;
apiKey: string;
apiBase: string;
requestTimeoutSeconds: string;
};
export default function StatusPage() {
const { locale } = useAppI18n();
const [status, setStatus] = useState<SystemStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [selectedProvider, setSelectedProvider] = useState<ProviderStatus | null>(null);
const [providerForm, setProviderForm] = useState<ProviderFormState>(() => ({
enabled: false,
model: '',
apiKey: '',
apiBase: '',
requestTimeoutSeconds: '',
}));
const [savingProvider, setSavingProvider] = useState(false);
const [providerError, setProviderError] = useState<string | null>(null);
const loadStatus = async () => {
setLoading(true);
setError(null);
try {
const data = await getStatus();
setStatus(data);
} catch (err: any) {
setError(err.message || pickAppText(locale, '连接后端失败', 'Failed to connect to the backend'));
} finally {
setLoading(false);
}
};
useEffect(() => {
loadStatus();
}, []);
const openProviderDialog = (provider: ProviderStatus) => {
setSelectedProvider(provider);
setProviderError(null);
setProviderForm({
enabled: Boolean(provider.enabled || provider.has_key),
model: status?.model || '',
apiKey: '',
apiBase: provider.api_base || provider.default_api_base || provider.detail || '',
requestTimeoutSeconds: '',
});
};
const handleSaveProvider = async () => {
if (!selectedProvider) return;
const providerId = selectedProvider.id || selectedProvider.name;
setSavingProvider(true);
setProviderError(null);
try {
const timeout = providerForm.requestTimeoutSeconds.trim()
? Number(providerForm.requestTimeoutSeconds.trim())
: undefined;
if (timeout !== undefined && (!Number.isFinite(timeout) || timeout <= 0)) {
throw new Error(pickAppText(locale, '请求超时必须是正数', 'Request timeout must be a positive number'));
}
await updateProviderConfig(providerId, {
enabled: providerForm.enabled,
model: providerForm.model.trim() || undefined,
api_key: providerForm.apiKey.trim() || undefined,
api_base: providerForm.apiBase.trim() || undefined,
request_timeout_seconds: timeout,
});
await loadStatus();
setSelectedProvider(null);
} catch (err: any) {
setProviderError(err.message || pickAppText(locale, '保存提供商配置失败', 'Failed to save provider settings'));
} finally {
setSavingProvider(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
if (error) {
return (
<div className="max-w-4xl mx-auto p-6">
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="flex items-center gap-3 text-destructive">
<AlertCircle className="w-5 h-5" />
<div>
<p className="font-medium">{pickAppText(locale, '无法连接到 Boardware Agent Sandbox 后端', 'Unable to connect to the Boardware Agent Sandbox backend')}</p>
<p className="text-sm text-muted-foreground mt-1">{error}</p>
<p className="text-sm text-muted-foreground mt-1">{pickAppText(locale, '请确认后端服务已启动,并且当前页面可以访问它。', 'Please confirm the backend service is running and reachable from this page.')}</p>
</div>
</div>
<Button onClick={loadStatus} variant="outline" size="sm" className="mt-4">
<RefreshCw className="w-4 h-4 mr-2" />
{pickAppText(locale, '重试', 'Retry')}
</Button>
</CardContent>
</Card>
</div>
);
}
if (!status) return null;
return (
<div className="mx-auto max-w-6xl p-6 space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-bold">{pickAppText(locale, '配置', 'Settings')}</h1>
<p className="mt-1 text-sm text-muted-foreground">
{pickAppText(
locale,
'集中管理模型、工具、集成和实例运行状态。Task 和通知只在各自页面处理。',
'Manage models, tools, integrations, and instance runtime status. Tasks and notifications stay in their own pages.'
)}
</p>
</div>
<Button onClick={loadStatus} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
{pickAppText(locale, '刷新', 'Refresh')}
</Button>
</div>
{/* System Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Server className="w-4 h-4" />
{pickAppText(locale, '实例运行', 'Instance runtime')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-5 lg:grid-cols-[1fr_auto] lg:items-center">
<div className="space-y-1">
<p className="text-sm font-medium">{pickAppText(locale, '运行与调试', 'Runtime and debugging')}</p>
<p className="text-sm text-muted-foreground">
{pickAppText(locale, '查看每次对话的运行日志和当前实例运行状态。', 'Inspect per-chat runtime logs and current instance status.')}
</p>
</div>
<div className="flex flex-wrap justify-start gap-2 lg:justify-end">
<Button asChild variant="outline">
<Link href="/logs">
<ScrollText className="w-4 h-4 mr-2" />
{pickAppText(locale, '运行日志', 'Runtime Logs')}
</Link>
</Button>
</div>
</div>
<div className="mt-5 grid gap-3 border-t pt-5 md:grid-cols-2">
<InfoRow label={pickAppText(locale, '配置文件', 'Config file')} value={status.config_path} />
<InfoRow label={pickAppText(locale, '工作区', 'Workspace')} value={status.workspace} />
</div>
</CardContent>
</Card>
{/* Model Config */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Cpu className="w-4 h-4" />
{pickAppText(locale, '智能体配置', 'Agent configuration')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<InfoRow label={pickAppText(locale, '模型', 'Model')} value={status.model} />
<InfoRow label={pickAppText(locale, '最大令牌数', 'Max tokens')} value={String(status.max_tokens)} />
<InfoRow label={pickAppText(locale, '温度', 'Temperature')} value={String(status.temperature)} />
<InfoRow
label={pickAppText(locale, '最大工具迭代次数', 'Max tool iterations')}
value={String(status.max_tool_iterations)}
/>
</CardContent>
</Card>
{/* Providers */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Key className="w-4 h-4" />
{pickAppText(locale, '提供商', 'Providers')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
{status.providers.map((p) => (
<button
key={p.id || p.name}
type="button"
onClick={() => openProviderDialog(p)}
className={[
'group flex min-h-[76px] w-full items-start justify-between rounded-lg border p-3 text-left transition',
p.active
? 'border-primary bg-primary/5 shadow-sm'
: 'border-border bg-background hover:border-primary/50 hover:bg-muted/40',
].join(' ')}
>
<span className="min-w-0 space-y-1">
<span className="flex items-center gap-2 text-sm font-medium">
{p.has_key ? (
<CheckCircle2 className="h-4 w-4 shrink-0 text-green-500" />
) : (
<XCircle className="h-4 w-4 shrink-0 text-muted-foreground/40" />
)}
<span className={p.has_key ? 'truncate' : 'truncate text-muted-foreground'}>
{providerLabel(p)}
</span>
</span>
<span className="block truncate text-xs text-muted-foreground">
{p.active
? pickAppText(locale, '当前默认', 'Current default')
: p.enabled
? pickAppText(locale, '已启用', 'Enabled')
: pickAppText(locale, '点击配置', 'Click to configure')}
</span>
{(p.detail || p.api_key_masked) && (
<span className="block truncate text-xs text-muted-foreground">
{p.api_key_masked || p.detail}
</span>
)}
</span>
<Settings2 className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground opacity-60 group-hover:text-primary" />
</button>
))}
</div>
</CardContent>
</Card>
<Dialog open={Boolean(selectedProvider)} onOpenChange={(open) => !open && setSelectedProvider(null)}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>
{pickAppText(locale, '配置提供商', 'Configure provider')}
{selectedProvider ? ` · ${providerLabel(selectedProvider)}` : ''}
</DialogTitle>
<DialogDescription>
{pickAppText(locale, '启用后会把它设为当前实例默认提供商。API Key 留空会保留已保存的值。', 'When enabled, this becomes the default provider for this instance. Leave API key empty to keep the saved value.')}
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-2">
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
<div>
<Label className="text-sm">{pickAppText(locale, '启用提供商', 'Enable provider')}</Label>
<p className="text-xs text-muted-foreground">
{pickAppText(locale, '关闭会从配置中移除这个提供商', 'Turning this off removes this provider from config')}
</p>
</div>
<Switch
checked={providerForm.enabled}
onCheckedChange={(checked) => setProviderForm((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="provider-model">{pickAppText(locale, '默认模型', 'Default model')}</Label>
<Input
id="provider-model"
value={providerForm.model}
onChange={(event) => setProviderForm((prev) => ({ ...prev, model: event.target.value }))}
placeholder="qwen-plus"
disabled={!providerForm.enabled}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="provider-api-key">API Key</Label>
<Input
id="provider-api-key"
type="password"
value={providerForm.apiKey}
onChange={(event) => setProviderForm((prev) => ({ ...prev, apiKey: event.target.value }))}
placeholder={selectedProvider?.api_key_masked || pickAppText(locale, '留空保持不变', 'Leave blank to keep existing')}
disabled={!providerForm.enabled || Boolean(selectedProvider?.is_oauth)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="provider-api-base">API Base</Label>
<Input
id="provider-api-base"
value={providerForm.apiBase}
onChange={(event) => setProviderForm((prev) => ({ ...prev, apiBase: event.target.value }))}
placeholder={selectedProvider?.default_api_base || 'https://api.example.com/v1'}
disabled={!providerForm.enabled || Boolean(selectedProvider?.is_oauth)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="provider-timeout">{pickAppText(locale, '请求超时(秒)', 'Request timeout (seconds)')}</Label>
<Input
id="provider-timeout"
inputMode="decimal"
value={providerForm.requestTimeoutSeconds}
onChange={(event) => setProviderForm((prev) => ({ ...prev, requestTimeoutSeconds: event.target.value }))}
placeholder={pickAppText(locale, '默认', 'Default')}
disabled={!providerForm.enabled}
/>
</div>
{providerError ? (
<p className="text-sm text-destructive">{providerError}</p>
) : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSelectedProvider(null)} disabled={savingProvider}>
{pickAppText(locale, '取消', 'Cancel')}
</Button>
<Button onClick={handleSaveProvider} disabled={savingProvider}>
{savingProvider ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{pickAppText(locale, '保存', 'Save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Channels */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Radio className="w-4 h-4" />
{pickAppText(locale, '通道', 'Channels')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{status.channels.map((ch) => (
<div key={ch.name} className="flex items-center gap-2 text-sm">
<Badge
variant={ch.enabled ? 'default' : 'secondary'}
className="text-xs"
>
{ch.enabled ? pickAppText(locale, '开启', 'On') : pickAppText(locale, '关闭', 'Off')}
</Badge>
<span className="capitalize">{ch.name}</span>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}
function InfoRow({
label,
value,
ok,
}: {
label: string;
value: string;
ok?: boolean;
}) {
return (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{label}</span>
<div className="flex items-center gap-2">
<code className="bg-muted px-2 py-0.5 rounded text-xs max-w-[400px] truncate">
{value}
</code>
{ok !== undefined &&
(ok ? (
<CheckCircle2 className="w-4 h-4 text-green-500" />
) : (
<XCircle className="w-4 h-4 text-destructive" />
))}
</div>
</div>
);
}
function providerLabel(provider: ProviderStatus): string {
return provider.label || provider.name;
}