Files
beaver_project/app-instance/frontend/app/(app)/status/page.tsx

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);
}