feat: refactor HomePage to integrate agents store and update related components
feat: add runtime event handling for providers in ProvidersSection feat: update routing to include Channels and Agents pages feat: extend route types and navigation items for Channels and Agents feat: implement agents store for managing agent data and interactions fix: update chat store to utilize agents store for agent-related functionality chore: export agents store from index fix: enhance runtime types for better event handling fix: update Vite config to handle dev server URL correctly
This commit is contained in:
436
electron/utils/channels.ts
Normal file
436
electron/utils/channels.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
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 type {
|
||||
ChannelAccountCatalogGroup,
|
||||
ChannelConnectionStatus,
|
||||
ChannelTargetCatalogItem,
|
||||
ChannelTargetKind,
|
||||
ChannelTargetSource,
|
||||
} from '@src/lib/channel-types';
|
||||
|
||||
interface SelectedChannelConfigItem {
|
||||
id: string;
|
||||
channelName: string;
|
||||
channelUrl: string;
|
||||
}
|
||||
|
||||
export interface LocalChannelAccount {
|
||||
id: string;
|
||||
accountId: string;
|
||||
channelType: string;
|
||||
channelName: string;
|
||||
channelUrl: string;
|
||||
label: string;
|
||||
ownerAgentId: string | null;
|
||||
ownerAgentName: string | null;
|
||||
bindingScope: 'account' | 'channel' | null;
|
||||
}
|
||||
|
||||
const CHANNEL_TYPE_ALIASES: Record<string, string> = {
|
||||
douyin: 'douyin',
|
||||
'抖音': 'douyin',
|
||||
fliggy: 'fliggy',
|
||||
'飞猪': 'fliggy',
|
||||
meituan: 'meituan',
|
||||
'美团': 'meituan',
|
||||
};
|
||||
|
||||
const CHANNEL_HOST_TYPE_ALIASES: Record<string, string> = {
|
||||
'life.douyin.com': 'douyin',
|
||||
'douyin.com': 'douyin',
|
||||
'hotel.fliggy.com': 'fliggy',
|
||||
'fliggy.com': 'fliggy',
|
||||
'me.meituan.com': 'meituan',
|
||||
'meituan.com': 'meituan',
|
||||
};
|
||||
|
||||
const TARGET_QUERY_PARAM_LABELS: Record<string, string> = {
|
||||
accountid: 'Account ID',
|
||||
chatid: 'Chat ID',
|
||||
conversationid: 'Conversation ID',
|
||||
groupid: 'Group ID',
|
||||
hotelid: 'Hotel ID',
|
||||
merchantid: 'Merchant ID',
|
||||
openconversationid: 'Open Conversation ID',
|
||||
roomid: 'Room ID',
|
||||
sellerid: 'Seller ID',
|
||||
shopid: 'Shop ID',
|
||||
threadid: 'Thread ID',
|
||||
userid: 'User ID',
|
||||
};
|
||||
|
||||
function normalizeTargetValue(value: string | null | undefined): string {
|
||||
return String(value ?? '').trim();
|
||||
}
|
||||
|
||||
function buildTargetCandidate(
|
||||
value: string | null | undefined,
|
||||
label: string,
|
||||
options: {
|
||||
description?: string;
|
||||
kind?: ChannelTargetKind;
|
||||
source?: ChannelTargetSource;
|
||||
channelType?: string;
|
||||
accountId?: string;
|
||||
} = {},
|
||||
): ChannelTargetCatalogItem | null {
|
||||
const normalizedValue = normalizeTargetValue(value);
|
||||
if (!normalizedValue) return null;
|
||||
|
||||
return {
|
||||
value: normalizedValue,
|
||||
label: label.trim() || normalizedValue,
|
||||
description: options.description?.trim() || undefined,
|
||||
kind: options.kind,
|
||||
source: options.source,
|
||||
channelType: options.channelType,
|
||||
accountId: options.accountId,
|
||||
};
|
||||
}
|
||||
|
||||
function pushTargetCandidate(
|
||||
targetMap: Map<string, ChannelTargetCatalogItem>,
|
||||
candidate: ChannelTargetCatalogItem | null,
|
||||
): void {
|
||||
if (!candidate) return;
|
||||
|
||||
const existing = targetMap.get(candidate.value);
|
||||
if (!existing) {
|
||||
targetMap.set(candidate.value, candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
targetMap.set(candidate.value, {
|
||||
...existing,
|
||||
label: existing.label || candidate.label,
|
||||
description: existing.description || candidate.description,
|
||||
kind: existing.kind || candidate.kind,
|
||||
source: existing.source || candidate.source,
|
||||
channelType: existing.channelType || candidate.channelType,
|
||||
accountId: existing.accountId || candidate.accountId,
|
||||
});
|
||||
}
|
||||
|
||||
function appendUrlTargetCandidates(
|
||||
account: Pick<LocalChannelAccount, 'channelType' | 'accountId' | 'channelName' | 'channelUrl'>,
|
||||
targetMap: Map<string, ChannelTargetCatalogItem>,
|
||||
): void {
|
||||
const rawUrl = String(account.channelUrl ?? '').trim();
|
||||
if (!rawUrl) return;
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(rawUrl);
|
||||
const queryEntries = [
|
||||
...parsedUrl.searchParams.entries(),
|
||||
...new URLSearchParams(parsedUrl.hash.split('?')[1] ?? '').entries(),
|
||||
];
|
||||
|
||||
for (const [rawKey, rawValue] of queryEntries) {
|
||||
const key = rawKey.trim().toLowerCase();
|
||||
const value = rawValue.trim();
|
||||
if (!value) continue;
|
||||
|
||||
const labelBase = TARGET_QUERY_PARAM_LABELS[key]
|
||||
|| (/id$/.test(key) ? key.replace(/id$/, ' ID') : '');
|
||||
if (!labelBase) continue;
|
||||
|
||||
pushTargetCandidate(
|
||||
targetMap,
|
||||
buildTargetCandidate(value, `${labelBase} · ${value}`, {
|
||||
description: `${account.channelName || account.accountId} URL 中发现的候选标识`,
|
||||
kind: 'identifier',
|
||||
source: parsedUrl.searchParams.has(rawKey) ? 'query-param' : 'hash-param',
|
||||
channelType: account.channelType,
|
||||
accountId: account.accountId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (/webhook|hook|bot|robot/i.test(parsedUrl.toString())) {
|
||||
pushTargetCandidate(
|
||||
targetMap,
|
||||
buildTargetCandidate(parsedUrl.toString(), `Webhook · ${account.channelName || account.accountId}`, {
|
||||
description: '渠道 URL 看起来像一个可直接发送的 webhook 目标',
|
||||
kind: 'webhook',
|
||||
source: 'channel-url',
|
||||
channelType: account.channelType,
|
||||
accountId: account.accountId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed URLs and keep the other fallback targets.
|
||||
}
|
||||
}
|
||||
|
||||
function appendHistoricalTargetCandidates(
|
||||
channelType: string,
|
||||
accountId: string | null | undefined,
|
||||
targetMap: Map<string, ChannelTargetCatalogItem>,
|
||||
): void {
|
||||
const normalizedChannelType = String(channelType ?? '').trim();
|
||||
const normalizedAccountId = String(accountId ?? '').trim();
|
||||
|
||||
for (const job of listCronJobs()) {
|
||||
const delivery = job.delivery;
|
||||
if (!delivery || delivery.mode !== 'announce') continue;
|
||||
if (String(delivery.channel ?? '').trim() !== normalizedChannelType) continue;
|
||||
|
||||
const deliveryAccountId = String(delivery.accountId ?? '').trim();
|
||||
if (normalizedAccountId && deliveryAccountId && deliveryAccountId !== normalizedAccountId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetValue = String(delivery.to ?? '').trim();
|
||||
if (!targetValue) continue;
|
||||
|
||||
pushTargetCandidate(
|
||||
targetMap,
|
||||
buildTargetCandidate(targetValue, targetValue, {
|
||||
description: `来自历史定时任务「${job.name || job.id}」的投递目标`,
|
||||
kind: /^https?:\/\//i.test(targetValue) ? 'webhook' : 'name',
|
||||
source: 'fallback',
|
||||
channelType: normalizedChannelType,
|
||||
accountId: deliveryAccountId || normalizedAccountId || undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function formatChannelLabel(channelType: string): string {
|
||||
const parts = String(channelType ?? '')
|
||||
.split(/[-_]/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (parts.length === 0) {
|
||||
return channelType;
|
||||
}
|
||||
|
||||
return parts
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function normalizeSelectedChannel(item: Partial<SelectedChannelConfigItem> | null | undefined): SelectedChannelConfigItem | null {
|
||||
const channelUrl = String(item?.channelUrl ?? '').trim();
|
||||
if (!channelUrl) return null;
|
||||
|
||||
const channelName = String(item?.channelName ?? channelUrl).trim() || channelUrl;
|
||||
const id = String(item?.id ?? channelUrl).trim() || channelUrl;
|
||||
|
||||
return {
|
||||
id,
|
||||
channelName,
|
||||
channelUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function slugifyChannelType(value: string): string {
|
||||
const normalized = value
|
||||
.normalize('NFKD')
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[_\s]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
|
||||
return normalized || 'channel';
|
||||
}
|
||||
|
||||
function inferChannelType(item: SelectedChannelConfigItem): string {
|
||||
const nameKey = item.channelName.trim();
|
||||
if (CHANNEL_TYPE_ALIASES[nameKey]) return CHANNEL_TYPE_ALIASES[nameKey];
|
||||
|
||||
const lowerNameKey = nameKey.toLowerCase();
|
||||
if (CHANNEL_TYPE_ALIASES[lowerNameKey]) return CHANNEL_TYPE_ALIASES[lowerNameKey];
|
||||
|
||||
try {
|
||||
const hostname = new URL(item.channelUrl).hostname.toLowerCase();
|
||||
if (CHANNEL_HOST_TYPE_ALIASES[hostname]) return CHANNEL_HOST_TYPE_ALIASES[hostname];
|
||||
|
||||
const matchedHost = Object.entries(CHANNEL_HOST_TYPE_ALIASES)
|
||||
.find(([host]) => hostname === host || hostname.endsWith(`.${host}`));
|
||||
if (matchedHost) return matchedHost[1];
|
||||
} catch {
|
||||
// Fall through to a slugified channel name.
|
||||
}
|
||||
|
||||
return slugifyChannelType(lowerNameKey || item.id || item.channelUrl);
|
||||
}
|
||||
|
||||
export function getSelectedChannelsConfig(): SelectedChannelConfigItem[] {
|
||||
const saved = configManager.get<SelectedChannelConfigItem[]>(CONFIG_KEYS.SELECTED_CHANNELS);
|
||||
if (!Array.isArray(saved)) return [];
|
||||
|
||||
const deduped = new Map<string, SelectedChannelConfigItem>();
|
||||
for (const item of saved) {
|
||||
const normalized = normalizeSelectedChannel(item);
|
||||
if (!normalized) continue;
|
||||
|
||||
const dedupeKey = `${inferChannelType(normalized)}:${normalized.id}`;
|
||||
if (!deduped.has(dedupeKey)) {
|
||||
deduped.set(dedupeKey, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(deduped.values());
|
||||
}
|
||||
|
||||
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)])
|
||||
: [],
|
||||
);
|
||||
|
||||
return channels.map((item) => {
|
||||
const channelType = inferChannelType(item);
|
||||
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;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
accountId: item.id,
|
||||
channelType,
|
||||
channelName: item.channelName,
|
||||
channelUrl: item.channelUrl,
|
||||
label: item.channelName,
|
||||
ownerAgentId: normalizedOwnerId,
|
||||
ownerAgentName: normalizedOwnerId ? agentNameById.get(normalizedOwnerId) ?? null : null,
|
||||
bindingScope: accountOwnerId ? 'account' : channelOwnerId ? 'channel' : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function listSelectedChannelAccountGroups(
|
||||
snapshot?: Pick<AgentsSnapshot, 'agents' | 'channelOwners' | 'channelAccountOwners'>,
|
||||
): ChannelAccountCatalogGroup[] {
|
||||
const accounts = listSelectedChannelAccounts(snapshot);
|
||||
const groups = new Map<string, ChannelAccountCatalogGroup>();
|
||||
|
||||
for (const account of accounts) {
|
||||
const existing = groups.get(account.channelType) ?? {
|
||||
channelType: account.channelType,
|
||||
channelLabel: account.channelName || formatChannelLabel(account.channelType),
|
||||
defaultAccountId: account.accountId,
|
||||
status: 'connected' as ChannelConnectionStatus,
|
||||
accounts: [],
|
||||
};
|
||||
|
||||
existing.accounts.push({
|
||||
accountId: account.accountId,
|
||||
name: account.label || account.channelName || account.accountId,
|
||||
configured: true,
|
||||
status: 'connected',
|
||||
isDefault: false,
|
||||
agentId: account.ownerAgentId ?? undefined,
|
||||
bindingScope: account.bindingScope ?? undefined,
|
||||
channelUrl: account.channelUrl,
|
||||
});
|
||||
|
||||
groups.set(account.channelType, existing);
|
||||
}
|
||||
|
||||
return Array.from(groups.values())
|
||||
.map((group) => {
|
||||
const sortedAccounts = [...group.accounts].sort((left, right) => left.name.localeCompare(right.name));
|
||||
const defaultAccountId = group.defaultAccountId || sortedAccounts[0]?.accountId || 'default';
|
||||
|
||||
return {
|
||||
...group,
|
||||
defaultAccountId,
|
||||
accounts: sortedAccounts.map((account) => ({
|
||||
...account,
|
||||
isDefault: account.accountId === defaultAccountId,
|
||||
})),
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.channelLabel.localeCompare(right.channelLabel));
|
||||
}
|
||||
|
||||
export function listSelectedChannelTargets(
|
||||
channelType: string,
|
||||
accountId?: string | null,
|
||||
query?: string | null,
|
||||
): ChannelTargetCatalogItem[] {
|
||||
const normalizedChannelType = String(channelType ?? '').trim();
|
||||
const normalizedAccountId = String(accountId ?? '').trim();
|
||||
const normalizedQuery = String(query ?? '').trim().toLowerCase();
|
||||
const accounts = listSelectedChannelAccounts().filter((entry) => {
|
||||
if (entry.channelType !== normalizedChannelType) return false;
|
||||
if (!normalizedAccountId) return true;
|
||||
return entry.accountId === normalizedAccountId;
|
||||
});
|
||||
|
||||
const targetMap = new Map<string, ChannelTargetCatalogItem>();
|
||||
|
||||
for (const account of accounts) {
|
||||
const displayName = account.channelName || account.label || account.accountId;
|
||||
pushTargetCandidate(
|
||||
targetMap,
|
||||
buildTargetCandidate(displayName, displayName, {
|
||||
description: '使用当前渠道账号名称作为默认发送目标',
|
||||
kind: 'name',
|
||||
source: 'channel-name',
|
||||
channelType: account.channelType,
|
||||
accountId: account.accountId,
|
||||
}),
|
||||
);
|
||||
|
||||
pushTargetCandidate(
|
||||
targetMap,
|
||||
buildTargetCandidate(account.accountId, `账号 ID · ${account.accountId}`, {
|
||||
description: '使用当前渠道账号标识作为发送目标',
|
||||
kind: 'identifier',
|
||||
source: 'account-id',
|
||||
channelType: account.channelType,
|
||||
accountId: account.accountId,
|
||||
}),
|
||||
);
|
||||
|
||||
appendUrlTargetCandidates(account, targetMap);
|
||||
}
|
||||
|
||||
appendHistoricalTargetCandidates(normalizedChannelType, normalizedAccountId || null, targetMap);
|
||||
|
||||
if (targetMap.size === 0 && normalizedAccountId) {
|
||||
pushTargetCandidate(
|
||||
targetMap,
|
||||
buildTargetCandidate(normalizedAccountId, `账号 ID · ${normalizedAccountId}`, {
|
||||
description: '当前账号没有更多可发现目标,保留账号标识作为兜底候选',
|
||||
kind: 'identifier',
|
||||
source: 'fallback',
|
||||
channelType: normalizedChannelType,
|
||||
accountId: normalizedAccountId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Array.from(targetMap.values()).sort((left, right) => {
|
||||
const kindOrder: Record<ChannelTargetKind, number> = {
|
||||
name: 0,
|
||||
identifier: 1,
|
||||
webhook: 2,
|
||||
url: 3,
|
||||
};
|
||||
const leftOrder = left.kind ? kindOrder[left.kind] : 99;
|
||||
const rightOrder = right.kind ? kindOrder[right.kind] : 99;
|
||||
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
|
||||
return left.label.localeCompare(right.label, 'zh-CN');
|
||||
}).filter((entry) => {
|
||||
if (!normalizedQuery) return true;
|
||||
const haystacks = [
|
||||
entry.value,
|
||||
entry.label,
|
||||
entry.description ?? '',
|
||||
].map((value) => value.toLowerCase());
|
||||
return haystacks.some((value) => value.includes(normalizedQuery));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user