Files
beaver_project/app-instance/frontend/app/(app)/status/page.tsx
steven_li 2c5205b06e feat: 添加MinIO文件系统支持并优化外部连接器功能
- 添加MinIO用户文件系统配置选项(BEAVER_MINIO_ROOT_USER等)
- 更新外部连接器配置结构,包括BASE_URL和认证令牌设置
- 改进connector provider支持更多类型(official, feishu_bot等)
- 实现Mistral模型推理模式支持reasoning_effort参数
- 增强外部连接器策略配置和运行时配置管理
- 添加connector bridge事件验证和安全保护机制
- 优化任务路由逻辑,区分simple_chat和new_task场景
- 更新初始技能工具提示配置,分离authoring admin功能
2026-06-05 11:46:40 +08:00

1907 lines
82 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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,
QrCode,
PlugZap,
} from 'lucide-react';
import {
getChannelConfig,
getChannelConnectorSession,
getStatus,
listChannelConnections,
listChannelConnectors,
listChannelEvents,
restartRuntime,
startChannelConnectorSession,
updateAgentConfig,
updateChannelConfig,
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import type {
ChannelConfigDetail,
ChannelConnectorDescriptor,
ChannelConnectionView,
ChannelEventRecord,
ChannelStatus,
ConnectorSessionResponse,
ProviderStatus,
SystemStatus,
} from '@/types';
import { AppLocale, pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { connectorChannelForKind, visibleConnectorCards } from '@/lib/channel-connector-state';
type ProviderFormState = {
enabled: boolean;
model: string;
apiKey: string;
apiBase: string;
requestTimeoutSeconds: string;
};
type AgentFormState = {
maxTokens: string;
temperature: string;
maxToolIterations: string;
};
type ChannelFormState = {
enabled: boolean;
kind: string;
mode: string;
accountId: string;
displayName: string;
botToken: string;
appId: string;
appSecret: string;
clientSecret: string;
token: string;
domain: string;
connectionMode: string;
botUsername: string;
botOpenId: string;
webhookUrl: string;
webhookSecret: string;
requireMentionInGroups: boolean;
allowFrom: string;
groupAllowFrom: string;
dmPolicy: string;
groupPolicy: string;
markdownSupport: boolean;
baseUrl: string;
cdnBaseUrl: string;
maxMessageChars: string;
textBatchDelaySeconds: string;
};
const EMPTY_CHANNEL_FORM: ChannelFormState = {
enabled: false,
kind: 'telegram',
mode: 'polling',
accountId: '',
displayName: '',
botToken: '',
appId: '',
appSecret: '',
clientSecret: '',
token: '',
domain: 'feishu',
connectionMode: 'websocket',
botUsername: '',
botOpenId: '',
webhookUrl: '',
webhookSecret: '',
requireMentionInGroups: true,
allowFrom: '',
groupAllowFrom: '',
dmPolicy: 'open',
groupPolicy: 'allowlist',
markdownSupport: false,
baseUrl: '',
cdnBaseUrl: '',
maxMessageChars: '',
textBatchDelaySeconds: '',
};
const CONFIGURABLE_CHANNEL_KINDS = new Set(['telegram', 'feishu', 'qqbot', 'weixin']);
const SESSION_CONNECTOR_KINDS = new Set(['weixin', 'feishu']);
const VISIBLE_PROVIDER_IDS = new Set(['openai', 'deepseek', 'dashscope', 'vllm']);
const LOCAL_CONNECTOR_KINDS = new Set(['terminal']);
type ConnectorWizardForm = {
kind: string;
displayName: string;
domain: string;
mode: 'create' | 'link';
appId: string;
appSecret: string;
verificationToken: string;
requireMentionInGroups: boolean;
respondToMentionAll: boolean;
dmMode: 'open' | 'allowlist' | 'pair' | 'disabled';
allowFrom: string;
groupAllowFrom: string;
maxMessageChars: string;
};
const EMPTY_CONNECTOR_WIZARD: ConnectorWizardForm = {
kind: '',
displayName: '',
domain: 'feishu',
mode: 'create',
appId: '',
appSecret: '',
verificationToken: '',
requireMentionInGroups: true,
respondToMentionAll: false,
dmMode: 'open',
allowFrom: '',
groupAllowFrom: '',
maxMessageChars: '20000',
};
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 [selectedChannel, setSelectedChannel] = useState<ChannelStatus | null>(null);
const [channelConfig, setChannelConfig] = useState<ChannelConfigDetail | null>(null);
const [channelForm, setChannelForm] = useState<ChannelFormState>(() => ({ ...EMPTY_CHANNEL_FORM }));
const [channelEvents, setChannelEvents] = useState<ChannelEventRecord[]>([]);
const [loadingChannelConfig, setLoadingChannelConfig] = useState(false);
const [loadingChannelEvents, setLoadingChannelEvents] = useState(false);
const [savingChannel, setSavingChannel] = useState(false);
const [channelError, setChannelError] = useState<string | null>(null);
const [channelRestartRequired, setChannelRestartRequired] = useState(false);
const [restartOpen, setRestartOpen] = useState(false);
const [restarting, setRestarting] = useState(false);
const [restartError, setRestartError] = useState<string | null>(null);
const [connectors, setConnectors] = useState<ChannelConnectorDescriptor[]>([]);
const [channelConnections, setChannelConnections] = useState<ChannelConnectionView[]>([]);
const [loadingConnectors, setLoadingConnectors] = useState(false);
const [connectorDialogOpen, setConnectorDialogOpen] = useState(false);
const [connectorForm, setConnectorForm] = useState<ConnectorWizardForm>(() => ({ ...EMPTY_CONNECTOR_WIZARD }));
const [connectorSession, setConnectorSession] = useState<ConnectorSessionResponse | null>(null);
const [startingConnector, setStartingConnector] = useState(false);
const [pollingConnector, setPollingConnector] = useState(false);
const [connectorError, setConnectorError] = 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 loadConnectors = async () => {
setLoadingConnectors(true);
try {
setConnectors(await listChannelConnectors());
} catch {
setConnectors([]);
} finally {
setLoadingConnectors(false);
}
};
const loadChannelConnections = async () => {
try {
setChannelConnections(await listChannelConnections());
} catch {
setChannelConnections([]);
}
};
useEffect(() => {
loadConnectors();
void loadChannelConnections();
}, []);
useEffect(() => {
const sessionId = connectorSession?.session.sessionId;
const status = connectorSession?.session.status;
if (!sessionId || connectorSessionDone(status)) return;
const timer = window.setInterval(() => {
void pollConnectorSession(sessionId);
}, 2500);
return () => window.clearInterval(timer);
}, [connectorSession?.session.sessionId, connectorSession?.session.status]);
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);
}
};
const openChannelDetails = async (channel: ChannelStatus) => {
setSelectedChannel(channel);
setChannelConfig(null);
setChannelForm(channelFormFromStatus(channel));
setChannelError(null);
setChannelRestartRequired(false);
setChannelEvents([]);
setLoadingChannelConfig(channel.kind !== 'terminal');
setLoadingChannelEvents(true);
if (channel.kind !== 'terminal') {
try {
const config = await getChannelConfig(channel.channel_id);
setChannelConfig(config);
setChannelForm(channelFormFromConfig(config));
} catch (err: any) {
setChannelError(err.message || pickAppText(locale, '加载通道配置失败', 'Failed to load channel configuration'));
} finally {
setLoadingChannelConfig(false);
}
} else {
setLoadingChannelConfig(false);
}
try {
setChannelEvents(await listChannelEvents(channel.channel_id, 20));
} catch {
setChannelEvents([]);
} finally {
setLoadingChannelEvents(false);
}
};
const handleSaveChannel = async () => {
if (!selectedChannel) return;
setSavingChannel(true);
setChannelError(null);
try {
const payload = channelPayloadFromForm(channelForm);
const result = await updateChannelConfig(selectedChannel.channel_id, payload);
setChannelConfig(result.channel);
setChannelForm(channelFormFromConfig(result.channel));
setChannelRestartRequired(Boolean(result.restart_required));
await loadStatus();
} catch (err: any) {
setChannelError(err.message || pickAppText(locale, '保存通道配置失败', 'Failed to save channel configuration'));
} finally {
setSavingChannel(false);
}
};
const handleRestart = async () => {
setRestarting(true);
setRestartError(null);
try {
await restartRuntime();
setRestartOpen(false);
window.setTimeout(() => {
void loadStatus();
}, 5000);
} catch (err: any) {
setRestartError(err.message || pickAppText(locale, '重启失败', 'Restart failed'));
} finally {
setRestarting(false);
}
};
const openConnectorDialog = (connector: ChannelConnectorDescriptor) => {
const kind = connector.kind;
setConnectorDialogOpen(true);
setConnectorSession(null);
setConnectorError(null);
setConnectorForm({
...EMPTY_CONNECTOR_WIZARD,
kind,
displayName: connectorDisplayName(connector),
domain: kind === 'feishu' ? 'feishu' : '',
});
};
const openLocalConnectorDetails = (kind: string, channel?: ChannelStatus) => {
if (kind !== 'terminal') return;
void openChannelDetails(channel || terminalFallbackChannel(locale));
};
const handleStartConnectorSession = async () => {
if (!connectorForm.kind || !SESSION_CONNECTOR_KINDS.has(connectorForm.kind)) return;
setStartingConnector(true);
setConnectorError(null);
try {
const options: Record<string, unknown> = {};
if (connectorForm.kind === 'feishu') {
options.domain = connectorForm.domain || 'feishu';
options.mode = connectorForm.mode;
options.requireMentionInGroups = connectorForm.requireMentionInGroups;
options.respondToMentionAll = connectorForm.respondToMentionAll;
options.dmMode = connectorForm.dmMode;
const allowFrom = parseList(connectorForm.allowFrom);
const groupAllowFrom = parseList(connectorForm.groupAllowFrom);
if (allowFrom.length) options.allowFrom = allowFrom;
if (groupAllowFrom.length) options.groupAllowFrom = groupAllowFrom;
if (connectorForm.maxMessageChars.trim()) options.maxMessageChars = Number(connectorForm.maxMessageChars.trim());
if (connectorForm.appId.trim()) options.appId = connectorForm.appId.trim();
if (connectorForm.appSecret.trim()) options.appSecret = connectorForm.appSecret.trim();
if (connectorForm.verificationToken.trim()) options.verificationToken = connectorForm.verificationToken.trim();
}
const response = await startChannelConnectorSession({
kind: connectorForm.kind,
displayName: connectorForm.displayName.trim() || connectorDisplayName({ kind: connectorForm.kind }),
options,
});
setConnectorSession(response);
if (response.session.status === 'connected') {
await loadStatus();
await loadChannelConnections();
}
if (!connectorSessionDone(response.session.status)) {
window.setTimeout(() => {
void pollConnectorSession(response.session.sessionId);
}, 1000);
}
} catch (err: any) {
setConnectorError(err.message || pickAppText(locale, '启动连接失败', 'Failed to start connector session'));
} finally {
setStartingConnector(false);
}
};
const pollConnectorSession = async (sessionId: string) => {
setPollingConnector(true);
try {
const response = await getChannelConnectorSession(sessionId);
setConnectorSession(response);
if (response.session.status === 'connected') {
await loadStatus();
await loadChannelConnections();
}
} catch (err: any) {
setConnectorError(err.message || pickAppText(locale, '刷新连接状态失败', 'Failed to refresh connector status'));
} finally {
setPollingConnector(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="mx-auto max-w-4xl p-4 sm:p-6">
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="flex items-start gap-3 text-destructive">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0" />
<div className="min-w-0">
<p className="font-medium">{pickAppText(locale, '无法连接到 Boardware Agent Sandbox 后端', 'Unable to connect to the Boardware Agent Sandbox backend')}</p>
<p className="mt-1 break-words text-sm text-muted-foreground">{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;
const visibleProviders = status.providers.filter(visibleProvider);
const connectorCards = visibleConnectorCards(connectors);
return (
<div className="mx-auto max-w-6xl space-y-6 p-4 sm:p-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>
{status.runtime_controls?.self_restart !== false ? (
<Button variant="outline" onClick={() => setRestartOpen(true)}>
<RefreshCw className="w-4 h-4 mr-2" />
{pickAppText(locale, '重启实例', 'Restart instance')}
</Button>
) : null}
</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="min-w-0 break-words 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">
{visibleProviders.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 gap-3 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 ? 'break-all' : 'break-all text-muted-foreground'}>
{providerLabel(p)}
</span>
</span>
<span className="block break-words 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 break-all 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 className="pr-10">
<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 gap-4 rounded-lg border px-3 py-2">
<div className="min-w-0">
<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="break-words 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>
<Dialog open={Boolean(selectedChannel)} onOpenChange={(open) => !open && setSelectedChannel(null)}>
<DialogContent className="sm:max-w-[820px]">
<DialogHeader className="pr-10">
<DialogTitle className="break-words leading-tight">{selectedChannel?.display_name || selectedChannel?.channel_id}</DialogTitle>
<DialogDescription className="break-all">
{selectedChannel ? `${selectedChannel.kind}/${selectedChannel.mode} · ${selectedChannel.channel_id}` : ''}
</DialogDescription>
</DialogHeader>
{selectedChannel ? (
<div className="space-y-5">
{selectedChannel.kind === 'terminal' ? (
<TerminalConnectionGuide channel={selectedChannel} locale={locale} />
) : null}
{CONFIGURABLE_CHANNEL_KINDS.has(channelForm.kind) ? (
<div className="min-w-0 space-y-5 rounded-lg border p-4">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p className="text-sm font-medium">{pickAppText(locale, '连接配置', 'Connection settings')}</p>
<p className="text-xs text-muted-foreground">
{pickAppText(locale, '凭据留空会保留已保存的值。保存后重启实例才会重新连接通道。', 'Leave credentials blank to keep saved values. Restart the instance after saving to reconnect channels.')}
</p>
</div>
<Switch
checked={channelForm.enabled}
onCheckedChange={(checked) => setChannelForm((prev) => ({ ...prev, enabled: checked }))}
disabled={loadingChannelConfig || savingChannel}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field id="channel-display-name" label={pickAppText(locale, '显示名', 'Display name')}>
<Input
id="channel-display-name"
value={channelForm.displayName}
onChange={(event) => setChannelForm((prev) => ({ ...prev, displayName: event.target.value }))}
placeholder={selectedChannel.display_name || selectedChannel.channel_id}
/>
</Field>
<Field id="channel-account-id" label="Account ID">
<Input
id="channel-account-id"
value={channelForm.accountId}
onChange={(event) => setChannelForm((prev) => ({ ...prev, accountId: event.target.value }))}
placeholder="bot-main"
/>
</Field>
<Field label={pickAppText(locale, '平台', 'Platform')}>
<Select
value={channelForm.kind}
onValueChange={(value) => {
const defaults = channelDefaults(value);
setChannelForm((prev) => ({
...defaults,
enabled: prev.enabled,
accountId: prev.accountId || defaults.accountId,
displayName: prev.displayName || defaults.displayName,
}));
}}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="telegram">Telegram</SelectItem>
<SelectItem value="feishu">Feishu/Lark</SelectItem>
<SelectItem value="qqbot">QQ Bot</SelectItem>
<SelectItem value="weixin">Weixin</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label={pickAppText(locale, '模式', 'Mode')}>
<Select
value={channelForm.mode}
onValueChange={(value) => setChannelForm((prev) => ({ ...prev, mode: value, connectionMode: value }))}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{modeOptionsForKind(channelForm.kind).map((mode) => (
<SelectItem key={mode} value={mode}>{mode}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
<ChannelCredentialFields
form={channelForm}
locale={locale}
maskedSecrets={channelConfig?.secrets || {}}
setForm={setChannelForm}
/>
<ChannelPolicyFields form={channelForm} locale={locale} setForm={setChannelForm} />
{channelError ? <p className="break-words text-sm text-destructive">{channelError}</p> : null}
{channelRestartRequired ? (
<div className="flex flex-col gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm sm:flex-row sm:items-center sm:justify-between">
<span className="min-w-0 break-words">{pickAppText(locale, '配置已保存。重启实例后通道会按新配置启动。', 'Configuration saved. Restart the instance to start channels with the new settings.')}</span>
<Button variant="outline" size="sm" className="shrink-0" onClick={() => setRestartOpen(true)}>
<RefreshCw className="mr-2 h-4 w-4" />
{pickAppText(locale, '重启实例', 'Restart instance')}
</Button>
</div>
) : null}
<div className="flex justify-end">
<Button onClick={handleSaveChannel} disabled={savingChannel || loadingChannelConfig}>
{savingChannel ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{pickAppText(locale, '保存通道配置', 'Save channel config')}
</Button>
</div>
</div>
) : null}
<div className="grid gap-2 text-sm sm:grid-cols-2">
<InfoRow label="State" value={selectedChannel.state} />
<InfoRow label="Account" value={selectedChannel.account_id || '-'} />
<InfoRow label="Webhook" value={selectedChannel.webhook_url || '-'} />
<InfoRow label="WebSocket" value={selectedChannel.websocket_url || '-'} />
<InfoRow label="Connected peers" value={String(selectedChannel.connected_peers ?? 0)} />
<InfoRow label="Last error" value={selectedChannel.last_error || '-'} />
</div>
<div className="space-y-2">
<p className="text-sm font-medium">{pickAppText(locale, '最近事件', 'Recent events')}</p>
{loadingChannelEvents ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<div className="max-h-[320px] overflow-auto rounded-md border">
{channelEvents.map((event) => (
<div key={event.event_id} className="border-b px-3 py-2 text-xs last:border-b-0">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-2">
<span className="break-all font-medium">{event.kind}</span>
<span className="break-all text-muted-foreground">{event.created_at}</span>
</div>
<div className="mt-1 break-words text-muted-foreground">
{event.status}{event.error ? ` · ${event.error}` : ''}
</div>
</div>
))}
{channelEvents.length === 0 ? (
<p className="px-3 py-6 text-sm text-muted-foreground">
{pickAppText(locale, '暂无事件', 'No events yet')}
</p>
) : null}
</div>
)}
</div>
</div>
) : null}
</DialogContent>
</Dialog>
<Dialog open={restartOpen} onOpenChange={setRestartOpen}>
<DialogContent className="sm:max-w-[420px]">
<DialogHeader className="pr-10">
<DialogTitle>{pickAppText(locale, '重启实例?', 'Restart instance?')}</DialogTitle>
<DialogDescription>
{pickAppText(
locale,
'应用会短暂不可用,正在运行的对话和通道请求可能会中断。',
'The app will be unavailable briefly. Running chats and channel requests may be interrupted.'
)}
</DialogDescription>
</DialogHeader>
{restartError ? <p className="break-words text-sm text-destructive">{restartError}</p> : null}
<DialogFooter>
<Button variant="outline" onClick={() => setRestartOpen(false)} disabled={restarting}>
{pickAppText(locale, '取消', 'Cancel')}
</Button>
<Button onClick={handleRestart} disabled={restarting}>
{restarting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{pickAppText(locale, '重启', 'Restart')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={connectorDialogOpen} onOpenChange={(open) => {
setConnectorDialogOpen(open);
if (!open) {
setConnectorSession(null);
setConnectorError(null);
}
}}>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader className="pr-10">
<DialogTitle>
{connectorForm.kind ? connectorDisplayName({ kind: connectorForm.kind }) : pickAppText(locale, '连接通道', 'Connect channel')}
</DialogTitle>
<DialogDescription>
{connectorForm.kind === 'weixin'
? pickAppText(locale, '使用扫码连接当前实例。', 'Connect this instance with QR login.')
: connectorForm.kind === 'feishu'
? pickAppText(locale, '启动飞书/Lark 插件连接流程。', 'Start the Feishu/Lark plugin connection flow.')
: pickAppText(locale, '选择连接方式。', 'Choose a connection method.')}
</DialogDescription>
</DialogHeader>
<div className="space-y-5">
<Field id="connector-display-name" label={pickAppText(locale, '显示名', 'Display name')}>
<Input
id="connector-display-name"
value={connectorForm.displayName}
onChange={(event) => setConnectorForm((prev) => ({ ...prev, displayName: event.target.value }))}
disabled={Boolean(connectorSession) || startingConnector}
/>
</Field>
{connectorForm.kind === 'feishu' ? (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<Field label="Domain">
<Select
value={connectorForm.domain}
onValueChange={(value) => setConnectorForm((prev) => ({ ...prev, domain: value }))}
disabled={Boolean(connectorSession) || startingConnector}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="feishu">feishu</SelectItem>
<SelectItem value="lark">lark</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label={pickAppText(locale, '模式', 'Mode')}>
<Select
value={connectorForm.mode}
onValueChange={(value) => setConnectorForm((prev) => ({ ...prev, mode: value as 'create' | 'link' }))}
disabled={Boolean(connectorSession) || startingConnector}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="create">{pickAppText(locale, '新建机器人', 'Create bot')}</SelectItem>
<SelectItem value="link">{pickAppText(locale, '关联已有机器人', 'Link existing bot')}</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
{!connectorSession ? (
<div className="space-y-2 rounded-lg border bg-muted/20 p-3 text-sm text-muted-foreground">
{(connectorForm.mode === 'create'
? feishuCreateGuide(locale)
: feishuLinkGuide(locale)
).map((item) => (
<p key={item}>{item}</p>
))}
</div>
) : null}
<div className="space-y-4 rounded-lg border p-3">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p className="text-sm font-medium">{pickAppText(locale, '群聊必须 @ Beaver', 'Require @ in groups')}</p>
<p className="text-xs text-muted-foreground">
{pickAppText(locale, '默认开启,避免群聊所有消息触发智能体。', 'Enabled by default to avoid processing every group message.')}
</p>
</div>
<Switch
checked={connectorForm.requireMentionInGroups}
onCheckedChange={(checked) => setConnectorForm((prev) => ({ ...prev, requireMentionInGroups: checked }))}
disabled={Boolean(connectorSession) || startingConnector}
/>
</div>
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p className="text-sm font-medium">{pickAppText(locale, '响应 @所有人', 'Respond to @all')}</p>
<p className="text-xs text-muted-foreground">
{pickAppText(locale, '默认关闭,避免群公告式消息触发。', 'Disabled by default to avoid broadcast-style triggers.')}
</p>
</div>
<Switch
checked={connectorForm.respondToMentionAll}
onCheckedChange={(checked) => setConnectorForm((prev) => ({ ...prev, respondToMentionAll: checked }))}
disabled={Boolean(connectorSession) || startingConnector}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label={pickAppText(locale, '私聊策略', 'DM policy')}>
<Select
value={connectorForm.dmMode}
onValueChange={(value) => setConnectorForm((prev) => ({ ...prev, dmMode: value as ConnectorWizardForm['dmMode'] }))}
disabled={Boolean(connectorSession) || startingConnector}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="open">{pickAppText(locale, '开放', 'Open')}</SelectItem>
<SelectItem value="allowlist">{pickAppText(locale, '白名单', 'Allowlist')}</SelectItem>
<SelectItem value="pair">{pickAppText(locale, '已配对', 'Paired')}</SelectItem>
<SelectItem value="disabled">{pickAppText(locale, '关闭', 'Disabled')}</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label={pickAppText(locale, '最大入站长度', 'Max inbound chars')}>
<Input
value={connectorForm.maxMessageChars}
onChange={(event) => setConnectorForm((prev) => ({ ...prev, maxMessageChars: event.target.value }))}
disabled={Boolean(connectorSession) || startingConnector}
placeholder="20000"
/>
</Field>
</div>
<Field label={pickAppText(locale, '允许私聊用户 Open ID', 'Allowed DM user Open IDs')}>
<Textarea
value={connectorForm.allowFrom}
onChange={(event) => setConnectorForm((prev) => ({ ...prev, allowFrom: event.target.value }))}
disabled={Boolean(connectorSession) || startingConnector}
placeholder="ou_xxx, ou_yyy"
/>
</Field>
<Field label={pickAppText(locale, '允许群 Chat ID', 'Allowed group Chat IDs')}>
<Textarea
value={connectorForm.groupAllowFrom}
onChange={(event) => setConnectorForm((prev) => ({ ...prev, groupAllowFrom: event.target.value }))}
disabled={Boolean(connectorSession) || startingConnector}
placeholder="oc_xxx, oc_yyy"
/>
</Field>
</div>
{connectorForm.mode === 'link' ? (
<div className="grid gap-4 md:grid-cols-2">
<Field label="App ID">
<Input
value={connectorForm.appId}
onChange={(event) => setConnectorForm((prev) => ({ ...prev, appId: event.target.value }))}
disabled={Boolean(connectorSession) || startingConnector}
placeholder="cli_a..."
/>
</Field>
<Field label="App Secret">
<Input
type="password"
value={connectorForm.appSecret}
onChange={(event) => setConnectorForm((prev) => ({ ...prev, appSecret: event.target.value }))}
disabled={Boolean(connectorSession) || startingConnector}
/>
</Field>
<Field label="Verification Token">
<Input
type="password"
value={connectorForm.verificationToken}
onChange={(event) => setConnectorForm((prev) => ({ ...prev, verificationToken: event.target.value }))}
disabled={Boolean(connectorSession) || startingConnector}
placeholder={pickAppText(locale, '事件订阅校验 Token', 'Event subscription token')}
/>
</Field>
</div>
) : null}
</div>
) : null}
{connectorSession ? (
<div className="space-y-4 rounded-lg border p-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="break-all text-sm font-medium">{connectorSession.session.sessionId}</p>
<p className="break-all text-xs text-muted-foreground">
{connectorSession.connection?.channel_id || connectorSession.connection?.connection_id || '-'}
</p>
</div>
<Badge variant={connectorSessionBadgeVariant(connectorSession.session.status)}>
{connectorSession.session.status}
</Badge>
</div>
{connectorSession.session.qrImage ? (
<div className="flex justify-center rounded-md bg-muted/40 p-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt="Connector QR"
src={connectorSession.session.qrImage}
className="h-56 w-56 rounded bg-background object-contain"
/>
</div>
) : null}
{connectorSession.session.instructions?.length ? (
<div className="space-y-2 text-sm">
{connectorSession.session.instructions.map((item, index) => (
<div key={`${index}-${item}`} className="rounded-md border bg-muted/30 px-3 py-2">
<span className="break-words">{item}</span>
</div>
))}
</div>
) : null}
{connectorSession.session.accountId || connectorSession.session.displayName ? (
<div className="grid gap-2 text-sm sm:grid-cols-2">
<InfoRow label="Account" value={connectorSession.session.accountId || '-'} />
<InfoRow label="Name" value={connectorSession.session.displayName || '-'} />
</div>
) : null}
{connectorSession.session.error ? (
<p className="break-words text-sm text-destructive">{connectorSession.session.error}</p>
) : null}
</div>
) : null}
{connectorError ? <p className="break-words text-sm text-destructive">{connectorError}</p> : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setConnectorDialogOpen(false)} disabled={startingConnector}>
{pickAppText(locale, '关闭', 'Close')}
</Button>
{connectorSession ? (
<Button
variant="outline"
onClick={() => pollConnectorSession(connectorSession.session.sessionId)}
disabled={pollingConnector}
>
{pollingConnector ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
{pickAppText(locale, '刷新状态', 'Refresh status')}
</Button>
) : SESSION_CONNECTOR_KINDS.has(connectorForm.kind) ? (
<Button onClick={handleStartConnectorSession} disabled={startingConnector}>
{startingConnector ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <QrCode className="mr-2 h-4 w-4" />}
{pickAppText(locale, '开始连接', 'Start connection')}
</Button>
) : null}
</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="space-y-5">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{connectorCards.map((connector) => {
const channel = connectorChannelForKind(connector.kind, status.channels);
const connection = connectorConnectionForKind(connector.kind, channelConnections);
const isRunning = channel?.state === 'running' || connection?.status === 'connected';
const isLocalConnector = LOCAL_CONNECTOR_KINDS.has(connector.kind);
const canStart = SESSION_CONNECTOR_KINDS.has(connector.kind) && !channel && !isRunning;
return (
<button
key={connector.kind}
type="button"
onClick={() => {
if (isLocalConnector) {
openLocalConnectorDetails(connector.kind, channel);
} else if (channel) {
void openChannelDetails(channel);
} else if (canStart) {
openConnectorDialog(connector);
}
}}
disabled={!channel && !canStart && !isLocalConnector}
className={[
'group flex min-h-[76px] w-full items-start justify-between gap-3 rounded-lg border p-3 text-left transition',
isRunning
? 'border-primary bg-primary/5 shadow-sm'
: canStart
? 'border-border bg-background hover:border-primary/50 hover:bg-muted/40'
: 'border-border bg-background opacity-70',
].join(' ')}
>
<span className="min-w-0 space-y-1">
<span className="flex items-center gap-2 text-sm font-medium">
{isRunning ? (
<CheckCircle2 className="h-4 w-4 shrink-0 text-green-500" />
) : canStart || isLocalConnector ? (
<QrCode className="h-4 w-4 shrink-0" />
) : (
<PlugZap className="h-4 w-4 shrink-0 text-muted-foreground/50" />
)}
<span className={isRunning ? 'break-all' : 'break-all text-muted-foreground'}>
{connectorDisplayName(connector)}
</span>
</span>
<span className="block break-words text-xs text-muted-foreground">
{connectorCardSubtitle(connector, channel, connection, locale)}
</span>
{channel || connection || isLocalConnector ? (
<span className="block break-all text-xs text-muted-foreground">
{connectorCardChannelLabel(channel, connection, locale)}
</span>
) : null}
</span>
<Badge variant={connectorCardBadgeVariant(channel, connection)} className="shrink-0">
{connectorCardBadgeLabel(connector, channel, connection, locale)}
</Badge>
</button>
);
})}
</div>
{loadingConnectors ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{pickAppText(locale, '正在加载连接器', 'Loading connectors')}
</div>
) : null}
</div>
</CardContent>
</Card>
</div>
);
}
function InfoRow({
label,
value,
ok,
}: {
label: string;
value: string;
ok?: boolean;
}) {
return (
<div className="grid min-w-0 gap-1 text-sm sm:grid-cols-[minmax(0,1fr)_minmax(0,auto)] sm:items-start">
<span className="min-w-0 break-words text-muted-foreground">{label}</span>
<div className="flex min-w-0 items-center gap-2 sm:justify-end">
<code className="min-w-0 max-w-full whitespace-normal break-all rounded bg-muted px-2 py-0.5 text-xs sm:max-w-[400px]">
{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 TerminalConnectionGuide({ channel, locale }: { channel: ChannelStatus; locale: AppLocale }) {
const connected = channel.state === 'running';
const instructions = terminalConnectionGuide(locale);
return (
<div className="space-y-4 rounded-lg border p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{pickAppText(locale, '小终端连接方式', 'Terminal connection method')}
</p>
<p className="text-xs text-muted-foreground">
{pickAppText(
locale,
'小终端通过本地 WebSocket 通道接入当前实例;这里展示的是连接状态和接入说明。',
'The terminal connects to this instance through the local WebSocket channel; this panel shows status and connection guidance.'
)}
</p>
</div>
<Badge variant={connected ? 'default' : 'secondary'}>{channel.state}</Badge>
</div>
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
<div className="rounded-md border bg-muted/30 p-3">
<div className="mx-auto grid h-44 w-44 grid-cols-7 grid-rows-7 gap-1 rounded bg-background p-3">
{TERMINAL_FAKE_QR_CELLS.map((filled, index) => (
<span
key={index}
className={filled ? 'rounded-[2px] bg-foreground' : 'rounded-[2px] bg-transparent'}
/>
))}
</div>
<p className="mt-3 text-center text-xs font-medium">
{pickAppText(locale, '示意二维码Fake QR', 'Illustrative QR (Fake QR)')}
</p>
<p className="mt-1 text-center text-xs text-muted-foreground">
{pickAppText(locale, '不可扫码,仅用于标识终端连接入口。', 'Not scannable; it only marks the terminal connection entry.')}
</p>
</div>
<div className="space-y-2 text-sm">
{instructions.map((item) => (
<div key={item} className="rounded-md border bg-muted/20 px-3 py-2">
{item}
</div>
))}
</div>
</div>
</div>
);
}
const TERMINAL_FAKE_QR_CELLS = [
true, true, true, false, true, true, true,
true, false, true, false, true, false, true,
true, true, true, false, true, true, true,
false, false, false, true, false, true, false,
true, false, true, false, true, false, true,
false, true, false, true, false, true, false,
true, false, true, true, false, false, true,
];
function terminalConnectionGuide(locale: AppLocale): string[] {
return [
pickAppText(locale, '保持本实例页面在线,终端客户端会通过 WebSocket 连接 Beaver。', 'Keep this instance online; the terminal client connects to Beaver over WebSocket.'),
pickAppText(locale, '连接成功后,通道状态会显示 running并显示当前 connected peers 数量。', 'After connection succeeds, the channel status shows running and the connected peers count is updated.'),
pickAppText(locale, '这里的二维码是 fake 的说明图,不代表真实扫码绑定流程。', 'The QR shown here is fake guidance artwork, not a real scan-to-bind flow.'),
];
}
function terminalFallbackChannel(locale: AppLocale): ChannelStatus {
return {
channel_id: 'terminal',
kind: 'terminal',
mode: 'websocket',
display_name: pickAppText(locale, '小终端', 'Terminal'),
enabled: false,
state: 'disabled',
account_id: 'local',
capabilities: ['receive_text', 'send_text'],
connected_peers: 0,
};
}
function providerLabel(provider: ProviderStatus): string {
return provider.label || provider.name;
}
function providerIdentity(provider: ProviderStatus): string {
const identity = (provider.id || provider.name || provider.label || '').trim().toLowerCase();
if (identity === 'vllm/local') return 'vllm';
return identity;
}
function visibleProvider(provider: ProviderStatus): boolean {
return VISIBLE_PROVIDER_IDS.has(providerIdentity(provider));
}
function connectorConnectionForKind(
kind: string,
connections: ChannelConnectionView[]
): ChannelConnectionView | undefined {
const matches = connections.filter((connection) => connection.kind === kind && connection.status !== 'revoked');
return (
matches.find((connection) => connection.status === 'connected') ||
matches.find((connection) => connection.status !== 'error') ||
matches[0]
);
}
function connectorCardSubtitle(
connector: ChannelConnectorDescriptor,
channel: ChannelStatus | undefined,
connection: ChannelConnectionView | undefined,
locale: AppLocale
): string {
if (channel?.state === 'running' || connection?.status === 'connected') {
return pickAppText(locale, '已连接', 'Connected');
}
if (channel) return channel.state;
if (connection) return connection.status;
if (connector.kind === 'terminal') return pickAppText(locale, '本地终端连接', 'Local terminal connection');
return connectorAuthLabel(connector, locale);
}
function connectorCardChannelLabel(
channel: ChannelStatus | undefined,
connection: ChannelConnectionView | undefined,
locale: AppLocale
): string {
const rawChannelId = connection?.channel_id || channel?.channel_id || '';
const channelId = compactMainSuffix(rawChannelId);
const accountId = compactMainSuffix(connection?.account_id || channel?.account_id || '');
const mode = connection?.mode || channel?.mode || '';
const parts = [channelId, mode, accountId].filter(Boolean);
if (!channel && !connection) return pickAppText(locale, '连接方式说明', 'Connection instructions');
if (parts.length === 0) return pickAppText(locale, '通道已连接', 'Channel connected');
return `${pickAppText(locale, '通道', 'Channel')}: ${parts.join(' · ')}`;
}
function compactMainSuffix(value: string): string {
return value.replace(/[-_]?main$/i, '').trim();
}
function connectorCardBadgeVariant(
channel: ChannelStatus | undefined,
connection: ChannelConnectionView | undefined
): 'default' | 'secondary' | 'destructive' | 'outline' {
if (channel?.state === 'running' || connection?.status === 'connected') return 'default';
if (channel?.state === 'error' || channel?.state === 'degraded' || connection?.status === 'error') {
return 'destructive';
}
if (channel?.state === 'disabled' || channel?.state === 'stopped' || connection?.status === 'revoked') {
return 'secondary';
}
return 'outline';
}
function connectorCardBadgeLabel(
connector: ChannelConnectorDescriptor,
channel: ChannelStatus | undefined,
connection: ChannelConnectionView | undefined,
locale: AppLocale
): string {
if (channel?.state === 'running' || connection?.status === 'connected') return 'running';
if (channel) return channel.state;
if (connection) return connection.status;
if (connector.kind === 'terminal') return pickAppText(locale, '说明', 'Guide');
return pickAppText(locale, '连接', 'Connect');
}
function connectorDisplayName(connector: Pick<ChannelConnectorDescriptor, 'kind' | 'displayName' | 'display_name'>): string {
if (connector.displayName || connector.display_name) return connector.displayName || connector.display_name || connector.kind;
if (connector.kind === 'weixin') return 'Weixin';
if (connector.kind === 'feishu') return 'Feishu/Lark';
if (connector.kind === 'terminal') return 'Terminal';
if (connector.kind === 'telegram') return 'Telegram';
return connector.kind;
}
function connectorAuthLabel(connector: ChannelConnectorDescriptor, locale: AppLocale): string {
const authType = connector.authType || connector.auth_type;
if (connector.kind === 'weixin') return authType || pickAppText(locale, 'QR', 'QR');
if (connector.kind === 'feishu') return authType || pickAppText(locale, '插件', 'Plugin');
if (connector.kind === 'terminal') return pickAppText(locale, 'Fake QR', 'Fake QR');
if (connector.kind === 'telegram') return authType || pickAppText(locale, 'Token', 'Token');
return authType || connector.kind;
}
function feishuCreateGuide(locale: AppLocale): string[] {
return [
pickAppText(locale, '点击开始连接后会生成飞书扫码二维码。', 'Start connection to generate a Feishu/Lark QR code.'),
pickAppText(locale, '用飞书客户端扫码,选择一键创建飞书机器人。', 'Scan with the Feishu/Lark client and choose one-click bot creation.'),
pickAppText(locale, '创建完成后打开机器人,发送任意消息或 /feishu start 验证。', 'After creation, open the bot and send any message or /feishu start to verify.'),
];
}
function feishuLinkGuide(locale: AppLocale): string[] {
return [
pickAppText(locale, '关联已有机器人需要填写 App ID 和 App Secret。', 'Linking an existing bot requires App ID and App Secret.'),
pickAppText(locale, '若提示凭证无效,请从飞书开放平台复制最新应用凭证。', 'If credentials are invalid, copy the latest app credentials from the Feishu/Lark developer console.'),
];
}
function connectorSessionDone(status?: string | null): boolean {
return ['connected', 'expired', 'error', 'cancelled'].includes(String(status || ''));
}
function connectorSessionBadgeVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
if (status === 'connected') return 'default';
if (status === 'error' || status === 'expired') return 'destructive';
if (status === 'cancelled') return 'secondary';
return 'outline';
}
function Field({ id, label, children }: { id?: string; label: string; children: React.ReactNode }) {
return (
<div className="grid min-w-0 gap-2">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);
}
function ChannelCredentialFields({
form,
locale,
maskedSecrets,
setForm,
}: {
form: ChannelFormState;
locale: AppLocale;
maskedSecrets: Record<string, string>;
setForm: React.Dispatch<React.SetStateAction<ChannelFormState>>;
}) {
if (form.kind === 'telegram') {
return (
<div className="grid gap-4 md:grid-cols-2">
<Field label="Bot Token">
<Input
type="password"
value={form.botToken}
onChange={(event) => setForm((prev) => ({ ...prev, botToken: event.target.value }))}
placeholder={maskedSecrets.botToken || pickAppText(locale, '留空保持不变', 'Leave blank to keep existing')}
/>
</Field>
<Field label="Bot Username">
<Input
value={form.botUsername}
onChange={(event) => setForm((prev) => ({ ...prev, botUsername: event.target.value }))}
placeholder="beaver_bot"
/>
</Field>
{form.mode === 'webhook' ? (
<>
<Field label="Webhook URL">
<Input
value={form.webhookUrl}
onChange={(event) => setForm((prev) => ({ ...prev, webhookUrl: event.target.value }))}
placeholder="https://example.com/telegram"
/>
</Field>
<Field label="Webhook Secret">
<Input
type="password"
value={form.webhookSecret}
onChange={(event) => setForm((prev) => ({ ...prev, webhookSecret: event.target.value }))}
placeholder={pickAppText(locale, '可选', 'Optional')}
/>
</Field>
</>
) : null}
</div>
);
}
if (form.kind === 'feishu') {
return (
<div className="grid gap-4 md:grid-cols-2">
<Field label="App ID">
<Input
value={form.appId}
onChange={(event) => setForm((prev) => ({ ...prev, appId: event.target.value }))}
placeholder={maskedSecrets.appId || 'cli_a...'}
/>
</Field>
<Field label="App Secret">
<Input
type="password"
value={form.appSecret}
onChange={(event) => setForm((prev) => ({ ...prev, appSecret: event.target.value }))}
placeholder={maskedSecrets.appSecret || pickAppText(locale, '留空保持不变', 'Leave blank to keep existing')}
/>
</Field>
<Field label="Domain">
<Select
value={form.domain}
onValueChange={(value) => setForm((prev) => ({ ...prev, domain: value }))}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="feishu">feishu</SelectItem>
<SelectItem value="lark">lark</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="Bot Open ID">
<Input
value={form.botOpenId}
onChange={(event) => setForm((prev) => ({ ...prev, botOpenId: event.target.value }))}
placeholder={pickAppText(locale, '可选,用于群 mention 判断', 'Optional, for group mention checks')}
/>
</Field>
</div>
);
}
if (form.kind === 'qqbot') {
return (
<div className="grid gap-4 md:grid-cols-2">
<Field label="App ID">
<Input
value={form.appId}
onChange={(event) => setForm((prev) => ({ ...prev, appId: event.target.value }))}
placeholder={maskedSecrets.appId || '1020...'}
/>
</Field>
<Field label="Client Secret">
<Input
type="password"
value={form.clientSecret}
onChange={(event) => setForm((prev) => ({ ...prev, clientSecret: event.target.value }))}
placeholder={maskedSecrets.clientSecret || pickAppText(locale, '留空保持不变', 'Leave blank to keep existing')}
/>
</Field>
<div className="flex items-center justify-between rounded-lg border px-3 py-2 md:col-span-2">
<div>
<Label className="text-sm">Markdown</Label>
<p className="text-xs text-muted-foreground">
{pickAppText(locale, '按 QQ Bot markdown 消息发送文本。', 'Send text as QQ Bot markdown messages.')}
</p>
</div>
<Switch
checked={form.markdownSupport}
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, markdownSupport: checked }))}
/>
</div>
</div>
);
}
if (form.kind === 'weixin') {
return (
<div className="grid gap-4 md:grid-cols-2">
<Field label="Token">
<Input
type="password"
value={form.token}
onChange={(event) => setForm((prev) => ({ ...prev, token: event.target.value }))}
placeholder={maskedSecrets.token || pickAppText(locale, '留空保持不变', 'Leave blank to keep existing')}
/>
</Field>
<Field label="Base URL">
<Input
value={form.baseUrl}
onChange={(event) => setForm((prev) => ({ ...prev, baseUrl: event.target.value }))}
placeholder={pickAppText(locale, '默认 iLink API', 'Default iLink API')}
/>
</Field>
<Field label="CDN Base URL">
<Input
value={form.cdnBaseUrl}
onChange={(event) => setForm((prev) => ({ ...prev, cdnBaseUrl: event.target.value }))}
placeholder={pickAppText(locale, '默认 CDN', 'Default CDN')}
/>
</Field>
<Field label={pickAppText(locale, '文本批处理延迟(秒)', 'Text batch delay seconds')}>
<Input
inputMode="decimal"
value={form.textBatchDelaySeconds}
onChange={(event) => setForm((prev) => ({ ...prev, textBatchDelaySeconds: event.target.value }))}
placeholder="0.5"
/>
</Field>
</div>
);
}
return null;
}
function ChannelPolicyFields({
form,
locale,
setForm,
}: {
form: ChannelFormState;
locale: AppLocale;
setForm: React.Dispatch<React.SetStateAction<ChannelFormState>>;
}) {
return (
<div className="grid gap-4 md:grid-cols-2">
{form.kind === 'telegram' || form.kind === 'feishu' ? (
<div className="flex items-center justify-between rounded-lg border px-3 py-2 md:col-span-2">
<div>
<Label className="text-sm">{pickAppText(locale, '群聊需要 mention', 'Require mention in groups')}</Label>
<p className="text-xs text-muted-foreground">
{pickAppText(locale, '开启后群聊只有提到 bot 才会触发。', 'When enabled, group messages trigger only when they mention the bot.')}
</p>
</div>
<Switch
checked={form.requireMentionInGroups}
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, requireMentionInGroups: checked }))}
/>
</div>
) : (
<>
<Field label={pickAppText(locale, '私聊策略', 'DM policy')}>
<PolicySelect value={form.dmPolicy} onChange={(value) => setForm((prev) => ({ ...prev, dmPolicy: value }))} />
</Field>
<Field label={pickAppText(locale, '群聊策略', 'Group policy')}>
<PolicySelect value={form.groupPolicy} onChange={(value) => setForm((prev) => ({ ...prev, groupPolicy: value }))} />
</Field>
</>
)}
<Field label={pickAppText(locale, '允许用户', 'Allowed users')}>
<Textarea
value={form.allowFrom}
onChange={(event) => setForm((prev) => ({ ...prev, allowFrom: event.target.value }))}
placeholder={pickAppText(locale, '每行或逗号分隔;留空表示不限制', 'One per line or comma separated; blank means unrestricted')}
/>
</Field>
<Field label={pickAppText(locale, '允许群/群用户', 'Allowed groups/group users')}>
<Textarea
value={form.groupAllowFrom}
onChange={(event) => setForm((prev) => ({ ...prev, groupAllowFrom: event.target.value }))}
placeholder={pickAppText(locale, '每行或逗号分隔;留空表示不限制', 'One per line or comma separated; blank means unrestricted')}
/>
</Field>
<Field label={pickAppText(locale, '最大消息长度', 'Max message chars')}>
<Input
inputMode="numeric"
value={form.maxMessageChars}
onChange={(event) => setForm((prev) => ({ ...prev, maxMessageChars: event.target.value }))}
placeholder={form.kind === 'telegram' || form.kind === 'feishu' ? '4096' : '2000'}
/>
</Field>
</div>
);
}
function PolicySelect({ value, onChange }: { value: string; onChange: (value: string) => void }) {
return (
<Select value={value || 'open'} onValueChange={onChange}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="open">open</SelectItem>
<SelectItem value="allowlist">allowlist</SelectItem>
<SelectItem value="disabled">disabled</SelectItem>
</SelectContent>
</Select>
);
}
function channelDefaults(kind: string): ChannelFormState {
const base = { ...EMPTY_CHANNEL_FORM, kind };
if (kind === 'feishu') return { ...base, mode: 'websocket', connectionMode: 'websocket', accountId: 'tenant-main', displayName: 'Feishu Main' };
if (kind === 'qqbot') return { ...base, mode: 'websocket', accountId: 'qqbot-main', displayName: 'QQ Bot Main', groupPolicy: 'allowlist' };
if (kind === 'weixin') return { ...base, mode: 'polling', accountId: 'wx-main', displayName: 'Weixin Main', groupPolicy: 'disabled', textBatchDelaySeconds: '0.5' };
return { ...base, mode: 'polling', accountId: 'bot-main', displayName: 'Telegram Main', maxMessageChars: '4096' };
}
function modeOptionsForKind(kind: string): string[] {
if (kind === 'telegram') return ['polling', 'webhook'];
if (kind === 'feishu') return ['websocket', 'webhook'];
if (kind === 'qqbot') return ['websocket'];
if (kind === 'weixin') return ['polling'];
return ['webhook'];
}
function channelFormFromStatus(channel: ChannelStatus): ChannelFormState {
return {
...channelDefaults(channel.kind),
enabled: channel.enabled,
kind: channel.kind,
mode: channel.mode,
connectionMode: channel.mode,
accountId: channel.account_id || '',
displayName: channel.display_name || channel.channel_id,
};
}
function channelFormFromConfig(detail: ChannelConfigDetail): ChannelFormState {
const config = detail.config || {};
return {
...channelFormFromStatus({
channel_id: detail.channel_id,
kind: detail.kind,
mode: detail.mode,
display_name: detail.display_name,
enabled: detail.enabled,
account_id: detail.account_id,
state: 'configured',
capabilities: [],
}),
domain: stringConfig(config, 'domain') || 'feishu',
connectionMode: stringConfig(config, 'connectionMode') || detail.mode,
botUsername: stringConfig(config, 'botUsername'),
botOpenId: stringConfig(config, 'botOpenId'),
webhookUrl: stringConfig(config, 'webhookUrl'),
webhookSecret: stringConfig(config, 'webhookSecret'),
requireMentionInGroups: boolConfig(config, 'requireMentionInGroups', true),
allowFrom: listConfigText(config, 'allowFrom'),
groupAllowFrom: listConfigText(config, 'groupAllowFrom'),
dmPolicy: stringConfig(config, 'dmPolicy') || 'open',
groupPolicy: stringConfig(config, 'groupPolicy') || (detail.kind === 'weixin' ? 'disabled' : 'allowlist'),
markdownSupport: boolConfig(config, 'markdownSupport', false),
baseUrl: stringConfig(config, 'baseUrl'),
cdnBaseUrl: stringConfig(config, 'cdnBaseUrl'),
maxMessageChars: stringConfig(config, 'maxMessageChars'),
textBatchDelaySeconds: stringConfig(config, 'textBatchDelaySeconds'),
};
}
function channelPayloadFromForm(form: ChannelFormState) {
const config: Record<string, unknown> = {};
const secrets: Record<string, string> = {};
const mode = form.kind === 'feishu' ? form.connectionMode || form.mode : form.mode;
if (form.maxMessageChars.trim()) config.maxMessageChars = Number(form.maxMessageChars.trim());
const allowFrom = parseList(form.allowFrom);
const groupAllowFrom = parseList(form.groupAllowFrom);
if (allowFrom.length) config.allowFrom = allowFrom;
if (groupAllowFrom.length) config.groupAllowFrom = groupAllowFrom;
if (form.kind === 'telegram') {
if (form.botToken.trim()) secrets.botToken = form.botToken.trim();
config.requireMentionInGroups = form.requireMentionInGroups;
if (form.botUsername.trim()) config.botUsername = form.botUsername.trim().replace(/^@+/, '');
if (form.webhookUrl.trim()) config.webhookUrl = form.webhookUrl.trim();
if (form.webhookSecret.trim()) config.webhookSecret = form.webhookSecret.trim();
} else if (form.kind === 'feishu') {
if (form.appId.trim()) secrets.appId = form.appId.trim();
if (form.appSecret.trim()) secrets.appSecret = form.appSecret.trim();
config.domain = form.domain || 'feishu';
config.connectionMode = mode;
config.requireMentionInGroups = form.requireMentionInGroups;
if (form.botOpenId.trim()) config.botOpenId = form.botOpenId.trim();
} else if (form.kind === 'qqbot') {
if (form.appId.trim()) secrets.appId = form.appId.trim();
if (form.clientSecret.trim()) secrets.clientSecret = form.clientSecret.trim();
config.dmPolicy = form.dmPolicy || 'open';
config.groupPolicy = form.groupPolicy || 'allowlist';
config.markdownSupport = form.markdownSupport;
} else if (form.kind === 'weixin') {
if (form.token.trim()) secrets.token = form.token.trim();
config.dmPolicy = form.dmPolicy || 'open';
config.groupPolicy = form.groupPolicy || 'disabled';
if (form.baseUrl.trim()) config.baseUrl = form.baseUrl.trim();
if (form.cdnBaseUrl.trim()) config.cdnBaseUrl = form.cdnBaseUrl.trim();
if (form.textBatchDelaySeconds.trim()) config.textBatchDelaySeconds = Number(form.textBatchDelaySeconds.trim());
}
return {
enabled: form.enabled,
kind: form.kind,
mode,
account_id: form.accountId.trim(),
display_name: form.displayName.trim(),
config,
secrets,
};
}
function stringConfig(config: Record<string, unknown>, key: string): string {
const value = config[key];
if (value == null) return '';
if (Array.isArray(value)) return value.join('\n');
return String(value);
}
function boolConfig(config: Record<string, unknown>, key: string, fallback: boolean): boolean {
const value = config[key];
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
}
return fallback;
}
function listConfigText(config: Record<string, unknown>, key: string): string {
const value = config[key];
if (Array.isArray(value)) return value.map((item) => String(item)).join('\n');
if (typeof value === 'string') return value;
return '';
}
function parseList(value: string): string[] {
return value
.split(/[\n,]/)
.map((item) => item.trim())
.filter(Boolean);
}