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:
500
electron/utils/agent-config.ts
Normal file
500
electron/utils/agent-config.ts
Normal 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
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));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
417
electron/utils/cron-store.ts
Normal file
417
electron/utils/cron-store.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user