feat: 添加MinIO文件系统支持并优化外部连接器功能

- 添加MinIO用户文件系统配置选项(BEAVER_MINIO_ROOT_USER等)
- 更新外部连接器配置结构,包括BASE_URL和认证令牌设置
- 改进connector provider支持更多类型(official, feishu_bot等)
- 实现Mistral模型推理模式支持reasoning_effort参数
- 增强外部连接器策略配置和运行时配置管理
- 添加connector bridge事件验证和安全保护机制
- 优化任务路由逻辑,区分simple_chat和new_task场景
- 更新初始技能工具提示配置,分离authoring admin功能
This commit is contained in:
2026-06-05 11:46:40 +08:00
parent 236ac19789
commit 2c5205b06e
120 changed files with 8321 additions and 1865 deletions

View File

@ -21,6 +21,7 @@ import {
getChannelConfig,
getChannelConnectorSession,
getStatus,
listChannelConnections,
listChannelConnectors,
listChannelEvents,
restartRuntime,
@ -54,6 +55,7 @@ import { Textarea } from '@/components/ui/textarea';
import type {
ChannelConfigDetail,
ChannelConnectorDescriptor,
ChannelConnectionView,
ChannelEventRecord,
ChannelStatus,
ConnectorSessionResponse,
@ -62,6 +64,7 @@ import type {
} from '@/types';
import { AppLocale, pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { connectorChannelForKind, visibleConnectorCards } from '@/lib/channel-connector-state';
type ProviderFormState = {
enabled: boolean;
@ -137,6 +140,8 @@ const EMPTY_CHANNEL_FORM: ChannelFormState = {
const CONFIGURABLE_CHANNEL_KINDS = new Set(['telegram', 'feishu', 'qqbot', 'weixin']);
const SESSION_CONNECTOR_KINDS = new Set(['weixin', 'feishu']);
const VISIBLE_PROVIDER_IDS = new Set(['openai', 'deepseek', 'dashscope', 'vllm']);
const LOCAL_CONNECTOR_KINDS = new Set(['terminal']);
type ConnectorWizardForm = {
kind: string;
@ -146,6 +151,12 @@ type ConnectorWizardForm = {
appId: string;
appSecret: string;
verificationToken: string;
requireMentionInGroups: boolean;
respondToMentionAll: boolean;
dmMode: 'open' | 'allowlist' | 'pair' | 'disabled';
allowFrom: string;
groupAllowFrom: string;
maxMessageChars: string;
};
const EMPTY_CONNECTOR_WIZARD: ConnectorWizardForm = {
@ -156,6 +167,12 @@ const EMPTY_CONNECTOR_WIZARD: ConnectorWizardForm = {
appId: '',
appSecret: '',
verificationToken: '',
requireMentionInGroups: true,
respondToMentionAll: false,
dmMode: 'open',
allowFrom: '',
groupAllowFrom: '',
maxMessageChars: '20000',
};
export default function StatusPage() {
@ -193,6 +210,7 @@ export default function StatusPage() {
const [restarting, setRestarting] = useState(false);
const [restartError, setRestartError] = useState<string | null>(null);
const [connectors, setConnectors] = useState<ChannelConnectorDescriptor[]>([]);
const [channelConnections, setChannelConnections] = useState<ChannelConnectionView[]>([]);
const [loadingConnectors, setLoadingConnectors] = useState(false);
const [connectorDialogOpen, setConnectorDialogOpen] = useState(false);
const [connectorForm, setConnectorForm] = useState<ConnectorWizardForm>(() => ({ ...EMPTY_CONNECTOR_WIZARD }));
@ -234,8 +252,17 @@ export default function StatusPage() {
}
};
const loadChannelConnections = async () => {
try {
setChannelConnections(await listChannelConnections());
} catch {
setChannelConnections([]);
}
};
useEffect(() => {
loadConnectors();
void loadChannelConnections();
}, []);
useEffect(() => {
@ -329,15 +356,19 @@ export default function StatusPage() {
setChannelError(null);
setChannelRestartRequired(false);
setChannelEvents([]);
setLoadingChannelConfig(true);
setLoadingChannelConfig(channel.kind !== 'terminal');
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 {
if (channel.kind !== 'terminal') {
try {
const config = await getChannelConfig(channel.channel_id);
setChannelConfig(config);
setChannelForm(channelFormFromConfig(config));
} catch (err: any) {
setChannelError(err.message || pickAppText(locale, '加载通道配置失败', 'Failed to load channel configuration'));
} finally {
setLoadingChannelConfig(false);
}
} else {
setLoadingChannelConfig(false);
}
try {
@ -396,6 +427,11 @@ export default function StatusPage() {
});
};
const openLocalConnectorDetails = (kind: string, channel?: ChannelStatus) => {
if (kind !== 'terminal') return;
void openChannelDetails(channel || terminalFallbackChannel(locale));
};
const handleStartConnectorSession = async () => {
if (!connectorForm.kind || !SESSION_CONNECTOR_KINDS.has(connectorForm.kind)) return;
setStartingConnector(true);
@ -405,6 +441,14 @@ export default function StatusPage() {
if (connectorForm.kind === 'feishu') {
options.domain = connectorForm.domain || 'feishu';
options.mode = connectorForm.mode;
options.requireMentionInGroups = connectorForm.requireMentionInGroups;
options.respondToMentionAll = connectorForm.respondToMentionAll;
options.dmMode = connectorForm.dmMode;
const allowFrom = parseList(connectorForm.allowFrom);
const groupAllowFrom = parseList(connectorForm.groupAllowFrom);
if (allowFrom.length) options.allowFrom = allowFrom;
if (groupAllowFrom.length) options.groupAllowFrom = groupAllowFrom;
if (connectorForm.maxMessageChars.trim()) options.maxMessageChars = Number(connectorForm.maxMessageChars.trim());
if (connectorForm.appId.trim()) options.appId = connectorForm.appId.trim();
if (connectorForm.appSecret.trim()) options.appSecret = connectorForm.appSecret.trim();
if (connectorForm.verificationToken.trim()) options.verificationToken = connectorForm.verificationToken.trim();
@ -415,6 +459,10 @@ export default function StatusPage() {
options,
});
setConnectorSession(response);
if (response.session.status === 'connected') {
await loadStatus();
await loadChannelConnections();
}
if (!connectorSessionDone(response.session.status)) {
window.setTimeout(() => {
void pollConnectorSession(response.session.sessionId);
@ -434,6 +482,7 @@ export default function StatusPage() {
setConnectorSession(response);
if (response.session.status === 'connected') {
await loadStatus();
await loadChannelConnections();
}
} catch (err: any) {
setConnectorError(err.message || pickAppText(locale, '刷新连接状态失败', 'Failed to refresh connector status'));
@ -452,14 +501,14 @@ export default function StatusPage() {
if (error) {
return (
<div className="max-w-4xl mx-auto p-6">
<div className="mx-auto max-w-4xl p-4 sm: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>
<div className="flex items-start gap-3 text-destructive">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0" />
<div className="min-w-0">
<p className="font-medium">{pickAppText(locale, '无法连接到 Boardware Agent Sandbox 后端', 'Unable to connect to the Boardware Agent Sandbox backend')}</p>
<p className="text-sm text-muted-foreground mt-1">{error}</p>
<p className="mt-1 break-words text-sm text-muted-foreground">{error}</p>
<p className="text-sm text-muted-foreground mt-1">{pickAppText(locale, '请确认后端服务已启动,并且当前页面可以访问它。', 'Please confirm the backend service is running and reachable from this page.')}</p>
</div>
</div>
@ -475,8 +524,11 @@ export default function StatusPage() {
if (!status) return null;
const visibleProviders = status.providers.filter(visibleProvider);
const connectorCards = visibleConnectorCards(connectors);
return (
<div className="mx-auto max-w-6xl p-6 space-y-6">
<div className="mx-auto max-w-6xl space-y-6 p-4 sm:p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-bold">{pickAppText(locale, '配置', 'Settings')}</h1>
@ -575,7 +627,7 @@ export default function StatusPage() {
</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>
<div className="min-w-0 break-words text-sm text-destructive">{agentError || ''}</div>
<Button onClick={handleSaveAgentConfig} disabled={savingAgent} className="sm:self-end">
{savingAgent ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{pickAppText(locale, '保存智能体配置', 'Save agent config')}
@ -594,13 +646,13 @@ export default function StatusPage() {
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
{status.providers.map((p) => (
{visibleProviders.map((p) => (
<button
key={p.id || p.name}
type="button"
onClick={() => openProviderDialog(p)}
className={[
'group flex min-h-[76px] w-full items-start justify-between rounded-lg border p-3 text-left transition',
'group flex min-h-[76px] w-full items-start justify-between gap-3 rounded-lg border p-3 text-left transition',
p.active
? 'border-primary bg-primary/5 shadow-sm'
: 'border-border bg-background hover:border-primary/50 hover:bg-muted/40',
@ -613,11 +665,11 @@ export default function StatusPage() {
) : (
<XCircle className="h-4 w-4 shrink-0 text-muted-foreground/40" />
)}
<span className={p.has_key ? 'truncate' : 'truncate text-muted-foreground'}>
<span className={p.has_key ? 'break-all' : 'break-all text-muted-foreground'}>
{providerLabel(p)}
</span>
</span>
<span className="block truncate text-xs text-muted-foreground">
<span className="block break-words text-xs text-muted-foreground">
{p.active
? pickAppText(locale, '当前默认', 'Current default')
: p.enabled
@ -625,7 +677,7 @@ export default function StatusPage() {
: pickAppText(locale, '点击配置', 'Click to configure')}
</span>
{(p.detail || p.api_key_masked) && (
<span className="block truncate text-xs text-muted-foreground">
<span className="block break-all text-xs text-muted-foreground">
{p.api_key_masked || p.detail}
</span>
)}
@ -639,7 +691,7 @@ export default function StatusPage() {
<Dialog open={Boolean(selectedProvider)} onOpenChange={(open) => !open && setSelectedProvider(null)}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogHeader className="pr-10">
<DialogTitle>
{pickAppText(locale, '配置提供商', 'Configure provider')}
{selectedProvider ? ` · ${providerLabel(selectedProvider)}` : ''}
@ -649,8 +701,8 @@ export default function StatusPage() {
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-2">
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
<div>
<div className="flex items-center justify-between gap-4 rounded-lg border px-3 py-2">
<div className="min-w-0">
<Label className="text-sm">{pickAppText(locale, '启用提供商', 'Enable provider')}</Label>
<p className="text-xs text-muted-foreground">
{pickAppText(locale, '关闭会从配置中移除这个提供商', 'Turning this off removes this provider from config')}
@ -709,7 +761,7 @@ export default function StatusPage() {
</div>
{providerError ? (
<p className="text-sm text-destructive">{providerError}</p>
<p className="break-words text-sm text-destructive">{providerError}</p>
) : null}
</div>
<DialogFooter>
@ -725,19 +777,22 @@ export default function StatusPage() {
</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>
<DialogContent className="sm:max-w-[820px]">
<DialogHeader className="pr-10">
<DialogTitle className="break-words leading-tight">{selectedChannel?.display_name || selectedChannel?.channel_id}</DialogTitle>
<DialogDescription className="break-all">
{selectedChannel ? `${selectedChannel.kind}/${selectedChannel.mode} · ${selectedChannel.channel_id}` : ''}
</DialogDescription>
</DialogHeader>
{selectedChannel ? (
<div className="space-y-5">
{selectedChannel.kind === 'terminal' ? (
<TerminalConnectionGuide channel={selectedChannel} locale={locale} />
) : null}
{CONFIGURABLE_CHANNEL_KINDS.has(channelForm.kind) ? (
<div className="space-y-5 rounded-lg border p-4">
<div className="min-w-0 space-y-5 rounded-lg border p-4">
<div className="flex items-center justify-between gap-4">
<div>
<div className="min-w-0">
<p className="text-sm font-medium">{pickAppText(locale, '连接配置', 'Connection settings')}</p>
<p className="text-xs text-muted-foreground">
{pickAppText(locale, '凭据留空会保留已保存的值。保存后重启实例才会重新连接通道。', 'Leave credentials blank to keep saved values. Restart the instance after saving to reconnect channels.')}
@ -751,15 +806,17 @@ export default function StatusPage() {
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label={pickAppText(locale, '显示名', 'Display name')}>
<Field id="channel-display-name" label={pickAppText(locale, '显示名', 'Display name')}>
<Input
id="channel-display-name"
value={channelForm.displayName}
onChange={(event) => setChannelForm((prev) => ({ ...prev, displayName: event.target.value }))}
placeholder={selectedChannel.display_name || selectedChannel.channel_id}
/>
</Field>
<Field label="Account ID">
<Field id="channel-account-id" label="Account ID">
<Input
id="channel-account-id"
value={channelForm.accountId}
onChange={(event) => setChannelForm((prev) => ({ ...prev, accountId: event.target.value }))}
placeholder="bot-main"
@ -810,11 +867,11 @@ export default function StatusPage() {
/>
<ChannelPolicyFields form={channelForm} locale={locale} setForm={setChannelForm} />
{channelError ? <p className="text-sm text-destructive">{channelError}</p> : null}
{channelError ? <p className="break-words text-sm text-destructive">{channelError}</p> : null}
{channelRestartRequired ? (
<div className="flex flex-col gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm sm:flex-row sm:items-center sm:justify-between">
<span>{pickAppText(locale, '配置已保存。重启实例后通道会按新配置启动。', 'Configuration saved. Restart the instance to start channels with the new settings.')}</span>
<Button variant="outline" size="sm" onClick={() => setRestartOpen(true)}>
<span className="min-w-0 break-words">{pickAppText(locale, '配置已保存。重启实例后通道会按新配置启动。', 'Configuration saved. Restart the instance to start channels with the new settings.')}</span>
<Button variant="outline" size="sm" className="shrink-0" onClick={() => setRestartOpen(true)}>
<RefreshCw className="mr-2 h-4 w-4" />
{pickAppText(locale, '重启实例', 'Restart instance')}
</Button>
@ -844,11 +901,11 @@ export default function StatusPage() {
<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 className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-2">
<span className="break-all font-medium">{event.kind}</span>
<span className="break-all text-muted-foreground">{event.created_at}</span>
</div>
<div className="mt-1 text-muted-foreground">
<div className="mt-1 break-words text-muted-foreground">
{event.status}{event.error ? ` · ${event.error}` : ''}
</div>
</div>
@ -868,7 +925,7 @@ export default function StatusPage() {
<Dialog open={restartOpen} onOpenChange={setRestartOpen}>
<DialogContent className="sm:max-w-[420px]">
<DialogHeader>
<DialogHeader className="pr-10">
<DialogTitle>{pickAppText(locale, '重启实例?', 'Restart instance?')}</DialogTitle>
<DialogDescription>
{pickAppText(
@ -878,7 +935,7 @@ export default function StatusPage() {
)}
</DialogDescription>
</DialogHeader>
{restartError ? <p className="text-sm text-destructive">{restartError}</p> : null}
{restartError ? <p className="break-words text-sm text-destructive">{restartError}</p> : null}
<DialogFooter>
<Button variant="outline" onClick={() => setRestartOpen(false)} disabled={restarting}>
{pickAppText(locale, '取消', 'Cancel')}
@ -898,8 +955,8 @@ export default function StatusPage() {
setConnectorError(null);
}
}}>
<DialogContent className="max-h-[92vh] overflow-y-auto sm:max-w-[560px]">
<DialogHeader>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader className="pr-10">
<DialogTitle>
{connectorForm.kind ? connectorDisplayName({ kind: connectorForm.kind }) : pickAppText(locale, '连接通道', 'Connect channel')}
</DialogTitle>
@ -913,8 +970,9 @@ export default function StatusPage() {
</DialogHeader>
<div className="space-y-5">
<Field label={pickAppText(locale, '显示名', 'Display name')}>
<Field id="connector-display-name" label={pickAppText(locale, '显示名', 'Display name')}>
<Input
id="connector-display-name"
value={connectorForm.displayName}
onChange={(event) => setConnectorForm((prev) => ({ ...prev, displayName: event.target.value }))}
disabled={Boolean(connectorSession) || startingConnector}
@ -961,6 +1019,75 @@ export default function StatusPage() {
))}
</div>
) : null}
<div className="space-y-4 rounded-lg border p-3">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p className="text-sm font-medium">{pickAppText(locale, '群聊必须 @ Beaver', 'Require @ in groups')}</p>
<p className="text-xs text-muted-foreground">
{pickAppText(locale, '默认开启,避免群聊所有消息触发智能体。', 'Enabled by default to avoid processing every group message.')}
</p>
</div>
<Switch
checked={connectorForm.requireMentionInGroups}
onCheckedChange={(checked) => setConnectorForm((prev) => ({ ...prev, requireMentionInGroups: checked }))}
disabled={Boolean(connectorSession) || startingConnector}
/>
</div>
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p className="text-sm font-medium">{pickAppText(locale, '响应 @所有人', 'Respond to @all')}</p>
<p className="text-xs text-muted-foreground">
{pickAppText(locale, '默认关闭,避免群公告式消息触发。', 'Disabled by default to avoid broadcast-style triggers.')}
</p>
</div>
<Switch
checked={connectorForm.respondToMentionAll}
onCheckedChange={(checked) => setConnectorForm((prev) => ({ ...prev, respondToMentionAll: checked }))}
disabled={Boolean(connectorSession) || startingConnector}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label={pickAppText(locale, '私聊策略', 'DM policy')}>
<Select
value={connectorForm.dmMode}
onValueChange={(value) => setConnectorForm((prev) => ({ ...prev, dmMode: value as ConnectorWizardForm['dmMode'] }))}
disabled={Boolean(connectorSession) || startingConnector}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="open">{pickAppText(locale, '开放', 'Open')}</SelectItem>
<SelectItem value="allowlist">{pickAppText(locale, '白名单', 'Allowlist')}</SelectItem>
<SelectItem value="pair">{pickAppText(locale, '已配对', 'Paired')}</SelectItem>
<SelectItem value="disabled">{pickAppText(locale, '关闭', 'Disabled')}</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label={pickAppText(locale, '最大入站长度', 'Max inbound chars')}>
<Input
value={connectorForm.maxMessageChars}
onChange={(event) => setConnectorForm((prev) => ({ ...prev, maxMessageChars: event.target.value }))}
disabled={Boolean(connectorSession) || startingConnector}
placeholder="20000"
/>
</Field>
</div>
<Field label={pickAppText(locale, '允许私聊用户 Open ID', 'Allowed DM user Open IDs')}>
<Textarea
value={connectorForm.allowFrom}
onChange={(event) => setConnectorForm((prev) => ({ ...prev, allowFrom: event.target.value }))}
disabled={Boolean(connectorSession) || startingConnector}
placeholder="ou_xxx, ou_yyy"
/>
</Field>
<Field label={pickAppText(locale, '允许群 Chat ID', 'Allowed group Chat IDs')}>
<Textarea
value={connectorForm.groupAllowFrom}
onChange={(event) => setConnectorForm((prev) => ({ ...prev, groupAllowFrom: event.target.value }))}
disabled={Boolean(connectorSession) || startingConnector}
placeholder="oc_xxx, oc_yyy"
/>
</Field>
</div>
{connectorForm.mode === 'link' ? (
<div className="grid gap-4 md:grid-cols-2">
<Field label="App ID">
@ -997,8 +1124,8 @@ export default function StatusPage() {
<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">
<p className="break-all text-sm font-medium">{connectorSession.session.sessionId}</p>
<p className="break-all text-xs text-muted-foreground">
{connectorSession.connection?.channel_id || connectorSession.connection?.connection_id || '-'}
</p>
</div>
@ -1020,7 +1147,7 @@ export default function StatusPage() {
<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}
<span className="break-words">{item}</span>
</div>
))}
</div>
@ -1032,12 +1159,12 @@ export default function StatusPage() {
</div>
) : null}
{connectorSession.session.error ? (
<p className="text-sm text-destructive">{connectorSession.session.error}</p>
<p className="break-words text-sm text-destructive">{connectorSession.session.error}</p>
) : null}
</div>
) : null}
{connectorError ? <p className="text-sm text-destructive">{connectorError}</p> : null}
{connectorError ? <p className="break-words text-sm text-destructive">{connectorError}</p> : null}
</div>
<DialogFooter>
@ -1073,31 +1200,60 @@ export default function StatusPage() {
</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);
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{connectorCards.map((connector) => {
const channel = connectorChannelForKind(connector.kind, status.channels);
const connection = connectorConnectionForKind(connector.kind, channelConnections);
const isRunning = channel?.state === 'running' || connection?.status === 'connected';
const isLocalConnector = LOCAL_CONNECTOR_KINDS.has(connector.kind);
const canStart = SESSION_CONNECTOR_KINDS.has(connector.kind) && !channel && !isRunning;
return (
<button
key={connector.kind}
type="button"
onClick={() => supportsSession && openConnectorDialog(connector)}
disabled={!supportsSession}
onClick={() => {
if (isLocalConnector) {
openLocalConnectorDetails(connector.kind, channel);
} else if (channel) {
void openChannelDetails(channel);
} else if (canStart) {
openConnectorDialog(connector);
}
}}
disabled={!channel && !canStart && !isLocalConnector}
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',
'group flex min-h-[76px] w-full items-start justify-between gap-3 rounded-lg border p-3 text-left transition',
isRunning
? 'border-primary bg-primary/5 shadow-sm'
: canStart
? 'border-border bg-background hover:border-primary/50 hover:bg-muted/40'
: 'border-border bg-background opacity-70',
].join(' ')}
>
<span className="min-w-0 space-y-1">
<span className="flex items-center gap-2 font-medium">
{supportsSession ? <QrCode className="h-4 w-4" /> : <PlugZap className="h-4 w-4" />}
<span className="truncate">{connectorDisplayName(connector)}</span>
<span className="flex items-center gap-2 text-sm font-medium">
{isRunning ? (
<CheckCircle2 className="h-4 w-4 shrink-0 text-green-500" />
) : canStart || isLocalConnector ? (
<QrCode className="h-4 w-4 shrink-0" />
) : (
<PlugZap className="h-4 w-4 shrink-0 text-muted-foreground/50" />
)}
<span className={isRunning ? 'break-all' : 'break-all text-muted-foreground'}>
{connectorDisplayName(connector)}
</span>
</span>
<span className="block truncate text-xs text-muted-foreground">
{connectorAuthLabel(connector, locale)}
<span className="block break-words text-xs text-muted-foreground">
{connectorCardSubtitle(connector, channel, connection, locale)}
</span>
{channel || connection || isLocalConnector ? (
<span className="block break-all text-xs text-muted-foreground">
{connectorCardChannelLabel(channel, connection, locale)}
</span>
) : null}
</span>
<Badge variant={supportsSession ? 'outline' : 'secondary'}>
{supportsSession ? pickAppText(locale, '连接', 'Connect') : pickAppText(locale, '令牌', 'Token')}
<Badge variant={connectorCardBadgeVariant(channel, connection)} className="shrink-0">
{connectorCardBadgeLabel(connector, channel, connection, locale)}
</Badge>
</button>
);
@ -1109,33 +1265,6 @@ export default function StatusPage() {
{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>
@ -1154,10 +1283,10 @@ function InfoRow({
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">
<div className="grid min-w-0 gap-1 text-sm sm:grid-cols-[minmax(0,1fr)_minmax(0,auto)] sm:items-start">
<span className="min-w-0 break-words text-muted-foreground">{label}</span>
<div className="flex min-w-0 items-center gap-2 sm:justify-end">
<code className="min-w-0 max-w-full whitespace-normal break-all rounded bg-muted px-2 py-0.5 text-xs sm:max-w-[400px]">
{value}
</code>
{ok !== undefined &&
@ -1171,14 +1300,180 @@ function InfoRow({
);
}
function TerminalConnectionGuide({ channel, locale }: { channel: ChannelStatus; locale: AppLocale }) {
const connected = channel.state === 'running';
const instructions = terminalConnectionGuide(locale);
return (
<div className="space-y-4 rounded-lg border p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{pickAppText(locale, '小终端连接方式', 'Terminal connection method')}
</p>
<p className="text-xs text-muted-foreground">
{pickAppText(
locale,
'小终端通过本地 WebSocket 通道接入当前实例;这里展示的是连接状态和接入说明。',
'The terminal connects to this instance through the local WebSocket channel; this panel shows status and connection guidance.'
)}
</p>
</div>
<Badge variant={connected ? 'default' : 'secondary'}>{channel.state}</Badge>
</div>
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
<div className="rounded-md border bg-muted/30 p-3">
<div className="mx-auto grid h-44 w-44 grid-cols-7 grid-rows-7 gap-1 rounded bg-background p-3">
{TERMINAL_FAKE_QR_CELLS.map((filled, index) => (
<span
key={index}
className={filled ? 'rounded-[2px] bg-foreground' : 'rounded-[2px] bg-transparent'}
/>
))}
</div>
<p className="mt-3 text-center text-xs font-medium">
{pickAppText(locale, '示意二维码Fake QR', 'Illustrative QR (Fake QR)')}
</p>
<p className="mt-1 text-center text-xs text-muted-foreground">
{pickAppText(locale, '不可扫码,仅用于标识终端连接入口。', 'Not scannable; it only marks the terminal connection entry.')}
</p>
</div>
<div className="space-y-2 text-sm">
{instructions.map((item) => (
<div key={item} className="rounded-md border bg-muted/20 px-3 py-2">
{item}
</div>
))}
</div>
</div>
</div>
);
}
const TERMINAL_FAKE_QR_CELLS = [
true, true, true, false, true, true, true,
true, false, true, false, true, false, true,
true, true, true, false, true, true, true,
false, false, false, true, false, true, false,
true, false, true, false, true, false, true,
false, true, false, true, false, true, false,
true, false, true, true, false, false, true,
];
function terminalConnectionGuide(locale: AppLocale): string[] {
return [
pickAppText(locale, '保持本实例页面在线,终端客户端会通过 WebSocket 连接 Beaver。', 'Keep this instance online; the terminal client connects to Beaver over WebSocket.'),
pickAppText(locale, '连接成功后,通道状态会显示 running并显示当前 connected peers 数量。', 'After connection succeeds, the channel status shows running and the connected peers count is updated.'),
pickAppText(locale, '这里的二维码是 fake 的说明图,不代表真实扫码绑定流程。', 'The QR shown here is fake guidance artwork, not a real scan-to-bind flow.'),
];
}
function terminalFallbackChannel(locale: AppLocale): ChannelStatus {
return {
channel_id: 'terminal',
kind: 'terminal',
mode: 'websocket',
display_name: pickAppText(locale, '小终端', 'Terminal'),
enabled: false,
state: 'disabled',
account_id: 'local',
capabilities: ['receive_text', 'send_text'],
connected_peers: 0,
};
}
function providerLabel(provider: ProviderStatus): string {
return provider.label || provider.name;
}
function providerIdentity(provider: ProviderStatus): string {
const identity = (provider.id || provider.name || provider.label || '').trim().toLowerCase();
if (identity === 'vllm/local') return 'vllm';
return identity;
}
function visibleProvider(provider: ProviderStatus): boolean {
return VISIBLE_PROVIDER_IDS.has(providerIdentity(provider));
}
function connectorConnectionForKind(
kind: string,
connections: ChannelConnectionView[]
): ChannelConnectionView | undefined {
const matches = connections.filter((connection) => connection.kind === kind && connection.status !== 'revoked');
return (
matches.find((connection) => connection.status === 'connected') ||
matches.find((connection) => connection.status !== 'error') ||
matches[0]
);
}
function connectorCardSubtitle(
connector: ChannelConnectorDescriptor,
channel: ChannelStatus | undefined,
connection: ChannelConnectionView | undefined,
locale: AppLocale
): string {
if (channel?.state === 'running' || connection?.status === 'connected') {
return pickAppText(locale, '已连接', 'Connected');
}
if (channel) return channel.state;
if (connection) return connection.status;
if (connector.kind === 'terminal') return pickAppText(locale, '本地终端连接', 'Local terminal connection');
return connectorAuthLabel(connector, locale);
}
function connectorCardChannelLabel(
channel: ChannelStatus | undefined,
connection: ChannelConnectionView | undefined,
locale: AppLocale
): string {
const rawChannelId = connection?.channel_id || channel?.channel_id || '';
const channelId = compactMainSuffix(rawChannelId);
const accountId = compactMainSuffix(connection?.account_id || channel?.account_id || '');
const mode = connection?.mode || channel?.mode || '';
const parts = [channelId, mode, accountId].filter(Boolean);
if (!channel && !connection) return pickAppText(locale, '连接方式说明', 'Connection instructions');
if (parts.length === 0) return pickAppText(locale, '通道已连接', 'Channel connected');
return `${pickAppText(locale, '通道', 'Channel')}: ${parts.join(' · ')}`;
}
function compactMainSuffix(value: string): string {
return value.replace(/[-_]?main$/i, '').trim();
}
function connectorCardBadgeVariant(
channel: ChannelStatus | undefined,
connection: ChannelConnectionView | undefined
): 'default' | 'secondary' | 'destructive' | 'outline' {
if (channel?.state === 'running' || connection?.status === 'connected') return 'default';
if (channel?.state === 'error' || channel?.state === 'degraded' || connection?.status === 'error') {
return 'destructive';
}
if (channel?.state === 'disabled' || channel?.state === 'stopped' || connection?.status === 'revoked') {
return 'secondary';
}
return 'outline';
}
function connectorCardBadgeLabel(
connector: ChannelConnectorDescriptor,
channel: ChannelStatus | undefined,
connection: ChannelConnectionView | undefined,
locale: AppLocale
): string {
if (channel?.state === 'running' || connection?.status === 'connected') return 'running';
if (channel) return channel.state;
if (connection) return connection.status;
if (connector.kind === 'terminal') return pickAppText(locale, '说明', 'Guide');
return pickAppText(locale, '连接', 'Connect');
}
function connectorDisplayName(connector: Pick<ChannelConnectorDescriptor, 'kind' | 'displayName' | 'display_name'>): string {
if (connector.displayName || connector.display_name) return connector.displayName || connector.display_name || connector.kind;
if (connector.kind === 'weixin') return 'Weixin';
if (connector.kind === 'feishu') return 'Feishu/Lark';
if (connector.kind === 'terminal') return 'Terminal';
if (connector.kind === 'telegram') return 'Telegram';
return connector.kind;
}
@ -1187,6 +1482,7 @@ function connectorAuthLabel(connector: ChannelConnectorDescriptor, locale: AppLo
const authType = connector.authType || connector.auth_type;
if (connector.kind === 'weixin') return authType || pickAppText(locale, 'QR', 'QR');
if (connector.kind === 'feishu') return authType || pickAppText(locale, '插件', 'Plugin');
if (connector.kind === 'terminal') return pickAppText(locale, 'Fake QR', 'Fake QR');
if (connector.kind === 'telegram') return authType || pickAppText(locale, 'Token', 'Token');
return authType || connector.kind;
}
@ -1217,19 +1513,10 @@ function connectorSessionBadgeVariant(status: string): 'default' | '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 }) {
function Field({ id, label, children }: { id?: string; label: string; children: React.ReactNode }) {
return (
<div className="grid gap-2">
<Label>{label}</Label>
<div className="grid min-w-0 gap-2">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);