Refactor channel account management: move binding/editing to Channels, align Agents display, and simplify UX (#523)

This commit is contained in:
Felix
2026-03-16 18:20:11 +08:00
committed by GitHub
Unverified
parent db480dff17
commit 4be679ac56
20 changed files with 1192 additions and 346 deletions

View File

@@ -103,6 +103,7 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
### 📡 マルチチャネル管理 ### 📡 マルチチャネル管理
複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。 複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。
現在は各チャンネルで複数アカウントを扱え、Channels ページでアカウントの Agent 紐付けやデフォルトアカウント切替を直接管理できます。
### ⏰ Cronベースの自動化 ### ⏰ Cronベースの自動化
AIタスクを自動的に実行するようスケジュール設定できます。トリガーを定義し、間隔を設定することで、手動介入なしにAIエージェントを24時間稼働させることができます。 AIタスクを自動的に実行するようスケジュール設定できます。トリガーを定義し、間隔を設定することで、手動介入なしにAIエージェントを24時間稼働させることができます。

View File

@@ -103,6 +103,7 @@ When you target another agent with `@agent`, ClawX switches into that agent's ow
### 📡 Multi-Channel Management ### 📡 Multi-Channel Management
Configure and monitor multiple AI channels simultaneously. Each channel operates independently, allowing you to run specialized agents for different tasks. Configure and monitor multiple AI channels simultaneously. Each channel operates independently, allowing you to run specialized agents for different tasks.
Each channel now supports multiple accounts, per-account agent binding, and switching the channel default account directly from the Channels page.
### ⏰ Cron-Based Automation ### ⏰ Cron-Based Automation
Schedule AI tasks to run automatically. Define triggers, set intervals, and let your AI agents work around the clock without manual intervention. Schedule AI tasks to run automatically. Define triggers, set intervals, and let your AI agents work around the clock without manual intervention.

View File

@@ -104,6 +104,7 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
### 📡 多频道管理 ### 📡 多频道管理
同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。 同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。
现在每个频道支持多个账号,并可在 Channels 页面直接完成账号绑定到 Agent 与默认账号切换。
### ⏰ 定时任务自动化 ### ⏰ 定时任务自动化
调度 AI 任务自动执行。定义触发器、设置时间间隔,让 AI 智能体 7×24 小时不间断工作。 调度 AI 任务自动执行。定义触发器、设置时间间隔,让 AI 智能体 7×24 小时不间断工作。

View File

@@ -188,9 +188,27 @@ export async function handleAgentRoutes(
try { try {
const agentId = decodeURIComponent(parts[0]); const agentId = decodeURIComponent(parts[0]);
const channelType = decodeURIComponent(parts[2]); const channelType = decodeURIComponent(parts[2]);
const accountId = resolveAccountIdForAgent(agentId); const ownerId = agentId.trim().toLowerCase();
await deleteChannelAccountConfig(channelType, accountId); const snapshotBefore = await listAgentsSnapshot();
const snapshot = await clearChannelBinding(channelType, accountId); const ownedAccountIds = Object.entries(snapshotBefore.channelAccountOwners)
.filter(([channelAccountKey, owner]) => {
if (owner !== ownerId) return false;
return channelAccountKey.startsWith(`${channelType}:`);
})
.map(([channelAccountKey]) => channelAccountKey.slice(channelAccountKey.indexOf(':') + 1));
// Backward compatibility for legacy agentId->accountId mapping.
if (ownedAccountIds.length === 0) {
const legacyAccountId = resolveAccountIdForAgent(agentId);
if (snapshotBefore.channelAccountOwners[`${channelType}:${legacyAccountId}`] === ownerId) {
ownedAccountIds.push(legacyAccountId);
}
}
for (const accountId of ownedAccountIds) {
await deleteChannelAccountConfig(channelType, accountId);
await clearChannelBinding(channelType, accountId);
}
const snapshot = await listAgentsSnapshot();
scheduleGatewayReload(ctx, 'remove-agent-channel'); scheduleGatewayReload(ctx, 'remove-agent-channel');
sendJson(res, 200, { success: true, ...snapshot }); sendJson(res, 200, { success: true, ...snapshot });
} catch (error) { } catch (error) {

View File

@@ -1,20 +1,29 @@
import type { IncomingMessage, ServerResponse } from 'http'; import type { IncomingMessage, ServerResponse } from 'http';
import { import {
deleteChannelAccountConfig,
deleteChannelConfig, deleteChannelConfig,
getChannelFormValues, getChannelFormValues,
listConfiguredChannelAccounts,
listConfiguredChannels, listConfiguredChannels,
readOpenClawConfig,
saveChannelConfig, saveChannelConfig,
setChannelDefaultAccount,
setChannelEnabled, setChannelEnabled,
validateChannelConfig, validateChannelConfig,
validateChannelCredentials, validateChannelCredentials,
} from '../../utils/channel-config'; } from '../../utils/channel-config';
import {
assignChannelAccountToAgent,
clearAllBindingsForChannel,
clearChannelBinding,
listAgentsSnapshot,
} from '../../utils/agent-config';
import { import {
ensureDingTalkPluginInstalled, ensureDingTalkPluginInstalled,
ensureFeishuPluginInstalled, ensureFeishuPluginInstalled,
ensureQQBotPluginInstalled, ensureQQBotPluginInstalled,
ensureWeComPluginInstalled, ensureWeComPluginInstalled,
} from '../../utils/plugin-install'; } from '../../utils/plugin-install';
import { assignChannelToAgent, clearAllBindingsForChannel } from '../../utils/agent-config';
import { whatsAppLoginManager } from '../../utils/whatsapp-login'; import { whatsAppLoginManager } from '../../utils/whatsapp-login';
import type { HostApiContext } from '../context'; import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils'; import { parseJsonBody, sendJson } from '../route-utils';
@@ -79,16 +88,167 @@ function isSameConfigValues(
return true; return true;
} }
function inferAgentIdFromAccountId(accountId: string): string {
if (accountId === 'default') return 'main';
return accountId;
}
async function ensureScopedChannelBinding(channelType: string, accountId?: string): Promise<void> { async function ensureScopedChannelBinding(channelType: string, accountId?: string): Promise<void> {
// Multi-agent safety: only bind when the caller explicitly scopes the account. // Multi-agent safety: only bind when the caller explicitly scopes the account.
// Global channel saves (no accountId) must not override routing to "main". // Global channel saves (no accountId) must not override routing to "main".
if (!accountId) return; if (!accountId) return;
await assignChannelToAgent(inferAgentIdFromAccountId(accountId), channelType).catch(() => undefined); const agents = await listAgentsSnapshot();
if (!agents.entries || agents.entries.length === 0) return;
// Keep backward compatibility for the legacy default account.
if (accountId === 'default') {
if (agents.entries.some((entry) => entry.id === 'main')) {
await assignChannelAccountToAgent('main', channelType, 'default');
}
return;
}
// Legacy compatibility: if accountId matches an existing agentId, keep auto-binding.
if (agents.entries.some((entry) => entry.id === accountId)) {
await assignChannelAccountToAgent(accountId, channelType, accountId);
}
}
interface GatewayChannelStatusPayload {
channelOrder?: string[];
channels?: Record<string, unknown>;
channelAccounts?: Record<string, Array<{
accountId?: string;
configured?: boolean;
connected?: boolean;
running?: boolean;
lastError?: string;
name?: string;
linked?: boolean;
lastConnectedAt?: number | null;
lastInboundAt?: number | null;
lastOutboundAt?: number | null;
}>>;
channelDefaultAccountId?: Record<string, string>;
}
interface ChannelAccountView {
accountId: string;
name: string;
configured: boolean;
connected: boolean;
running: boolean;
linked: boolean;
lastError?: string;
status: 'connected' | 'connecting' | 'disconnected' | 'error';
isDefault: boolean;
agentId?: string;
}
interface ChannelAccountsView {
channelType: string;
defaultAccountId: string;
status: 'connected' | 'connecting' | 'disconnected' | 'error';
accounts: ChannelAccountView[];
}
function computeAccountStatus(account: {
connected?: boolean;
linked?: boolean;
running?: boolean;
lastError?: string;
lastConnectedAt?: number | null;
lastInboundAt?: number | null;
lastOutboundAt?: number | null;
}): 'connected' | 'connecting' | 'disconnected' | 'error' {
const now = Date.now();
const recentMs = 10 * 60 * 1000;
const hasRecentActivity =
(typeof account.lastInboundAt === 'number' && now - account.lastInboundAt < recentMs)
|| (typeof account.lastOutboundAt === 'number' && now - account.lastOutboundAt < recentMs)
|| (typeof account.lastConnectedAt === 'number' && now - account.lastConnectedAt < recentMs);
if (account.connected === true || account.linked === true || hasRecentActivity) return 'connected';
if (account.running === true && !account.lastError) return 'connecting';
if (account.lastError) return 'error';
return 'disconnected';
}
function pickChannelStatus(accounts: ChannelAccountView[]): 'connected' | 'connecting' | 'disconnected' | 'error' {
if (accounts.some((account) => account.status === 'connected')) return 'connected';
if (accounts.some((account) => account.status === 'error')) return 'error';
if (accounts.some((account) => account.status === 'connecting')) return 'connecting';
return 'disconnected';
}
async function buildChannelAccountsView(ctx: HostApiContext): Promise<ChannelAccountsView[]> {
const [configuredChannels, configuredAccounts, openClawConfig, agentsSnapshot] = await Promise.all([
listConfiguredChannels(),
listConfiguredChannelAccounts(),
readOpenClawConfig(),
listAgentsSnapshot(),
]);
let gatewayStatus: GatewayChannelStatusPayload | null;
try {
gatewayStatus = await ctx.gatewayManager.rpc<GatewayChannelStatusPayload>('channels.status', { probe: true });
} catch {
gatewayStatus = null;
}
const channelTypes = new Set<string>([
...configuredChannels,
...Object.keys(configuredAccounts),
...Object.keys(gatewayStatus?.channelAccounts || {}),
]);
const channels: ChannelAccountsView[] = [];
for (const channelType of channelTypes) {
const channelAccountsFromConfig = configuredAccounts[channelType]?.accountIds ?? [];
const hasLocalConfig = configuredChannels.includes(channelType) || Boolean(configuredAccounts[channelType]);
const channelSection = openClawConfig.channels?.[channelType];
const fallbackDefault =
typeof channelSection?.defaultAccount === 'string' && channelSection.defaultAccount.trim()
? channelSection.defaultAccount
: 'default';
const defaultAccountId = configuredAccounts[channelType]?.defaultAccountId
?? gatewayStatus?.channelDefaultAccountId?.[channelType]
?? fallbackDefault;
const runtimeAccounts = gatewayStatus?.channelAccounts?.[channelType] ?? [];
const hasRuntimeConfigured = runtimeAccounts.some((account) => account.configured === true);
if (!hasLocalConfig && !hasRuntimeConfigured) {
continue;
}
const runtimeAccountIds = runtimeAccounts
.map((account) => account.accountId)
.filter((accountId): accountId is string => typeof accountId === 'string' && accountId.trim().length > 0);
const accountIds = Array.from(new Set([...channelAccountsFromConfig, ...runtimeAccountIds, defaultAccountId]));
const accounts: ChannelAccountView[] = accountIds.map((accountId) => {
const runtime = runtimeAccounts.find((item) => item.accountId === accountId);
const status = computeAccountStatus(runtime ?? {});
return {
accountId,
name: runtime?.name || accountId,
configured: channelAccountsFromConfig.includes(accountId) || runtime?.configured === true,
connected: runtime?.connected === true,
running: runtime?.running === true,
linked: runtime?.linked === true,
lastError: typeof runtime?.lastError === 'string' ? runtime.lastError : undefined,
status,
isDefault: accountId === defaultAccountId,
agentId: agentsSnapshot.channelAccountOwners[`${channelType}:${accountId}`],
};
}).sort((left, right) => {
if (left.accountId === defaultAccountId) return -1;
if (right.accountId === defaultAccountId) return 1;
return left.accountId.localeCompare(right.accountId);
});
channels.push({
channelType,
defaultAccountId,
status: pickChannelStatus(accounts),
accounts,
});
}
return channels.sort((left, right) => left.channelType.localeCompare(right.channelType));
} }
export async function handleChannelRoutes( export async function handleChannelRoutes(
@@ -102,6 +262,52 @@ export async function handleChannelRoutes(
return true; return true;
} }
if (url.pathname === '/api/channels/accounts' && req.method === 'GET') {
try {
const channels = await buildChannelAccountsView(ctx);
sendJson(res, 200, { success: true, channels });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/channels/default-account' && req.method === 'PUT') {
try {
const body = await parseJsonBody<{ channelType: string; accountId: string }>(req);
await setChannelDefaultAccount(body.channelType, body.accountId);
scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:setDefaultAccount:${body.channelType}`);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/channels/binding' && req.method === 'PUT') {
try {
const body = await parseJsonBody<{ channelType: string; accountId: string; agentId: string }>(req);
await assignChannelAccountToAgent(body.agentId, body.channelType, body.accountId);
scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:setBinding:${body.channelType}`);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/channels/binding' && req.method === 'DELETE') {
try {
const body = await parseJsonBody<{ channelType: string; accountId: string }>(req);
await clearChannelBinding(body.channelType, body.accountId);
scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:clearBinding:${body.channelType}`);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/channels/config/validate' && req.method === 'POST') { if (url.pathname === '/api/channels/config/validate' && req.method === 'POST') {
try { try {
const body = await parseJsonBody<{ channelType: string }>(req); const body = await parseJsonBody<{ channelType: string }>(req);
@@ -219,9 +425,16 @@ export async function handleChannelRoutes(
if (url.pathname.startsWith('/api/channels/config/') && req.method === 'DELETE') { if (url.pathname.startsWith('/api/channels/config/') && req.method === 'DELETE') {
try { try {
const channelType = decodeURIComponent(url.pathname.slice('/api/channels/config/'.length)); const channelType = decodeURIComponent(url.pathname.slice('/api/channels/config/'.length));
await deleteChannelConfig(channelType); const accountId = url.searchParams.get('accountId') || undefined;
await clearAllBindingsForChannel(channelType); if (accountId) {
scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${channelType}`); await deleteChannelAccountConfig(channelType, accountId);
await clearChannelBinding(channelType, accountId);
scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:deleteAccount:${channelType}`);
} else {
await deleteChannelConfig(channelType);
await clearAllBindingsForChannel(channelType);
scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${channelType}`);
}
sendJson(res, 200, { success: true }); sendJson(res, 200, { success: true });
} catch (error) { } catch (error) {
sendJson(res, 500, { success: false, error: String(error) }); sendJson(res, 500, { success: false, error: String(error) });

View File

@@ -92,6 +92,7 @@ export interface AgentsSnapshot {
defaultAgentId: string; defaultAgentId: string;
configuredChannelTypes: string[]; configuredChannelTypes: string[];
channelOwners: Record<string, string>; channelOwners: Record<string, string>;
channelAccountOwners: Record<string, string>;
} }
function formatModelLabel(model: unknown): string | null { function formatModelLabel(model: unknown): string | null {
@@ -266,10 +267,16 @@ function upsertBindingsForChannel(
agentId: string | null, agentId: string | null,
accountId?: string, accountId?: string,
): BindingConfig[] | undefined { ): BindingConfig[] | undefined {
const normalizedAgentId = agentId ? normalizeAgentIdForBinding(agentId) : '';
const nextBindings = Array.isArray(bindings) const nextBindings = Array.isArray(bindings)
? [...bindings as BindingConfig[]].filter((binding) => { ? [...bindings as BindingConfig[]].filter((binding) => {
if (!isChannelBinding(binding)) return true; if (!isChannelBinding(binding)) return true;
if (binding.match?.channel !== channelType) return true; if (binding.match?.channel !== channelType) return true;
// Keep a single account binding per (agent, channelType). Rebinding to
// another account should replace the previous one.
if (normalizedAgentId && normalizeAgentIdForBinding(binding.agentId || '') === normalizedAgentId) {
return false;
}
// Only remove binding that matches the exact accountId scope // Only remove binding that matches the exact accountId scope
if (accountId) { if (accountId) {
return binding.match?.accountId !== accountId; return binding.match?.accountId !== accountId;
@@ -290,6 +297,30 @@ function upsertBindingsForChannel(
return nextBindings.length > 0 ? nextBindings : undefined; return nextBindings.length > 0 ? nextBindings : undefined;
} }
function assertAgentNotBoundToOtherChannel(
bindings: unknown,
agentId: string,
nextChannelType: string,
): void {
if (!Array.isArray(bindings)) return;
const normalizedAgentId = normalizeAgentIdForBinding(agentId);
if (!normalizedAgentId) return;
const conflictChannels = new Set<string>();
for (const binding of bindings) {
if (!isChannelBinding(binding)) continue;
if (normalizeAgentIdForBinding(binding.agentId || '') !== normalizedAgentId) continue;
const boundChannel = binding.match?.channel;
if (!boundChannel || boundChannel === nextChannelType) continue;
conflictChannels.add(boundChannel);
}
if (conflictChannels.size > 0) {
const channels = Array.from(conflictChannels).sort().join(', ');
throw new Error(`Agent "${agentId}" is already bound to channel(s): ${channels}. One agent can only bind one channel.`);
}
}
async function listExistingAgentIdsOnDisk(): Promise<Set<string>> { async function listExistingAgentIdsOnDisk(): Promise<Set<string>> {
const ids = new Set<string>(); const ids = new Set<string>();
const agentsDir = join(getOpenClawConfigDir(), 'agents'); const agentsDir = join(getOpenClawConfigDir(), 'agents');
@@ -429,6 +460,7 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise<Age
const { channelToAgent, accountToAgent } = getChannelBindingMap(config.bindings); const { channelToAgent, accountToAgent } = getChannelBindingMap(config.bindings);
const defaultAgentIdNorm = normalizeAgentIdForBinding(defaultAgentId); const defaultAgentIdNorm = normalizeAgentIdForBinding(defaultAgentId);
const channelOwners: Record<string, string> = {}; const channelOwners: Record<string, string> = {};
const channelAccountOwners: Record<string, string> = {};
// Build per-agent channel lists from account-scoped bindings // Build per-agent channel lists from account-scoped bindings
const agentChannelSets = new Map<string, Set<string>>(); const agentChannelSets = new Map<string, Set<string>>();
@@ -436,16 +468,24 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise<Age
for (const channelType of configuredChannels) { for (const channelType of configuredChannels) {
const accountIds = listConfiguredAccountIdsForChannel(config, channelType); const accountIds = listConfiguredAccountIdsForChannel(config, channelType);
let primaryOwner: string | undefined; let primaryOwner: string | undefined;
const hasExplicitAccountBindingForChannel = accountIds.some((accountId) =>
accountToAgent.has(`${channelType}:${accountId}`),
);
for (const accountId of accountIds) { for (const accountId of accountIds) {
const owner = const owner =
accountToAgent.get(`${channelType}:${accountId}`) accountToAgent.get(`${channelType}:${accountId}`)
|| (accountId === DEFAULT_ACCOUNT_ID ? (channelToAgent.get(channelType) || defaultAgentIdNorm) : undefined); || (
accountId === DEFAULT_ACCOUNT_ID && !hasExplicitAccountBindingForChannel
? (channelToAgent.get(channelType) || defaultAgentIdNorm)
: undefined
);
if (!owner) { if (!owner) {
continue; continue;
} }
channelAccountOwners[`${channelType}:${accountId}`] = owner;
primaryOwner ??= owner; primaryOwner ??= owner;
const existing = agentChannelSets.get(owner) ?? new Set(); const existing = agentChannelSets.get(owner) ?? new Set();
existing.add(channelType); existing.add(channelType);
@@ -486,6 +526,7 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise<Age
defaultAgentId, defaultAgentId,
configuredChannelTypes: configuredChannels, configuredChannelTypes: configuredChannels,
channelOwners, channelOwners,
channelAccountOwners,
}; };
} }
@@ -575,6 +616,7 @@ export async function deleteAgentConfig(agentId: string): Promise<{ snapshot: Ag
const config = await readOpenClawConfig() as AgentConfigDocument; const config = await readOpenClawConfig() as AgentConfigDocument;
const { agentsConfig, entries, defaultAgentId } = normalizeAgentsConfig(config); const { agentsConfig, entries, defaultAgentId } = normalizeAgentsConfig(config);
const snapshotBeforeDeletion = await buildSnapshotFromConfig(config);
const removedEntry = entries.find((entry) => entry.id === agentId); const removedEntry = entries.find((entry) => entry.id === agentId);
const nextEntries = entries.filter((entry) => entry.id !== agentId); const nextEntries = entries.filter((entry) => entry.id !== agentId);
if (!removedEntry || nextEntries.length === entries.length) { if (!removedEntry || nextEntries.length === entries.length) {
@@ -596,8 +638,20 @@ export async function deleteAgentConfig(agentId: string): Promise<{ snapshot: Ag
}; };
} }
const normalizedAgentId = normalizeAgentIdForBinding(agentId);
const legacyAccountId = resolveAccountIdForAgent(agentId);
const ownedLegacyAccounts = new Set(
Object.entries(snapshotBeforeDeletion.channelAccountOwners)
.filter(([channelAccountKey, owner]) => {
if (owner !== normalizedAgentId) return false;
const accountId = channelAccountKey.slice(channelAccountKey.indexOf(':') + 1);
return accountId === legacyAccountId;
})
.map(([channelAccountKey]) => channelAccountKey),
);
await writeOpenClawConfig(config); await writeOpenClawConfig(config);
await deleteAgentChannelAccounts(agentId); await deleteAgentChannelAccounts(agentId, ownedLegacyAccounts);
await removeAgentRuntimeDirectory(agentId); await removeAgentRuntimeDirectory(agentId);
// NOTE: workspace directory is NOT deleted here intentionally. // NOTE: workspace directory is NOT deleted here intentionally.
// The caller (route handler) defers workspace removal until after // The caller (route handler) defers workspace removal until after
@@ -618,6 +672,7 @@ export async function assignChannelToAgent(agentId: string, channelType: string)
throw new Error(`Agent "${agentId}" not found`); throw new Error(`Agent "${agentId}" not found`);
} }
assertAgentNotBoundToOtherChannel(config.bindings, agentId, channelType);
const accountId = resolveAccountIdForAgent(agentId); const accountId = resolveAccountIdForAgent(agentId);
config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, accountId); config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, accountId);
await writeOpenClawConfig(config); await writeOpenClawConfig(config);
@@ -626,6 +681,29 @@ export async function assignChannelToAgent(agentId: string, channelType: string)
}); });
} }
export async function assignChannelAccountToAgent(
agentId: string,
channelType: string,
accountId: string,
): Promise<AgentsSnapshot> {
return withConfigLock(async () => {
const config = await readOpenClawConfig() as AgentConfigDocument;
const { entries } = normalizeAgentsConfig(config);
if (!entries.some((entry) => entry.id === agentId)) {
throw new Error(`Agent "${agentId}" not found`);
}
if (!accountId.trim()) {
throw new Error('accountId is required');
}
assertAgentNotBoundToOtherChannel(config.bindings, agentId, channelType);
config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, accountId.trim());
await writeOpenClawConfig(config);
logger.info('Assigned channel account to agent', { agentId, channelType, accountId: accountId.trim() });
return buildSnapshotFromConfig(config);
});
}
export async function clearChannelBinding(channelType: string, accountId?: string): Promise<AgentsSnapshot> { export async function clearChannelBinding(channelType: string, accountId?: string): Promise<AgentsSnapshot> {
return withConfigLock(async () => { return withConfigLock(async () => {
const config = await readOpenClawConfig() as AgentConfigDocument; const config = await readOpenClawConfig() as AgentConfigDocument;

View File

@@ -16,8 +16,7 @@ import { withConfigLock } from './config-mutex';
const OPENCLAW_DIR = join(homedir(), '.openclaw'); const OPENCLAW_DIR = join(homedir(), '.openclaw');
const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json'); const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json');
const WECOM_PLUGIN_ID = 'wecom'; const WECOM_PLUGIN_ID = 'wecom';
const FEISHU_PLUGIN_ID = 'openclaw-lark'; const FEISHU_PLUGIN_ID_CANDIDATES = ['openclaw-lark', 'feishu-openclaw-plugin'] as const;
const LEGACY_FEISHU_PLUGIN_ID = 'feishu-openclaw-plugin';
const DEFAULT_ACCOUNT_ID = 'default'; const DEFAULT_ACCOUNT_ID = 'default';
const CHANNEL_TOP_LEVEL_KEYS_TO_KEEP = new Set(['accounts', 'defaultAccount', 'enabled']); const CHANNEL_TOP_LEVEL_KEYS_TO_KEEP = new Set(['accounts', 'defaultAccount', 'enabled']);
@@ -53,6 +52,24 @@ function normalizeCredentialValue(value: string): string {
return value.trim(); return value.trim();
} }
async function resolveFeishuPluginId(): Promise<string> {
const extensionRoot = join(homedir(), '.openclaw', 'extensions');
for (const dirName of FEISHU_PLUGIN_ID_CANDIDATES) {
const manifestPath = join(extensionRoot, dirName, 'openclaw.plugin.json');
try {
const raw = await readFile(manifestPath, 'utf-8');
const parsed = JSON.parse(raw) as { id?: unknown };
if (typeof parsed.id === 'string' && parsed.id.trim()) {
return parsed.id.trim();
}
} catch {
// ignore and try next candidate
}
}
// Fallback to the modern id when extension manifests are not available yet.
return FEISHU_PLUGIN_ID_CANDIDATES[0];
}
// ── Types ──────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────
export interface ChannelConfigData { export interface ChannelConfigData {
@@ -121,14 +138,15 @@ export async function writeOpenClawConfig(config: OpenClawConfig): Promise<void>
// ── Channel operations ─────────────────────────────────────────── // ── Channel operations ───────────────────────────────────────────
function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType: string): void { async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType: string): Promise<void> {
if (channelType === 'feishu') { if (channelType === 'feishu') {
const feishuPluginId = await resolveFeishuPluginId();
if (!currentConfig.plugins) { if (!currentConfig.plugins) {
currentConfig.plugins = { currentConfig.plugins = {
allow: [FEISHU_PLUGIN_ID], allow: [feishuPluginId],
enabled: true, enabled: true,
entries: { entries: {
[FEISHU_PLUGIN_ID]: { enabled: true } [feishuPluginId]: { enabled: true }
} }
}; };
} else { } else {
@@ -136,12 +154,12 @@ function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType: strin
const allow: string[] = Array.isArray(currentConfig.plugins.allow) const allow: string[] = Array.isArray(currentConfig.plugins.allow)
? (currentConfig.plugins.allow as string[]) ? (currentConfig.plugins.allow as string[])
: []; : [];
// Remove legacy IDs: 'feishu' (built-in) and old 'feishu-openclaw-plugin' // Keep only one active feishu plugin id to avoid doctor validation conflicts.
const normalizedAllow = allow.filter( const normalizedAllow = allow.filter(
(pluginId) => pluginId !== 'feishu' && pluginId !== LEGACY_FEISHU_PLUGIN_ID (pluginId) => pluginId !== 'feishu' && !FEISHU_PLUGIN_ID_CANDIDATES.includes(pluginId as typeof FEISHU_PLUGIN_ID_CANDIDATES[number])
); );
if (!normalizedAllow.includes(FEISHU_PLUGIN_ID)) { if (!normalizedAllow.includes(feishuPluginId)) {
currentConfig.plugins.allow = [...normalizedAllow, FEISHU_PLUGIN_ID]; currentConfig.plugins.allow = [...normalizedAllow, feishuPluginId];
} else if (normalizedAllow.length !== allow.length) { } else if (normalizedAllow.length !== allow.length) {
currentConfig.plugins.allow = normalizedAllow; currentConfig.plugins.allow = normalizedAllow;
} }
@@ -149,14 +167,18 @@ function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType: strin
if (!currentConfig.plugins.entries) { if (!currentConfig.plugins.entries) {
currentConfig.plugins.entries = {}; currentConfig.plugins.entries = {};
} }
// Remove legacy entries that would conflict with the current plugin ID // Remove conflicting feishu entries; keep only the resolved plugin id.
delete currentConfig.plugins.entries['feishu']; delete currentConfig.plugins.entries['feishu'];
delete currentConfig.plugins.entries[LEGACY_FEISHU_PLUGIN_ID]; for (const candidateId of FEISHU_PLUGIN_ID_CANDIDATES) {
if (candidateId !== feishuPluginId) {
if (!currentConfig.plugins.entries[FEISHU_PLUGIN_ID]) { delete currentConfig.plugins.entries[candidateId];
currentConfig.plugins.entries[FEISHU_PLUGIN_ID] = {}; }
} }
currentConfig.plugins.entries[FEISHU_PLUGIN_ID].enabled = true;
if (!currentConfig.plugins.entries[feishuPluginId]) {
currentConfig.plugins.entries[feishuPluginId] = {};
}
currentConfig.plugins.entries[feishuPluginId].enabled = true;
} }
} }
@@ -407,7 +429,7 @@ export async function saveChannelConfig(
const currentConfig = await readOpenClawConfig(); const currentConfig = await readOpenClawConfig();
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID; const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID;
ensurePluginAllowlist(currentConfig, channelType); await ensurePluginAllowlist(currentConfig, channelType);
// Plugin-based channels (e.g. WhatsApp) go under plugins.entries, not channels // Plugin-based channels (e.g. WhatsApp) go under plugins.entries, not channels
if (PLUGIN_CHANNELS.includes(channelType)) { if (PLUGIN_CHANNELS.includes(channelType)) {
@@ -587,9 +609,23 @@ export async function deleteChannelAccountConfig(channelType: string, accountId:
if (Object.keys(accounts).length === 0) { if (Object.keys(accounts).length === 0) {
delete currentConfig.channels![channelType]; delete currentConfig.channels![channelType];
} else { } else {
if (channelSection.defaultAccount === accountId) {
const nextDefaultAccountId = Object.keys(accounts).sort((a, b) => {
if (a === DEFAULT_ACCOUNT_ID) return -1;
if (b === DEFAULT_ACCOUNT_ID) return 1;
return a.localeCompare(b);
})[0];
if (nextDefaultAccountId) {
channelSection.defaultAccount = nextDefaultAccountId;
}
}
// Re-mirror default account credentials to top level after migration // Re-mirror default account credentials to top level after migration
// stripped them (same rationale as saveChannelConfig). // stripped them (same rationale as saveChannelConfig).
const defaultAccountData = accounts[DEFAULT_ACCOUNT_ID]; const mirroredAccountId =
typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim()
? channelSection.defaultAccount
: DEFAULT_ACCOUNT_ID;
const defaultAccountData = accounts[mirroredAccountId] ?? accounts[DEFAULT_ACCOUNT_ID];
if (defaultAccountData) { if (defaultAccountData) {
for (const [key, value] of Object.entries(defaultAccountData)) { for (const [key, value] of Object.entries(defaultAccountData)) {
channelSection[key] = value; channelSection[key] = value;
@@ -686,7 +722,85 @@ export async function listConfiguredChannels(): Promise<string[]> {
return channels; return channels;
} }
export async function deleteAgentChannelAccounts(agentId: string): Promise<void> { export interface ConfiguredChannelAccounts {
defaultAccountId: string;
accountIds: string[];
}
export async function listConfiguredChannelAccounts(): Promise<Record<string, ConfiguredChannelAccounts>> {
const config = await readOpenClawConfig();
const result: Record<string, ConfiguredChannelAccounts> = {};
if (!config.channels) {
return result;
}
for (const [channelType, section] of Object.entries(config.channels)) {
if (!section || section.enabled === false) continue;
const accountIds = section.accounts && typeof section.accounts === 'object'
? Object.keys(section.accounts).filter(Boolean)
: [];
const defaultAccountId = typeof section.defaultAccount === 'string' && section.defaultAccount.trim()
? section.defaultAccount
: DEFAULT_ACCOUNT_ID;
if (accountIds.length === 0) {
const hasAnyPayload = Object.keys(section).some((key) => !CHANNEL_TOP_LEVEL_KEYS_TO_KEEP.has(key));
if (!hasAnyPayload) continue;
result[channelType] = {
defaultAccountId,
accountIds: [DEFAULT_ACCOUNT_ID],
};
continue;
}
result[channelType] = {
defaultAccountId,
accountIds: accountIds.sort((a, b) => {
if (a === DEFAULT_ACCOUNT_ID) return -1;
if (b === DEFAULT_ACCOUNT_ID) return 1;
return a.localeCompare(b);
}),
};
}
return result;
}
export async function setChannelDefaultAccount(channelType: string, accountId: string): Promise<void> {
return withConfigLock(async () => {
const trimmedAccountId = accountId.trim();
if (!trimmedAccountId) {
throw new Error('accountId is required');
}
const currentConfig = await readOpenClawConfig();
const channelSection = currentConfig.channels?.[channelType];
if (!channelSection) {
throw new Error(`Channel "${channelType}" is not configured`);
}
migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID);
const accounts = channelSection.accounts as Record<string, ChannelConfigData> | undefined;
if (!accounts || !accounts[trimmedAccountId]) {
throw new Error(`Account "${trimmedAccountId}" is not configured for channel "${channelType}"`);
}
channelSection.defaultAccount = trimmedAccountId;
const defaultAccountData = accounts[trimmedAccountId];
for (const [key, value] of Object.entries(defaultAccountData)) {
channelSection[key] = value;
}
await writeOpenClawConfig(currentConfig);
logger.info('Set channel default account', { channelType, accountId: trimmedAccountId });
});
}
export async function deleteAgentChannelAccounts(agentId: string, ownedChannelAccounts?: Set<string>): Promise<void> {
return withConfigLock(async () => { return withConfigLock(async () => {
const currentConfig = await readOpenClawConfig(); const currentConfig = await readOpenClawConfig();
if (!currentConfig.channels) return; if (!currentConfig.channels) return;
@@ -699,14 +813,31 @@ export async function deleteAgentChannelAccounts(agentId: string): Promise<void>
migrateLegacyChannelConfigToAccounts(section, DEFAULT_ACCOUNT_ID); migrateLegacyChannelConfigToAccounts(section, DEFAULT_ACCOUNT_ID);
const accounts = section.accounts as Record<string, ChannelConfigData> | undefined; const accounts = section.accounts as Record<string, ChannelConfigData> | undefined;
if (!accounts?.[accountId]) continue; if (!accounts?.[accountId]) continue;
if (ownedChannelAccounts && !ownedChannelAccounts.has(`${channelType}:${accountId}`)) {
continue;
}
delete accounts[accountId]; delete accounts[accountId];
if (Object.keys(accounts).length === 0) { if (Object.keys(accounts).length === 0) {
delete currentConfig.channels[channelType]; delete currentConfig.channels[channelType];
} else { } else {
if (section.defaultAccount === accountId) {
const nextDefaultAccountId = Object.keys(accounts).sort((a, b) => {
if (a === DEFAULT_ACCOUNT_ID) return -1;
if (b === DEFAULT_ACCOUNT_ID) return 1;
return a.localeCompare(b);
})[0];
if (nextDefaultAccountId) {
section.defaultAccount = nextDefaultAccountId;
}
}
// Re-mirror default account credentials to top level after migration // Re-mirror default account credentials to top level after migration
// stripped them (same rationale as saveChannelConfig). // stripped them (same rationale as saveChannelConfig).
const defaultAccountData = accounts[DEFAULT_ACCOUNT_ID]; const mirroredAccountId =
typeof section.defaultAccount === 'string' && section.defaultAccount.trim()
? section.defaultAccount
: DEFAULT_ACCOUNT_ID;
const defaultAccountData = accounts[mirroredAccountId] ?? accounts[DEFAULT_ACCOUNT_ID];
if (defaultAccountData) { if (defaultAccountData) {
for (const [key, value] of Object.entries(defaultAccountData)) { for (const [key, value] of Object.entries(defaultAccountData)) {
section[key] = value; section[key] = value;

View File

@@ -131,12 +131,25 @@ async function discoverAgentIds(): Promise<string[]> {
// ── OpenClaw Config Helpers ────────────────────────────────────── // ── OpenClaw Config Helpers ──────────────────────────────────────
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json'); const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
const FEISHU_PLUGIN_ID_CANDIDATES = ['openclaw-lark', 'feishu-openclaw-plugin'] as const;
const VALID_COMPACTION_MODES = new Set(['default', 'safeguard']); const VALID_COMPACTION_MODES = new Set(['default', 'safeguard']);
async function readOpenClawJson(): Promise<Record<string, unknown>> { async function readOpenClawJson(): Promise<Record<string, unknown>> {
return (await readJsonFile<Record<string, unknown>>(OPENCLAW_CONFIG_PATH)) ?? {}; return (await readJsonFile<Record<string, unknown>>(OPENCLAW_CONFIG_PATH)) ?? {};
} }
async function resolveInstalledFeishuPluginId(): Promise<string | null> {
const extensionRoot = join(homedir(), '.openclaw', 'extensions');
for (const dirName of FEISHU_PLUGIN_ID_CANDIDATES) {
const manifestPath = join(extensionRoot, dirName, 'openclaw.plugin.json');
const manifest = await readJsonFile<{ id?: unknown }>(manifestPath);
if (typeof manifest?.id === 'string' && manifest.id.trim()) {
return manifest.id.trim();
}
}
return null;
}
function normalizeAgentsDefaultsCompactionMode(config: Record<string, unknown>): void { function normalizeAgentsDefaultsCompactionMode(config: Record<string, unknown>): void {
const agents = (config.agents && typeof config.agents === 'object' const agents = (config.agents && typeof config.agents === 'object'
? config.agents as Record<string, unknown> ? config.agents as Record<string, unknown>
@@ -1016,44 +1029,59 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
} }
// ── plugins.entries.feishu cleanup ────────────────────────────── // ── plugins.entries.feishu cleanup ──────────────────────────────
// The official feishu plugin registers its channel AS 'feishu' via // Normalize feishu plugin ids dynamically based on installed manifest.
// openclaw.plugin.json. An explicit entries.feishu.enabled=false // Different environments may report either "openclaw-lark" or
// (set by older ClawX to disable the legacy built-in) blocks the // "feishu-openclaw-plugin" as the runtime plugin id.
// official plugin's channel from starting. Only clean up when the
// new openclaw-lark plugin is already configured (to avoid removing
// a legitimate old-style feishu plugin from users who haven't upgraded).
if (typeof plugins === 'object' && !Array.isArray(plugins)) { if (typeof plugins === 'object' && !Array.isArray(plugins)) {
const pluginsObj = plugins as Record<string, unknown>; const pluginsObj = plugins as Record<string, unknown>;
const pEntries = pluginsObj.entries as Record<string, Record<string, unknown>> | undefined; const pEntries = (
pluginsObj.entries && typeof pluginsObj.entries === 'object' && !Array.isArray(pluginsObj.entries)
? pluginsObj.entries
: {}
) as Record<string, Record<string, unknown>>;
if (!pluginsObj.entries || typeof pluginsObj.entries !== 'object' || Array.isArray(pluginsObj.entries)) {
pluginsObj.entries = pEntries;
}
// ── feishu-openclaw-plugin → openclaw-lark migration ──────── const allowArr = Array.isArray(pluginsObj.allow) ? pluginsObj.allow as string[] : [];
// Plugin @larksuite/openclaw-lark ≥2026.3.12 changed its manifest if (!Array.isArray(pluginsObj.allow)) {
// id from 'feishu-openclaw-plugin' to 'openclaw-lark'. Migrate pluginsObj.allow = allowArr;
// both plugins.allow and plugins.entries so Gateway validation }
// doesn't reject the config with "plugin not found".
const LEGACY_FEISHU_ID = 'feishu-openclaw-plugin'; const installedFeishuId = await resolveInstalledFeishuPluginId();
const NEW_FEISHU_ID = 'openclaw-lark'; const configuredFeishuId =
if (Array.isArray(pluginsObj.allow)) { FEISHU_PLUGIN_ID_CANDIDATES.find((id) => allowArr.includes(id))
const allowArr = pluginsObj.allow as string[]; || FEISHU_PLUGIN_ID_CANDIDATES.find((id) => Boolean(pEntries[id]));
const legacyIdx = allowArr.indexOf(LEGACY_FEISHU_ID); const canonicalFeishuId = installedFeishuId || configuredFeishuId || FEISHU_PLUGIN_ID_CANDIDATES[0];
if (legacyIdx !== -1) {
if (!allowArr.includes(NEW_FEISHU_ID)) { const existingFeishuEntry =
allowArr[legacyIdx] = NEW_FEISHU_ID; FEISHU_PLUGIN_ID_CANDIDATES.map((id) => pEntries[id]).find(Boolean)
} else { || pEntries.feishu;
allowArr.splice(legacyIdx, 1);
} const normalizedAllow = allowArr.filter(
console.log(`[sanitize] Migrated plugins.allow: ${LEGACY_FEISHU_ID}${NEW_FEISHU_ID}`); (id) => id !== 'feishu' && !FEISHU_PLUGIN_ID_CANDIDATES.includes(id as typeof FEISHU_PLUGIN_ID_CANDIDATES[number]),
);
normalizedAllow.push(canonicalFeishuId);
if (JSON.stringify(normalizedAllow) !== JSON.stringify(allowArr)) {
pluginsObj.allow = normalizedAllow;
modified = true;
console.log(`[sanitize] Normalized plugins.allow for feishu -> ${canonicalFeishuId}`);
}
if (existingFeishuEntry || !pEntries[canonicalFeishuId]) {
pEntries[canonicalFeishuId] = {
...(existingFeishuEntry || {}),
...(pEntries[canonicalFeishuId] || {}),
enabled: true,
};
modified = true;
}
for (const id of FEISHU_PLUGIN_ID_CANDIDATES) {
if (id !== canonicalFeishuId && pEntries[id]) {
delete pEntries[id];
modified = true; modified = true;
} }
} }
if (pEntries?.[LEGACY_FEISHU_ID]) {
if (!pEntries[NEW_FEISHU_ID]) {
pEntries[NEW_FEISHU_ID] = pEntries[LEGACY_FEISHU_ID];
}
delete pEntries[LEGACY_FEISHU_ID];
console.log(`[sanitize] Migrated plugins.entries: ${LEGACY_FEISHU_ID}${NEW_FEISHU_ID}`);
modified = true;
}
// ── wecom-openclaw-plugin → wecom migration ──────────────── // ── wecom-openclaw-plugin → wecom migration ────────────────
const LEGACY_WECOM_ID = 'wecom-openclaw-plugin'; const LEGACY_WECOM_ID = 'wecom-openclaw-plugin';
@@ -1080,27 +1108,27 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
modified = true; modified = true;
} }
// ── Remove bare 'feishu' when openclaw-lark is present ───────── // ── Remove bare 'feishu' when canonical feishu plugin is present ──
// The Gateway binary automatically adds bare 'feishu' to plugins.allow // The Gateway binary automatically adds bare 'feishu' to plugins.allow
// because the openclaw-lark plugin registers the 'feishu' channel. // because the official plugin registers the 'feishu' channel.
// However, there's no plugin with id='feishu', so Gateway validation // However, there's no plugin with id='feishu', so Gateway validation
// fails with "plugin not found: feishu". Remove it from allow[] and // fails with "plugin not found: feishu". Remove it from allow[] and
// disable the entries.feishu entry to prevent Gateway from re-adding it. // disable the entries.feishu entry to prevent Gateway from re-adding it.
const allowArr2 = Array.isArray(pluginsObj.allow) ? pluginsObj.allow as string[] : []; const allowArr2 = Array.isArray(pluginsObj.allow) ? pluginsObj.allow as string[] : [];
const hasNewFeishu = allowArr2.includes(NEW_FEISHU_ID) || !!pEntries?.[NEW_FEISHU_ID]; const hasCanonicalFeishu = allowArr2.includes(canonicalFeishuId) || !!pEntries[canonicalFeishuId];
if (hasNewFeishu) { if (hasCanonicalFeishu) {
// Remove bare 'feishu' from plugins.allow // Remove bare 'feishu' from plugins.allow
const bareFeishuIdx = allowArr2.indexOf('feishu'); const bareFeishuIdx = allowArr2.indexOf('feishu');
if (bareFeishuIdx !== -1) { if (bareFeishuIdx !== -1) {
allowArr2.splice(bareFeishuIdx, 1); allowArr2.splice(bareFeishuIdx, 1);
console.log('[sanitize] Removed bare "feishu" from plugins.allow (openclaw-lark is configured)'); console.log('[sanitize] Removed bare "feishu" from plugins.allow (feishu plugin is configured)');
modified = true; modified = true;
} }
// Disable bare 'feishu' in plugins.entries so Gateway won't re-add it // Disable bare 'feishu' in plugins.entries so Gateway won't re-add it
if (pEntries?.feishu) { if (pEntries.feishu) {
if (pEntries.feishu.enabled !== false) { if (pEntries.feishu.enabled !== false) {
pEntries.feishu.enabled = false; pEntries.feishu.enabled = false;
console.log('[sanitize] Disabled bare plugins.entries.feishu (openclaw-lark is configured)'); console.log('[sanitize] Disabled bare plugins.entries.feishu (feishu plugin is configured)');
modified = true; modified = true;
} }
} }

View File

@@ -47,7 +47,11 @@ interface ChannelConfigModalProps {
configuredTypes?: string[]; configuredTypes?: string[];
showChannelName?: boolean; showChannelName?: boolean;
allowExistingConfig?: boolean; allowExistingConfig?: boolean;
allowEditAccountId?: boolean;
existingAccountIds?: string[];
initialConfigValues?: Record<string, string>;
agentId?: string; agentId?: string;
accountId?: string;
onClose: () => void; onClose: () => void;
onChannelSaved?: (channelType: ChannelType) => void | Promise<void>; onChannelSaved?: (channelType: ChannelType) => void | Promise<void>;
} }
@@ -62,7 +66,11 @@ export function ChannelConfigModal({
configuredTypes = [], configuredTypes = [],
showChannelName = true, showChannelName = true,
allowExistingConfig = true, allowExistingConfig = true,
allowEditAccountId = false,
existingAccountIds = [],
initialConfigValues,
agentId, agentId,
accountId,
onClose, onClose,
onChannelSaved, onChannelSaved,
}: ChannelConfigModalProps) { }: ChannelConfigModalProps) {
@@ -71,6 +79,7 @@ export function ChannelConfigModal({
const [selectedType, setSelectedType] = useState<ChannelType | null>(initialSelectedType); const [selectedType, setSelectedType] = useState<ChannelType | null>(initialSelectedType);
const [configValues, setConfigValues] = useState<Record<string, string>>({}); const [configValues, setConfigValues] = useState<Record<string, string>>({});
const [channelName, setChannelName] = useState(''); const [channelName, setChannelName] = useState('');
const [accountIdInput, setAccountIdInput] = useState(accountId || '');
const [connecting, setConnecting] = useState(false); const [connecting, setConnecting] = useState(false);
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({}); const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
const [qrCode, setQrCode] = useState<string | null>(null); const [qrCode, setQrCode] = useState<string | null>(null);
@@ -86,11 +95,18 @@ export function ChannelConfigModal({
const meta: ChannelMeta | null = selectedType ? CHANNEL_META[selectedType] : null; const meta: ChannelMeta | null = selectedType ? CHANNEL_META[selectedType] : null;
const shouldUseCredentialValidation = selectedType !== 'feishu'; const shouldUseCredentialValidation = selectedType !== 'feishu';
const resolvedAccountId = allowEditAccountId
? accountIdInput.trim()
: (accountId ?? (agentId ? (agentId === 'main' ? 'default' : agentId) : undefined));
useEffect(() => { useEffect(() => {
setSelectedType(initialSelectedType); setSelectedType(initialSelectedType);
}, [initialSelectedType]); }, [initialSelectedType]);
useEffect(() => {
setAccountIdInput(accountId || '');
}, [accountId]);
useEffect(() => { useEffect(() => {
if (!selectedType) { if (!selectedType) {
setConfigValues({}); setConfigValues({});
@@ -112,13 +128,21 @@ export function ChannelConfigModal({
return; return;
} }
if (initialConfigValues) {
setConfigValues(initialConfigValues);
setIsExistingConfig(Object.keys(initialConfigValues).length > 0);
setLoadingConfig(false);
setChannelName(showChannelName ? CHANNEL_NAMES[selectedType] : '');
return;
}
let cancelled = false; let cancelled = false;
setLoadingConfig(true); setLoadingConfig(true);
setChannelName(showChannelName ? CHANNEL_NAMES[selectedType] : ''); setChannelName(showChannelName ? CHANNEL_NAMES[selectedType] : '');
(async () => { (async () => {
try { try {
const accountParam = agentId ? `?accountId=${encodeURIComponent(agentId === 'main' ? 'default' : agentId)}` : ''; const accountParam = resolvedAccountId ? `?accountId=${encodeURIComponent(resolvedAccountId)}` : '';
const result = await hostApiFetch<{ success: boolean; values?: Record<string, string> }>( const result = await hostApiFetch<{ success: boolean; values?: Record<string, string> }>(
`/api/channels/config/${encodeURIComponent(selectedType)}${accountParam}` `/api/channels/config/${encodeURIComponent(selectedType)}${accountParam}`
); );
@@ -144,7 +168,7 @@ export function ChannelConfigModal({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [agentId, allowExistingConfig, configuredTypes, selectedType, showChannelName]); }, [allowExistingConfig, configuredTypes, initialConfigValues, resolvedAccountId, selectedType, showChannelName]);
useEffect(() => { useEffect(() => {
if (selectedType && !loadingConfig && showChannelName && firstInputRef.current) { if (selectedType && !loadingConfig && showChannelName && firstInputRef.current) {
@@ -187,13 +211,18 @@ export function ChannelConfigModal({
try { try {
const saveResult = await hostApiFetch<{ success?: boolean; error?: string }>('/api/channels/config', { const saveResult = await hostApiFetch<{ success?: boolean; error?: string }>('/api/channels/config', {
method: 'POST', method: 'POST',
body: JSON.stringify({ channelType: 'whatsapp', config: { enabled: true } }), body: JSON.stringify({ channelType: 'whatsapp', config: { enabled: true }, accountId: resolvedAccountId }),
}); });
if (!saveResult?.success) { if (!saveResult?.success) {
throw new Error(saveResult?.error || 'Failed to save WhatsApp config'); throw new Error(saveResult?.error || 'Failed to save WhatsApp config');
} }
await finishSave('whatsapp'); try {
await finishSave('whatsapp');
} catch (postSaveError) {
toast.warning(t('toast.savedButRefreshFailed'));
console.warn('Channel saved but post-save refresh failed:', postSaveError);
}
// Gateway restart is already triggered by scheduleGatewayChannelRestart // Gateway restart is already triggered by scheduleGatewayChannelRestart
// in the POST /api/channels/config route handler (debounced). Calling // in the POST /api/channels/config route handler (debounced). Calling
// restart() here directly races with that debounced restart and the // restart() here directly races with that debounced restart and the
@@ -222,7 +251,7 @@ export function ChannelConfigModal({
removeErrorListener(); removeErrorListener();
hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => { }); hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => { });
}; };
}, [selectedType, finishSave, onClose, t]); }, [finishSave, onClose, resolvedAccountId, selectedType, t]);
const handleValidate = async () => { const handleValidate = async () => {
if (!selectedType || !shouldUseCredentialValidation) return; if (!selectedType || !shouldUseCredentialValidation) return;
@@ -273,10 +302,25 @@ export function ChannelConfigModal({
setValidationResult(null); setValidationResult(null);
try { try {
if (allowEditAccountId) {
const nextAccountId = accountIdInput.trim();
if (!nextAccountId) {
toast.error(t('account.invalidId'));
setConnecting(false);
return;
}
const duplicateExists = existingAccountIds.some((id) => id === nextAccountId && id !== (accountId || '').trim());
if (duplicateExists) {
toast.error(t('account.accountIdExists', { accountId: nextAccountId }));
setConnecting(false);
return;
}
}
if (meta.connectionType === 'qr') { if (meta.connectionType === 'qr') {
await hostApiFetch('/api/channels/whatsapp/start', { await hostApiFetch('/api/channels/whatsapp/start', {
method: 'POST', method: 'POST',
body: JSON.stringify({ accountId: 'default' }), body: JSON.stringify({ accountId: resolvedAccountId || 'default' }),
}); });
return; return;
} }
@@ -319,7 +363,6 @@ export function ChannelConfigModal({
} }
const config: Record<string, unknown> = { ...configValues }; const config: Record<string, unknown> = { ...configValues };
const resolvedAccountId = agentId ? (agentId === 'main' ? 'default' : agentId) : undefined;
const saveResult = await hostApiFetch<{ const saveResult = await hostApiFetch<{
success?: boolean; success?: boolean;
error?: string; error?: string;
@@ -335,7 +378,12 @@ export function ChannelConfigModal({
toast.warning(saveResult.warning); toast.warning(saveResult.warning);
} }
await finishSave(selectedType); try {
await finishSave(selectedType);
} catch (postSaveError) {
toast.warning(t('toast.savedButRefreshFailed'));
console.warn('Channel saved but post-save refresh failed:', postSaveError);
}
toast.success(t('toast.channelSaved', { name: meta.name })); toast.success(t('toast.channelSaved', { name: meta.name }));
toast.success(t('toast.channelConnecting', { name: meta.name })); toast.success(t('toast.channelConnecting', { name: meta.name }));
@@ -534,6 +582,20 @@ export function ChannelConfigModal({
</div> </div>
)} )}
{allowEditAccountId && (
<div className="space-y-2.5">
<Label htmlFor="account-id" className={labelClasses}>{t('account.customIdLabel')}</Label>
<Input
id="account-id"
value={accountIdInput}
onChange={(event) => setAccountIdInput(event.target.value)}
placeholder={t('account.customIdPlaceholder')}
className={inputClasses}
/>
<p className="text-[12px] text-muted-foreground">{t('account.customIdHint')}</p>
</div>
)}
<div className="space-y-4"> <div className="space-y-4">
{meta?.configFields.map((field) => ( {meta?.configFields.map((field) => (
<ConfigField <ConfigField
@@ -623,7 +685,7 @@ export function ChannelConfigModal({
onClick={() => { onClick={() => {
void handleConnect(); void handleConnect();
}} }}
disabled={connecting || !isFormValid()} disabled={connecting || !isFormValid() || (allowEditAccountId && !accountIdInput.trim())}
className={primaryButtonClasses} className={primaryButtonClasses}
> >
{connecting ? ( {connecting ? (

View File

@@ -29,7 +29,9 @@
"agentIdLabel": "Agent ID", "agentIdLabel": "Agent ID",
"modelLabel": "Model", "modelLabel": "Model",
"channelsTitle": "Channels", "channelsTitle": "Channels",
"channelsDescription": "Each channel type has a single ClawX configuration. Saving a configured channel here moves ownership to this agent.", "channelsDescription": "This list is read-only. Manage channel accounts and bindings in the Channels page.",
"mainAccount": "Main account",
"channelsManagedInChannels": "This agent is linked to channel types. Manage exact account bindings in the Channels page.",
"addChannel": "Add Channel", "addChannel": "Add Channel",
"noChannels": "No channels are assigned to this agent yet." "noChannels": "No channels are assigned to this agent yet."
}, },

View File

@@ -1,6 +1,6 @@
{ {
"title": "Messaging Channels", "title": "Messaging Channels",
"subtitle": "Manage your messaging channels and connections. The configuration is only effective for the main Agent.", "subtitle": "Manage messaging channels, accounts, account-to-agent bindings, and each channel's default account.",
"refresh": "Refresh", "refresh": "Refresh",
"addChannel": "Add Channel", "addChannel": "Add Channel",
"stats": { "stats": {
@@ -24,8 +24,45 @@
"whatsappFailed": "WhatsApp connection failed: {{error}}", "whatsappFailed": "WhatsApp connection failed: {{error}}",
"channelSaved": "Channel {{name}} saved", "channelSaved": "Channel {{name}} saved",
"channelConnecting": "Connecting to {{name}}...", "channelConnecting": "Connecting to {{name}}...",
"savedButRefreshFailed": "Configuration was saved, but refreshing page data failed. Please refresh manually.",
"restartManual": "Please restart the gateway manually", "restartManual": "Please restart the gateway manually",
"configFailed": "Configuration failed: {{error}}" "configFailed": "Configuration failed: {{error}}",
"bindingUpdated": "Account binding updated",
"defaultUpdated": "Default account updated",
"accountDeleted": "Account deleted",
"channelDeleted": "Channel deleted"
},
"account": {
"add": "Add Account",
"edit": "Edit",
"delete": "Delete Account",
"deleteChannel": "Delete channel",
"deleteConfirm": "Are you sure you want to delete this account?",
"default": "Current Default",
"setDefault": "Set as channel default",
"unassigned": "Unassigned",
"mainAccount": "Primary Account",
"customIdLabel": "Account ID",
"customIdPlaceholder": "e.g. feishu-sales-bot",
"customIdHint": "Use a custom account ID to distinguish multiple accounts under one channel.",
"invalidId": "Account ID cannot be empty",
"idLabel": "ID: {{id}}",
"boundTo": "Bound to: {{agent}}",
"handledBy": "Handled by {{agent}}",
"bindingStatusLabel": "Binding: {{status}}",
"connectionStatusLabel": "Connection: {{status}}",
"bindingStatus": {
"bound": "Bound",
"unbound": "Unbound"
},
"connectionStatus": {
"connected": "Connected",
"connecting": "Connecting",
"disconnected": "Disconnected",
"error": "Error"
},
"accountIdPrompt": "Enter a new account ID for this channel",
"accountIdExists": "Account ID {{accountId}} already exists"
}, },
"dialog": { "dialog": {
"updateTitle": "Update {{name}}", "updateTitle": "Update {{name}}",

View File

@@ -29,7 +29,9 @@
"agentIdLabel": "Agent ID", "agentIdLabel": "Agent ID",
"modelLabel": "Model", "modelLabel": "Model",
"channelsTitle": "Channels", "channelsTitle": "Channels",
"channelsDescription": "各 Channel 種別は ClawX で 1 つだけ設定されます。ここで既存の Channel を保存すると、この Agent に所属が移動します。", "channelsDescription": "この一覧は読み取り専用です。チャンネルアカウントと紐付けは Channels ページで管理してください。",
"mainAccount": "メインアカウント",
"channelsManagedInChannels": "この Agent はチャンネル種別に紐付いています。アカウント単位の紐付けは Channels ページで管理してください。",
"addChannel": "Channel を追加", "addChannel": "Channel を追加",
"noChannels": "この Agent にはまだ Channel が割り当てられていません。" "noChannels": "この Agent にはまだ Channel が割り当てられていません。"
}, },

View File

@@ -1,6 +1,6 @@
{ {
"title": "メッセージングチャンネル", "title": "メッセージングチャンネル",
"subtitle": "メッセージングチャンネルと接続を管理。設定はメイン Agent のみ有効です", "subtitle": "メッセージングチャンネル、アカウント、Agent への紐付け、チャンネルのデフォルトアカウントを一元管理します",
"refresh": "更新", "refresh": "更新",
"addChannel": "チャンネルを追加", "addChannel": "チャンネルを追加",
"stats": { "stats": {
@@ -24,8 +24,45 @@
"whatsappFailed": "WhatsApp 接続に失敗しました: {{error}}", "whatsappFailed": "WhatsApp 接続に失敗しました: {{error}}",
"channelSaved": "チャンネル {{name}} が保存されました", "channelSaved": "チャンネル {{name}} が保存されました",
"channelConnecting": "{{name}} に接続中...", "channelConnecting": "{{name}} に接続中...",
"savedButRefreshFailed": "設定は保存されましたが、画面データの更新に失敗しました。手動で再読み込みしてください。",
"restartManual": "ゲートウェイを手動で再起動してください", "restartManual": "ゲートウェイを手動で再起動してください",
"configFailed": "設定に失敗しました: {{error}}" "configFailed": "設定に失敗しました: {{error}}",
"bindingUpdated": "アカウントの紐付けを保存しました",
"defaultUpdated": "デフォルトアカウントを更新しました",
"accountDeleted": "アカウントを削除しました",
"channelDeleted": "チャンネルを削除しました"
},
"account": {
"add": "アカウントを追加",
"edit": "編集",
"delete": "アカウントを削除",
"deleteChannel": "チャンネルを削除",
"deleteConfirm": "このアカウントを削除してもよろしいですか?",
"default": "現在の既定",
"setDefault": "チャンネル既定に設定",
"unassigned": "未割り当て",
"mainAccount": "メインアカウント",
"customIdLabel": "アカウント ID",
"customIdPlaceholder": "例: feishu-sales-bot",
"customIdHint": "同じチャンネル内の複数アカウントを区別するため、任意の ID を設定できます。",
"invalidId": "アカウント ID は空にできません",
"idLabel": "ID: {{id}}",
"boundTo": "割り当て先: {{agent}}",
"handledBy": "{{agent}} が処理",
"bindingStatusLabel": "紐付け状態:{{status}}",
"connectionStatusLabel": "接続状態:{{status}}",
"bindingStatus": {
"bound": "紐付け済み",
"unbound": "未紐付け"
},
"connectionStatus": {
"connected": "接続済み",
"connecting": "接続中",
"disconnected": "未接続",
"error": "異常"
},
"accountIdPrompt": "このチャンネルの新しいアカウント ID を入力してください",
"accountIdExists": "アカウント ID {{accountId}} はすでに存在します"
}, },
"dialog": { "dialog": {
"updateTitle": "{{name}} を更新", "updateTitle": "{{name}} を更新",

View File

@@ -29,7 +29,9 @@
"agentIdLabel": "Agent ID", "agentIdLabel": "Agent ID",
"modelLabel": "Model", "modelLabel": "Model",
"channelsTitle": "频道", "channelsTitle": "频道",
"channelsDescription": "每种频道类型在 ClawX 中只保留一份配置。在这里保存已配置的频道会将归属切换到当前 Agent。", "channelsDescription": "该列表为只读。频道账号与绑定关系请在 Channels 页面管理。",
"mainAccount": "主账号",
"channelsManagedInChannels": "该 Agent 绑定了频道类型,但具体账号绑定请在 Channels 页面查看。",
"addChannel": "添加频道", "addChannel": "添加频道",
"noChannels": "这个 Agent 还没有分配任何频道。" "noChannels": "这个 Agent 还没有分配任何频道。"
}, },

View File

@@ -1,6 +1,6 @@
{ {
"title": "消息频道", "title": "消息频道",
"subtitle": "连接到消息平台,配置仅对主 Agent 生效", "subtitle": "统一管理消息频道、账号、账号与智能体的绑定关系,以及频道默认账号",
"refresh": "刷新", "refresh": "刷新",
"addChannel": "添加频道", "addChannel": "添加频道",
"stats": { "stats": {
@@ -24,8 +24,45 @@
"whatsappFailed": "WhatsApp 连接失败: {{error}}", "whatsappFailed": "WhatsApp 连接失败: {{error}}",
"channelSaved": "频道 {{name}} 已保存", "channelSaved": "频道 {{name}} 已保存",
"channelConnecting": "正在连接 {{name}}...", "channelConnecting": "正在连接 {{name}}...",
"savedButRefreshFailed": "配置已保存,但刷新页面数据失败,请手动刷新查看最新状态",
"restartManual": "请手动重启网关", "restartManual": "请手动重启网关",
"configFailed": "配置失败: {{error}}" "configFailed": "配置失败: {{error}}",
"bindingUpdated": "账号绑定已更新",
"defaultUpdated": "默认账号已更新",
"accountDeleted": "账号已删除",
"channelDeleted": "频道已删除"
},
"account": {
"add": "添加账号",
"edit": "编辑",
"delete": "删除账号",
"deleteChannel": "删除频道",
"deleteConfirm": "确定要删除该账号吗?",
"default": "当前默认",
"setDefault": "设为频道默认账号",
"unassigned": "未绑定",
"mainAccount": "主账号",
"customIdLabel": "账号 ID",
"customIdPlaceholder": "例如feishu-sales-bot",
"customIdHint": "可自定义账号 ID用于区分同一频道下的多个账号。",
"invalidId": "账号 ID 不能为空",
"idLabel": "ID: {{id}}",
"boundTo": "绑定对象:{{agent}}",
"handledBy": "由 {{agent}} 处理",
"bindingStatusLabel": "绑定状态:{{status}}",
"connectionStatusLabel": "连接状态:{{status}}",
"bindingStatus": {
"bound": "已绑定",
"unbound": "未绑定"
},
"connectionStatus": {
"connected": "已连接",
"connecting": "连接中",
"disconnected": "未连接",
"error": "异常"
},
"accountIdPrompt": "请输入该频道的新账号 ID",
"accountIdExists": "账号 ID {{accountId}} 已存在"
}, },
"dialog": { "dialog": {
"updateTitle": "更新 {{name}}", "updateTitle": "更新 {{name}}",

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { AlertCircle, Bot, Check, Plus, RefreshCw, Settings2, Trash2, X } from 'lucide-react'; import { AlertCircle, Bot, Check, Plus, RefreshCw, Settings2, Trash2, X } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -6,12 +6,10 @@ import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { StatusBadge } from '@/components/common/StatusBadge';
import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { ChannelConfigModal } from '@/components/channels/ChannelConfigModal';
import { useAgentsStore } from '@/stores/agents'; import { useAgentsStore } from '@/stores/agents';
import { useChannelsStore } from '@/stores/channels';
import { useGatewayStore } from '@/stores/gateway'; import { useGatewayStore } from '@/stores/gateway';
import { hostApiFetch } from '@/lib/host-api';
import { CHANNEL_ICONS, CHANNEL_NAMES, type ChannelType } from '@/types/channel'; import { CHANNEL_ICONS, CHANNEL_NAMES, type ChannelType } from '@/types/channel';
import type { AgentSummary } from '@/types/agent'; import type { AgentSummary } from '@/types/agent';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -25,6 +23,23 @@ import feishuIcon from '@/assets/channels/feishu.svg';
import wecomIcon from '@/assets/channels/wecom.svg'; import wecomIcon from '@/assets/channels/wecom.svg';
import qqIcon from '@/assets/channels/qq.svg'; import qqIcon from '@/assets/channels/qq.svg';
interface ChannelAccountItem {
accountId: string;
name: string;
configured: boolean;
status: 'connected' | 'connecting' | 'disconnected' | 'error';
lastError?: string;
isDefault: boolean;
agentId?: string;
}
interface ChannelGroupItem {
channelType: string;
defaultAccountId: string;
status: 'connected' | 'connecting' | 'disconnected' | 'error';
accounts: ChannelAccountItem[];
}
export function Agents() { export function Agents() {
const { t } = useTranslation('agents'); const { t } = useTranslation('agents');
const gatewayStatus = useGatewayStore((state) => state.status); const gatewayStatus = useGatewayStore((state) => state.status);
@@ -36,21 +51,31 @@ export function Agents() {
createAgent, createAgent,
deleteAgent, deleteAgent,
} = useAgentsStore(); } = useAgentsStore();
const { channels, fetchChannels } = useChannelsStore(); const [channelGroups, setChannelGroups] = useState<ChannelGroupItem[]>([]);
const [showAddDialog, setShowAddDialog] = useState(false); const [showAddDialog, setShowAddDialog] = useState(false);
const [activeAgentId, setActiveAgentId] = useState<string | null>(null); const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
const [agentToDelete, setAgentToDelete] = useState<AgentSummary | null>(null); const [agentToDelete, setAgentToDelete] = useState<AgentSummary | null>(null);
const fetchChannelAccounts = useCallback(async () => {
try {
const response = await hostApiFetch<{ success: boolean; channels?: ChannelGroupItem[] }>('/api/channels/accounts');
setChannelGroups(response.channels || []);
} catch {
setChannelGroups([]);
}
}, []);
useEffect(() => { useEffect(() => {
void Promise.all([fetchAgents(), fetchChannels()]); // eslint-disable-next-line react-hooks/set-state-in-effect
}, [fetchAgents, fetchChannels]); void Promise.all([fetchAgents(), fetchChannelAccounts()]);
}, [fetchAgents, fetchChannelAccounts]);
const activeAgent = useMemo( const activeAgent = useMemo(
() => agents.find((agent) => agent.id === activeAgentId) ?? null, () => agents.find((agent) => agent.id === activeAgentId) ?? null,
[activeAgentId, agents], [activeAgentId, agents],
); );
const handleRefresh = () => { const handleRefresh = () => {
void Promise.all([fetchAgents(), fetchChannels()]); void Promise.all([fetchAgents(), fetchChannelAccounts()]);
}; };
if (loading) { if (loading) {
@@ -117,6 +142,7 @@ export function Agents() {
<AgentCard <AgentCard
key={agent.id} key={agent.id}
agent={agent} agent={agent}
channelGroups={channelGroups}
onOpenSettings={() => setActiveAgentId(agent.id)} onOpenSettings={() => setActiveAgentId(agent.id)}
onDelete={() => setAgentToDelete(agent)} onDelete={() => setAgentToDelete(agent)}
/> />
@@ -139,7 +165,7 @@ export function Agents() {
{activeAgent && ( {activeAgent && (
<AgentSettingsModal <AgentSettingsModal
agent={activeAgent} agent={activeAgent}
channels={channels} channelGroups={channelGroups}
onClose={() => setActiveAgentId(null)} onClose={() => setActiveAgentId(null)}
/> />
)} )}
@@ -173,16 +199,30 @@ export function Agents() {
function AgentCard({ function AgentCard({
agent, agent,
channelGroups,
onOpenSettings, onOpenSettings,
onDelete, onDelete,
}: { }: {
agent: AgentSummary; agent: AgentSummary;
channelGroups: ChannelGroupItem[];
onOpenSettings: () => void; onOpenSettings: () => void;
onDelete: () => void; onDelete: () => void;
}) { }) {
const { t } = useTranslation('agents'); const { t } = useTranslation('agents');
const channelsText = agent.channelTypes.length > 0 const boundChannelAccounts = channelGroups.flatMap((group) =>
? agent.channelTypes.map((channelType) => CHANNEL_NAMES[channelType as ChannelType] || channelType).join(', ') group.accounts
.filter((account) => account.agentId === agent.id)
.map((account) => {
const channelName = CHANNEL_NAMES[group.channelType as ChannelType] || group.channelType;
const accountLabel =
account.accountId === 'default'
? t('settingsDialog.mainAccount')
: account.name || account.accountId;
return `${channelName} · ${accountLabel}`;
}),
);
const channelsText = boundChannelAccounts.length > 0
? boundChannelAccounts.join(', ')
: t('none'); : t('none');
return ( return (
@@ -350,30 +390,22 @@ function AddAgentDialog({
function AgentSettingsModal({ function AgentSettingsModal({
agent, agent,
channels, channelGroups,
onClose, onClose,
}: { }: {
agent: AgentSummary; agent: AgentSummary;
channels: Array<{ type: string; name: string; status: 'connected' | 'connecting' | 'disconnected' | 'error'; error?: string }>; channelGroups: ChannelGroupItem[];
onClose: () => void; onClose: () => void;
}) { }) {
const { t } = useTranslation('agents'); const { t } = useTranslation('agents');
const { updateAgent, assignChannel, removeChannel } = useAgentsStore(); const { updateAgent } = useAgentsStore();
const { fetchChannels } = useChannelsStore();
const [name, setName] = useState(agent.name); const [name, setName] = useState(agent.name);
const [savingName, setSavingName] = useState(false); const [savingName, setSavingName] = useState(false);
const [showChannelModal, setShowChannelModal] = useState(false);
const [channelToRemove, setChannelToRemove] = useState<ChannelType | null>(null);
useEffect(() => { useEffect(() => {
setName(agent.name); setName(agent.name);
}, [agent.name]); }, [agent.name]);
const runtimeChannelsByType = useMemo(
() => Object.fromEntries(channels.map((channel) => [channel.type, channel])),
[channels],
);
const handleSaveName = async () => { const handleSaveName = async () => {
if (!name.trim() || name.trim() === agent.name) return; if (!name.trim() || name.trim() === agent.name) return;
setSavingName(true); setSavingName(true);
@@ -387,26 +419,19 @@ function AgentSettingsModal({
} }
}; };
const handleChannelSaved = async (channelType: ChannelType) => { const assignedChannels = channelGroups.flatMap((group) =>
try { group.accounts
await assignChannel(agent.id, channelType); .filter((account) => account.agentId === agent.id)
await fetchChannels(); .map((account) => ({
toast.success(t('toast.channelAssigned', { channel: CHANNEL_NAMES[channelType] || channelType })); channelType: group.channelType as ChannelType,
} catch (error) { accountId: account.accountId,
toast.error(t('toast.channelAssignFailed', { error: String(error) })); name:
throw error; account.accountId === 'default'
} ? t('settingsDialog.mainAccount')
}; : account.name || account.accountId,
error: account.lastError,
const assignedChannels = agent.channelTypes.map((channelType) => { })),
const runtimeChannel = runtimeChannelsByType[channelType]; );
return {
channelType: channelType as ChannelType,
name: runtimeChannel?.name || CHANNEL_NAMES[channelType as ChannelType] || channelType,
status: runtimeChannel?.status || 'disconnected',
error: runtimeChannel?.error,
};
});
return ( return (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
@@ -485,23 +510,16 @@ function AgentSettingsModal({
</h3> </h3>
<p className="text-[14px] text-foreground/70 mt-1">{t('settingsDialog.channelsDescription')}</p> <p className="text-[14px] text-foreground/70 mt-1">{t('settingsDialog.channelsDescription')}</p>
</div> </div>
<Button
onClick={() => setShowChannelModal(true)}
className="h-9 text-[13px] font-medium rounded-full px-4 shadow-none"
>
<Plus className="h-3.5 w-3.5 mr-2" />
{t('settingsDialog.addChannel')}
</Button>
</div> </div>
{assignedChannels.length === 0 ? ( {assignedChannels.length === 0 && agent.channelTypes.length === 0 ? (
<div className="rounded-2xl border border-dashed border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 p-4 text-[13.5px] text-muted-foreground"> <div className="rounded-2xl border border-dashed border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 p-4 text-[13.5px] text-muted-foreground">
{t('settingsDialog.noChannels')} {t('settingsDialog.noChannels')}
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{assignedChannels.map((channel) => ( {assignedChannels.map((channel) => (
<div key={channel.channelType} className="flex items-center justify-between rounded-2xl bg-black/5 dark:bg-white/5 border border-transparent p-4"> <div key={`${channel.channelType}-${channel.accountId}`} className="flex items-center justify-between rounded-2xl bg-black/5 dark:bg-white/5 border border-transparent p-4">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<div className="h-[40px] w-[40px] shrink-0 flex items-center justify-center text-foreground bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/10 rounded-full shadow-sm"> <div className="h-[40px] w-[40px] shrink-0 flex items-center justify-center text-foreground bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/10 rounded-full shadow-sm">
<ChannelLogo type={channel.channelType} /> <ChannelLogo type={channel.channelType} />
@@ -509,67 +527,26 @@ function AgentSettingsModal({
<div className="min-w-0"> <div className="min-w-0">
<p className="text-[15px] font-semibold text-foreground">{channel.name}</p> <p className="text-[15px] font-semibold text-foreground">{channel.name}</p>
<p className="text-[13.5px] text-muted-foreground"> <p className="text-[13.5px] text-muted-foreground">
{CHANNEL_NAMES[channel.channelType]} {CHANNEL_NAMES[channel.channelType]} · {channel.accountId === 'default' ? t('settingsDialog.mainAccount') : channel.accountId}
</p> </p>
{channel.error && ( {channel.error && (
<p className="text-xs text-destructive mt-1">{channel.error}</p> <p className="text-xs text-destructive mt-1">{channel.error}</p>
)} )}
</div> </div>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="shrink-0" />
<StatusBadge status={channel.status} />
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={() => setChannelToRemove(channel.channelType)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div> </div>
))} ))}
{assignedChannels.length === 0 && agent.channelTypes.length > 0 && (
<div className="rounded-2xl border border-dashed border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 p-4 text-[13.5px] text-muted-foreground">
{t('settingsDialog.channelsManagedInChannels')}
</div>
)}
</div> </div>
)} )}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{showChannelModal && (
<ChannelConfigModal
configuredTypes={agent.channelTypes}
showChannelName={false}
allowExistingConfig
agentId={agent.id}
onClose={() => setShowChannelModal(false)}
onChannelSaved={async (channelType) => {
await handleChannelSaved(channelType);
setShowChannelModal(false);
}}
/>
)}
<ConfirmDialog
open={!!channelToRemove}
title={t('removeChannelDialog.title')}
message={channelToRemove ? t('removeChannelDialog.message', { name: CHANNEL_NAMES[channelToRemove] || channelToRemove }) : ''}
confirmLabel={t('common:actions.delete')}
cancelLabel={t('common:actions.cancel')}
variant="destructive"
onConfirm={async () => {
if (!channelToRemove) return;
try {
await removeChannel(agent.id, channelToRemove);
await fetchChannels();
toast.success(t('toast.channelRemoved', { channel: CHANNEL_NAMES[channelToRemove] || channelToRemove }));
} catch (error) {
toast.error(t('toast.channelRemoveFailed', { error: String(error) }));
} finally {
setChannelToRemove(null);
}
}}
onCancel={() => setChannelToRemove(null)}
/>
</div> </div>
); );
} }

View File

@@ -1,13 +1,8 @@
/** import { useState, useEffect, useCallback, useMemo } from 'react';
* Channels Page import { RefreshCw, Trash2, AlertCircle, Plus } from 'lucide-react';
* Manage messaging channel connections with configuration UI
*/
import { useState, useEffect, useCallback } from 'react';
import { RefreshCw, Trash2, AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useChannelsStore } from '@/stores/channels';
import { useGatewayStore } from '@/stores/gateway'; import { useGatewayStore } from '@/stores/gateway';
import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { hostApiFetch } from '@/lib/host-api'; import { hostApiFetch } from '@/lib/host-api';
@@ -20,9 +15,9 @@ import {
CHANNEL_META, CHANNEL_META,
getPrimaryChannels, getPrimaryChannels,
type ChannelType, type ChannelType,
type Channel,
} from '@/types/channel'; } from '@/types/channel';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import telegramIcon from '@/assets/channels/telegram.svg'; import telegramIcon from '@/assets/channels/telegram.svg';
import discordIcon from '@/assets/channels/discord.svg'; import discordIcon from '@/assets/channels/discord.svg';
@@ -32,57 +27,182 @@ import feishuIcon from '@/assets/channels/feishu.svg';
import wecomIcon from '@/assets/channels/wecom.svg'; import wecomIcon from '@/assets/channels/wecom.svg';
import qqIcon from '@/assets/channels/qq.svg'; import qqIcon from '@/assets/channels/qq.svg';
interface ChannelAccountItem {
accountId: string;
name: string;
configured: boolean;
status: 'connected' | 'connecting' | 'disconnected' | 'error';
lastError?: string;
isDefault: boolean;
agentId?: string;
}
interface ChannelGroupItem {
channelType: string;
defaultAccountId: string;
status: 'connected' | 'connecting' | 'disconnected' | 'error';
accounts: ChannelAccountItem[];
}
interface AgentItem {
id: string;
name: string;
}
interface DeleteTarget {
channelType: string;
accountId?: string;
}
function removeDeletedTarget(groups: ChannelGroupItem[], target: DeleteTarget): ChannelGroupItem[] {
if (target.accountId) {
return groups
.map((group) => {
if (group.channelType !== target.channelType) return group;
return {
...group,
accounts: group.accounts.filter((account) => account.accountId !== target.accountId),
};
})
.filter((group) => group.accounts.length > 0);
}
return groups.filter((group) => group.channelType !== target.channelType);
}
export function Channels() { export function Channels() {
const { t } = useTranslation('channels'); const { t } = useTranslation('channels');
const { channels, loading, error, fetchChannels, deleteChannel } = useChannelsStore();
const gatewayStatus = useGatewayStore((state) => state.status); const gatewayStatus = useGatewayStore((state) => state.status);
const [showAddDialog, setShowAddDialog] = useState(false); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [channelGroups, setChannelGroups] = useState<ChannelGroupItem[]>([]);
const [agents, setAgents] = useState<AgentItem[]>([]);
const [showConfigModal, setShowConfigModal] = useState(false);
const [selectedChannelType, setSelectedChannelType] = useState<ChannelType | null>(null); const [selectedChannelType, setSelectedChannelType] = useState<ChannelType | null>(null);
const [configuredTypes, setConfiguredTypes] = useState<string[]>([]); const [selectedAccountId, setSelectedAccountId] = useState<string | undefined>(undefined);
const [channelToDelete, setChannelToDelete] = useState<{ id: string } | null>(null); const [allowExistingConfigInModal, setAllowExistingConfigInModal] = useState(true);
const [allowEditAccountIdInModal, setAllowEditAccountIdInModal] = useState(false);
const [existingAccountIdsForModal, setExistingAccountIdsForModal] = useState<string[]>([]);
const [initialConfigValuesForModal, setInitialConfigValuesForModal] = useState<Record<string, string> | undefined>(undefined);
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null);
useEffect(() => { const displayedChannelTypes = getPrimaryChannels();
void fetchChannels();
}, [fetchChannels]);
const fetchConfiguredTypes = useCallback(async () => { const fetchPageData = useCallback(async () => {
setLoading(true);
setError(null);
try { try {
const result = await hostApiFetch<{ const [channelsRes, agentsRes] = await Promise.all([
success: boolean; hostApiFetch<{ success: boolean; channels?: ChannelGroupItem[]; error?: string }>('/api/channels/accounts'),
channels?: string[]; hostApiFetch<{ success: boolean; agents?: AgentItem[]; error?: string }>('/api/agents'),
}>('/api/channels/configured'); ]);
if (result.success && result.channels) {
setConfiguredTypes(result.channels); if (!channelsRes.success) {
throw new Error(channelsRes.error || 'Failed to load channels');
} }
} catch {
// Ignore refresh errors here and keep the last known state. if (!agentsRes.success) {
throw new Error(agentsRes.error || 'Failed to load agents');
}
setChannelGroups(channelsRes.channels || []);
setAgents(agentsRes.agents || []);
} catch (fetchError) {
setError(String(fetchError));
} finally {
setLoading(false);
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
const timer = window.setTimeout(() => { void fetchPageData();
void fetchConfiguredTypes(); }, [fetchPageData]);
}, 0);
return () => window.clearTimeout(timer);
}, [fetchConfiguredTypes]);
useEffect(() => { useEffect(() => {
const unsubscribe = subscribeHostEvent('gateway:channel-status', () => { const unsubscribe = subscribeHostEvent('gateway:channel-status', () => {
void fetchChannels(); void fetchPageData();
void fetchConfiguredTypes();
}); });
return () => { return () => {
if (typeof unsubscribe === 'function') { if (typeof unsubscribe === 'function') {
unsubscribe(); unsubscribe();
} }
}; };
}, [fetchChannels, fetchConfiguredTypes]); }, [fetchPageData]);
const displayedChannelTypes = getPrimaryChannels(); const configuredTypes = useMemo(
() => channelGroups.map((group) => group.channelType),
[channelGroups],
);
const groupedByType = useMemo(() => {
return Object.fromEntries(channelGroups.map((group) => [group.channelType, group]));
}, [channelGroups]);
const configuredGroups = useMemo(() => {
const known = displayedChannelTypes
.map((type) => groupedByType[type])
.filter((group): group is ChannelGroupItem => Boolean(group));
const unknown = channelGroups.filter((group) => !displayedChannelTypes.includes(group.channelType as ChannelType));
return [...known, ...unknown];
}, [channelGroups, displayedChannelTypes, groupedByType]);
const unsupportedGroups = displayedChannelTypes.filter((type) => !configuredTypes.includes(type));
const handleRefresh = () => { const handleRefresh = () => {
void Promise.all([fetchChannels(), fetchConfiguredTypes()]); void fetchPageData();
};
const handleBindAgent = async (channelType: string, accountId: string, agentId: string) => {
try {
if (!agentId) {
await hostApiFetch<{ success: boolean; error?: string }>('/api/channels/binding', {
method: 'DELETE',
body: JSON.stringify({ channelType, accountId }),
});
} else {
await hostApiFetch<{ success: boolean; error?: string }>('/api/channels/binding', {
method: 'PUT',
body: JSON.stringify({ channelType, accountId, agentId }),
});
}
await fetchPageData();
toast.success(t('toast.bindingUpdated'));
} catch (bindError) {
toast.error(t('toast.configFailed', { error: String(bindError) }));
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
const suffix = deleteTarget.accountId
? `?accountId=${encodeURIComponent(deleteTarget.accountId)}`
: '';
await hostApiFetch(`/api/channels/config/${encodeURIComponent(deleteTarget.channelType)}${suffix}`, {
method: 'DELETE',
});
setChannelGroups((prev) => removeDeletedTarget(prev, deleteTarget));
toast.success(deleteTarget.accountId ? t('toast.accountDeleted') : t('toast.channelDeleted'));
// Channel reload is debounced in main process; pull again shortly to
// converge with runtime state without flashing deleted rows back in.
window.setTimeout(() => {
void fetchPageData();
}, 1200);
} catch (deleteError) {
toast.error(t('toast.configFailed', { error: String(deleteError) }));
} finally {
setDeleteTarget(null);
}
};
const createNewAccountId = (channelType: string, existingAccounts: string[]): string => {
// Generate a collision-safe default account id for user editing.
let nextAccountId = `${channelType}-${crypto.randomUUID().slice(0, 8)}`;
while (existingAccounts.includes(nextAccountId)) {
nextAccountId = `${channelType}-${crypto.randomUUID().slice(0, 8)}`;
}
return nextAccountId;
}; };
if (loading) { if (loading) {
@@ -93,17 +213,6 @@ export function Channels() {
); );
} }
const safeChannels = Array.isArray(channels) ? channels : [];
const configuredPlaceholderChannels: Channel[] = displayedChannelTypes
.filter((type) => configuredTypes.includes(type) && !safeChannels.some((channel) => channel.type === type))
.map((type) => ({
id: `${type}-default`,
type,
name: CHANNEL_NAMES[type] || CHANNEL_META[type].name,
status: 'disconnected',
}));
const availableChannels = [...safeChannels, ...configuredPlaceholderChannels];
return ( return (
<div className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden"> <div className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
<div className="w-full max-w-5xl mx-auto flex flex-col h-full p-10 pt-16"> <div className="w-full max-w-5xl mx-auto flex flex-col h-full p-10 pt-16">
@@ -124,7 +233,7 @@ export function Channels() {
disabled={gatewayStatus.state !== 'running'} disabled={gatewayStatus.state !== 'running'}
className="h-9 text-[13px] font-medium rounded-full px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/80 hover:text-foreground transition-colors" className="h-9 text-[13px] font-medium rounded-full px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/80 hover:text-foreground transition-colors"
> >
<RefreshCw className={cn("h-3.5 w-3.5 mr-2", loading && "animate-spin")} /> <RefreshCw className="h-3.5 w-3.5 mr-2" />
{t('refresh')} {t('refresh')}
</Button> </Button>
</div> </div>
@@ -149,22 +258,147 @@ export function Channels() {
</div> </div>
)} )}
{availableChannels.length > 0 && ( {configuredGroups.length > 0 && (
<div className="mb-12"> <div className="mb-12">
<h2 className="text-3xl font-serif text-foreground mb-6 font-normal tracking-tight" style={{ fontFamily: 'Georgia, Cambria, "Times New Roman", Times, serif' }}> <h2 className="text-3xl font-serif text-foreground mb-6 font-normal tracking-tight" style={{ fontFamily: 'Georgia, Cambria, "Times New Roman", Times, serif' }}>
{t('availableChannels')} {t('configured')}
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4"> <div className="space-y-4">
{availableChannels.map((channel) => ( {configuredGroups.map((group) => (
<ChannelCard <div key={group.channelType} className="rounded-2xl border border-black/10 dark:border-white/10 p-4 bg-transparent">
key={channel.id} <div className="flex items-center justify-between gap-2 mb-3">
channel={channel} <div className="flex items-center gap-3 min-w-0">
onClick={() => { <div className="h-[40px] w-[40px] shrink-0 flex items-center justify-center text-foreground bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/10 rounded-full shadow-sm">
setSelectedChannelType(channel.type); <ChannelLogo type={group.channelType as ChannelType} />
setShowAddDialog(true); </div>
}} <div className="min-w-0">
onDelete={() => setChannelToDelete({ id: channel.id })} <h3 className="text-[16px] font-semibold text-foreground truncate">
/> {CHANNEL_NAMES[group.channelType as ChannelType] || group.channelType}
</h3>
<p className="text-[12px] text-muted-foreground">{group.channelType}</p>
</div>
<div
className={cn(
'w-2 h-2 rounded-full shrink-0',
group.status === 'connected'
? 'bg-green-500'
: group.status === 'connecting'
? 'bg-yellow-500 animate-pulse'
: group.status === 'error'
? 'bg-destructive'
: 'bg-muted-foreground'
)}
/>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="h-8 text-xs rounded-full"
onClick={() => {
const nextAccountId = createNewAccountId(
group.channelType,
group.accounts.map((item) => item.accountId),
);
setSelectedChannelType(group.channelType as ChannelType);
setSelectedAccountId(nextAccountId);
setAllowExistingConfigInModal(false);
setAllowEditAccountIdInModal(true);
setExistingAccountIdsForModal(group.accounts.map((item) => item.accountId));
setInitialConfigValuesForModal(undefined);
setShowConfigModal(true);
}}
>
<Plus className="h-3.5 w-3.5 mr-1" />
{t('account.add')}
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={() => setDeleteTarget({ channelType: group.channelType })}
title={t('account.deleteChannel')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-2">
{group.accounts.map((account) => {
const displayName =
account.accountId === 'default' && account.name === account.accountId
? t('account.mainAccount')
: account.name;
return (
<div key={`${group.channelType}-${account.accountId}`} className="rounded-xl bg-black/5 dark:bg-white/5 px-3 py-2">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-[13px] font-medium text-foreground truncate">{displayName}</p>
</div>
{account.lastError && (
<div className="text-[12px] text-destructive mt-1">{account.lastError}</div>
)}
</div>
<div className="flex items-center gap-2">
<select
className="h-8 rounded-lg border border-black/10 dark:border-white/10 bg-background px-2 text-xs"
value={account.agentId || ''}
onChange={(event) => {
void handleBindAgent(group.channelType, account.accountId, event.target.value);
}}
>
<option value="">{t('account.unassigned')}</option>
{agents.map((agent) => (
<option key={agent.id} value={agent.id}>{agent.name}</option>
))}
</select>
<Button
size="sm"
variant="outline"
className="h-8 text-xs rounded-full"
onClick={() => {
void (async () => {
try {
const accountParam = `?accountId=${encodeURIComponent(account.accountId)}`;
const result = await hostApiFetch<{ success: boolean; values?: Record<string, string> }>(
`/api/channels/config/${encodeURIComponent(group.channelType)}${accountParam}`
);
setInitialConfigValuesForModal(result.success ? (result.values || {}) : undefined);
} catch {
// Fall back to modal-side loading when prefetch fails.
setInitialConfigValuesForModal(undefined);
}
setSelectedChannelType(group.channelType as ChannelType);
setSelectedAccountId(account.accountId);
setAllowExistingConfigInModal(true);
setAllowEditAccountIdInModal(false);
setExistingAccountIdsForModal([]);
setShowConfigModal(true);
})();
}}
>
{t('account.edit')}
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={() => setDeleteTarget({ channelType: group.channelType, accountId: account.accountId })}
title={t('account.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
})}
</div>
</div>
))} ))}
</div> </div>
</div> </div>
@@ -176,17 +410,19 @@ export function Channels() {
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
{displayedChannelTypes.map((type) => { {unsupportedGroups.map((type) => {
const meta = CHANNEL_META[type]; const meta = CHANNEL_META[type];
const isAvailable = availableChannels.some((channel) => channel.type === type);
if (isAvailable) return null;
return ( return (
<button <button
key={type} key={type}
onClick={() => { onClick={() => {
setSelectedChannelType(type); setSelectedChannelType(type);
setShowAddDialog(true); setSelectedAccountId(undefined);
setAllowExistingConfigInModal(true);
setAllowEditAccountIdInModal(false);
setExistingAccountIdsForModal([]);
setInitialConfigValuesForModal(undefined);
setShowConfigModal(true);
}} }}
className={cn( className={cn(
'group flex items-start gap-4 p-4 rounded-2xl transition-all text-left border relative overflow-hidden bg-transparent border-transparent hover:bg-black/5 dark:hover:bg-white/5' 'group flex items-start gap-4 p-4 rounded-2xl transition-all text-left border relative overflow-hidden bg-transparent border-transparent hover:bg-black/5 dark:hover:bg-white/5'
@@ -216,38 +452,49 @@ export function Channels() {
</div> </div>
</div> </div>
{showAddDialog && ( {showConfigModal && (
<ChannelConfigModal <ChannelConfigModal
initialSelectedType={selectedChannelType} initialSelectedType={selectedChannelType}
accountId={selectedAccountId}
configuredTypes={configuredTypes} configuredTypes={configuredTypes}
allowExistingConfig={allowExistingConfigInModal}
allowEditAccountId={allowEditAccountIdInModal}
existingAccountIds={existingAccountIdsForModal}
initialConfigValues={initialConfigValuesForModal}
showChannelName={false}
onClose={() => { onClose={() => {
setShowAddDialog(false); setShowConfigModal(false);
setSelectedChannelType(null); setSelectedChannelType(null);
setSelectedAccountId(undefined);
setAllowExistingConfigInModal(true);
setAllowEditAccountIdInModal(false);
setExistingAccountIdsForModal([]);
setInitialConfigValuesForModal(undefined);
}} }}
onChannelSaved={async () => { onChannelSaved={async () => {
await Promise.all([fetchChannels(), fetchConfiguredTypes()]); await fetchPageData();
setShowAddDialog(false); setShowConfigModal(false);
setSelectedChannelType(null); setSelectedChannelType(null);
setSelectedAccountId(undefined);
setAllowExistingConfigInModal(true);
setAllowEditAccountIdInModal(false);
setExistingAccountIdsForModal([]);
setInitialConfigValuesForModal(undefined);
}} }}
/> />
)} )}
<ConfirmDialog <ConfirmDialog
open={!!channelToDelete} open={!!deleteTarget}
title={t('common.confirm', 'Confirm')} title={t('common.confirm', 'Confirm')}
message={t('deleteConfirm')} message={deleteTarget?.accountId ? t('account.deleteConfirm') : t('deleteConfirm')}
confirmLabel={t('common.delete', 'Delete')} confirmLabel={t('common.delete', 'Delete')}
cancelLabel={t('common.cancel', 'Cancel')} cancelLabel={t('common.cancel', 'Cancel')}
variant="destructive" variant="destructive"
onConfirm={async () => { onConfirm={() => {
if (channelToDelete) { void handleDelete();
await deleteChannel(channelToDelete.id);
const [channelType] = channelToDelete.id.split('-');
setConfiguredTypes((prev) => prev.filter((type) => type !== channelType));
setChannelToDelete(null);
}
}} }}
onCancel={() => setChannelToDelete(null)} onCancel={() => setDeleteTarget(null)}
/> />
</div> </div>
); );
@@ -274,76 +521,4 @@ function ChannelLogo({ type }: { type: ChannelType }) {
} }
} }
interface ChannelCardProps {
channel: Channel;
onClick: () => void;
onDelete: () => void;
}
function ChannelCard({ channel, onClick, onDelete }: ChannelCardProps) {
const { t } = useTranslation('channels');
const meta = CHANNEL_META[channel.type];
return (
<div
onClick={onClick}
className="group flex items-start gap-4 p-4 rounded-2xl transition-all text-left border relative overflow-hidden bg-transparent border-transparent hover:bg-black/5 dark:hover:bg-white/5 cursor-pointer"
>
<div className="h-[46px] w-[46px] shrink-0 flex items-center justify-center text-foreground bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/10 rounded-full shadow-sm mb-3">
<ChannelLogo type={channel.type} />
</div>
<div className="flex flex-col flex-1 min-w-0 py-0.5 mt-1">
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0">
<h3 className="text-[16px] font-semibold text-foreground truncate">{channel.name}</h3>
{meta?.isPlugin && (
<Badge
variant="secondary"
className="font-mono text-[10px] font-medium px-2 py-0.5 rounded-full bg-black/[0.04] dark:bg-white/[0.08] border-0 shadow-none text-foreground/70"
>
{t('pluginBadge', 'Plugin')}
</Badge>
)}
<div
className={cn(
'w-2 h-2 rounded-full shrink-0',
channel.status === 'connected'
? 'bg-green-500'
: channel.status === 'connecting'
? 'bg-yellow-500 animate-pulse'
: channel.status === 'error'
? 'bg-destructive'
: 'bg-muted-foreground'
)}
title={channel.status}
/>
</div>
<Button
variant="ghost"
size="icon"
className="opacity-0 group-hover:opacity-100 h-7 w-7 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all shrink-0 -mr-2"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{channel.error ? (
<p className="text-[13.5px] text-destructive line-clamp-2 leading-[1.5]">
{channel.error}
</p>
) : (
<p className="text-[13.5px] text-muted-foreground line-clamp-2 leading-[1.5]">
{meta ? t(meta.description.replace('channels:', '')) : CHANNEL_NAMES[channel.type]}
</p>
)}
</div>
</div>
);
}
export default Channels; export default Channels;

View File

@@ -8,6 +8,7 @@ interface AgentsState {
defaultAgentId: string; defaultAgentId: string;
configuredChannelTypes: string[]; configuredChannelTypes: string[];
channelOwners: Record<string, string>; channelOwners: Record<string, string>;
channelAccountOwners: Record<string, string>;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
fetchAgents: () => Promise<void>; fetchAgents: () => Promise<void>;
@@ -25,6 +26,7 @@ function applySnapshot(snapshot: AgentsSnapshot | undefined) {
defaultAgentId: snapshot.defaultAgentId, defaultAgentId: snapshot.defaultAgentId,
configuredChannelTypes: snapshot.configuredChannelTypes, configuredChannelTypes: snapshot.configuredChannelTypes,
channelOwners: snapshot.channelOwners, channelOwners: snapshot.channelOwners,
channelAccountOwners: snapshot.channelAccountOwners,
} : {}; } : {};
} }
@@ -33,6 +35,7 @@ export const useAgentsStore = create<AgentsState>((set) => ({
defaultAgentId: 'main', defaultAgentId: 'main',
configuredChannelTypes: [], configuredChannelTypes: [],
channelOwners: {}, channelOwners: {},
channelAccountOwners: {},
loading: false, loading: false,
error: null, error: null,

View File

@@ -15,4 +15,5 @@ export interface AgentsSnapshot {
defaultAgentId: string; defaultAgentId: string;
configuredChannelTypes: string[]; configuredChannelTypes: string[];
channelOwners: Record<string, string>; channelOwners: Record<string, string>;
channelAccountOwners: Record<string, string>;
} }

View File

@@ -220,4 +220,44 @@ describe('agent config lifecycle', () => {
warnSpy.mockRestore(); warnSpy.mockRestore();
infoSpy.mockRestore(); infoSpy.mockRestore();
}); });
it('does not delete a legacy-named account when it is owned by another agent', async () => {
await writeOpenClawJson({
agents: {
list: [
{ id: 'main', name: 'Main', default: true },
{ id: 'test2', name: 'test2' },
{ id: 'test3', name: 'test3' },
],
},
channels: {
feishu: {
enabled: true,
defaultAccount: 'default',
accounts: {
default: { enabled: true, appId: 'main-app' },
test2: { enabled: true, appId: 'legacy-test2-app' },
},
},
},
bindings: [
{
agentId: 'test3',
match: {
channel: 'feishu',
accountId: 'test2',
},
},
],
});
const { deleteAgentConfig } = await import('@electron/utils/agent-config');
await deleteAgentConfig('test2');
const config = await readOpenClawJson();
const feishu = (config.channels as Record<string, unknown>).feishu as {
accounts?: Record<string, unknown>;
};
expect(feishu.accounts?.test2).toBeDefined();
});
}); });