feat(engine): 添加技能查看工具并优化异步任务管理 - 添加SkillViewTool到引擎加载器中,增强技能管理功能 - 在AgentLoop中引入_active_direct_task来跟踪活跃任务 - 实现直接任务执行时的同步处理逻辑 - 更新工具实例化方式以支持依赖注入 feat(config): 增加智能体运行时参数配置支持 - 扩展AgentDefaultsConfig添加max_tokens和temperature字段 - 实现配置解析函数_first_config_value处理多个配置源 - 支持通过Web API动态更新智能体运行时参数 - 添加前端页面配置表单和验证逻辑 refactor(provider): 统一最大令牌数参数类型为可选整型 - 将所有LLM提供者的max_tokens参数改为int | None类型 - 为AnthropicProvider实现模型特定的最大令牌数默认值 - 调整参数传递逻辑,优先级:调用参数 > 配置文件 > 模型默认值 - 移除硬编码的默认值,改用条件判断 feat(process): 增强事件投影功能 - 添加工具调用开始/结束事件的映射逻辑 - 实现技能激活事件的识别和展示 - 添加辅助函数处理工具调用名称和参数提取 - 优化运行记录关联逻辑,提升事件匹配准确性 fix(web): 更新网络请求客户端信任环境设置 - 将WebFetchTool和WebSearchTool的trust_env参数设为True - 确保HTTP客户端能够正确使用系统代理配置 - 修复可能的网络连接问题 test: 添加配置加载和事件投影相关测试 - 新增智能体默认参数配置测试用例 - 实现API配置持久化和重载测试 - 添加技能卡片和工具事件的投影测试 ```
504 lines
20 KiB
TypeScript
504 lines
20 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, updateAgentConfig, 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;
|
|
};
|
|
|
|
type AgentFormState = {
|
|
maxTokens: string;
|
|
temperature: string;
|
|
maxToolIterations: 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 [agentForm, setAgentForm] = useState<AgentFormState>(() => ({
|
|
maxTokens: '',
|
|
temperature: '0.2',
|
|
maxToolIterations: '30',
|
|
}));
|
|
const [savingAgent, setSavingAgent] = useState(false);
|
|
const [agentError, setAgentError] = useState<string | null>(null);
|
|
|
|
const loadStatus = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const data = await getStatus();
|
|
setStatus(data);
|
|
setAgentForm({
|
|
maxTokens: data.max_tokens == null ? '' : String(data.max_tokens),
|
|
temperature: String(data.temperature),
|
|
maxToolIterations: String(data.max_tool_iterations),
|
|
});
|
|
} 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);
|
|
}
|
|
};
|
|
|
|
const handleSaveAgentConfig = async () => {
|
|
setSavingAgent(true);
|
|
setAgentError(null);
|
|
try {
|
|
const maxTokensText = agentForm.maxTokens.trim();
|
|
const maxTokens = maxTokensText ? Number(maxTokensText) : null;
|
|
const temperature = Number(agentForm.temperature.trim());
|
|
const maxToolIterations = Number(agentForm.maxToolIterations.trim());
|
|
if (
|
|
maxTokens !== null &&
|
|
(!Number.isInteger(maxTokens) || maxTokens <= 0)
|
|
) {
|
|
throw new Error(pickAppText(locale, '最大令牌数必须为空或正整数', 'Max tokens must be blank or a positive integer'));
|
|
}
|
|
if (!Number.isFinite(temperature) || temperature < 0 || temperature > 2) {
|
|
throw new Error(pickAppText(locale, '温度必须在 0 到 2 之间', 'Temperature must be between 0 and 2'));
|
|
}
|
|
if (!Number.isInteger(maxToolIterations) || maxToolIterations < 0) {
|
|
throw new Error(pickAppText(locale, '最大工具迭代次数必须是非负整数', 'Max tool iterations must be a non-negative integer'));
|
|
}
|
|
await updateAgentConfig({
|
|
max_tokens: maxTokens,
|
|
temperature,
|
|
max_tool_iterations: maxToolIterations,
|
|
});
|
|
await loadStatus();
|
|
} catch (err: any) {
|
|
setAgentError(err.message || pickAppText(locale, '保存智能体配置失败', 'Failed to save agent configuration'));
|
|
} finally {
|
|
setSavingAgent(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-5">
|
|
<InfoRow label={pickAppText(locale, '模型', 'Model')} value={status.model} />
|
|
<div className="grid gap-4 border-t pt-5 md:grid-cols-3">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="agent-max-tokens">{pickAppText(locale, '最大令牌数', 'Max tokens')}</Label>
|
|
<Input
|
|
id="agent-max-tokens"
|
|
inputMode="numeric"
|
|
value={agentForm.maxTokens}
|
|
onChange={(event) => setAgentForm((prev) => ({ ...prev, maxTokens: event.target.value }))}
|
|
placeholder={pickAppText(locale, '模型默认', 'Model default')}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="agent-temperature">{pickAppText(locale, '温度', 'Temperature')}</Label>
|
|
<Input
|
|
id="agent-temperature"
|
|
inputMode="decimal"
|
|
value={agentForm.temperature}
|
|
onChange={(event) => setAgentForm((prev) => ({ ...prev, temperature: event.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="agent-max-tool-iterations">
|
|
{pickAppText(locale, '最大工具迭代次数', 'Max tool iterations')}
|
|
</Label>
|
|
<Input
|
|
id="agent-max-tool-iterations"
|
|
inputMode="numeric"
|
|
value={agentForm.maxToolIterations}
|
|
onChange={(event) => setAgentForm((prev) => ({ ...prev, maxToolIterations: event.target.value }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="text-sm text-destructive">{agentError || ''}</div>
|
|
<Button onClick={handleSaveAgentConfig} disabled={savingAgent} className="sm:self-end">
|
|
{savingAgent ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
|
{pickAppText(locale, '保存智能体配置', 'Save agent config')}
|
|
</Button>
|
|
</div>
|
|
</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;
|
|
}
|