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:
@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user