feat: prepare Zhinian desktop client for pilot release

This commit is contained in:
inman
2026-04-29 10:23:20 +08:00
parent f9361e686a
commit 47b83b79fc
149 changed files with 15341 additions and 3590 deletions

View File

@@ -21,6 +21,8 @@ import {
assignChannelAccountToAgent,
clearAllBindingsForChannel,
clearChannelBinding,
deleteAgentConfig,
ensureChannelAgentForAccount,
listAgentsSnapshot,
listAgentsSnapshotFromConfig,
} from '../../utils/agent-config';
@@ -48,6 +50,7 @@ import {
import { getOpenClawConfigDir } from '../../utils/paths';
import {
cancelWeChatLoginSession,
listWeChatAccountAliases,
saveWeChatAccountState,
startWeChatLoginSession,
waitForWeChatLoginSession,
@@ -309,28 +312,13 @@ function isSameConfigValues(
async function ensureScopedChannelBinding(channelType: string, accountId?: string): Promise<void> {
const storedChannelType = resolveStoredChannelType(channelType);
// Multi-agent safety: only bind when the caller explicitly scopes the account.
// Global channel saves (no accountId) must not override routing to "main".
if (!accountId) return;
const agents = await listAgentsSnapshot();
if (!agents.agents || agents.agents.length === 0) return;
const scopedAccountId = accountId?.trim() || 'default';
// Keep backward compatibility for the legacy default account.
if (accountId === 'default') {
if (agents.agents.some((entry) => entry.id === 'main')) {
await assignChannelAccountToAgent('main', storedChannelType, 'default');
}
return;
}
// Legacy compatibility: if accountId matches an existing agentId, keep auto-binding.
if (agents.agents.some((entry) => entry.id === accountId)) {
if (scopedAccountId !== 'default') {
await migrateLegacyChannelWideBinding(storedChannelType);
await assignChannelAccountToAgent(accountId, storedChannelType, accountId);
return;
}
await migrateLegacyChannelWideBinding(storedChannelType);
await ensureChannelAgentForAccount(storedChannelType, scopedAccountId);
}
async function migrateLegacyChannelWideBinding(channelType: string): Promise<void> {
@@ -512,6 +500,78 @@ const channelTargetCache = new Map<string, { expiresAt: number; targets: Channel
let lastChannelsStatusOkAt: number | undefined;
let lastChannelsStatusFailureAt: number | undefined;
const AUTO_AGENT_BOUND_CHANNELS = new Set([
'agentbus',
]);
function nextChannelStatusTimestamp(previous?: number): number {
return Math.max(Date.now(), (previous ?? 0) + 1);
}
function normalizeChannelAgentIdSegment(value: string): string {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 48);
return normalized || 'default';
}
function buildManagedChannelAgentId(channelType: string, accountId: string): string {
const channelSegment = normalizeChannelAgentIdSegment(toUiChannelType(channelType));
const accountSegment = normalizeChannelAgentIdSegment(accountId || 'default');
return accountSegment === 'default'
? `channel-${channelSegment}`
: `channel-${channelSegment}-${accountSegment}`;
}
function isManagedChannelAgent(channelType: string, accountId: string, agentId: string | null | undefined): boolean {
return Boolean(agentId && agentId === buildManagedChannelAgentId(channelType, accountId));
}
async function deleteManagedChannelAgentIfOwned(channelType: string, accountId: string): Promise<void> {
const storedChannelType = resolveStoredChannelType(channelType);
const owner = await readChannelBindingOwner(storedChannelType, accountId);
if (!isManagedChannelAgent(storedChannelType, accountId, owner)) return;
await deleteAgentConfig(owner!);
logger.info('[channels.config] deleted managed channel agent', {
channelType: storedChannelType,
accountId,
agentId: owner,
});
}
async function deleteManagedChannelAgentsForChannel(channelType: string): Promise<void> {
const storedChannelType = resolveStoredChannelType(channelType);
const config = await readOpenClawConfig();
const configuredAccounts = listConfiguredChannelAccountsFromConfig(config);
const accountIds = configuredAccounts[storedChannelType]?.accountIds ?? [];
const candidates = new Set(accountIds);
if (Array.isArray((config as { bindings?: unknown }).bindings)) {
for (const binding of (config as { bindings: unknown[] }).bindings) {
if (!binding || typeof binding !== 'object') continue;
const candidate = binding as { match?: { channel?: unknown; accountId?: unknown } };
if (!candidate.match || typeof candidate.match !== 'object') continue;
if (candidate.match.channel !== storedChannelType) continue;
if (typeof candidate.match.accountId === 'string' && candidate.match.accountId.trim()) {
candidates.add(candidate.match.accountId.trim());
}
}
}
for (const accountId of candidates) {
await deleteManagedChannelAgentIfOwned(storedChannelType, accountId);
}
}
function isEnabledConfigAccount(account: unknown): boolean {
if (!account || typeof account !== 'object') return false;
const candidate = account as { enabled?: unknown };
return candidate.enabled !== false;
}
export async function buildChannelAccountsView(
ctx: HostApiContext,
options?: { probe?: boolean; skipRuntime?: boolean },
@@ -521,11 +581,26 @@ export async function buildChannelAccountsView(
// Read config once and share across all sub-calls (was 5 readFile calls before).
const openClawConfig = await readOpenClawConfig();
const [configuredChannels, configuredAccounts, agentsSnapshot] = await Promise.all([
const [configuredChannels, configuredAccounts, initialAgentsSnapshot] = await Promise.all([
listConfiguredChannelsFromConfig(openClawConfig),
Promise.resolve(listConfiguredChannelAccountsFromConfig(openClawConfig)),
listAgentsSnapshotFromConfig(openClawConfig),
]);
let agentsSnapshot = initialAgentsSnapshot;
for (const [rawChannelType, accountSummary] of Object.entries(configuredAccounts)) {
const storedChannelType = resolveStoredChannelType(rawChannelType);
if (!AUTO_AGENT_BOUND_CHANNELS.has(storedChannelType)) continue;
for (const accountId of accountSummary.accountIds ?? []) {
const scopedAccountId = accountId?.trim() || 'default';
if (agentsSnapshot.channelAccountOwners[`${storedChannelType}:${scopedAccountId}`]) continue;
agentsSnapshot = await ensureChannelAgentForAccount(storedChannelType, scopedAccountId);
logger.info('[channels.accounts] auto-created missing channel agent binding', {
channelType: storedChannelType,
accountId: scopedAccountId,
});
}
}
let gatewayStatus: GatewayChannelStatusPayload | null = null;
if (!skipRuntime) {
@@ -540,13 +615,13 @@ export async function buildChannelAccountsView(
{ probe },
probe ? 5000 : 8000,
);
lastChannelsStatusOkAt = Date.now();
lastChannelsStatusOkAt = nextChannelStatusTimestamp(lastChannelsStatusFailureAt);
logger.info(
`[channels.accounts] channels.status probe=${probe ? '1' : '0'} elapsedMs=${Date.now() - rpcStartedAt} snapshot=${buildGatewayStatusSnapshot(gatewayStatus)}`
);
} catch {
const probe = options?.probe === true;
lastChannelsStatusFailureAt = Date.now();
lastChannelsStatusFailureAt = nextChannelStatusTimestamp(lastChannelsStatusOkAt);
logger.warn(
`[channels.accounts] channels.status probe=${probe ? '1' : '0'} failed after ${Date.now() - startedAt}ms`
);
@@ -605,6 +680,9 @@ export async function buildChannelAccountsView(
if (!accountId) {
return acc;
}
if (rawChannelType === 'agentbus' && accountId === 'default') {
return acc;
}
if (!shouldIncludeRuntimeAccountId(accountId, configuredAccountIdSet, account)) {
return acc;
}
@@ -615,7 +693,15 @@ export async function buildChannelAccountsView(
const accounts: ChannelAccountView[] = accountIds.map((accountId) => {
const runtime = runtimeAccounts.find((item) => item.accountId === accountId);
const runtimeSnapshot: ChannelRuntimeAccountSnapshot = runtime ?? {};
const configuredAccount = channelSection?.accounts?.[accountId];
const isAgentBusConfigConnected =
rawChannelType === 'agentbus'
&& channelAccountsFromConfig.includes(accountId)
&& isEnabledConfigAccount(configuredAccount)
&& !(typeof runtime?.lastError === 'string' && runtime.lastError.trim());
const runtimeSnapshot: ChannelRuntimeAccountSnapshot = isAgentBusConfigConnected
? { ...runtime, connected: true, running: true }
: (runtime ?? {});
const status = computeChannelRuntimeStatus(runtimeSnapshot, {
gatewayHealthState: effectiveGatewayHealthState,
});
@@ -623,8 +709,8 @@ export async function buildChannelAccountsView(
accountId,
name: runtime?.name || accountId,
configured: channelAccountsFromConfig.includes(accountId) || runtime?.configured === true,
connected: runtime?.connected === true,
running: runtime?.running === true,
connected: runtimeSnapshot.connected === true,
running: runtimeSnapshot.running === true,
linked: runtime?.linked === true,
lastError: typeof runtime?.lastError === 'string' ? runtime.lastError : undefined,
status,
@@ -1286,6 +1372,22 @@ export async function handleChannelRoutes(
return true;
}
if (url.pathname === '/api/channels/account-aliases' && req.method === 'GET') {
try {
const channelType = toUiChannelType(url.searchParams.get('channelType')?.trim() || '');
if (channelType !== UI_WECHAT_CHANNEL_TYPE) {
sendJson(res, 200, { success: true, channelType, aliases: [] });
return true;
}
const aliases = await listWeChatAccountAliases();
sendJson(res, 200, { success: true, channelType, aliases });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/channels/targets' && req.method === 'GET') {
try {
const channelType = url.searchParams.get('channelType')?.trim() || '';
@@ -1534,10 +1636,12 @@ export async function handleChannelRoutes(
const accountId = url.searchParams.get('accountId') || undefined;
const storedChannelType = resolveStoredChannelType(channelType);
if (accountId) {
await deleteManagedChannelAgentIfOwned(storedChannelType, accountId);
await deleteChannelAccountConfig(channelType, accountId);
await clearChannelBinding(storedChannelType, accountId);
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:deleteAccount:${storedChannelType}`);
} else {
await deleteManagedChannelAgentsForChannel(storedChannelType);
await deleteChannelConfig(channelType);
await clearAllBindingsForChannel(storedChannelType);
scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${storedChannelType}`);