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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
40
electron/gateway/diagnostics.ts
Normal file
40
electron/gateway/diagnostics.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
391
electron/utils/channel-config.ts
Normal file
391
electron/utils/channel-config.ts
Normal 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);
|
||||
}
|
||||
184
electron/utils/channel-status.ts
Normal file
184
electron/utils/channel-status.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user