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:
duanshuwen
2026-04-18 14:56:32 +08:00
parent dfa4388087
commit ee72cf7261
52 changed files with 6626 additions and 189 deletions

436
electron/utils/channels.ts Normal file
View 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));
});
}