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

View File

@@ -0,0 +1,500 @@
import * as fs from 'fs';
import * as path from 'path';
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';
interface StoredAgentEntry {
id: string;
name: string;
providerAccountId?: string | null;
modelRef?: string | null;
workspace?: string | null;
agentDir?: string | null;
channelTypes?: string[];
createdAt?: string;
updatedAt?: string;
}
interface StoredAgentsConfig {
agents: StoredAgentEntry[];
channelOwners?: Record<string, string>;
channelAccountOwners?: Record<string, string>;
mainSessionSuffix?: string;
}
const STORE_FILE_NAME = 'agents.json';
function getStorePath(): string {
return path.join(getUserDataDir(), STORE_FILE_NAME);
}
function getAgentsRootDir(): string {
return path.join(getUserDataDir(), 'agents');
}
function getMainWorkspacePath(): string {
return path.join(getAgentsRootDir(), DEFAULT_AGENT_ID, 'workspace');
}
function getMainAgentDirPath(): string {
return path.join(getAgentsRootDir(), DEFAULT_AGENT_ID, 'agent');
}
function getAgentWorkspacePath(agentId: string): string {
return path.join(getAgentsRootDir(), agentId, 'workspace');
}
function getAgentDirPath(agentId: string): string {
return path.join(getAgentsRootDir(), agentId, 'agent');
}
function ensureDir(dirPath: string | null | undefined): void {
if (!dirPath) return;
fs.mkdirSync(dirPath, { recursive: true });
}
function readStore(): StoredAgentsConfig {
try {
const filePath = getStorePath();
if (fs.existsSync(filePath)) {
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Partial<StoredAgentsConfig>;
return {
agents: Array.isArray(parsed.agents) ? parsed.agents : [],
channelOwners: parsed.channelOwners && typeof parsed.channelOwners === 'object' ? parsed.channelOwners : {},
channelAccountOwners: parsed.channelAccountOwners && typeof parsed.channelAccountOwners === 'object'
? parsed.channelAccountOwners
: {},
mainSessionSuffix: typeof parsed.mainSessionSuffix === 'string' ? parsed.mainSessionSuffix : DEFAULT_MAIN_SESSION_SUFFIX,
};
}
} catch {
// Fall back to an empty store on malformed JSON.
}
return {
agents: [],
channelOwners: {},
channelAccountOwners: {},
mainSessionSuffix: DEFAULT_MAIN_SESSION_SUFFIX,
};
}
function writeStore(store: StoredAgentsConfig): void {
const filePath = getStorePath();
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(store, null, 2), 'utf-8');
}
function formatModelDisplay(modelRef: string | null | undefined, fallbackLabel: string): string {
const trimmed = String(modelRef ?? '').trim();
if (!trimmed) return fallbackLabel;
const parts = trimmed.split('/');
return parts[parts.length - 1] || trimmed;
}
function normalizeAgentName(name: string): string {
return name.trim() || 'Agent';
}
function slugifyAgentId(name: string): string {
const normalized = name
.normalize('NFKD')
.replace(/[^\w\s-]/g, '')
.toLowerCase()
.replace(/[_\s]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
if (!normalized || /^\d+$/.test(normalized)) return 'agent';
if (normalized === DEFAULT_AGENT_ID) return 'agent';
return normalized;
}
function buildUniqueAgentId(existingIds: Set<string>, name: string): string {
const baseId = slugifyAgentId(name);
let nextId = baseId;
let index = 2;
while (existingIds.has(nextId)) {
nextId = `${baseId}-${index}`;
index += 1;
}
return nextId;
}
function getDefaultAccount(accounts: ProviderAccount[], defaultAccountId: string | null): ProviderAccount | null {
return accounts.find((account) => account.id === defaultAccountId) ?? accounts[0] ?? null;
}
function buildMainAgent(defaultAccount: ProviderAccount | null, mainSessionSuffix: string): AgentSummary {
ensureDir(getMainWorkspacePath());
ensureDir(getMainAgentDirPath());
return {
id: DEFAULT_AGENT_ID,
name: 'Main Agent',
isDefault: true,
providerAccountId: defaultAccount?.id ?? null,
modelRef: defaultAccount?.model ?? null,
modelDisplay: formatModelDisplay(defaultAccount?.model, defaultAccount?.label || 'Unassigned'),
mainSessionKey: buildMainSessionKey(DEFAULT_AGENT_ID, mainSessionSuffix),
vendorId: defaultAccount?.vendorId ?? null,
source: 'synthetic-main',
overrideModelRef: null,
inheritedModel: true,
workspace: getMainWorkspacePath(),
agentDir: getMainAgentDirPath(),
channelTypes: [],
};
}
function collectChannelOwners(
store: StoredAgentsConfig,
entries: StoredAgentEntry[],
): { channelOwners: Record<string, string>; channelAccountOwners: Record<string, string> } {
const explicitChannelOwners = store.channelOwners && typeof store.channelOwners === 'object'
? { ...store.channelOwners }
: {};
const explicitChannelAccountOwners = store.channelAccountOwners && typeof store.channelAccountOwners === 'object'
? { ...store.channelAccountOwners }
: {};
for (const entry of entries) {
const normalizedId = normalizeAgentId(entry.id);
const channels = Array.isArray(entry.channelTypes) ? entry.channelTypes.filter(Boolean) : [];
for (const channelType of channels) {
if (!explicitChannelOwners[channelType]) {
explicitChannelOwners[channelType] = normalizedId;
}
}
}
return {
channelOwners: explicitChannelOwners,
channelAccountOwners: explicitChannelAccountOwners,
};
}
function mapStoredAgentToSummary(
entry: StoredAgentEntry,
accounts: ProviderAccount[],
defaultAccount: ProviderAccount | null,
mainSessionSuffix: string,
): AgentSummary {
const normalizedId = normalizeAgentId(entry.id);
const configuredAccount = entry.providerAccountId
? accounts.find((account) => account.id === entry.providerAccountId) ?? null
: null;
const effectiveAccount = configuredAccount ?? defaultAccount;
const effectiveModelRef = entry.modelRef ?? effectiveAccount?.model ?? defaultAccount?.model ?? null;
const workspace = entry.workspace || getAgentWorkspacePath(normalizedId);
const agentDir = entry.agentDir || getAgentDirPath(normalizedId);
ensureDir(agentDir);
ensureDir(workspace);
return {
id: normalizedId,
name: normalizeAgentName(entry.name),
isDefault: false,
providerAccountId: entry.providerAccountId ?? null,
modelRef: effectiveModelRef,
modelDisplay: formatModelDisplay(effectiveModelRef, normalizeAgentName(entry.name)),
mainSessionKey: buildMainSessionKey(normalizedId, mainSessionSuffix),
vendorId: configuredAccount?.vendorId ?? effectiveAccount?.vendorId ?? null,
source: 'agent-config',
overrideModelRef: entry.modelRef ?? null,
inheritedModel: !entry.modelRef,
workspace,
agentDir,
channelTypes: Array.isArray(entry.channelTypes) ? entry.channelTypes.filter(Boolean) : [],
};
}
function buildSnapshotFromStore(
store: StoredAgentsConfig,
accounts: ProviderAccount[],
defaultAccountId: string | null,
): AgentsSnapshot {
const defaultAccount = getDefaultAccount(accounts, defaultAccountId);
const mainSessionSuffix = store.mainSessionSuffix || DEFAULT_MAIN_SESSION_SUFFIX;
const normalizedEntries = Array.from(
new Map(
store.agents
.filter((entry) => Boolean(entry) && typeof entry.id === 'string' && entry.id.trim().length > 0)
.map((entry) => [normalizeAgentId(entry.id), { ...entry, id: normalizeAgentId(entry.id) }]),
).values(),
).filter((entry) => entry.id !== DEFAULT_AGENT_ID);
const { channelOwners, channelAccountOwners } = collectChannelOwners(store, normalizedEntries);
const agents = [
buildMainAgent(defaultAccount, mainSessionSuffix),
...normalizedEntries.map((entry) => mapStoredAgentToSummary(entry, accounts, defaultAccount, mainSessionSuffix)),
];
const configuredChannelTypes = Array.from(new Set([
...Object.keys(channelOwners),
...Object.keys(channelAccountOwners).map((key) => key.split(':')[0]).filter(Boolean),
]));
return {
agents,
models: agents,
defaultAgentId: DEFAULT_AGENT_ID,
defaultProviderAccountId: defaultAccount?.id ?? null,
defaultModelRef: defaultAccount?.model ?? null,
mainSessionSuffix,
configuredChannelTypes,
channelOwners,
channelAccountOwners,
};
}
function syncAgentChannelMembership(store: StoredAgentsConfig, channelType: string): void {
const normalizedChannelType = String(channelType ?? '').trim();
if (!normalizedChannelType) return;
const ownerIds = new Set<string>();
const channelOwnerId = store.channelOwners?.[normalizedChannelType];
if (channelOwnerId) {
ownerIds.add(normalizeAgentId(channelOwnerId));
}
for (const [key, ownerId] of Object.entries(store.channelAccountOwners ?? {})) {
if (!key.startsWith(`${normalizedChannelType}:`) || !ownerId) continue;
ownerIds.add(normalizeAgentId(ownerId));
}
store.agents = store.agents.map((entry) => {
const normalizedId = normalizeAgentId(entry.id);
const currentChannelTypes = Array.isArray(entry.channelTypes) ? entry.channelTypes.filter(Boolean) : [];
const hasChannel = currentChannelTypes.includes(normalizedChannelType);
const shouldHaveChannel = ownerIds.has(normalizedId);
if (hasChannel === shouldHaveChannel) {
return entry;
}
return {
...entry,
channelTypes: shouldHaveChannel
? [...currentChannelTypes, normalizedChannelType]
: currentChannelTypes.filter((value) => value !== normalizedChannelType),
updatedAt: new Date().toISOString(),
};
});
}
export function listAgentsSnapshot(
accounts: ProviderAccount[],
defaultAccountId: string | null,
): AgentsSnapshot {
return buildSnapshotFromStore(readStore(), accounts, defaultAccountId);
}
export function createAgentConfig(
name: string,
options: { inheritWorkspace?: boolean } | undefined,
accounts: ProviderAccount[],
defaultAccountId: string | null,
): AgentsSnapshot {
const trimmedName = normalizeAgentName(name);
const store = readStore();
const existingIds = new Set(store.agents.map((entry) => normalizeAgentId(entry.id)));
const agentId = buildUniqueAgentId(existingIds, trimmedName);
const now = new Date().toISOString();
const workspace = options?.inheritWorkspace ? getMainWorkspacePath() : getAgentWorkspacePath(agentId);
const agentDir = getAgentDirPath(agentId);
ensureDir(workspace);
ensureDir(agentDir);
store.agents = [
...store.agents,
{
id: agentId,
name: trimmedName,
providerAccountId: null,
modelRef: null,
workspace,
agentDir,
channelTypes: [],
createdAt: now,
updatedAt: now,
},
];
writeStore(store);
return buildSnapshotFromStore(store, accounts, defaultAccountId);
}
export function updateAgentName(
agentId: string,
name: string,
accounts: ProviderAccount[],
defaultAccountId: string | null,
): AgentsSnapshot {
const normalizedId = normalizeAgentId(agentId);
if (normalizedId === DEFAULT_AGENT_ID) {
throw new Error('Main Agent cannot be renamed');
}
const store = readStore();
const index = store.agents.findIndex((entry) => normalizeAgentId(entry.id) === normalizedId);
if (index === -1) {
throw new Error(`Agent "${normalizedId}" not found`);
}
store.agents[index] = {
...store.agents[index],
name: normalizeAgentName(name),
updatedAt: new Date().toISOString(),
};
writeStore(store);
return buildSnapshotFromStore(store, accounts, defaultAccountId);
}
export function updateAgentModelConfig(
agentId: string,
modelRef: string | null,
providerAccountId: string | null | undefined,
accounts: ProviderAccount[],
defaultAccountId: string | null,
): AgentsSnapshot {
const normalizedId = normalizeAgentId(agentId);
if (normalizedId === DEFAULT_AGENT_ID) {
throw new Error('Main Agent model is managed from Models');
}
const store = readStore();
const index = store.agents.findIndex((entry) => normalizeAgentId(entry.id) === normalizedId);
if (index === -1) {
throw new Error(`Agent "${normalizedId}" not found`);
}
const trimmedModelRef = typeof modelRef === 'string' ? modelRef.trim() : '';
const inferredAccountId = providerAccountId
?? (
trimmedModelRef
? accounts.find((account) => account.model === trimmedModelRef)?.id ?? store.agents[index].providerAccountId ?? null
: null
);
store.agents[index] = {
...store.agents[index],
providerAccountId: inferredAccountId ?? null,
modelRef: trimmedModelRef || null,
updatedAt: new Date().toISOString(),
};
writeStore(store);
return buildSnapshotFromStore(store, accounts, defaultAccountId);
}
export function deleteAgentConfig(
agentId: string,
accounts: ProviderAccount[],
defaultAccountId: string | null,
): AgentsSnapshot {
const normalizedId = normalizeAgentId(agentId);
if (normalizedId === DEFAULT_AGENT_ID) {
throw new Error('Main Agent cannot be deleted');
}
const store = readStore();
const existingAgent = store.agents.find((entry) => normalizeAgentId(entry.id) === normalizedId);
if (!existingAgent) {
throw new Error(`Agent "${normalizedId}" not found`);
}
store.agents = store.agents.filter((entry) => normalizeAgentId(entry.id) !== normalizedId);
store.channelOwners = Object.fromEntries(
Object.entries(store.channelOwners ?? {}).filter(([, ownerId]) => normalizeAgentId(ownerId) !== normalizedId),
);
store.channelAccountOwners = Object.fromEntries(
Object.entries(store.channelAccountOwners ?? {}).filter(([, ownerId]) => normalizeAgentId(ownerId) !== normalizedId),
);
writeStore(store);
const agentRootDir = path.join(getAgentsRootDir(), normalizedId);
try {
fs.rmSync(agentRootDir, { recursive: true, force: true });
} catch {
// Best effort cleanup only.
}
return buildSnapshotFromStore(store, accounts, defaultAccountId);
}
export function assignChannelToAgent(
agentId: string,
channelType: string,
accountId: string | null | undefined,
accounts: ProviderAccount[],
defaultAccountId: string | null,
): AgentsSnapshot {
const normalizedId = normalizeAgentId(agentId);
const normalizedChannelType = String(channelType ?? '').trim();
if (!normalizedChannelType) {
throw new Error('channelType is required');
}
const store = readStore();
if (normalizedId !== DEFAULT_AGENT_ID && !store.agents.some((entry) => normalizeAgentId(entry.id) === normalizedId)) {
throw new Error(`Agent "${normalizedId}" not found`);
}
store.channelOwners = {
...(store.channelOwners ?? {}),
...(accountId ? {} : { [normalizedChannelType]: normalizedId }),
};
if (accountId) {
store.channelAccountOwners = {
...(store.channelAccountOwners ?? {}),
[`${normalizedChannelType}:${accountId}`]: normalizedId,
};
}
syncAgentChannelMembership(store, normalizedChannelType);
writeStore(store);
return buildSnapshotFromStore(store, accounts, defaultAccountId);
}
export function clearChannelBinding(
channelType: string,
accountId: string | null | undefined,
accounts: ProviderAccount[],
defaultAccountId: string | null,
): AgentsSnapshot {
const normalizedChannelType = String(channelType ?? '').trim();
if (!normalizedChannelType) {
throw new Error('channelType is required');
}
const store = readStore();
const ownerId = accountId
? store.channelAccountOwners?.[`${normalizedChannelType}:${accountId}`]
: store.channelOwners?.[normalizedChannelType];
if (accountId) {
const nextAccountOwners = { ...(store.channelAccountOwners ?? {}) };
delete nextAccountOwners[`${normalizedChannelType}:${accountId}`];
store.channelAccountOwners = nextAccountOwners;
} else {
const nextChannelOwners = { ...(store.channelOwners ?? {}) };
delete nextChannelOwners[normalizedChannelType];
store.channelOwners = nextChannelOwners;
}
if (ownerId) {
syncAgentChannelMembership(store, normalizedChannelType);
}
writeStore(store);
return buildSnapshotFromStore(store, accounts, defaultAccountId);
}

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));
});
}

View File

@@ -1,7 +1,7 @@
import path from "node:path";
import { app } from 'electron';
import { getUserDataDir } from '../paths';
// 多账号隔离
export function getProfileDir (accountId: string) {
return path.join(app.getPath('userData'), `profiles`, accountId);
}
return path.join(getUserDataDir(), `profiles`, accountId);
}

View File

@@ -0,0 +1,417 @@
import { randomUUID } from 'node:crypto';
import * as fs from 'fs';
import * as path from 'path';
import type {
CronJob,
CronJobCreateInput,
CronJobDelivery,
CronJobLastRun,
CronJobUpdateInput,
CronSchedule,
} from '@src/lib/cron-types';
import { getUserDataDir } from './paths';
interface StoredCronJob extends CronJob {
agentId?: string | null;
}
interface CronStore {
jobs: StoredCronJob[];
}
const CRON_STORE_PATH = path.join(getUserDataDir(), 'cron', 'jobs.json');
const MAX_CRON_LOOKAHEAD_MINUTES = 366 * 24 * 60;
function readStore(): CronStore {
try {
if (fs.existsSync(CRON_STORE_PATH)) {
const parsed = JSON.parse(fs.readFileSync(CRON_STORE_PATH, 'utf-8')) as Partial<CronStore>;
return {
jobs: Array.isArray(parsed.jobs) ? parsed.jobs : [],
};
}
} catch {
// Fall back to an empty store on malformed JSON.
}
return { jobs: [] };
}
function writeStore(store: CronStore): void {
fs.mkdirSync(path.dirname(CRON_STORE_PATH), { recursive: true });
fs.writeFileSync(CRON_STORE_PATH, JSON.stringify(store, null, 2), 'utf-8');
}
function normalizeString(value: unknown): string {
return String(value ?? '').trim();
}
function normalizeIsoDate(value: unknown, fallback?: string): string {
const raw = normalizeString(value);
const ms = Date.parse(raw);
if (Number.isFinite(ms)) {
return new Date(ms).toISOString();
}
if (fallback) {
return fallback;
}
return new Date().toISOString();
}
function normalizeDelivery(value: unknown): CronJobDelivery | undefined {
if (!value || typeof value !== 'object') {
return { mode: 'none' };
}
const input = value as Partial<CronJobDelivery>;
const mode = input.mode === 'announce' ? 'announce' : 'none';
if (mode === 'announce') {
const channel = normalizeString(input.channel);
const to = normalizeString(input.to);
const accountId = normalizeString(input.accountId);
return {
mode,
channel: channel || undefined,
to: to || undefined,
accountId: accountId || undefined,
};
}
return { mode: 'none' };
}
function normalizeLastRun(value: unknown): CronJobLastRun | undefined {
if (!value || typeof value !== 'object') return undefined;
const input = value as Partial<CronJobLastRun>;
const time = normalizeString(input.time);
if (!time) return undefined;
return {
time: normalizeIsoDate(time),
success: input.success !== false,
error: normalizeString(input.error) || undefined,
duration: typeof input.duration === 'number' && Number.isFinite(input.duration) ? input.duration : undefined,
};
}
function normalizeSchedule(value: unknown, fallback?: CronJob['schedule']): CronJob['schedule'] | null {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed || fallback || null;
}
if (!value || typeof value !== 'object') {
return fallback || null;
}
const input = value as Partial<CronSchedule>;
if (input.kind === 'at') {
const at = normalizeString(input.at);
return at ? { kind: 'at', at: normalizeIsoDate(at) } : fallback || null;
}
if (input.kind === 'every') {
const everyMs = typeof input.everyMs === 'number' && Number.isFinite(input.everyMs) ? input.everyMs : 0;
if (everyMs <= 0) return fallback || null;
return {
kind: 'every',
everyMs,
anchorMs: typeof input.anchorMs === 'number' && Number.isFinite(input.anchorMs) ? input.anchorMs : undefined,
};
}
if (input.kind === 'cron') {
const expr = normalizeString(input.expr);
return expr
? {
kind: 'cron',
expr,
tz: normalizeString(input.tz) || undefined,
}
: fallback || null;
}
return fallback || null;
}
function parseCronNumberToken(token: string, min: number, max: number, isDayOfWeek = false): number | null {
if (!/^\d+$/.test(token)) return null;
const parsed = Number(token);
if (!Number.isFinite(parsed)) return null;
if (isDayOfWeek && parsed === 7) return 0;
if (parsed < min || parsed > max) return null;
return parsed;
}
function matchesCronField(expression: string, value: number, min: number, max: number, isDayOfWeek = false): boolean {
const trimmed = expression.trim();
if (!trimmed) return false;
if (trimmed === '*') return true;
return trimmed.split(',').some((segment) => {
const part = segment.trim();
if (!part) return false;
if (part === '*') return true;
const [rangeExpression, stepExpression] = part.split('/');
const step = stepExpression ? Number(stepExpression) : 1;
if (!Number.isFinite(step) || step <= 0) return false;
if (rangeExpression === '*') {
return (value - min) % step === 0;
}
if (rangeExpression.includes('-')) {
const [startRaw, endRaw] = rangeExpression.split('-');
const start = parseCronNumberToken(startRaw.trim(), min, max, isDayOfWeek);
const end = parseCronNumberToken(endRaw.trim(), min, max, isDayOfWeek);
if (start == null || end == null || start > end) return false;
if (value < start || value > end) return false;
return (value - start) % step === 0;
}
const literal = parseCronNumberToken(rangeExpression.trim(), min, max, isDayOfWeek);
if (literal == null) return false;
return value === literal;
});
}
function estimateNextCronRun(expr: string, now = new Date()): string | undefined {
const parts = expr.trim().split(/\s+/);
if (parts.length !== 5) return undefined;
const [minuteExpr, hourExpr, dayOfMonthExpr, monthExpr, dayOfWeekExpr] = parts;
const candidate = new Date(now);
candidate.setSeconds(0, 0);
candidate.setMinutes(candidate.getMinutes() + 1);
for (let index = 0; index < MAX_CRON_LOOKAHEAD_MINUTES; index += 1) {
const minute = candidate.getMinutes();
const hour = candidate.getHours();
const dayOfMonth = candidate.getDate();
const month = candidate.getMonth() + 1;
const dayOfWeek = candidate.getDay();
if (
matchesCronField(minuteExpr, minute, 0, 59)
&& matchesCronField(hourExpr, hour, 0, 23)
&& matchesCronField(dayOfMonthExpr, dayOfMonth, 1, 31)
&& matchesCronField(monthExpr, month, 1, 12)
&& matchesCronField(dayOfWeekExpr, dayOfWeek, 0, 7, true)
) {
return candidate.toISOString();
}
candidate.setMinutes(candidate.getMinutes() + 1);
}
return undefined;
}
function estimateNextRun(schedule: CronJob['schedule'], enabled: boolean): string | undefined {
if (!enabled) return undefined;
if (typeof schedule === 'string') {
return estimateNextCronRun(schedule);
}
if (schedule.kind === 'at') {
const atMs = Date.parse(schedule.at);
if (!Number.isFinite(atMs) || atMs <= Date.now()) return undefined;
return new Date(atMs).toISOString();
}
if (schedule.kind === 'every') {
if (!Number.isFinite(schedule.everyMs) || schedule.everyMs <= 0) return undefined;
const nowMs = Date.now();
const anchorMs = typeof schedule.anchorMs === 'number' && Number.isFinite(schedule.anchorMs)
? schedule.anchorMs
: nowMs;
const delta = Math.max(nowMs - anchorMs, 0);
const steps = Math.floor(delta / schedule.everyMs) + 1;
return new Date(anchorMs + steps * schedule.everyMs).toISOString();
}
return estimateNextCronRun(schedule.expr);
}
function normalizeStoredJob(input: Partial<StoredCronJob> | null | undefined): StoredCronJob | null {
if (!input || typeof input !== 'object') return null;
const id = normalizeString(input.id);
const name = normalizeString(input.name);
const message = normalizeString(input.message);
const schedule = normalizeSchedule(input.schedule);
if (!id || !name || !message || !schedule) {
return null;
}
const createdAt = normalizeIsoDate(input.createdAt);
const updatedAt = normalizeIsoDate(input.updatedAt, createdAt);
const enabled = input.enabled !== false;
return {
id,
name,
message,
schedule,
delivery: normalizeDelivery(input.delivery),
enabled,
createdAt,
updatedAt,
lastRun: normalizeLastRun(input.lastRun),
nextRun: estimateNextRun(schedule, enabled),
agentId: normalizeString(input.agentId) || undefined,
};
}
function listNormalizedJobs(): StoredCronJob[] {
return readStore().jobs
.map((job) => normalizeStoredJob(job))
.filter((job): job is StoredCronJob => Boolean(job))
.sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
}
function writeJobs(jobs: StoredCronJob[]): StoredCronJob[] {
writeStore({ jobs });
return jobs;
}
function ensureCreateInput(input: CronJobCreateInput): void {
if (!normalizeString(input.name)) {
throw new Error('name is required');
}
if (!normalizeString(input.message)) {
throw new Error('message is required');
}
if (!normalizeString(input.schedule)) {
throw new Error('schedule is required');
}
}
export function listCronJobs(): CronJob[] {
return listNormalizedJobs();
}
export function createCronJob(input: CronJobCreateInput & { agentId?: string | null }): CronJob {
ensureCreateInput(input);
const jobs = listNormalizedJobs();
const now = new Date().toISOString();
const schedule = normalizeSchedule(input.schedule);
if (!schedule) {
throw new Error('schedule is required');
}
const nextJob = normalizeStoredJob({
id: `cron-${randomUUID()}`,
name: input.name,
message: input.message,
schedule,
delivery: input.delivery,
enabled: input.enabled !== false,
createdAt: now,
updatedAt: now,
agentId: input.agentId,
});
if (!nextJob) {
throw new Error('Failed to create cron job');
}
writeJobs([...jobs, nextJob]);
return nextJob;
}
export function updateCronJob(jobId: string, input: CronJobUpdateInput & { agentId?: string | null }): CronJob {
const normalizedJobId = normalizeString(jobId);
if (!normalizedJobId) {
throw new Error('id is required');
}
const jobs = listNormalizedJobs();
const index = jobs.findIndex((job) => job.id === normalizedJobId);
if (index === -1) {
throw new Error(`Cron job "${normalizedJobId}" not found`);
}
const currentJob = jobs[index];
const schedule = normalizeSchedule(input.schedule, currentJob.schedule);
const nextJob = normalizeStoredJob({
...currentJob,
name: normalizeString(input.name) || currentJob.name,
message: normalizeString(input.message) || currentJob.message,
schedule,
delivery: typeof input.delivery === 'undefined' ? currentJob.delivery : input.delivery,
enabled: typeof input.enabled === 'boolean' ? input.enabled : currentJob.enabled,
updatedAt: new Date().toISOString(),
agentId: typeof input.agentId === 'undefined' ? currentJob.agentId : input.agentId,
});
if (!nextJob) {
throw new Error(`Cron job "${normalizedJobId}" could not be normalized`);
}
jobs[index] = nextJob;
writeJobs(jobs);
return nextJob;
}
export function deleteCronJob(jobId: string): { id: string } {
const normalizedJobId = normalizeString(jobId);
if (!normalizedJobId) {
throw new Error('id is required');
}
const jobs = listNormalizedJobs();
const nextJobs = jobs.filter((job) => job.id !== normalizedJobId);
if (nextJobs.length === jobs.length) {
throw new Error(`Cron job "${normalizedJobId}" not found`);
}
writeJobs(nextJobs);
return { id: normalizedJobId };
}
export function toggleCronJob(jobId: string, enabled: boolean): CronJob {
return updateCronJob(jobId, { enabled });
}
export function triggerCronJob(jobId: string): CronJob {
const normalizedJobId = normalizeString(jobId);
if (!normalizedJobId) {
throw new Error('id is required');
}
const jobs = listNormalizedJobs();
const index = jobs.findIndex((job) => job.id === normalizedJobId);
if (index === -1) {
throw new Error(`Cron job "${normalizedJobId}" not found`);
}
const currentJob = jobs[index];
const nextJob = normalizeStoredJob({
...currentJob,
updatedAt: new Date().toISOString(),
lastRun: {
time: new Date().toISOString(),
success: true,
},
});
if (!nextJob) {
throw new Error(`Cron job "${normalizedJobId}" could not be normalized`);
}
jobs[index] = nextJob;
writeJobs(jobs);
return nextJob;
}

View File

@@ -7,6 +7,7 @@ export const OPENCLAW_CONFIG_DIR_NAME = '.openclaw';
export const OPENCLAW_RUNTIME_DIR_NAME = 'runtime';
export const OPENCLAW_PACKAGE_DIR_NAME = 'openclaw';
export const OPENCLAW_ENTRY_FILE_NAME = 'openclaw.mjs';
export const USER_DATA_DIR_ENV_NAME = 'ZN_AI_USER_DATA_DIR';
export interface OpenClawRuntimePaths {
configDir: string;
@@ -20,6 +21,14 @@ export function getOpenClawConfigDir(): string {
return join(homedir(), OPENCLAW_CONFIG_DIR_NAME);
}
export function getUserDataDir(): string {
const override = process.env[USER_DATA_DIR_ENV_NAME]?.trim();
if (override) {
return override;
}
return app.getPath('userData');
}
export function getOpenClawRuntimeDir(): string {
return join(getOpenClawConfigDir(), OPENCLAW_RUNTIME_DIR_NAME);
}

View File

@@ -1,7 +1,7 @@
import { app } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { parseSessionKey } from '@runtime/lib/models';
import { getUserDataDir } from './paths';
const PRIMARY_TRANSCRIPT_ROOT_DIR = 'models';
const LEGACY_TRANSCRIPT_ROOT_DIR = 'agents';
@@ -15,7 +15,7 @@ function buildTranscriptFilePath(sessionKey: string, rootDirName: string): strin
sessionId = 'unknown';
}
const baseDir = path.join(app.getPath('userData'), rootDirName, agentId, 'sessions');
const baseDir = path.join(getUserDataDir(), rootDirName, agentId, 'sessions');
return path.join(baseDir, `${sessionId}.jsonl`);
}

View File

@@ -1,7 +1,7 @@
import { readdir, readFile, stat } from 'fs/promises';
import { join } from 'path';
import { app } from 'electron';
import logManager from '@electron/service/logger';
import { getUserDataDir } from './paths';
import {
extractSessionIdFromTranscriptFileName,
parseUsageEntriesFromJsonl,
@@ -17,7 +17,7 @@ export {
const TRANSCRIPT_ROOT_DIR_NAMES = ['models', 'agents'] as const;
async function listAgentIdsWithSessionDirs(rootDirName: string): Promise<string[]> {
const rootDir = join(app.getPath('userData'), rootDirName);
const rootDir = join(getUserDataDir(), rootDirName);
const agentIds = new Set<string>();
try {
@@ -45,7 +45,7 @@ async function listRecentSessionFiles(): Promise<Array<{ filePath: string; sessi
const agentEntries = await listAgentIdsWithSessionDirs(rootDirName);
for (const agentId of agentEntries) {
const sessionsDir = join(app.getPath('userData'), rootDirName, agentId, 'sessions');
const sessionsDir = join(getUserDataDir(), rootDirName, agentId, 'sessions');
try {
const sessionEntries = await readdir(sessionsDir);