Files
zn-ai/electron/api/routes/channels.ts
duanshuwen 18f12d6ce3 feat: enhance channel configuration UI and validation
- Updated ChannelInstructionsPanel to include a button for viewing documentation, improving user guidance.
- Enhanced ChannelTokenField to support showing/hiding secret values with appropriate labels and icons.
- Refined ChannelTypeSelector to display connection type icons and improved layout for better user experience.
- Added new messages for documentation links, validation feedback, and secret management in i18n.
- Extended ChannelMeta to include optional documentation URLs for better context on configuration fields.
- Implemented credential validation logic in ChannelsPage to ensure user inputs are validated before saving.
- Introduced ChannelLogo component to display channel icons in the UI.
- Added tests for channel credential validation to ensure proper error handling and feedback.
2026-04-19 16:43:07 +08:00

452 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { HostApiContext } from '../context';
import type { NormalizedHostApiRequest } from '../route-utils';
import { fail, ok, parseJsonBody } from '../route-utils';
import {
assignChannelToAgent,
clearAllChannelBindings,
clearChannelBinding,
listAgentsSnapshot,
} from '../../utils/agent-config';
import {
getChannelFormValues,
hasStoredChannelAccount,
isCanonicalChannelAccountId,
listStoredChannelTypes,
saveChannelConfig,
setChannelDefaultAccount,
setChannelEnabled,
deleteChannelConfig,
} from '../../utils/channel-config';
import {
listSelectedChannelAccountGroups,
listSelectedChannelTargets,
} from '../../utils/channels';
import { getChannelMeta } from '@src/lib/channel-meta';
function getProviderSnapshot(ctx: HostApiContext) {
const accounts = ctx.providerApiService
.getAccounts()
.filter((account) => account.enabled !== false);
const defaultAccountId = ctx.providerApiService.getDefault().accountId;
return { accounts, defaultAccountId };
}
const VALIDATION_RESERVED_KEYS = new Set([
'channelType',
'accountId',
'credentials',
'values',
'config',
'fields',
'success',
'valid',
'errors',
'warnings',
'details',
]);
interface CredentialFieldValidationResult {
key: string;
label: string;
kind: string;
required: boolean;
provided: boolean;
valid: boolean;
errors: string[];
warnings: string[];
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function collectValidationValues(body: Record<string, unknown> | null | undefined): Record<string, unknown> {
const merged: Record<string, unknown> = {};
const sources = [
isPlainRecord(body?.credentials) ? body?.credentials : null,
isPlainRecord(body?.values) ? body?.values : null,
isPlainRecord(body?.config) ? body?.config : null,
isPlainRecord(body?.fields) ? body?.fields : null,
body,
];
for (const source of sources) {
if (!isPlainRecord(source)) continue;
for (const [key, value] of Object.entries(source)) {
if (VALIDATION_RESERVED_KEYS.has(key)) continue;
merged[key] = value;
}
}
return merged;
}
function normalizeCredentialValue(value: unknown): string {
return String(value ?? '').trim();
}
function validateUrlField(label: string, value: string, result: CredentialFieldValidationResult): void {
try {
const parsed = new URL(value);
if (parsed.protocol === 'http:') {
result.warnings.push(`${label} 使用了不安全的 http URL建议改用 https`);
}
} catch {
result.errors.push(`${label} 必须是有效的 URL`);
result.valid = false;
}
}
function validateCredentialField(
field: { key: string; label: string; kind: string; required?: boolean },
rawValue: unknown,
): CredentialFieldValidationResult {
const value = normalizeCredentialValue(rawValue);
const result: CredentialFieldValidationResult = {
key: field.key,
label: field.label,
kind: field.kind,
required: field.required === true,
provided: value.length > 0,
valid: true,
errors: [],
warnings: [],
};
if (field.required && !value) {
result.errors.push(`${field.label} is required`);
result.valid = false;
return result;
}
if (!value) {
return result;
}
if (field.kind === 'url') {
validateUrlField(field.label, value, result);
return result;
}
if (field.kind === 'token' || field.kind === 'password') {
if (/\s/.test(value)) {
result.errors.push(`${field.label} 不能包含空格`);
result.valid = false;
}
return result;
}
if (field.kind === 'text' || field.kind === 'textarea') {
if (/[\r\n]/.test(value)) {
result.warnings.push(`${field.label} 包含换行符,确认是否符合目标渠道格式`);
}
}
return result;
}
export async function handleChannelRoutes(
request: NormalizedHostApiRequest,
ctx: HostApiContext,
) {
const { pathname, method } = request;
const { accounts, defaultAccountId } = getProviderSnapshot(ctx);
const snapshot = listAgentsSnapshot(accounts, defaultAccountId);
if (pathname === '/api/channels/accounts' && method === 'GET') {
return ok({
success: true,
channels: listSelectedChannelAccountGroups(snapshot),
});
}
if (pathname === '/api/channels/configured' && method === 'GET') {
return ok({
success: true,
channels: listStoredChannelTypes(),
});
}
if (pathname === '/api/channels/targets' && method === 'GET') {
const channelType = request.url.searchParams.get('channelType')?.trim() || '';
const accountId = request.url.searchParams.get('accountId')?.trim() || null;
const query = request.url.searchParams.get('query')?.trim() || null;
if (!channelType) {
return fail(400, 'channelType is required');
}
return ok({
success: true,
targets: listSelectedChannelTargets(channelType, accountId, query),
});
}
if (pathname === '/api/channels/credentials/validate' && method === 'POST') {
try {
const body = parseJsonBody<Record<string, unknown>>(request.body);
const channelType = String(body?.channelType ?? '').trim();
const accountId = String(body?.accountId ?? '').trim() || undefined;
const meta = getChannelMeta(channelType);
const validationValues = collectValidationValues(body);
const fieldResults = meta.configFields.map((field) => validateCredentialField(field, validationValues[field.key]));
const errors = fieldResults.flatMap((field) => field.errors);
const warnings = fieldResults.flatMap((field) => field.warnings);
if (!channelType) {
errors.unshift('channelType is required');
}
return ok({
success: true,
valid: errors.length === 0,
errors,
warnings,
details: {
channelType,
accountId,
channelName: meta.name,
connectionType: meta.connectionType,
fields: fieldResults,
},
});
} catch (error) {
return fail(500, error instanceof Error ? error.message : String(error));
}
}
if (pathname === '/api/channels/default-account' && method === 'PUT') {
try {
const body = parseJsonBody<{
channelType?: string;
accountId?: string | null;
}>(request.body);
const channelType = String(body?.channelType ?? '').trim();
const accountId = String(body?.accountId ?? '').trim();
if (!channelType) {
return fail(400, 'channelType is required');
}
if (!accountId) {
return fail(400, 'accountId is required');
}
setChannelDefaultAccount(channelType, accountId);
ctx.gatewayManager.notifyRuntimeChanged({
topics: ['channels', 'channel-targets', 'agents'],
reason: 'channels:default-account-updated',
channelType,
accountId,
});
return ok({
success: true,
channels: listSelectedChannelAccountGroups(listAgentsSnapshot(accounts, defaultAccountId)),
});
} catch (error) {
return fail(500, error instanceof Error ? error.message : String(error));
}
}
if (pathname === '/api/channels/config/enabled' && method === 'PUT') {
try {
const body = parseJsonBody<{
channelType?: string;
enabled?: boolean;
}>(request.body);
const channelType = String(body?.channelType ?? '').trim();
if (!channelType) {
return fail(400, 'channelType is required');
}
setChannelEnabled(channelType, body?.enabled !== false);
ctx.gatewayManager.notifyRuntimeChanged({
topics: ['channels', 'channel-targets', 'agents'],
reason: 'channels:enabled-updated',
channelType,
});
return ok({
success: true,
channels: listSelectedChannelAccountGroups(listAgentsSnapshot(accounts, defaultAccountId)),
});
} catch (error) {
return fail(500, error instanceof Error ? error.message : String(error));
}
}
if (pathname === '/api/channels/binding' && method === 'PUT') {
try {
const body = parseJsonBody<{
channelType?: string;
accountId?: string | null;
agentId?: string;
}>(request.body);
const channelType = String(body?.channelType ?? '').trim();
const accountId = String(body?.accountId ?? '').trim();
const agentId = String(body?.agentId ?? '').trim();
if (!channelType) {
return fail(400, 'channelType is required');
}
if (!accountId) {
return fail(400, 'accountId is required');
}
if (!agentId) {
return fail(400, 'agentId is required');
}
if (!snapshot.agents.some((agent) => agent.id === agentId)) {
return fail(404, `Agent "${agentId}" not found`);
}
const groupedChannels = listSelectedChannelAccountGroups(snapshot);
const group = groupedChannels.find((entry) => entry.channelType === channelType);
if (!group || !group.accounts.some((entry) => entry.accountId === accountId)) {
return fail(404, `Channel account "${channelType}:${accountId}" not found`);
}
const result = assignChannelToAgent(agentId, channelType, accountId, accounts, defaultAccountId);
ctx.gatewayManager.notifyRuntimeChanged({
topics: ['agents', 'channels', 'channel-targets'],
reason: 'channels:binding-updated',
channelType,
accountId,
});
return ok({
success: true,
...result,
});
} catch (error) {
return fail(500, error instanceof Error ? error.message : String(error));
}
}
if (pathname === '/api/channels/config' && method === 'POST') {
try {
const body = parseJsonBody<{
channelType?: string;
accountId?: string | null;
channelLabel?: string | null;
accountName?: string | null;
channelUrl?: string | null;
enabled?: boolean;
config?: Record<string, unknown>;
metadata?: Record<string, unknown>;
}>(request.body);
const channelType = String(body?.channelType ?? '').trim();
const accountId = String(body?.accountId ?? '').trim();
if (!channelType) {
return fail(400, 'channelType is required');
}
if (accountId && !isCanonicalChannelAccountId(accountId) && !hasStoredChannelAccount(channelType, accountId)) {
return fail(400, 'Invalid accountId format. Use lowercase letters, numbers, hyphens, or underscores only (max 64 chars, must start with a letter or number).');
}
saveChannelConfig({
channelType,
accountId: accountId || undefined,
channelLabel: body?.channelLabel,
accountName: body?.accountName,
channelUrl: body?.channelUrl,
enabled: body?.enabled,
config: body?.config,
metadata: body?.metadata,
});
ctx.gatewayManager.notifyRuntimeChanged({
topics: ['channels', 'channel-targets', 'agents'],
reason: 'channels:config-saved',
channelType,
accountId: accountId || undefined,
});
return ok({
success: true,
channels: listSelectedChannelAccountGroups(listAgentsSnapshot(accounts, defaultAccountId)),
});
} catch (error) {
return fail(500, error instanceof Error ? error.message : String(error));
}
}
if (pathname === '/api/channels/binding' && method === 'DELETE') {
try {
const body = parseJsonBody<{
channelType?: string;
accountId?: string | null;
}>(request.body);
const channelType = String(body?.channelType ?? '').trim();
const accountId = String(body?.accountId ?? '').trim();
if (!channelType) {
return fail(400, 'channelType is required');
}
if (!accountId) {
return fail(400, 'accountId is required');
}
const result = clearChannelBinding(channelType, accountId, accounts, defaultAccountId);
ctx.gatewayManager.notifyRuntimeChanged({
topics: ['agents', 'channels', 'channel-targets'],
reason: 'channels:binding-cleared',
channelType,
accountId,
});
return ok({
success: true,
...result,
});
} catch (error) {
return fail(500, error instanceof Error ? error.message : String(error));
}
}
if (pathname.startsWith('/api/channels/config/') && method === 'GET') {
try {
const channelType = decodeURIComponent(pathname.slice('/api/channels/config/'.length));
const accountId = request.url.searchParams.get('accountId')?.trim() || null;
return ok({
success: true,
values: getChannelFormValues(channelType, accountId) ?? {},
});
} catch (error) {
return fail(500, error instanceof Error ? error.message : String(error));
}
}
if (pathname.startsWith('/api/channels/config/') && method === 'DELETE') {
try {
const channelType = decodeURIComponent(pathname.slice('/api/channels/config/'.length));
const accountId = request.url.searchParams.get('accountId')?.trim() || null;
deleteChannelConfig(channelType, accountId);
if (accountId) {
clearChannelBinding(channelType, accountId, accounts, defaultAccountId);
} else {
clearAllChannelBindings(channelType, accounts, defaultAccountId);
}
ctx.gatewayManager.notifyRuntimeChanged({
topics: ['channels', 'channel-targets', 'agents'],
reason: 'channels:config-deleted',
channelType,
accountId: accountId || undefined,
});
return ok({
success: true,
channels: listSelectedChannelAccountGroups(listAgentsSnapshot(accounts, defaultAccountId)),
});
} catch (error) {
return fail(500, error instanceof Error ? error.message : String(error));
}
}
return null;
}