Files
zn-ai/electron/utils/agent-config.ts
duanshuwen ee72cf7261 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
2026-04-18 14:56:32 +08:00

501 lines
16 KiB
TypeScript

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