1620 lines
67 KiB
TypeScript
1620 lines
67 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,
|
|
QrCode,
|
|
PlugZap,
|
|
} from 'lucide-react';
|
|
import {
|
|
getChannelConfig,
|
|
getChannelConnectorSession,
|
|
getStatus,
|
|
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,
|
|
ChannelEventRecord,
|
|
ChannelStatus,
|
|
ConnectorSessionResponse,
|
|
ProviderStatus,
|
|
SystemStatus,
|
|
} from '@/types';
|
|
import { AppLocale, 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;
|
|
};
|
|
|
|
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']);
|
|
|
|
type ConnectorWizardForm = {
|
|
kind: string;
|
|
displayName: string;
|
|
domain: string;
|
|
mode: 'create' | 'link';
|
|
appId: string;
|
|
appSecret: string;
|
|
verificationToken: string;
|
|
};
|
|
|
|
const EMPTY_CONNECTOR_WIZARD: ConnectorWizardForm = {
|
|
kind: '',
|
|
displayName: '',
|
|
domain: 'feishu',
|
|
mode: 'create',
|
|
appId: '',
|
|
appSecret: '',
|
|
verificationToken: '',
|
|
};
|
|
|
|
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 [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);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadConnectors();
|
|
}, []);
|
|
|
|
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(true);
|
|
setLoadingChannelEvents(true);
|
|
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);
|
|
}
|
|
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 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;
|
|
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 (!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();
|
|
}
|
|
} 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="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>
|
|
{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="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>
|
|
|
|
<Dialog open={Boolean(selectedChannel)} onOpenChange={(open) => !open && setSelectedChannel(null)}>
|
|
<DialogContent className="max-h-[92vh] overflow-y-auto sm:max-w-[820px]">
|
|
<DialogHeader>
|
|
<DialogTitle>{selectedChannel?.display_name || selectedChannel?.channel_id}</DialogTitle>
|
|
<DialogDescription>
|
|
{selectedChannel ? `${selectedChannel.kind}/${selectedChannel.mode} · ${selectedChannel.channel_id}` : ''}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{selectedChannel ? (
|
|
<div className="space-y-5">
|
|
{CONFIGURABLE_CHANNEL_KINDS.has(channelForm.kind) ? (
|
|
<div className="space-y-5 rounded-lg border p-4">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div>
|
|
<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 label={pickAppText(locale, '显示名', 'Display name')}>
|
|
<Input
|
|
value={channelForm.displayName}
|
|
onChange={(event) => setChannelForm((prev) => ({ ...prev, displayName: event.target.value }))}
|
|
placeholder={selectedChannel.display_name || selectedChannel.channel_id}
|
|
/>
|
|
</Field>
|
|
<Field label="Account ID">
|
|
<Input
|
|
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="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>{pickAppText(locale, '配置已保存。重启实例后通道会按新配置启动。', 'Configuration saved. Restart the instance to start channels with the new settings.')}</span>
|
|
<Button variant="outline" size="sm" 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 items-center justify-between gap-2">
|
|
<span className="font-medium">{event.kind}</span>
|
|
<span className="text-muted-foreground">{event.created_at}</span>
|
|
</div>
|
|
<div className="mt-1 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>
|
|
<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="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="max-h-[92vh] overflow-y-auto sm:max-w-[560px]">
|
|
<DialogHeader>
|
|
<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 label={pickAppText(locale, '显示名', 'Display name')}>
|
|
<Input
|
|
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}
|
|
{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="truncate text-sm font-medium">{connectorSession.session.sessionId}</p>
|
|
<p className="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">
|
|
{item}
|
|
</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="text-sm text-destructive">{connectorSession.session.error}</p>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{connectorError ? <p className="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 gap-2 sm:grid-cols-3">
|
|
{(connectors.length ? connectors : [{ kind: 'telegram' }, { kind: 'weixin' }, { kind: 'feishu' }]).map((connector) => {
|
|
const supportsSession = SESSION_CONNECTOR_KINDS.has(connector.kind);
|
|
return (
|
|
<button
|
|
key={connector.kind}
|
|
type="button"
|
|
onClick={() => supportsSession && openConnectorDialog(connector)}
|
|
disabled={!supportsSession}
|
|
className={[
|
|
'flex min-h-[86px] w-full items-start justify-between rounded-lg border px-3 py-3 text-left text-sm transition',
|
|
supportsSession ? 'hover:border-primary/50 hover:bg-muted/40' : 'opacity-70',
|
|
].join(' ')}
|
|
>
|
|
<span className="min-w-0 space-y-1">
|
|
<span className="flex items-center gap-2 font-medium">
|
|
{supportsSession ? <QrCode className="h-4 w-4" /> : <PlugZap className="h-4 w-4" />}
|
|
<span className="truncate">{connectorDisplayName(connector)}</span>
|
|
</span>
|
|
<span className="block truncate text-xs text-muted-foreground">
|
|
{connectorAuthLabel(connector, locale)}
|
|
</span>
|
|
</span>
|
|
<Badge variant={supportsSession ? 'outline' : 'secondary'}>
|
|
{supportsSession ? pickAppText(locale, '连接', 'Connect') : pickAppText(locale, '令牌', 'Token')}
|
|
</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 className="space-y-2">
|
|
{status.channels.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
{pickAppText(locale, '尚未配置通道', 'No channels configured')}
|
|
</p>
|
|
) : (
|
|
status.channels.map((ch) => (
|
|
<button
|
|
key={ch.channel_id}
|
|
type="button"
|
|
onClick={() => openChannelDetails(ch)}
|
|
className="flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left text-sm hover:bg-muted/40"
|
|
>
|
|
<span className="min-w-0">
|
|
<span className="block truncate font-medium">{ch.display_name || ch.channel_id}</span>
|
|
<span className="block truncate text-xs text-muted-foreground">
|
|
{ch.channel_id} · {ch.kind}/{ch.mode} · {ch.account_id}
|
|
{typeof ch.connected_peers === 'number' ? ` · ${ch.connected_peers} peer${ch.connected_peers === 1 ? '' : 's'}` : ''}
|
|
</span>
|
|
</span>
|
|
<Badge variant={channelStateBadgeVariant(ch.state)}>
|
|
{ch.state}
|
|
</Badge>
|
|
</button>
|
|
))
|
|
)}
|
|
</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;
|
|
}
|
|
|
|
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 === '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 === '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 channelStateBadgeVariant(
|
|
state: ChannelStatus['state']
|
|
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
|
if (state === 'running') return 'default';
|
|
if (state === 'error' || state === 'degraded') return 'destructive';
|
|
if (state === 'disabled' || state === 'stopped') return 'secondary';
|
|
return 'outline';
|
|
}
|
|
|
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="grid gap-2">
|
|
<Label>{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);
|
|
}
|