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:
duanshuwen
2026-04-18 16:12:49 +08:00
parent ee72cf7261
commit ef46c73c3e
26 changed files with 4056 additions and 186 deletions

View File

@@ -3,9 +3,20 @@ 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,
@@ -34,6 +45,13 @@ export async function handleChannelRoutes(
});
}
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;
@@ -49,6 +67,67 @@ export async function handleChannelRoutes(
});
}
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<{
@@ -96,6 +175,56 @@ export async function handleChannelRoutes(
}
}
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<{
@@ -129,5 +258,46 @@ export async function handleChannelRoutes(
}
}
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;
}

View File

@@ -1,6 +1,19 @@
import type { HostApiContext } from '../context';
import type { NormalizedHostApiRequest } from '../route-utils';
import { fail, ok } from '../route-utils';
import { buildGatewayDiagnosticsSummary } from '../../gateway/diagnostics';
import { listAgentsSnapshot } from '../../utils/agent-config';
import { listSelectedChannelAccountGroups } from '../../utils/channels';
function buildChannelGroups(ctx: HostApiContext) {
const accounts = ctx.providerApiService
.getAccounts()
.filter((account) => account.enabled !== false);
const defaultAccountId = ctx.providerApiService.getDefault().accountId;
const snapshot = listAgentsSnapshot(accounts, defaultAccountId);
return listSelectedChannelAccountGroups(snapshot);
}
export async function handleGatewayRoutes(
request: NormalizedHostApiRequest,
@@ -9,21 +22,31 @@ export async function handleGatewayRoutes(
const { pathname, method } = request;
if (pathname === '/api/app/gateway-info' && method === 'GET') {
const status = ctx.gatewayManager.getStatus();
const health = await ctx.gatewayManager.checkHealth();
const summary = buildGatewayDiagnosticsSummary(health, buildChannelGroups(ctx));
return ok({
transport: 'ipc-bridge',
rpcChannel: 'gateway:rpc',
eventChannel: 'gateway:event',
...status,
...health,
summary,
});
}
if (pathname === '/api/gateway/status' && method === 'GET') {
return ok(ctx.gatewayManager.getStatus());
const status = await ctx.gatewayManager.checkHealth();
return ok({
...status,
summary: buildGatewayDiagnosticsSummary(status, buildChannelGroups(ctx)),
});
}
if (pathname === '/api/gateway/health' && method === 'GET') {
return ok(await ctx.gatewayManager.checkHealth());
const health = await ctx.gatewayManager.checkHealth();
return ok({
...health,
summary: buildGatewayDiagnosticsSummary(health, buildChannelGroups(ctx)),
});
}
if (pathname === '/api/gateway/start' && method === 'POST') {

View File

@@ -0,0 +1,40 @@
import type { ChannelAccountCatalogGroup, ChannelConnectionStatus } from '@src/lib/channel-types';
import { buildChannelStatusSummary, type ChannelStatusSummary } from '@electron/utils/channel-status';
export interface GatewayHealthSnapshot {
ok: boolean;
status: 'connected' | 'disconnected' | 'reconnecting';
initialized: boolean;
mode: 'in-process';
}
export interface GatewayDiagnosticsSummary {
status: ChannelConnectionStatus;
gateway: GatewayHealthSnapshot;
channels: ChannelStatusSummary;
}
function normalizeGatewayStatus(status: GatewayHealthSnapshot['status']): ChannelConnectionStatus {
if (status === 'reconnecting') return 'connecting';
return status;
}
export function buildGatewayDiagnosticsSummary(
health: GatewayHealthSnapshot,
channelGroups: readonly ChannelAccountCatalogGroup[],
): GatewayDiagnosticsSummary {
const channels = buildChannelStatusSummary(channelGroups);
const gatewayStatus = normalizeGatewayStatus(health.status);
return {
status: health.ok
? (channels.status === 'connected'
? 'connected'
: channels.status === 'disconnected'
? 'degraded'
: channels.status)
: gatewayStatus,
gateway: health,
channels,
};
}

View File

@@ -35,11 +35,6 @@ function refreshProviderRuntime(): { warnings: string[] } {
}
}
function shouldPreferUpstreamHostApi(path: string, method: string): boolean {
const pathname = new URL(path, 'http://127.0.0.1').pathname;
return method.toUpperCase() === 'GET' && pathname === '/api/channels/targets';
}
async function requestUpstreamHostApi(path: string, method: string, headers: Record<string, string> | undefined, body: unknown) {
const url = `${HOST_API_BASE_URL}${path}`;
try {
@@ -81,13 +76,6 @@ async function requestUpstreamHostApi(path: string, method: string, headers: Rec
ipcMain.handle('hostapi:fetch', async (_event, { path, method, headers, body }) => {
const normalizedMethod = method || 'GET';
if (shouldPreferUpstreamHostApi(path, normalizedMethod)) {
const upstreamPreferred = await requestUpstreamHostApi(path, normalizedMethod, headers, body);
if (upstreamPreferred.success !== false && upstreamPreferred.ok !== false) {
return upstreamPreferred;
}
}
// 1. 优先本地处理 Host API 路由(逐步对齐 ClawX
const localResult = await dispatchLocalHostApi({
path,

View File

@@ -4,6 +4,7 @@ import type { ProviderAccount } from '@runtime/lib/providers';
import { DEFAULT_AGENT_ID, DEFAULT_MAIN_SESSION_SUFFIX, type AgentSummary, type AgentsSnapshot } from '@runtime/lib/agents';
import { buildMainSessionKey, normalizeAgentId } from '@runtime/lib/models';
import { getUserDataDir } from './paths';
import { listStoredChannelTypes } from './channel-config';
interface StoredAgentEntry {
id: string;
@@ -237,6 +238,7 @@ function buildSnapshotFromStore(
];
const configuredChannelTypes = Array.from(new Set([
...listStoredChannelTypes(),
...Object.keys(channelOwners),
...Object.keys(channelAccountOwners).map((key) => key.split(':')[0]).filter(Boolean),
]));
@@ -498,3 +500,28 @@ export function clearChannelBinding(
writeStore(store);
return buildSnapshotFromStore(store, accounts, defaultAccountId);
}
export function clearAllChannelBindings(
channelType: string,
accounts: ProviderAccount[],
defaultAccountId: string | null,
): AgentsSnapshot {
const normalizedChannelType = String(channelType ?? '').trim();
if (!normalizedChannelType) {
throw new Error('channelType is required');
}
const store = readStore();
const nextChannelOwners = { ...(store.channelOwners ?? {}) };
delete nextChannelOwners[normalizedChannelType];
store.channelOwners = nextChannelOwners;
const nextChannelAccountOwners = Object.fromEntries(
Object.entries(store.channelAccountOwners ?? {}).filter(([key]) => !key.startsWith(`${normalizedChannelType}:`)),
);
store.channelAccountOwners = nextChannelAccountOwners;
syncAgentChannelMembership(store, normalizedChannelType);
writeStore(store);
return buildSnapshotFromStore(store, accounts, defaultAccountId);
}

View File

@@ -0,0 +1,391 @@
import * as fs from 'fs';
import * as path from 'path';
import { getUserDataDir } from './paths';
export const CHANNEL_STORE_FILE_NAME = 'channels.json';
export const DEFAULT_CHANNEL_ACCOUNT_ID = 'default';
export interface StoredChannelAccountEntry {
accountId: string;
name?: string | null;
channelUrl?: string | null;
enabled?: boolean;
config?: Record<string, unknown>;
metadata?: Record<string, unknown>;
createdAt?: string;
updatedAt?: string;
}
export interface StoredChannelEntry {
channelType: string;
channelLabel?: string | null;
defaultAccountId?: string | null;
enabled?: boolean;
accounts?: Record<string, StoredChannelAccountEntry>;
createdAt?: string;
updatedAt?: string;
}
interface StoredChannelsDocument {
channels?: Record<string, StoredChannelEntry>;
}
export interface StoredChannelAccountRecord {
channelType: string;
channelLabel: string;
defaultAccountId: string;
channelEnabled: boolean;
accountId: string;
accountName: string;
accountEnabled: boolean;
channelUrl?: string;
config: Record<string, unknown>;
metadata: Record<string, unknown>;
}
function getStorePath(): string {
return path.join(getUserDataDir(), CHANNEL_STORE_FILE_NAME);
}
function formatChannelLabel(channelType: string, fallback?: string | null): string {
const preferred = String(fallback ?? '').trim();
if (preferred) return preferred;
const parts = String(channelType ?? '')
.split(/[-_]/)
.map((part) => part.trim())
.filter(Boolean);
if (parts.length === 0) {
return String(channelType ?? '').trim();
}
return parts
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
function normalizeChannelType(value: string): string {
return String(value ?? '').trim().toLowerCase();
}
function normalizeAccountId(value: string | null | undefined): string {
const trimmed = String(value ?? '').trim();
return trimmed || DEFAULT_CHANNEL_ACCOUNT_ID;
}
function ensureStoreDir(): void {
fs.mkdirSync(path.dirname(getStorePath()), { recursive: true });
}
function readStore(): StoredChannelsDocument {
try {
const filePath = getStorePath();
if (!fs.existsSync(filePath)) {
return { channels: {} };
}
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as StoredChannelsDocument;
return {
channels: parsed.channels && typeof parsed.channels === 'object' ? parsed.channels : {},
};
} catch {
return { channels: {} };
}
}
function writeStore(store: StoredChannelsDocument): void {
ensureStoreDir();
fs.writeFileSync(getStorePath(), JSON.stringify(store, null, 2), 'utf-8');
}
function ensureChannelEntry(
store: StoredChannelsDocument,
channelType: string,
): StoredChannelEntry {
const normalizedChannelType = normalizeChannelType(channelType);
if (!normalizedChannelType) {
throw new Error('channelType is required');
}
if (!store.channels) {
store.channels = {};
}
const existing = store.channels[normalizedChannelType];
if (existing) {
if (!existing.accounts || typeof existing.accounts !== 'object') {
existing.accounts = {};
}
existing.channelType = normalizedChannelType;
existing.channelLabel = formatChannelLabel(normalizedChannelType, existing.channelLabel);
existing.defaultAccountId = normalizeAccountId(existing.defaultAccountId);
existing.enabled = existing.enabled !== false;
return existing;
}
const now = new Date().toISOString();
const created: StoredChannelEntry = {
channelType: normalizedChannelType,
channelLabel: formatChannelLabel(normalizedChannelType),
defaultAccountId: DEFAULT_CHANNEL_ACCOUNT_ID,
enabled: true,
accounts: {},
createdAt: now,
updatedAt: now,
};
store.channels[normalizedChannelType] = created;
return created;
}
function ensureAccountEntry(
channel: StoredChannelEntry,
accountId: string,
): StoredChannelAccountEntry {
const normalizedAccountId = normalizeAccountId(accountId);
if (!channel.accounts || typeof channel.accounts !== 'object') {
channel.accounts = {};
}
const existing = channel.accounts[normalizedAccountId];
if (existing) {
existing.accountId = normalizedAccountId;
existing.enabled = existing.enabled !== false;
return existing;
}
const now = new Date().toISOString();
const created: StoredChannelAccountEntry = {
accountId: normalizedAccountId,
name: normalizedAccountId,
enabled: true,
config: {},
metadata: {},
createdAt: now,
updatedAt: now,
};
channel.accounts[normalizedAccountId] = created;
return created;
}
function coerceFormValues(config: Record<string, unknown> | undefined): Record<string, string> | undefined {
if (!config || typeof config !== 'object') return undefined;
const values: Record<string, string> = {};
for (const [key, value] of Object.entries(config)) {
if (value == null) continue;
if (typeof value === 'string') {
values[key] = value;
continue;
}
if (typeof value === 'number' || typeof value === 'boolean') {
values[key] = String(value);
}
}
return Object.keys(values).length > 0 ? values : undefined;
}
export function isCanonicalChannelAccountId(value: string): boolean {
return /^[a-z0-9](?:[a-z0-9_-]{0,63})$/.test(String(value ?? '').trim());
}
export function listStoredChannelTypes(): string[] {
return Object.keys(readStore().channels ?? {}).sort((left, right) => left.localeCompare(right, 'zh-CN'));
}
export function hasStoredChannelAccount(channelType: string, accountId?: string | null): boolean {
const normalizedChannelType = normalizeChannelType(channelType);
if (!normalizedChannelType) return false;
const channel = readStore().channels?.[normalizedChannelType];
if (!channel) return false;
const normalizedAccountId = normalizeAccountId(accountId);
return Boolean(channel.accounts?.[normalizedAccountId]);
}
export function listStoredChannelAccountRecords(): StoredChannelAccountRecord[] {
const store = readStore();
const records: StoredChannelAccountRecord[] = [];
for (const [rawChannelType, rawChannel] of Object.entries(store.channels ?? {})) {
const channelType = normalizeChannelType(rawChannelType);
if (!channelType || !rawChannel || typeof rawChannel !== 'object') continue;
const channelLabel = formatChannelLabel(channelType, rawChannel.channelLabel);
const channelEnabled = rawChannel.enabled !== false;
const accounts = rawChannel.accounts && typeof rawChannel.accounts === 'object'
? rawChannel.accounts
: {};
const sortedAccountIds = Object.keys(accounts).sort((left, right) => {
if (left === DEFAULT_CHANNEL_ACCOUNT_ID) return -1;
if (right === DEFAULT_CHANNEL_ACCOUNT_ID) return 1;
return left.localeCompare(right, 'zh-CN');
});
const defaultAccountId = normalizeAccountId(
rawChannel.defaultAccountId && sortedAccountIds.includes(normalizeAccountId(rawChannel.defaultAccountId))
? rawChannel.defaultAccountId
: sortedAccountIds[0],
);
for (const accountId of sortedAccountIds) {
const account = accounts[accountId];
if (!account || typeof account !== 'object') continue;
records.push({
channelType,
channelLabel,
defaultAccountId,
channelEnabled,
accountId,
accountName: String(account.name ?? accountId).trim() || accountId,
accountEnabled: account.enabled !== false,
channelUrl: typeof account.channelUrl === 'string' ? account.channelUrl : undefined,
config: account.config && typeof account.config === 'object' ? account.config : {},
metadata: account.metadata && typeof account.metadata === 'object' ? account.metadata : {},
});
}
}
return records.sort((left, right) => {
if (left.channelLabel !== right.channelLabel) {
return left.channelLabel.localeCompare(right.channelLabel, 'zh-CN');
}
return left.accountName.localeCompare(right.accountName, 'zh-CN');
});
}
export function getChannelFormValues(
channelType: string,
accountId?: string | null,
): Record<string, string> | undefined {
const normalizedChannelType = normalizeChannelType(channelType);
if (!normalizedChannelType) return undefined;
const channel = readStore().channels?.[normalizedChannelType];
if (!channel) return undefined;
const account = channel.accounts?.[normalizeAccountId(accountId)];
if (!account) return undefined;
return coerceFormValues(account.config);
}
export function saveChannelConfig(input: {
channelType: string;
accountId?: string | null;
channelLabel?: string | null;
accountName?: string | null;
channelUrl?: string | null;
enabled?: boolean;
config?: Record<string, unknown>;
metadata?: Record<string, unknown>;
}): StoredChannelEntry {
const normalizedChannelType = normalizeChannelType(input.channelType);
if (!normalizedChannelType) {
throw new Error('channelType is required');
}
const normalizedAccountId = normalizeAccountId(input.accountId);
const store = readStore();
const channel = ensureChannelEntry(store, normalizedChannelType);
const account = ensureAccountEntry(channel, normalizedAccountId);
const now = new Date().toISOString();
channel.channelLabel = formatChannelLabel(normalizedChannelType, input.channelLabel ?? channel.channelLabel);
channel.enabled = input.enabled ?? channel.enabled ?? true;
channel.defaultAccountId = normalizeAccountId(channel.defaultAccountId || normalizedAccountId);
channel.updatedAt = now;
account.name = String(input.accountName ?? account.name ?? normalizedAccountId).trim() || normalizedAccountId;
account.channelUrl = typeof input.channelUrl === 'string'
? input.channelUrl.trim() || undefined
: account.channelUrl ?? undefined;
account.enabled = input.enabled ?? account.enabled ?? true;
account.config = input.config && typeof input.config === 'object' ? input.config : {};
account.metadata = input.metadata && typeof input.metadata === 'object' ? input.metadata : (account.metadata ?? {});
account.updatedAt = now;
if (!channel.accounts || Object.keys(channel.accounts).length === 1) {
channel.defaultAccountId = normalizedAccountId;
}
writeStore(store);
return channel;
}
export function setChannelDefaultAccount(channelType: string, accountId: string): StoredChannelEntry {
const normalizedChannelType = normalizeChannelType(channelType);
if (!normalizedChannelType) {
throw new Error('channelType is required');
}
const normalizedAccountId = normalizeAccountId(accountId);
const store = readStore();
const channel = ensureChannelEntry(store, normalizedChannelType);
if (!channel.accounts?.[normalizedAccountId]) {
throw new Error(`Channel account "${normalizedChannelType}:${normalizedAccountId}" not found`);
}
channel.defaultAccountId = normalizedAccountId;
channel.updatedAt = new Date().toISOString();
writeStore(store);
return channel;
}
export function setChannelEnabled(channelType: string, enabled: boolean): StoredChannelEntry {
const normalizedChannelType = normalizeChannelType(channelType);
if (!normalizedChannelType) {
throw new Error('channelType is required');
}
const store = readStore();
const channel = ensureChannelEntry(store, normalizedChannelType);
channel.enabled = Boolean(enabled);
channel.updatedAt = new Date().toISOString();
writeStore(store);
return channel;
}
export function deleteChannelConfig(channelType: string, accountId?: string | null): void {
const normalizedChannelType = normalizeChannelType(channelType);
if (!normalizedChannelType) {
throw new Error('channelType is required');
}
const store = readStore();
const channels = store.channels ?? {};
const channel = channels[normalizedChannelType];
if (!channel) return;
const normalizedAccountId = accountId == null ? '' : normalizeAccountId(accountId);
if (!normalizedAccountId) {
delete channels[normalizedChannelType];
writeStore(store);
return;
}
if (channel.accounts?.[normalizedAccountId]) {
delete channel.accounts[normalizedAccountId];
}
const remainingAccountIds = Object.keys(channel.accounts ?? {});
if (remainingAccountIds.length === 0) {
delete channels[normalizedChannelType];
writeStore(store);
return;
}
if (!remainingAccountIds.includes(normalizeAccountId(channel.defaultAccountId))) {
channel.defaultAccountId = remainingAccountIds.sort((left, right) => {
if (left === DEFAULT_CHANNEL_ACCOUNT_ID) return -1;
if (right === DEFAULT_CHANNEL_ACCOUNT_ID) return 1;
return left.localeCompare(right, 'zh-CN');
})[0];
}
channel.updatedAt = new Date().toISOString();
writeStore(store);
}

View File

@@ -0,0 +1,184 @@
import type { ChannelAccountCatalogGroup, ChannelConnectionStatus } from '@src/lib/channel-types';
const KNOWN_CHANNEL_STATUSES: ChannelConnectionStatus[] = [
'connected',
'connecting',
'disconnected',
'error',
'degraded',
];
export interface ChannelStatusInferenceInput {
status?: unknown;
configured?: boolean;
channelUrl?: string | null;
lastError?: string | null;
error?: unknown;
warnings?: readonly string[] | null;
warningCount?: number;
isConnecting?: boolean;
isLoading?: boolean;
connectionState?: unknown;
hasBinding?: boolean;
degraded?: boolean;
}
export interface ChannelStatusSummary {
status: ChannelConnectionStatus;
counts: Record<ChannelConnectionStatus, number>;
groupCount: number;
accountCount: number;
}
function isMeaningfulText(value: unknown): boolean {
return String(value ?? '').trim().length > 0;
}
function isValidUrl(value: string): boolean {
try {
new URL(value);
return true;
} catch {
return false;
}
}
export function normalizeChannelConnectionStatus(value: unknown): ChannelConnectionStatus | null {
const normalized = String(value ?? '').trim().toLowerCase();
return KNOWN_CHANNEL_STATUSES.includes(normalized as ChannelConnectionStatus)
? (normalized as ChannelConnectionStatus)
: null;
}
export function inferChannelConnectionStatus(
input: ChannelStatusInferenceInput = {},
): ChannelConnectionStatus {
const explicitStatus = normalizeChannelConnectionStatus(input.status);
const normalizedConnectionState = normalizeChannelConnectionStatus(input.connectionState);
const channelUrl = String(input.channelUrl ?? '').trim();
const warningCount = typeof input.warningCount === 'number'
? input.warningCount
: Array.isArray(input.warnings)
? input.warnings.filter(isMeaningfulText).length
: 0;
if (isMeaningfulText(input.error) || isMeaningfulText(input.lastError)) {
return 'error';
}
if (explicitStatus === 'error' || normalizedConnectionState === 'error') {
return 'error';
}
if (input.configured === false || !channelUrl) {
return 'disconnected';
}
if (!isValidUrl(channelUrl)) {
return 'error';
}
if (
explicitStatus === 'connecting'
|| normalizedConnectionState === 'connecting'
|| input.isConnecting
|| input.isLoading
) {
return 'connecting';
}
if (explicitStatus === 'disconnected' || normalizedConnectionState === 'disconnected') {
return 'disconnected';
}
if (explicitStatus === 'degraded' || normalizedConnectionState === 'degraded') {
return 'degraded';
}
if (warningCount > 0 || input.degraded || input.hasBinding === false) {
return 'degraded';
}
if (explicitStatus === 'connected') {
return 'connected';
}
return 'connected';
}
export function summarizeChannelConnectionStatuses(
statuses: readonly ChannelConnectionStatus[],
): ChannelConnectionStatus {
const counts = statuses.reduce<Record<ChannelConnectionStatus, number>>(
(acc, status) => {
acc[status] += 1;
return acc;
},
{
connected: 0,
connecting: 0,
disconnected: 0,
error: 0,
degraded: 0,
},
);
if (counts.error > 0) {
return counts.connected > 0 || counts.connecting > 0 || counts.degraded > 0 || counts.disconnected > 0
? 'degraded'
: 'error';
}
if (counts.connecting > 0) {
return 'connecting';
}
if (counts.degraded > 0) {
return 'degraded';
}
if (counts.disconnected > 0 && counts.connected === 0) {
return 'disconnected';
}
if (counts.connected > 0 && counts.disconnected > 0) {
return 'degraded';
}
return counts.connected > 0 ? 'connected' : 'disconnected';
}
export function buildChannelStatusSummary(
groups: readonly ChannelAccountCatalogGroup[],
): ChannelStatusSummary {
const counts: Record<ChannelConnectionStatus, number> = {
connected: 0,
connecting: 0,
disconnected: 0,
error: 0,
degraded: 0,
};
let accountCount = 0;
for (const group of groups) {
accountCount += group.accounts.length;
for (const account of group.accounts) {
counts[account.status] += 1;
}
}
const accountStatuses: ChannelConnectionStatus[] = [];
for (const group of groups) {
for (const account of group.accounts) {
accountStatuses.push(account.status);
}
}
return {
status: summarizeChannelConnectionStatuses(accountStatuses),
counts,
groupCount: groups.length,
accountCount,
};
}

View File

@@ -2,6 +2,11 @@ import { CONFIG_KEYS } from '@runtime/lib/constants';
import { normalizeAgentId, type AgentsSnapshot } from '@runtime/lib/models';
import configManager from '@electron/service/config-service';
import { listCronJobs } from './cron-store';
import { listStoredChannelAccountRecords } from './channel-config';
import {
buildChannelStatusSummary,
inferChannelConnectionStatus,
} from './channel-status';
import type {
ChannelAccountCatalogGroup,
ChannelConnectionStatus,
@@ -23,6 +28,12 @@ export interface LocalChannelAccount {
channelName: string;
channelUrl: string;
label: string;
configured: boolean;
enabled: boolean;
channelEnabled: boolean;
status: ChannelConnectionStatus;
isDefault: boolean;
lastError?: string;
ownerAgentId: string | null;
ownerAgentName: string | null;
bindingScope: 'account' | 'channel' | null;
@@ -280,32 +291,93 @@ export function getSelectedChannelsConfig(): SelectedChannelConfigItem[] {
}
export function listSelectedChannelAccounts(snapshot?: Pick<AgentsSnapshot, 'agents' | 'channelOwners' | 'channelAccountOwners'>): LocalChannelAccount[] {
const channels = getSelectedChannelsConfig();
const agentNameById = new Map<string, string>(
Array.isArray(snapshot?.agents)
? snapshot.agents.map((agent) => [normalizeAgentId(agent.id), agent.name || normalizeAgentId(agent.id)])
: [],
);
const accounts = new Map<string, LocalChannelAccount>();
return channels.map((item) => {
for (const record of listStoredChannelAccountRecords()) {
const accountOwnerKey = `${record.channelType}:${record.accountId}`;
const accountOwnerId = snapshot?.channelAccountOwners?.[accountOwnerKey];
const channelOwnerId = snapshot?.channelOwners?.[record.channelType];
const ownerAgentId = accountOwnerId || channelOwnerId || null;
const normalizedOwnerId = ownerAgentId ? normalizeAgentId(ownerAgentId) : null;
const key = `${record.channelType}:${record.accountId}`;
const status = inferChannelConnectionStatus({
configured: true,
channelUrl: record.channelUrl ?? '',
status: record.channelEnabled && record.accountEnabled ? 'connected' : 'disconnected',
hasBinding: Boolean(accountOwnerId || channelOwnerId),
degraded: !record.channelEnabled || !record.accountEnabled,
});
accounts.set(key, {
id: record.accountId,
accountId: record.accountId,
channelType: record.channelType,
channelName: record.channelLabel,
channelUrl: record.channelUrl ?? '',
label: record.accountName || record.accountId,
configured: true,
enabled: record.channelEnabled && record.accountEnabled,
channelEnabled: record.channelEnabled,
status,
isDefault: record.accountId === record.defaultAccountId,
lastError: status === 'error'
? '渠道链接格式无效'
: undefined,
ownerAgentId: normalizedOwnerId,
ownerAgentName: normalizedOwnerId ? agentNameById.get(normalizedOwnerId) ?? null : null,
bindingScope: accountOwnerId ? 'account' : channelOwnerId ? 'channel' : null,
});
}
const legacyChannels = getSelectedChannelsConfig();
for (const item of legacyChannels) {
const channelType = inferChannelType(item);
const key = `${channelType}:${item.id}`;
if (accounts.has(key)) continue;
const accountOwnerKey = `${channelType}:${item.id}`;
const accountOwnerId = snapshot?.channelAccountOwners?.[accountOwnerKey];
const channelOwnerId = snapshot?.channelOwners?.[channelType];
const ownerAgentId = accountOwnerId || channelOwnerId || null;
const normalizedOwnerId = ownerAgentId ? normalizeAgentId(ownerAgentId) : null;
const status = inferChannelConnectionStatus({
configured: true,
channelUrl: item.channelUrl,
status: ownerAgentId ? 'connected' : undefined,
hasBinding: Boolean(accountOwnerId || channelOwnerId),
});
return {
accounts.set(key, {
id: item.id,
accountId: item.id,
channelType,
channelName: item.channelName,
channelUrl: item.channelUrl,
label: item.channelName,
configured: true,
enabled: true,
channelEnabled: true,
status,
isDefault: false,
lastError: status === 'error'
? '渠道链接格式无效'
: undefined,
ownerAgentId: normalizedOwnerId,
ownerAgentName: normalizedOwnerId ? agentNameById.get(normalizedOwnerId) ?? null : null,
bindingScope: accountOwnerId ? 'account' : channelOwnerId ? 'channel' : null,
};
});
}
return Array.from(accounts.values()).sort((left, right) => {
if (left.channelName !== right.channelName) {
return left.channelName.localeCompare(right.channelName, 'zh-CN');
}
return left.label.localeCompare(right.label, 'zh-CN');
});
}
@@ -319,22 +391,38 @@ export function listSelectedChannelAccountGroups(
const existing = groups.get(account.channelType) ?? {
channelType: account.channelType,
channelLabel: account.channelName || formatChannelLabel(account.channelType),
defaultAccountId: account.accountId,
status: 'connected' as ChannelConnectionStatus,
defaultAccountId: account.isDefault ? account.accountId : '',
enabled: account.channelEnabled,
status: account.status,
accounts: [],
};
existing.enabled = existing.enabled !== false && account.channelEnabled;
existing.accounts.push({
accountId: account.accountId,
name: account.label || account.channelName || account.accountId,
configured: true,
status: 'connected',
isDefault: false,
configured: account.configured,
enabled: account.enabled,
status: account.status,
lastError: account.lastError,
isDefault: account.isDefault,
agentId: account.ownerAgentId ?? undefined,
bindingScope: account.bindingScope ?? undefined,
channelUrl: account.channelUrl,
});
if (!existing.defaultAccountId && account.isDefault) {
existing.defaultAccountId = account.accountId;
}
if (existing.status !== 'error' && account.status === 'error') {
existing.status = 'error';
} else if (existing.status === 'disconnected' && account.status !== 'disconnected') {
existing.status = account.status;
} else if (existing.status !== 'connected' && account.status === 'connected') {
existing.status = 'connected';
}
groups.set(account.channelType, existing);
}
@@ -342,13 +430,20 @@ export function listSelectedChannelAccountGroups(
.map((group) => {
const sortedAccounts = [...group.accounts].sort((left, right) => left.name.localeCompare(right.name));
const defaultAccountId = group.defaultAccountId || sortedAccounts[0]?.accountId || 'default';
const status = buildChannelStatusSummary([
{
...group,
accounts: sortedAccounts,
},
]).status;
return {
...group,
defaultAccountId,
status,
accounts: sortedAccounts.map((account) => ({
...account,
isDefault: account.accountId === defaultAccountId,
isDefault: account.isDefault || account.accountId === defaultAccountId,
})),
};
})