Add unit tests for channel utilities and configure testing environment
- Created a new test file `channels.test.ts` to cover utilities related to channel configurations and targets. - Implemented tests for normalizing and grouping selected channels by type, as well as building channel targets from account data and cron history. - Mocked necessary dependencies to isolate tests and ensure accurate results. - Updated `vite.config.ts` to set up the testing environment with jsdom and enable global variables for tests.
This commit is contained in:
34
src/components/channels/ChannelAccountIdField.tsx
Normal file
34
src/components/channels/ChannelAccountIdField.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
type ChannelAccountIdFieldProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
helpText?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export default function ChannelAccountIdField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
helpText,
|
||||
disabled,
|
||||
}: ChannelAccountIdFieldProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[13px] font-medium text-[#525866] dark:text-gray-300">{label}</div>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
className="h-[44px] w-full rounded-[12px] border border-[#E5E8EE] bg-white px-3 text-[13px] text-[#171717] outline-none transition-colors placeholder:text-[#99A0AE] focus:border-[#2B7FFF] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-100"
|
||||
/>
|
||||
{helpText ? (
|
||||
<div className="text-[12px] leading-[18px] text-[#99A0AE] dark:text-gray-500">{helpText}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
src/components/channels/ChannelConfigActions.tsx
Normal file
39
src/components/channels/ChannelConfigActions.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
type ChannelConfigActionsProps = {
|
||||
cancelLabel: string;
|
||||
confirmLabel: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
disabled?: boolean;
|
||||
submitting?: boolean;
|
||||
};
|
||||
|
||||
export default function ChannelConfigActions({
|
||||
cancelLabel,
|
||||
confirmLabel,
|
||||
onClose,
|
||||
onConfirm,
|
||||
disabled,
|
||||
submitting,
|
||||
}: ChannelConfigActionsProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
className="h-[40px] rounded-full bg-[#EDECE4] px-5 text-[13px] font-semibold text-[#4B4B4B] transition-colors hover:bg-[#E5E4DC] disabled:cursor-not-allowed disabled:opacity-60 dark:bg-[#222225] dark:text-gray-200"
|
||||
onClick={onClose}
|
||||
disabled={disabled || submitting}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="h-[40px] rounded-full bg-[#2B7FFF] px-5 text-[13px] font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={onConfirm}
|
||||
disabled={disabled || submitting}
|
||||
>
|
||||
{submitting ? `${confirmLabel}...` : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
src/components/channels/ChannelConfigFields.tsx
Normal file
114
src/components/channels/ChannelConfigFields.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ChannelConfigFieldMeta, ChannelConfigFieldValueMap } from '../../lib/channel-types';
|
||||
import ChannelAccountIdField from './ChannelAccountIdField';
|
||||
import ChannelTokenField from './ChannelTokenField';
|
||||
|
||||
type ChannelConfigFieldsProps = {
|
||||
fields: ChannelConfigFieldMeta[];
|
||||
values: ChannelConfigFieldValueMap;
|
||||
onValueChange: (key: string, value: string) => void;
|
||||
disabled?: boolean;
|
||||
accountIdLabel: string;
|
||||
accountIdHelpText?: string;
|
||||
tokenHelpText?: string;
|
||||
};
|
||||
|
||||
function renderField({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
accountIdLabel,
|
||||
accountIdHelpText,
|
||||
tokenHelpText,
|
||||
}: {
|
||||
field: ChannelConfigFieldMeta;
|
||||
value: string;
|
||||
onChange: (nextValue: string) => void;
|
||||
disabled?: boolean;
|
||||
accountIdLabel: string;
|
||||
accountIdHelpText?: string;
|
||||
tokenHelpText?: string;
|
||||
}): ReactNode {
|
||||
const commonProps = {
|
||||
label: field.label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder: field.placeholder,
|
||||
helpText: field.description,
|
||||
disabled,
|
||||
};
|
||||
|
||||
if (field.key === 'accountId') {
|
||||
return (
|
||||
<ChannelAccountIdField
|
||||
{...commonProps}
|
||||
label={field.label || accountIdLabel}
|
||||
helpText={field.description ?? accountIdHelpText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.kind === 'token' || field.kind === 'password') {
|
||||
return <ChannelTokenField {...commonProps} helpText={field.description ?? tokenHelpText} />;
|
||||
}
|
||||
|
||||
if (field.kind === 'textarea') {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[13px] font-medium text-[#525866] dark:text-gray-300">{field.label}</div>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
rows={field.rows ?? 4}
|
||||
className="w-full rounded-[12px] border border-[#E5E8EE] bg-white px-3 py-2 text-[13px] text-[#171717] outline-none transition-colors placeholder:text-[#99A0AE] focus:border-[#2B7FFF] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-100"
|
||||
/>
|
||||
{field.description ? (
|
||||
<div className="text-[12px] leading-[18px] text-[#99A0AE] dark:text-gray-500">{field.description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChannelTokenField
|
||||
{...commonProps}
|
||||
label={field.label}
|
||||
helpText={field.description ?? tokenHelpText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChannelConfigFields({
|
||||
fields,
|
||||
values,
|
||||
onValueChange,
|
||||
disabled,
|
||||
accountIdLabel,
|
||||
accountIdHelpText,
|
||||
tokenHelpText,
|
||||
}: ChannelConfigFieldsProps) {
|
||||
if (fields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{fields.map((field) => (
|
||||
<div key={field.key}>
|
||||
{renderField({
|
||||
field,
|
||||
value: values[field.key] ?? '',
|
||||
onChange: (nextValue) => onValueChange(field.key, nextValue),
|
||||
disabled,
|
||||
accountIdLabel,
|
||||
accountIdHelpText,
|
||||
tokenHelpText,
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
src/components/channels/ChannelConfigModal.tsx
Normal file
157
src/components/channels/ChannelConfigModal.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useI18n } from '../../i18n';
|
||||
import type { ChannelConfigFieldValueMap } from '../../lib/channel-types';
|
||||
import { getChannelMeta, getChannelOptions, type ChannelMeta } from '../../lib/channel-meta';
|
||||
import DialogSurface from '../../pages/Home/components/DialogSurface';
|
||||
import ChannelAccountIdField from './ChannelAccountIdField';
|
||||
import ChannelConfigActions from './ChannelConfigActions';
|
||||
import ChannelConfigFields from './ChannelConfigFields';
|
||||
import ChannelInstructionsPanel from './ChannelInstructionsPanel';
|
||||
import ChannelTypeSelector from './ChannelTypeSelector';
|
||||
|
||||
export type ChannelConfigModalProps = {
|
||||
open: boolean;
|
||||
selectedChannelType: string;
|
||||
channelTypes?: ChannelMeta[];
|
||||
values: ChannelConfigFieldValueMap;
|
||||
accountId: string;
|
||||
disableChannelType?: boolean;
|
||||
disableAccountId?: boolean;
|
||||
onChannelTypeChange: (value: string) => void;
|
||||
onValueChange: (key: string, value: string) => void;
|
||||
onAccountIdChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
error?: string | null;
|
||||
submitting?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
typeLabel?: string;
|
||||
accountIdLabel?: string;
|
||||
accountIdHelpText?: string;
|
||||
tokenHelpText?: string;
|
||||
instructionsTitle?: string;
|
||||
docsLabel?: string;
|
||||
diagnosticsNote?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
widthClassName?: string;
|
||||
};
|
||||
|
||||
export default function ChannelConfigModal({
|
||||
open,
|
||||
selectedChannelType,
|
||||
channelTypes,
|
||||
values,
|
||||
accountId,
|
||||
disableChannelType,
|
||||
disableAccountId,
|
||||
onChannelTypeChange,
|
||||
onValueChange,
|
||||
onAccountIdChange,
|
||||
onClose,
|
||||
onConfirm,
|
||||
error,
|
||||
submitting,
|
||||
title,
|
||||
description,
|
||||
typeLabel,
|
||||
accountIdLabel,
|
||||
accountIdHelpText,
|
||||
tokenHelpText,
|
||||
instructionsTitle,
|
||||
docsLabel,
|
||||
diagnosticsNote,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
widthClassName,
|
||||
}: ChannelConfigModalProps) {
|
||||
const { t } = useI18n();
|
||||
const options = channelTypes ?? getChannelOptions();
|
||||
const activeMeta = options.find((item) => item.type === selectedChannelType) ?? getChannelMeta(selectedChannelType);
|
||||
const accountFieldMeta = activeMeta.configFields.find((field) => field.key === 'accountId');
|
||||
const accountLabel = accountIdLabel ?? accountFieldMeta?.label ?? t('channels.modal.accountIdLabel');
|
||||
const accountHelp = accountIdHelpText ?? accountFieldMeta?.description;
|
||||
const typeSelectLabel = typeLabel ?? t('channels.modal.typeLabel');
|
||||
const docsText = docsLabel ?? t('channels.modal.docsLabel');
|
||||
const instructionsHeading = instructionsTitle ?? t('channels.modal.instructionsTitle');
|
||||
const confirmText = confirmLabel ?? t('channels.modal.confirm');
|
||||
const cancelText = cancelLabel ?? t('dialog.cancel');
|
||||
const notesText = diagnosticsNote ?? t('channels.modal.diagnosticsNote');
|
||||
const descriptionText = description ?? t('channels.modal.description');
|
||||
const supportedFields = activeMeta.configFields.filter((field) => field.key !== 'accountId');
|
||||
|
||||
return (
|
||||
<DialogSurface
|
||||
open={open}
|
||||
title={title ?? t('channels.modal.title')}
|
||||
widthClassName={widthClassName ?? 'max-w-[920px]'}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="text-[13px] leading-[20px] text-[#525866] dark:text-gray-300">
|
||||
{descriptionText}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-[12px] border border-[#f2c7cd] bg-[#fff5f6] px-4 py-3 text-[13px] text-[#c24150] dark:border-[#4b2229] dark:bg-[#2b1c1f] dark:text-[#ffb4bf]">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div className="space-y-4">
|
||||
<ChannelTypeSelector
|
||||
label={typeSelectLabel}
|
||||
value={activeMeta.type}
|
||||
options={options}
|
||||
onChange={onChannelTypeChange}
|
||||
disabled={disableChannelType || submitting}
|
||||
helpText={activeMeta.connectionType}
|
||||
/>
|
||||
|
||||
<ChannelAccountIdField
|
||||
label={accountLabel}
|
||||
value={accountId}
|
||||
onChange={onAccountIdChange}
|
||||
placeholder={accountFieldMeta?.placeholder}
|
||||
helpText={accountHelp}
|
||||
disabled={disableAccountId || submitting}
|
||||
/>
|
||||
|
||||
<ChannelConfigFields
|
||||
fields={supportedFields}
|
||||
values={values}
|
||||
onValueChange={onValueChange}
|
||||
disabled={submitting}
|
||||
accountIdLabel={accountLabel}
|
||||
accountIdHelpText={accountHelp}
|
||||
tokenHelpText={tokenHelpText ?? t('channels.modal.tokenHelpText')}
|
||||
/>
|
||||
|
||||
<div className="rounded-[16px] border border-dashed border-[#E5E8EE] bg-white px-4 py-3 text-[12px] leading-[18px] text-[#525866] dark:border-[#2a2a2d] dark:bg-[#17171a] dark:text-gray-300">
|
||||
{t('channels.modal.todoNote')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChannelInstructionsPanel
|
||||
meta={activeMeta}
|
||||
title={instructionsHeading}
|
||||
docsLabel={docsText}
|
||||
diagnosticsNote={notesText}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChannelConfigActions
|
||||
cancelLabel={cancelText}
|
||||
confirmLabel={confirmText}
|
||||
onClose={onClose}
|
||||
onConfirm={() => {
|
||||
void onConfirm();
|
||||
}}
|
||||
disabled={false}
|
||||
submitting={submitting}
|
||||
/>
|
||||
</div>
|
||||
</DialogSurface>
|
||||
);
|
||||
}
|
||||
47
src/components/channels/ChannelInstructionsPanel.tsx
Normal file
47
src/components/channels/ChannelInstructionsPanel.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { ChannelMeta } from '../../lib/channel-meta';
|
||||
|
||||
type ChannelInstructionsPanelProps = {
|
||||
meta: ChannelMeta;
|
||||
title: string;
|
||||
docsLabel: string;
|
||||
diagnosticsNote: string;
|
||||
};
|
||||
|
||||
export default function ChannelInstructionsPanel({
|
||||
meta,
|
||||
title,
|
||||
docsLabel,
|
||||
diagnosticsNote,
|
||||
}: ChannelInstructionsPanelProps) {
|
||||
return (
|
||||
<section className="rounded-[16px] border border-[#E5E8EE] bg-[#FAFBFC] p-4 dark:border-[#2a2a2d] dark:bg-[#17171a]">
|
||||
<div className="text-[13px] font-medium text-[#171717] dark:text-gray-100">{title}</div>
|
||||
<div className="mt-1 text-[12px] leading-[18px] text-[#99A0AE] dark:text-gray-500">
|
||||
{docsLabel}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="rounded-[12px] border border-dashed border-[#DCE5F1] bg-white px-3 py-2 text-[12px] leading-[18px] text-[#525866] dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-300">
|
||||
{meta.description}
|
||||
</div>
|
||||
|
||||
{meta.instructions.length > 0 ? (
|
||||
<ol className="space-y-2">
|
||||
{meta.instructions.map((instruction) => (
|
||||
<li
|
||||
key={instruction}
|
||||
className="rounded-[12px] border border-[#E5E8EE] bg-white px-3 py-2 text-[12px] leading-[18px] text-[#525866] dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-300"
|
||||
>
|
||||
{instruction}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-[12px] bg-[#EFF6FF] px-3 py-2 text-[12px] leading-[18px] text-[#2B4E8C] dark:bg-[#1d2633] dark:text-[#93c5fd]">
|
||||
{diagnosticsNote}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
34
src/components/channels/ChannelTokenField.tsx
Normal file
34
src/components/channels/ChannelTokenField.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
type ChannelTokenFieldProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
helpText?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export default function ChannelTokenField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
helpText,
|
||||
disabled,
|
||||
}: ChannelTokenFieldProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[13px] font-medium text-[#525866] dark:text-gray-300">{label}</div>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
className="h-[44px] w-full rounded-[12px] border border-[#E5E8EE] bg-white px-3 text-[13px] text-[#171717] outline-none transition-colors placeholder:text-[#99A0AE] focus:border-[#2B7FFF] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-100"
|
||||
/>
|
||||
{helpText ? (
|
||||
<div className="text-[12px] leading-[18px] text-[#99A0AE] dark:text-gray-500">{helpText}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/components/channels/ChannelTypeSelector.tsx
Normal file
46
src/components/channels/ChannelTypeSelector.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { ChannelMeta } from '../../lib/channel-meta';
|
||||
|
||||
type ChannelTypeSelectorProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
options: ChannelMeta[];
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
helpText?: string;
|
||||
};
|
||||
|
||||
export default function ChannelTypeSelector({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
disabled,
|
||||
helpText,
|
||||
}: ChannelTypeSelectorProps) {
|
||||
const selected = options.find((item) => item.type === value);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[13px] font-medium text-[#525866] dark:text-gray-300">{label}</div>
|
||||
<select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="h-[44px] w-full rounded-[12px] border border-[#E5E8EE] bg-white px-3 text-[13px] text-[#171717] outline-none transition-colors focus:border-[#2B7FFF] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-100"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.type} value={option.type}>
|
||||
{option.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="min-h-[36px] rounded-[12px] border border-dashed border-[#E5E8EE] bg-white px-3 py-2 text-[12px] leading-[18px] text-[#525866] dark:border-[#2a2a2d] dark:bg-[#17171a] dark:text-gray-300">
|
||||
<div className="font-medium text-[#171717] dark:text-gray-100">
|
||||
{selected?.name ?? value}
|
||||
</div>
|
||||
<div className="mt-1">{selected?.description ?? helpText}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
src/components/channels/index.ts
Normal file
8
src/components/channels/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { default as ChannelAccountIdField } from './ChannelAccountIdField';
|
||||
export { default as ChannelConfigActions } from './ChannelConfigActions';
|
||||
export { default as ChannelConfigFields } from './ChannelConfigFields';
|
||||
export { default as ChannelConfigModal } from './ChannelConfigModal';
|
||||
export { default as ChannelInstructionsPanel } from './ChannelInstructionsPanel';
|
||||
export { default as ChannelTokenField } from './ChannelTokenField';
|
||||
export { default as ChannelTypeSelector } from './ChannelTypeSelector';
|
||||
export type { ChannelConfigModalProps } from './ChannelConfigModal';
|
||||
@@ -51,6 +51,20 @@ export const messages: I18nMessages = {
|
||||
scripts: 'Scripts',
|
||||
settings: 'Settings',
|
||||
},
|
||||
channels: {
|
||||
modal: {
|
||||
title: 'Channel configuration',
|
||||
description: 'Choose a channel type, then fill in the connection fields that are available for this template.',
|
||||
typeLabel: 'Channel type',
|
||||
accountIdLabel: 'Account ID',
|
||||
docsLabel: 'Docs and instructions',
|
||||
instructionsTitle: 'Setup instructions',
|
||||
tokenHelpText: 'Paste the token, secret, or access credential required by the selected channel.',
|
||||
diagnosticsNote: 'QR code and diagnostics hooks will be wired up later when the backend contract is ready.',
|
||||
todoNote: 'TODO: QR and diagnostics are placeholders for now; the modal stays props-driven until the channel APIs land.',
|
||||
confirm: 'Save channel',
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
title: 'Agents',
|
||||
subtitle: 'Create new Agents to route specific channels into different personas or workspaces.',
|
||||
@@ -254,6 +268,20 @@ export const messages: I18nMessages = {
|
||||
scripts: '脚本',
|
||||
settings: '设置',
|
||||
},
|
||||
channels: {
|
||||
modal: {
|
||||
title: '渠道配置',
|
||||
description: '先选择渠道类型,再填写这个模板可用的连接字段。',
|
||||
typeLabel: '渠道类型',
|
||||
accountIdLabel: 'Account ID',
|
||||
docsLabel: '文档与说明',
|
||||
instructionsTitle: '接入说明',
|
||||
tokenHelpText: '请填写所选渠道所需的 token、密钥或访问凭证。',
|
||||
diagnosticsNote: '二维码和诊断能力会在后端契约完成后再接入。',
|
||||
todoNote: 'TODO:二维码和诊断目前仅保留占位,modal 先保持 props 驱动,等待渠道 API 落地。',
|
||||
confirm: '保存渠道',
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
title: 'Agents',
|
||||
subtitle: '创建新的 Agent,可以将特定频道路由到不同的人格配置或工作区。',
|
||||
@@ -457,6 +485,20 @@ export const messages: I18nMessages = {
|
||||
scripts: 'スクリプト',
|
||||
settings: '設定',
|
||||
},
|
||||
channels: {
|
||||
modal: {
|
||||
title: 'チャンネル設定',
|
||||
description: 'まずチャンネル種類を選び、このテンプレートで使える接続項目を入力します。',
|
||||
typeLabel: 'チャンネル種類',
|
||||
accountIdLabel: 'Account ID',
|
||||
docsLabel: 'ドキュメントと手順',
|
||||
instructionsTitle: '接続手順',
|
||||
tokenHelpText: '選択したチャンネルに必要な token、secret、またはアクセス資格情報を入力してください。',
|
||||
diagnosticsNote: 'QR コードと診断の導線は、バックエンド契約が整ってから接続します。',
|
||||
todoNote: 'TODO: QR と診断は現在プレースホルダです。modal は引き続き props 駆動のままにしておきます。',
|
||||
confirm: 'チャンネルを保存',
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
title: 'Agents',
|
||||
subtitle: '新しい Agent を作成し、特定のチャンネルを別の人格設定やワークスペースへ振り分けます。',
|
||||
|
||||
376
src/lib/channel-meta.ts
Normal file
376
src/lib/channel-meta.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import type {
|
||||
ChannelConfigFieldMeta,
|
||||
ChannelConnectionType,
|
||||
} from './channel-types';
|
||||
|
||||
export interface ChannelMeta {
|
||||
type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
connectionType: ChannelConnectionType;
|
||||
configFields: ChannelConfigFieldMeta[];
|
||||
isPlugin: boolean;
|
||||
instructions: string[];
|
||||
}
|
||||
|
||||
function createField(
|
||||
key: string,
|
||||
label: string,
|
||||
kind: ChannelConfigFieldMeta['kind'],
|
||||
placeholder?: string,
|
||||
description?: string,
|
||||
required = false,
|
||||
): ChannelConfigFieldMeta {
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
kind,
|
||||
placeholder,
|
||||
description,
|
||||
required,
|
||||
autoComplete: 'off',
|
||||
};
|
||||
}
|
||||
|
||||
function createTokenField(
|
||||
key: string,
|
||||
label: string,
|
||||
placeholder?: string,
|
||||
description?: string,
|
||||
required = false,
|
||||
): ChannelConfigFieldMeta {
|
||||
return createField(key, label, 'token', placeholder, description, required);
|
||||
}
|
||||
|
||||
function createTextField(
|
||||
key: string,
|
||||
label: string,
|
||||
placeholder?: string,
|
||||
description?: string,
|
||||
required = false,
|
||||
): ChannelConfigFieldMeta {
|
||||
return createField(key, label, 'text', placeholder, description, required);
|
||||
}
|
||||
|
||||
function createPasswordField(
|
||||
key: string,
|
||||
label: string,
|
||||
placeholder?: string,
|
||||
description?: string,
|
||||
required = false,
|
||||
): ChannelConfigFieldMeta {
|
||||
return createField(key, label, 'password', placeholder, description, required);
|
||||
}
|
||||
|
||||
function createTextareaField(
|
||||
key: string,
|
||||
label: string,
|
||||
placeholder?: string,
|
||||
description?: string,
|
||||
required = false,
|
||||
): ChannelConfigFieldMeta {
|
||||
return {
|
||||
...createField(key, label, 'textarea', placeholder, description, required),
|
||||
rows: 5,
|
||||
};
|
||||
}
|
||||
|
||||
function createUrlField(
|
||||
key: string,
|
||||
label: string,
|
||||
placeholder?: string,
|
||||
description?: string,
|
||||
required = false,
|
||||
): ChannelConfigFieldMeta {
|
||||
return createField(key, label, 'url', placeholder, description, required);
|
||||
}
|
||||
|
||||
export const PRIMARY_CHANNEL_TYPES = [
|
||||
'telegram',
|
||||
'discord',
|
||||
'whatsapp',
|
||||
'wechat',
|
||||
'dingtalk',
|
||||
'feishu',
|
||||
'wecom',
|
||||
'qqbot',
|
||||
] as const;
|
||||
|
||||
export const CHANNEL_META_LIST: ChannelMeta[] = [
|
||||
{
|
||||
type: 'telegram',
|
||||
name: 'Telegram',
|
||||
description: '使用 @BotFather 提供的机器人令牌连接 Telegram。',
|
||||
connectionType: 'token',
|
||||
configFields: [
|
||||
createPasswordField('botToken', 'Bot Token', '123456:telegram-bot-token', '从 @BotFather 获取的机器人令牌。', true),
|
||||
createTextField('allowedUsers', 'Allowed Users', '12345678,98765432', '可选。限制允许与机器人对话的 Telegram 用户 ID。'),
|
||||
],
|
||||
isPlugin: false,
|
||||
instructions: [
|
||||
'在 Telegram 中使用 @BotFather 创建机器人并复制 Bot Token。',
|
||||
'如果需要限制测试范围,可填写允许访问的用户 ID 列表。',
|
||||
'保存后在消息频道页确认默认账号、归属 Agent 和运行态状态。',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'discord',
|
||||
name: 'Discord',
|
||||
description: '使用开发者门户提供的机器人令牌连接 Discord。',
|
||||
connectionType: 'token',
|
||||
configFields: [
|
||||
createPasswordField('token', 'Bot Token', 'discord-bot-token', 'Discord Bot Token。', true),
|
||||
createTextField('guildId', 'Guild ID', '123456789012345678', '推荐填写,便于定位绑定的服务器。', true),
|
||||
createTextField('channelId', 'Channel ID', '123456789012345678', '可选。指定默认投递频道。'),
|
||||
],
|
||||
isPlugin: false,
|
||||
instructions: [
|
||||
'在 Discord Developer Portal 创建应用并启用 Bot。',
|
||||
'复制 Token,并在目标服务器中邀请该机器人。',
|
||||
'如果需要固定默认频道,可补充填写 Guild ID / Channel ID。',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'whatsapp',
|
||||
name: 'WhatsApp',
|
||||
description: '通过扫描二维码连接 WhatsApp(无需手机号)。',
|
||||
connectionType: 'qr',
|
||||
configFields: [],
|
||||
isPlugin: false,
|
||||
instructions: [
|
||||
'保存后等待 runtime 侧二维码能力接入。',
|
||||
'当前 `zn-ai` 已预留配置和状态位,二维码拉起仍是后续波次。',
|
||||
'在完全对齐 ClawX 前,建议先使用默认账号完成路由与绑定链路验证。',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'wechat',
|
||||
name: 'WeChat',
|
||||
description: '通过腾讯官方 OpenClaw 插件扫码连接个人微信。',
|
||||
connectionType: 'qr',
|
||||
configFields: [],
|
||||
isPlugin: true,
|
||||
instructions: [
|
||||
'该频道依赖插件与二维码链路,当前页面已保留入口。',
|
||||
'后续需要补齐扫码事件、成功回调和账号自动发现。',
|
||||
'Agent 绑定、默认账号和删除流程已可先行对齐。',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'dingtalk',
|
||||
name: 'DingTalk',
|
||||
description: '通过 OpenClaw 渠道插件连接钉钉(Stream 模式)。',
|
||||
connectionType: 'token',
|
||||
configFields: [
|
||||
createTextField('clientId', 'Client ID', 'ding-app-key', '钉钉应用的 AppKey。', true),
|
||||
createPasswordField('clientSecret', 'Client Secret', 'ding-app-secret', '钉钉应用的 AppSecret。', true),
|
||||
createTextField('robotCode', 'Robot Code', 'dingxxxx', '可选。机器人编码。'),
|
||||
createTextField('corpId', 'Corp ID', 'dingcorp123', '可选。企业 corpId。'),
|
||||
createTextField('agentId', 'Agent ID', '123456789', '可选。机器人 AgentId。'),
|
||||
],
|
||||
isPlugin: true,
|
||||
instructions: [
|
||||
'在钉钉开发者后台创建机器人并获取 AppKey / AppSecret。',
|
||||
'按需补充 Robot Code、Corp ID 或 Agent ID 以兼容不同部署方式。',
|
||||
'保存后优先验证默认账号、频道归属和 gateway 重载是否收敛。',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'feishu',
|
||||
name: 'Feishu / Lark',
|
||||
description: '通过飞书官方推出的 OpenClaw 插件连接飞书/Lark 机器人。',
|
||||
connectionType: 'token',
|
||||
configFields: [
|
||||
createTextField('appId', 'App ID', 'cli_xxxxxxxxx', '飞书应用的 App ID。', true),
|
||||
createPasswordField('appSecret', 'App Secret', 'app-secret', '飞书应用的 App Secret。', true),
|
||||
],
|
||||
isPlugin: true,
|
||||
instructions: [
|
||||
'在飞书开放平台创建机器人应用并开启事件订阅。',
|
||||
'填入 App ID 与 App Secret,保存后完成默认账号与 Agent 绑定。',
|
||||
'这是 ClawX 中验证最完整的频道之一,建议作为首个联调样板。',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'wecom',
|
||||
name: 'WeCom',
|
||||
description: '通过插件连接企业微信机器人。',
|
||||
connectionType: 'token',
|
||||
configFields: [
|
||||
createTextField('botId', 'Bot ID', 'wecom-bot-id', '企业微信机器人或应用标识。', true),
|
||||
createPasswordField('secret', 'Secret', 'wecom-secret', '企业微信机器人密钥。', true),
|
||||
],
|
||||
isPlugin: true,
|
||||
instructions: [
|
||||
'在企业微信管理后台创建机器人并复制关键信息。',
|
||||
'建议先用默认账号联通消息,再扩展多账号分工。',
|
||||
'保存后使用消息频道页统一管理默认账号和 Agent 归属。',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'qqbot',
|
||||
name: 'QQ Bot',
|
||||
description: '连接 QQ 机器人频道(OpenClaw 3.31 起内置)。',
|
||||
connectionType: 'token',
|
||||
configFields: [
|
||||
createTextField('appId', 'App ID', 'qq-app-id', 'QQ Bot App ID。', true),
|
||||
createPasswordField('clientSecret', 'Client Secret', 'qq-client-secret', 'QQ Bot Client Secret。', true),
|
||||
],
|
||||
isPlugin: false,
|
||||
instructions: [
|
||||
'在 QQ Bot 平台创建机器人并获取 App ID 与密钥。',
|
||||
'如需多账号场景,新增账号时建议使用清晰的 accountId 规则。',
|
||||
'对齐完成后,Cron、目标选择和路由会直接复用这里的配置。',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'signal',
|
||||
name: 'Signal',
|
||||
description: '通过 Signal 服务号码接入消息路由。',
|
||||
connectionType: 'token',
|
||||
configFields: [
|
||||
createTextField('phoneNumber', 'Phone Number', '+8613800138000', 'Signal 注册号码。', true),
|
||||
],
|
||||
isPlugin: false,
|
||||
instructions: [
|
||||
'准备已注册 Signal 的号码。',
|
||||
'补充 runtime 侧连接能力后即可复用当前配置模型。',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'imessage',
|
||||
name: 'iMessage',
|
||||
description: '通过桥接服务接入 iMessage。',
|
||||
connectionType: 'token',
|
||||
configFields: [
|
||||
createUrlField('serverUrl', 'Server URL', 'https://imessage-bridge.example.com', '桥接服务地址。', true),
|
||||
createPasswordField('password', 'Password', 'bridge-password', '桥接服务认证密码。', true),
|
||||
],
|
||||
isPlugin: false,
|
||||
instructions: [
|
||||
'先部署 iMessage bridge 服务。',
|
||||
'保存后验证服务地址和认证信息是否可用。',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'matrix',
|
||||
name: 'Matrix',
|
||||
description: '通过 homeserver 和 access token 接入 Matrix。',
|
||||
connectionType: 'token',
|
||||
configFields: [
|
||||
createUrlField('homeserver', 'Homeserver', 'https://matrix.example.com', 'Matrix homeserver 地址。', true),
|
||||
createPasswordField('accessToken', 'Access Token', 'matrix-access-token', 'Matrix access token。', true),
|
||||
],
|
||||
isPlugin: true,
|
||||
instructions: [
|
||||
'准备 Matrix homeserver 与 access token。',
|
||||
'建议后续将 channel target 自动发现扩展到 roomId 级别。',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'line',
|
||||
name: 'LINE',
|
||||
description: '通过 LINE Messaging API 接入机器人。',
|
||||
connectionType: 'token',
|
||||
configFields: [
|
||||
createPasswordField('channelAccessToken', 'Channel Access Token', 'line-channel-access-token', 'LINE Channel Access Token。', true),
|
||||
createPasswordField('channelSecret', 'Channel Secret', 'line-channel-secret', 'LINE Channel Secret。', true),
|
||||
],
|
||||
isPlugin: true,
|
||||
instructions: [
|
||||
'在 LINE Developers 中创建 Messaging API channel。',
|
||||
'保存后验证 token、secret 与默认投递账号。',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'msteams',
|
||||
name: 'Microsoft Teams',
|
||||
description: '通过 Bot Framework 凭证接入 Microsoft Teams。',
|
||||
connectionType: 'token',
|
||||
configFields: [
|
||||
createTextField('appId', 'App ID', 'teams-app-id', 'Bot Framework App ID。', true),
|
||||
createPasswordField('appPassword', 'App Password', 'teams-app-password', 'Bot Framework App Password。', true),
|
||||
],
|
||||
isPlugin: true,
|
||||
instructions: [
|
||||
'在 Azure / Bot Framework 中创建 Teams Bot。',
|
||||
'保存后与默认账号、Agent 绑定一并验证。',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'googlechat',
|
||||
name: 'Google Chat',
|
||||
description: '通过 Google Chat 服务账号接入消息投递。',
|
||||
connectionType: 'webhook',
|
||||
configFields: [
|
||||
createTextareaField('serviceAccountKey', 'Service Account Key', '{ ...json... }', 'Google 服务账号 JSON 内容。', true),
|
||||
],
|
||||
isPlugin: false,
|
||||
instructions: [
|
||||
'在 Google Cloud 中创建服务账号并开启 Chat API。',
|
||||
'将服务账号 JSON 粘贴到配置中,后续可扩展 webhook / space 目标发现。',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'mattermost',
|
||||
name: 'Mattermost',
|
||||
description: '通过服务地址与 Bot Token 接入 Mattermost。',
|
||||
connectionType: 'token',
|
||||
configFields: [
|
||||
createUrlField('serverUrl', 'Server URL', 'https://mattermost.example.com', 'Mattermost 服务地址。', true),
|
||||
createPasswordField('botToken', 'Bot Token', 'mattermost-bot-token', 'Mattermost Bot Token。', true),
|
||||
],
|
||||
isPlugin: true,
|
||||
instructions: [
|
||||
'在 Mattermost 中创建 Bot 并生成 Token。',
|
||||
'后续建议把 channel target 扩展到 team/channel 自动发现。',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const CHANNEL_META_MAP = CHANNEL_META_LIST.reduce<Record<string, ChannelMeta>>((accumulator, meta) => {
|
||||
accumulator[meta.type] = meta;
|
||||
return accumulator;
|
||||
}, {});
|
||||
|
||||
export const DEFAULT_CHANNEL_META: ChannelMeta = {
|
||||
type: 'custom',
|
||||
name: 'Custom Channel',
|
||||
description: '通用频道模板,用于承接尚未内建 schema 的自定义渠道或插件。',
|
||||
connectionType: 'plugin',
|
||||
configFields: [
|
||||
createTokenField('token', 'Token', 'token-or-secret', '粘贴该渠道要求的 token、secret 或访问凭据。'),
|
||||
],
|
||||
isPlugin: true,
|
||||
instructions: [
|
||||
'当渠道 schema 仍在演进,或由外部插件提供时,可先使用该模板。',
|
||||
'保存后仍可通过消息频道页管理默认账号、Agent 绑定与运行态状态。',
|
||||
],
|
||||
};
|
||||
|
||||
export function getChannelMeta(channelType: string | null | undefined): ChannelMeta {
|
||||
const trimmed = String(channelType ?? '').trim();
|
||||
if (!trimmed) {
|
||||
return DEFAULT_CHANNEL_META;
|
||||
}
|
||||
|
||||
return CHANNEL_META_MAP[trimmed] ?? {
|
||||
...DEFAULT_CHANNEL_META,
|
||||
type: trimmed,
|
||||
name: trimmed,
|
||||
description: `${trimmed} 的通用频道模板。`,
|
||||
instructions: [
|
||||
...DEFAULT_CHANNEL_META.instructions,
|
||||
`当前 runtime 尚未为 ${trimmed} 提供专用 schema,先以数据驱动方式接入。`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function getChannelOptions(): ChannelMeta[] {
|
||||
return [...CHANNEL_META_LIST, DEFAULT_CHANNEL_META];
|
||||
}
|
||||
|
||||
export function getPrimaryChannelOptions(): ChannelMeta[] {
|
||||
const primarySet = new Set<string>(PRIMARY_CHANNEL_TYPES);
|
||||
return CHANNEL_META_LIST.filter((item) => primarySet.has(item.type));
|
||||
}
|
||||
@@ -1,9 +1,34 @@
|
||||
export type ChannelConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error';
|
||||
export type ChannelConnectionStatus =
|
||||
| 'connected'
|
||||
| 'connecting'
|
||||
| 'disconnected'
|
||||
| 'error'
|
||||
| 'degraded';
|
||||
|
||||
export type ChannelConnectionType = 'token' | 'account' | 'oauth' | 'plugin' | 'qr' | 'webhook';
|
||||
|
||||
export type ChannelConfigFieldKind = 'text' | 'password' | 'textarea' | 'token' | 'url';
|
||||
|
||||
export interface ChannelConfigFieldMeta {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: ChannelConfigFieldKind;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
rows?: number;
|
||||
autoComplete?: string;
|
||||
}
|
||||
|
||||
export interface ChannelConfigFieldValueMap {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export interface ChannelAccountCatalogItem {
|
||||
accountId: string;
|
||||
name: string;
|
||||
configured: boolean;
|
||||
enabled?: boolean;
|
||||
status: ChannelConnectionStatus;
|
||||
lastError?: string;
|
||||
isDefault: boolean;
|
||||
@@ -16,6 +41,7 @@ export interface ChannelAccountCatalogGroup {
|
||||
channelType: string;
|
||||
channelLabel: string;
|
||||
defaultAccountId: string;
|
||||
enabled?: boolean;
|
||||
status: ChannelConnectionStatus;
|
||||
accounts: ChannelAccountCatalogItem[];
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user