diff --git a/README.md b/README.md index ac7cb08e..d0ec98b6 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。 - Agents 侧栏的「渠道」用于把自己的智能体接到微信。每个微信一张卡,展示备注、绑定的智能体头像/名称和是否可用;一个账号同时只有一个目标,多个账号可以使用同一智能体。没有可连接的智能体时,渠道页和添加窗口说明需先创建、配置并点击“开始使用”,提供“去我的智能体”入口;加载失败不显示该空态,已有微信卡片仍可管理。添加流程为选择自己已开始使用的智能体、扫码连接、明确完成绑定并启用;备注可选,扫码确认后收起二维码与验证码,未确认或启用失败不显示完成。管理已有账号不会重新创建账号;更换目标保留微信登录与原启停状态,解除智能体绑定清空目标并停用但保留微信登录,暂停保留绑定/登录并停止该渠道当前任务,断开微信连接则需要重新扫码。更换、解除和断开有明确确认,历史保留。渠道配置页不展示微信对话或活动,不读取这些列表;聊天在微信中进行,既有云端历史、文件、审批、费用和账号隔离合同不变。页面保留加载失败重试、未确认操作恢复,创建后绑定失败可继续使用原账号,重试沿用原操作身份;仅添加/扫码不自动启用。实际微信身份与重复扫码识别仍由云端负责,客户端不以备注推测同一微信。渠道触发的应计费模型调用仍由智能体创建者支付,Main 持有认证与恢复;用户无需填写服务凭据。应用 API 管理独立于微信渠道。 + Agents 侧栏的「渠道」按每个微信一张卡展示备注、当前智能体与使用状态;一个微信同时只绑定一个智能体,多个微信可以使用同一智能体。常用操作是“更换智能体”,在同一个选择窗口中确认;暂停自动回复和解除微信绑定放在“更多操作”。暂停停止当前任务并保留登录、绑定和历史;更换保留登录与原启停状态;解除微信绑定是完整操作,停止任务、清除目标并退出微信登录,历史保留,再使用需扫码。掉线显示重新扫码入口,暂停显示继续回复,未知或未同步状态显示正在确认,不宣称可用。添加流程为选择已开始使用的智能体、扫码、自动完成绑定;只有这次用户发起的绑定在扫码确认、目标正确、启用成功且最终状态回读可用后才显示完成,不因页面打开、旧记录或验证码提交自动启用。无可用智能体时说明创建和“开始使用”的前提,加载失败不冒充空态,已有微信仍可管理或解绑。Main 持有凭据与原操作恢复;解除需要配套 Yuxi 用户级 `/channel-accounts/{id}/unbind`,旧后端不会通过改按钮文案或组合未确认操作假装完成。变更后以最新读取状态为准,未完成操作保留原身份供显式恢复;失败在当前窗口可见,结果未知不自动重放。渠道页不展示微信聊天或活动,既有历史、费用、内容隔离与应用 API 保持原边界。实际微信身份及重复扫码识别仍由云端负责,不从备注推测。 - `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧项目栏负责新建、切换和管理 Workspace,并在桌面设计模式下以 256px 宽度常驻展开;中央沿用 AI 编程的安静对话画布、自然消息流和底部悬浮输入器,AI 整理出的制作方案作为对话内的轻量可编辑稿持续更新;桌面端右侧同为 256px 的全高历史作品栏集中展示当前项目的制作记录与生成结果。任务中的已生成图片可通过“放大查看图片”按钮直接打开大图,支持适应窗口、原始尺寸及 Esc 关闭,无需先下载。紧凑窗口通过左侧抽屉访问项目列表,历史记录保留在时间线中。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。 - `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 diff --git a/electron/services/cloud-agent-operations.ts b/electron/services/cloud-agent-operations.ts index 22477711..8562c29b 100644 --- a/electron/services/cloud-agent-operations.ts +++ b/electron/services/cloud-agent-operations.ts @@ -60,6 +60,7 @@ function projectMutation(value: unknown): unknown { ...(source.worker && typeof source.worker === 'object' ? { worker: copy(source.worker, ['account_id', 'address']) } : {}), ...(source.wechat && typeof source.wechat === 'object' ? { wechat: copy(source.wechat, ['status', 'message']) } : {}), ...(source.disconnected === undefined ? {} : { disconnected: source.disconnected === true }), + ...(source.unbound === undefined ? {} : { unbound: source.unbound === true }), }; } function projectWechat(value: unknown): unknown { @@ -187,7 +188,7 @@ function projectSchedules(value: unknown): unknown { return { jobs: list(object( function validateChannelInput(operation: string, args: ObjectRecord): void { const channelOperations = new Set([ 'createChannelAccount', 'routeChannelAccount', 'enableChannelAccount', 'pauseChannelAccount', - 'disconnectChannelAccount', 'channelAccountWechatBindStart', 'channelAccountWechatBindStatus', + 'disconnectChannelAccount', 'unbindChannelAccount', 'channelAccountWechatBindStart', 'channelAccountWechatBindStatus', 'channelAccountWechatBindVerification', 'channelAccountWechatBindAccount', 'channelAccountWechatUnbind', 'channelAccountOperation', 'createChannelBinding', 'switchChannelBindingAgent', 'enableChannelBinding', 'pauseChannelBinding', @@ -211,6 +212,9 @@ function validateChannelInput(operation: string, args: ObjectRecord): void { if (operation === 'routeChannelAccount' && args.target_agent_slug !== null && (typeof args.target_agent_slug !== 'string' || !/^ml-[a-f0-9]{32}$/.test(args.target_agent_slug))) throw new Error('invalid_id'); if (operation === 'routeChannelAccount' && args.target_agent_slug === null && args.enabled !== false) throw new Error('invalid_input'); + if (operation === 'unbindChannelAccount' && (typeof args.channel_account_id !== 'string' + || typeof args.operation_id !== 'string' || !Number.isSafeInteger(args.expected_revision) + || Number(args.expected_revision) < 0)) throw new Error('invalid_input'); if (operation === 'switchChannelBindingAgent' && (typeof args.target_agent_slug !== 'string' || !/^ml-[a-f0-9]{32}$/.test(args.target_agent_slug))) throw new Error('invalid_id'); if (operation === 'wechatBindStart' && typeof args.force !== 'boolean') throw new Error('invalid_input'); if (operation === 'channelAccountWechatBindStart' && typeof args.force !== 'boolean') throw new Error('invalid_input'); @@ -294,6 +298,7 @@ const operations: Record = { enableChannelAccount: { method: 'POST', path: '/channel-accounts/:channel_account_id/enable', body: ['operation_id', 'expected_revision'], output: ['channel'], project: projectMutation }, pauseChannelAccount: { method: 'POST', path: '/channel-accounts/:channel_account_id/pause', body: ['operation_id', 'expected_revision'], output: ['channel'], project: projectMutation }, disconnectChannelAccount: { method: 'POST', path: '/channel-accounts/:channel_account_id/disconnect', body: ['operation_id', 'expected_revision'], output: ['channel', 'wechat', 'disconnected'], project: projectMutation }, + unbindChannelAccount: { method: 'POST', path: '/channel-accounts/:channel_account_id/unbind', body: ['operation_id', 'expected_revision'], output: ['channel', 'wechat', 'disconnected', 'unbound'], project: projectMutation }, channelAccountWechatBindStart: { method: 'POST', path: '/channel-accounts/:channel_account_id/wechat-bind/start', body: ['operation_id', 'force'], output: ['session_key', 'status', 'message', 'qrcode_url'], project: projectWechat }, channelAccountWechatBindStatus: { method: 'GET', path: '/channel-accounts/:channel_account_id/wechat-bind/status', query: { session_key: 'session_key' }, output: ['session_key', 'status', 'message', 'qrcode_url'], project: projectWechat }, channelAccountWechatBindVerification: { method: 'POST', path: '/channel-accounts/:channel_account_id/wechat-bind/verification', body: ['operation_id', 'session_key', 'verify_code'], output: ['session_key', 'status', 'message', 'qrcode_url'], project: projectWechat }, diff --git a/electron/services/cloud-agents.ts b/electron/services/cloud-agents.ts index 81865c4c..e9e20aef 100644 --- a/electron/services/cloud-agents.ts +++ b/electron/services/cloud-agents.ts @@ -55,6 +55,7 @@ const MESSAGES: Record = { download_failed: '文件保存失败,请重试', channel_not_found: '微信渠道不存在或已解绑', channel_operation_conflict: '微信渠道正在处理其他操作,请稍后查看状态', + channel_operation_unconfirmed: '微信绑定的变更尚未确认,请查看待确认操作后重试', channel_request_invalid: '微信渠道请求无效,请刷新后重试', channel_rate_limited: '微信渠道操作过于频繁,请稍后重试', channel_conversation_not_found: '微信会话不存在或已关闭', @@ -424,9 +425,20 @@ export class CloudAgentsModule { let plan; try { plan = operationPlan(value); } catch { throw new CloudAgentsError(422, 'invalid_input'); } - const invoke = async () => plan.project(await this.request(plan.path, plan.method, plan.body, plan.cloud === 'ws')); + const invoke = async () => { + const result = plan.project(await this.request(plan.path, plan.method, plan.body, plan.cloud === 'ws')); + if (plan.operation === 'unbindChannelAccount') { + const value = record(result), channel = record(value.channel); + if (value.unbound !== true || value.disconnected !== true || channel.id !== plan.input.channel_account_id + || channel.enabled !== false || channel.target_agent_slug !== null || channel.health !== 'login_required') { + // Keep the original operation in recovery until both routing and login are confirmed removed. + throw new CloudAgentsError(503, 'channel_operation_unconfirmed'); + } + } + return result; + }; const recoverable = ['submit', 'preview', 'resume', 'publish', 'createApplication', 'createKey', 'createKnowledge', 'processKnowledge', 'createSchedule', 'runSchedule', 'importKnowledgeAttachment', 'createChild', - 'createChannelAccount', 'routeChannelAccount', 'enableChannelAccount', 'pauseChannelAccount', 'disconnectChannelAccount', + 'createChannelAccount', 'routeChannelAccount', 'enableChannelAccount', 'pauseChannelAccount', 'disconnectChannelAccount', 'unbindChannelAccount', 'channelAccountWechatBindStart', 'channelAccountWechatBindVerification', 'channelAccountWechatUnbind', 'createChannelBinding', 'switchChannelBindingAgent', 'enableChannelBinding', 'pauseChannelBinding', 'disconnectChannelBinding', 'wechatBindStart', 'wechatBindVerification', 'wechatUnbind', diff --git a/shared/cloud-agents.ts b/shared/cloud-agents.ts index f9266556..aef4e919 100644 --- a/shared/cloud-agents.ts +++ b/shared/cloud-agents.ts @@ -222,10 +222,11 @@ export interface CloudUserChannelAccount { target_agent_published_version: number | null; provider_generation?: string; desired_state?: string; + sync_state?: string; last_confirmed_at?: string | null; } export interface CloudChannelAccountPage { items: CloudUserChannelAccount[] } -export interface CloudChannelAccountMutation { channel: CloudUserChannelAccount; target_agent?: string | null; wechat?: Record; disconnected?: boolean } +export interface CloudChannelAccountMutation { channel: CloudUserChannelAccount; target_agent?: string | null; wechat?: Record; disconnected?: boolean; unbound?: boolean } export interface CloudChannelOperation { operation_id: string; status: 'pending' | 'completed' | 'failed' | 'unknown' | string; @@ -424,6 +425,7 @@ export interface CloudAgentOperations { enableChannelAccount: Op<{ channel_account_id: string; operation_id: string; expected_revision: number }, CloudChannelAccountMutation>; pauseChannelAccount: Op<{ channel_account_id: string; operation_id: string; expected_revision: number }, CloudChannelAccountMutation>; disconnectChannelAccount: Op<{ channel_account_id: string; operation_id: string; expected_revision: number }, CloudChannelAccountMutation>; + unbindChannelAccount: Op<{ channel_account_id: string; operation_id: string; expected_revision: number }, CloudChannelAccountMutation>; channelAccountWechatBindStart: Op<{ channel_account_id: string; operation_id: string; force: boolean }, CloudWechatBindState>; channelAccountWechatBindStatus: Op<{ channel_account_id: string; session_key: string }, CloudWechatBindState>; channelAccountWechatBindVerification: Op<{ channel_account_id: string; operation_id: string; session_key: string; verify_code: string }, CloudWechatBindState>; diff --git a/src/pages/CloudAgents/ChannelAccounts.tsx b/src/pages/CloudAgents/ChannelAccounts.tsx index 10c3b269..679b0bd2 100644 --- a/src/pages/CloudAgents/ChannelAccounts.tsx +++ b/src/pages/CloudAgents/ChannelAccounts.tsx @@ -1,42 +1,97 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { ArrowRight, Check, CheckCircle2, ChevronLeft, Link2, Loader2, Pause, Play, Plus, QrCode, RefreshCw, Unplug } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + ArrowRight, + CheckCircle2, + ChevronLeft, + Link2, + Loader2, + MoreHorizontal, + Pause, + Play, + Plus, + QrCode, + RefreshCw, + Unplug, +} from 'lucide-react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import wechatMark from '@/assets/channels/wechat.svg'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Select } from '@/components/ui/select'; -import { ConfirmDialog } from '@/components/ui/confirm-dialog'; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; -import type { CloudAgentDraft, CloudPendingOperation, CloudUserChannelAccount, CloudWechatBindState } from '../../../shared/cloud-agents'; +import type { + CloudAgentDraft, + CloudPendingOperation, + CloudUserChannelAccount, + CloudWechatBindState, +} from '../../../shared/cloud-agents'; import { CloudAvatar } from './CloudAvatar'; import { CloudRecovery } from './CloudRecovery'; import { CloudWechatQr } from './CloudWechatQr'; -type Flow = { mode: 'new' | 'manage'; step: 'choose' | 'connect' | 'result'; accountId: string; target: string; remark: string }; -type Confirmation = 'switch' | 'unlink' | 'disconnect' | null; -const errorText = (error: unknown) => error instanceof Error ? error.message : '操作没有完成,请重试'; -const blockerText: Record = { - target_required: '还没有绑定智能体', target_unavailable: '绑定的智能体暂时不能使用,请选择另一个', - publish_required: '请先在智能体页面完成配置并开始使用', login_required: '需要重新扫码连接微信', - worker_offline: '微信连接暂时不可用,请稍后刷新', channel_sync_degraded: '连接状态正在确认,请稍后刷新', +type Flow = { + mode: 'new' | 'switch' | 'reconnect' | 'unbind'; + step: 'choose' | 'connect' | 'result'; + accountId: string; + target: string; }; +type Expected = { target: string | null; enabled: boolean; disconnected?: boolean }; +const errorText = (error: unknown) => (error instanceof Error ? error.message : '操作没有完成,请重试'); +const usableBlockers = (account: CloudUserChannelAccount) => + account.blockers.filter((item) => item !== 'binding_paused'); +const matches = (account: CloudUserChannelAccount | undefined, expected: Expected) => + Boolean( + account && + account.target_agent_slug === expected.target && + account.enabled === expected.enabled && + !unsettled(account) && + (!expected.disconnected || account.health === 'login_required') + ); const qrLabels: Record = { - pending: '等待微信确认', scanned: '已扫码,请在微信中确认', verification_required: '需要微信验证码', - expired: '二维码已过期', failed: '连接没有完成', provisioning: '正在准备二维码', unknown: '正在确认连接', + pending: '等待微信确认', + scanned: '已扫码,请在微信中确认', + verification_required: '需要微信验证码', + expired: '二维码已过期', + failed: '连接没有完成', + provisioning: '正在准备二维码', + unknown: '正在确认连接', }; -function accountState(account: CloudUserChannelAccount) { - if (account.health !== 'connected') return { text: ['login_required', 'verification_required'].includes(account.health) ? '需要扫码' : '连接待确认', tone: 'bg-[#fff0d8] text-[#876033]' }; - if (!account.target_agent_slug) return { text: '未绑定智能体', tone: 'bg-[#f0edf8] text-[#75618e]' }; - if (account.blockers.length) return { text: '暂时不可用', tone: 'bg-[#fff0d8] text-[#876033]' }; - return account.enabled ? { text: '可以使用', tone: 'bg-[#e7f4ec] text-[#3f7458]' } : { text: '已暂停', tone: 'bg-[#f0edf8] text-[#75618e]' }; +const blockerText: Record = { + target_unavailable: '当前智能体暂时不能使用,可以更换一个智能体。', + publish_required: '请先让智能体开始使用。', + worker_offline: '微信连接暂时不可用,请稍后刷新。', + channel_sync_degraded: '正在确认微信的使用状态。', +}; +const unsettled = (account: CloudUserChannelAccount) => + Boolean(account.sync_state && account.sync_state !== 'ready') || + account.blockers.includes('channel_sync_degraded') || + (['paused', 'disabled'].includes(account.desired_state ?? '') && account.enabled) || + (account.desired_state === 'enabled' && !account.enabled); +function accountState(account: CloudUserChannelAccount, uncertain: boolean) { + if (uncertain || unsettled(account)) return { text: '正在确认', tone: 'bg-[#fff0d8] text-[#876033]' }; + if (!account.target_agent_slug) return { text: '未绑定', tone: 'bg-[#f0edf8] text-[#75618e]' }; + if (['login_required', 'verification_required'].includes(account.health)) + return { text: '需要重新扫码', tone: 'bg-[#fff0d8] text-[#876033]' }; + if (account.health !== 'connected' || unsettled(account) || usableBlockers(account).length) + return { text: '正在确认', tone: 'bg-[#fff0d8] text-[#876033]' }; + return account.enabled + ? { text: '使用中', tone: 'bg-[#e7f4ec] text-[#3f7458]' } + : { text: '已暂停', tone: 'bg-[#f0edf8] text-[#75618e]' }; } async function loadAvailableAgents(): Promise { const items: CloudAgentDraft[] = []; let cursor: string | null = null; do { const page = await cloudAgentsApi.list(cursor); - items.push(...page.agents.filter(agent => Boolean(agent.published_version) && agent.enabled !== false)); + items.push( + ...page.agents.filter((agent) => Boolean(agent.published_version) && agent.enabled !== false) + ); cursor = page.next_cursor; } while (cursor); return items; @@ -45,307 +100,935 @@ async function loadAvailableAgents(): Promise { export function ChannelAccounts() { const navigate = useNavigate(); const [params, setParams] = useSearchParams(); - const initialAccount = useRef(params.get('account') ?? ''); - const initialTarget = useRef(params.get('target') ?? ''); + const initial = useRef({ account: params.get('account') ?? '', target: params.get('target') ?? '' }); const [accounts, setAccounts] = useState([]); const [agents, setAgents] = useState([]); const [loading, setLoading] = useState(true); - const [loaded, setLoaded] = useState(false); + const [accountsLoaded, setAccountsLoaded] = useState(false); + const [agentsLoaded, setAgentsLoaded] = useState(false); + const [agentsError, setAgentsError] = useState(''); + const [recoveryLoaded, setRecoveryLoaded] = useState(false); const [flow, setFlow] = useState(null); - const [confirmation, setConfirmation] = useState(null); + const flowRef = useRef(null); + const [more, setMore] = useState(''); const [busy, setBusy] = useState(''); const busyRef = useRef(false); + const [busyAccount, setBusyAccount] = useState(''); const [error, setError] = useState(''); const [notice, setNotice] = useState(''); const [qr, setQr] = useState(null); const [qrAccountId, setQrAccountId] = useState(''); - const [connectedAccountId, setConnectedAccountId] = useState(''); const [verifyCode, setVerifyCode] = useState(''); + const [connectionFailed, setConnectionFailed] = useState(false); + const confirmedSessions = useRef(new Set()); const [pending, setPending] = useState([]); const alive = useRef(true); const intents = useRef(new Map()); - const selected = accounts.find(account => account.id === flow?.accountId) ?? null; - const recoveryBlocked = pending.some(item => /channelaccount/iu.test(item.operation)); - const disabled = Boolean(busy) || recoveryBlocked; - const targetAgent = useMemo(() => agents.find(agent => agent.slug === flow?.target), [agents, flow?.target]); - - const refresh = useCallback(async () => { - const [accountPage, available, recovery] = await Promise.all([ - cloudAgentsApi.call('channelAccounts', {}), loadAvailableAgents(), cloudAgentsApi.recovery(), - ]); - if (!alive.current) return; - setAccounts(accountPage.items); setAgents(available); setPending(recovery.pending); setLoaded(true); + const checks = useRef(new Map()); + const readGeneration = useRef(0); + const refreshGeneration = useRef(0); + const [checkingIds, setCheckingIds] = useState([]); + const selected = accounts.find((account) => account.id === flow?.accountId); + const recoveryBlocked = pending.some((item) => /channelaccount/iu.test(item.operation)); + const disabled = Boolean(busy) || recoveryBlocked || !recoveryLoaded; + const pendingFor = (id: string) => + pending.some( + (item) => /channelaccount/iu.test(item.operation) && item.input.channel_account_id === id + ); + const updateFlow = (next: Flow | null) => { + flowRef.current = next; + setFlow(next); + }; + const setCheck = (id: string, expected: Expected) => { + checks.current.set(id, expected); + setCheckingIds([...checks.current.keys()]); + }; + const readAccounts = useCallback(async () => { + const generation = ++readGeneration.current; + const page = await cloudAgentsApi.call('channelAccounts', {}); + if (alive.current && generation === readGeneration.current) { + setAccounts(page.items); + setAccountsLoaded(true); + for (const [id, expected] of checks.current) + if ( + matches( + page.items.find((item) => item.id === id), + expected + ) + ) + checks.current.delete(id); + setCheckingIds([...checks.current.keys()]); + } + return page.items; }, []); + const refresh = useCallback(async () => { + const generation = ++refreshGeneration.current; + const [accountResult, agentResult, recoveryResult] = await Promise.allSettled([ + readAccounts(), + loadAvailableAgents(), + cloudAgentsApi.recovery(), + ]); + if (!alive.current || generation !== refreshGeneration.current) return; + if (accountResult.status === 'rejected') setError(errorText(accountResult.reason)); + if (agentResult.status === 'fulfilled') { + setAgents(agentResult.value); + setAgentsLoaded(true); + setAgentsError(''); + } else setAgentsError(errorText(agentResult.reason)); + if (recoveryResult.status === 'fulfilled') { + setPending(recoveryResult.value.pending); + setRecoveryLoaded(true); + } else { + setRecoveryLoaded(false); + setError('暂时无法确认上次操作,请刷新后再试。'); + } + }, [readAccounts]); useEffect(() => { alive.current = true; - void refresh().catch(failure => { if (alive.current) setError(errorText(failure)); }) - .finally(() => { if (alive.current) setLoading(false); }); - return () => { alive.current = false; }; + void refresh() + .catch((failure) => { + if (alive.current) setError(errorText(failure)); + }) + .finally(() => { + if (alive.current) setLoading(false); + }); + return () => { + alive.current = false; + }; }, [refresh]); useEffect(() => { - if (loading || !initialAccount.current) return; - const account = accounts.find(item => item.id === initialAccount.current); + if (loading || !initial.current.account) return; + const account = accounts.find((item) => item.id === initial.current.account); if (!account) return; - initialAccount.current = ''; - const target = initialTarget.current || account.target_agent_slug || ''; - setFlow({ mode: 'manage', step: 'choose', accountId: account.id, target, remark: account.display_name }); - initialTarget.current = ''; - const next = new URLSearchParams(params); next.delete('target'); + updateFlow({ + mode: 'switch', + step: 'choose', + accountId: account.id, + target: initial.current.target || account.target_agent_slug || '', + }); + initial.current = { account: '', target: '' }; + const next = new URLSearchParams(params); + next.delete('target'); setParams(next, { replace: true }); - // Consume deep-link selection once; live refreshes must not replace user input. + // Consume the deep link once; refresh must preserve the user's selection. // eslint-disable-next-line react-hooks/exhaustive-deps }, [accounts, loading]); - const operationId = (key: string, input: unknown) => { const fingerprint = JSON.stringify(input); const old = intents.current.get(key); if (old?.fingerprint === fingerprint) return old.id; - const id = crypto.randomUUID(); intents.current.set(key, { fingerprint, id }); return id; + const id = crypto.randomUUID(); + intents.current.set(key, { fingerprint, id }); + return id; }; - const mergeAccount = (account: CloudUserChannelAccount) => { - if (!alive.current) return; - setAccounts(current => current.some(item => item.id === account.id) - ? current.map(item => item.id === account.id ? account : item) : [...current, account]); - }; - const run = async (key: string, task: () => Promise, success = ''): Promise => { - if (busyRef.current || recoveryBlocked) return null; - busyRef.current = true; setBusy(key); setError(''); setNotice(''); + const run = async (key: string, accountId: string, task: () => Promise): Promise => { + if (busyRef.current || recoveryBlocked || !recoveryLoaded) return null; + busyRef.current = true; + const generation = ++refreshGeneration.current; + setBusy(key); + setBusyAccount(accountId); + setError(''); + setNotice(''); try { - const result = await task(); - intents.current.delete(key); - if (alive.current) setNotice(success); - // A successful mutation stays successful even if the following read fails. - await refresh().catch(() => { if (alive.current) setError('操作已完成,列表刷新失败。请刷新查看最新状态。'); }); - return result; + return await task(); } catch (failure) { - if (alive.current) setError(errorText(failure)); - await cloudAgentsApi.recovery().then(state => { if (alive.current) setPending(state.pending); }).catch(() => undefined); + if (alive.current) { + setError(errorText(failure)); + await cloudAgentsApi + .recovery() + .then((state) => { + if (alive.current && generation === refreshGeneration.current) { + setPending(state.pending); + setRecoveryLoaded(true); + } + }) + .catch(() => { + if (alive.current && generation === refreshGeneration.current) setRecoveryLoaded(false); + }); + } return null; } finally { - busyRef.current = false; if (alive.current) setBusy(''); + busyRef.current = false; + if (alive.current) { + setBusy(''); + setBusyAccount(''); + } } }; - const resetConnection = () => { setQr(null); setQrAccountId(''); setConnectedAccountId(''); setVerifyCode(''); }; - const close = () => { + const resetConnection = () => { + setQr(null); + setQrAccountId(''); + setVerifyCode(''); + setConnectionFailed(false); + }; + const close = (clear = true) => { if (busyRef.current) return; - setFlow(null); setConfirmation(null); resetConnection(); setError(''); setNotice(''); - const next = new URLSearchParams(params); next.delete('account'); next.delete('target'); setParams(next, { replace: true }); + updateFlow(null); + resetConnection(); + setMore(''); + if (clear) { + setError(''); + setNotice(''); + } + const next = new URLSearchParams(params); + next.delete('account'); + next.delete('target'); + setParams(next, { replace: true }); }; - const openAccount = (account: CloudUserChannelAccount) => { - resetConnection(); setError(''); setNotice(''); - setFlow({ mode: 'manage', step: 'choose', accountId: account.id, target: account.target_agent_slug ?? '', remark: account.display_name }); - const next = new URLSearchParams(params); next.set('account', account.id); next.delete('target'); setParams(next, { replace: true }); + const open = (account: CloudUserChannelAccount, mode: Flow['mode']) => { + resetConnection(); + setMore(''); + setError(''); + setNotice(''); + updateFlow({ + mode, + step: mode === 'reconnect' ? 'connect' : 'choose', + accountId: account.id, + target: account.target_agent_slug ?? '', + }); + const next = new URLSearchParams(params); + next.set('account', account.id); + next.delete('target'); + setParams(next, { replace: true }); + if (mode === 'reconnect') void startQr(account); }; - const applyBindState = useCallback((result: CloudWechatBindState, accountId: string) => { - if (result.status === 'confirmed') { - setQr(null); setQrAccountId(''); setVerifyCode(''); setConnectedAccountId(accountId); setNotice(''); setError(''); - setAccounts(current => current.map(item => item.id === accountId ? { ...item, health: 'connected' } : item)); - void refresh().catch(() => { if (alive.current) setError('微信已确认连接,请刷新连接状态后完成绑定。'); }); - } else { setQr(result); setQrAccountId(accountId); setConnectedAccountId(''); } - }, [refresh]); - const startQr = async (account: CloudUserChannelAccount, force = false) => { - setConnectedAccountId(''); setVerifyCode(''); - const input = { channel_account_id: account.id, force }; - const result = await run('qr', () => cloudAgentsApi.call('channelAccountWechatBindStart', { - ...input, operation_id: operationId('qr', input), - })); - if (result && alive.current) applyBindState(result, account.id); + const verifyFinal = async (id: string, expected: Expected) => { + setCheck(id, expected); + const fresh = (await readAccounts()).find((item) => item.id === id); + if (!matches(fresh, expected)) throw new Error('操作结果还没有确认,请刷新状态后再试。'); + return fresh!; }; const route = async (account: CloudUserChannelAccount, target: string) => { - const input = { channel_account_id: account.id, target_agent_slug: target || null, expected_revision: account.revision, enabled: target ? account.enabled : false }; - const result = await run('route', () => cloudAgentsApi.call('routeChannelAccount', { ...input, operation_id: operationId('route', input) })); - if (result) mergeAccount(result.channel); - if (result && result.channel.target_agent_slug !== input.target_agent_slug) { setError('绑定还没有完成,请刷新状态后再试。'); return null; } - return result?.channel ?? null; + const input = { + channel_account_id: account.id, + target_agent_slug: target, + expected_revision: account.revision, + enabled: account.enabled, + }; + await cloudAgentsApi.call('routeChannelAccount', { + ...input, + operation_id: operationId('route', input), + }); + const fresh = await verifyFinal(account.id, { target, enabled: account.enabled }); + intents.current.delete('route'); + return fresh; }; - const next = async () => { - if (!flow || !targetAgent || disabled) return; - if (flow.accountId && !selected) { setError('这个微信绑定已发生变化,请关闭窗口并刷新列表。'); return; } - let account = selected; - if (!account) { - const input = { display_name: flow.remark.trim() || `微信 ${accounts.length + 1}` }; - const result = await run('create', () => cloudAgentsApi.call('createChannelAccount', { ...input, operation_id: operationId('create', input) })); - if (!result || !alive.current) return; - account = result.channel; mergeAccount(account); - const createdId = account.id; - setFlow(current => current ? { ...current, accountId: createdId } : null); - } - if (account.target_agent_slug !== flow.target) { - account = await route(account, flow.target); - if (!account || !alive.current) return; - } - const connectedId = account.id; - setFlow(current => current ? { ...current, step: 'connect', accountId: connectedId } : null); - if (account.health !== 'connected') await startQr(account); + const finishBinding = async (id: string, target: string) => { + setConnectionFailed(false); + const fresh = await run('activate', id, async () => { + let account = (await readAccounts()).find((item) => item.id === id); + if ( + !alive.current || + flowRef.current?.accountId !== id || + flowRef.current.target !== target || + flowRef.current.step !== 'connect' + ) + return null; + if (!account || account.target_agent_slug !== target) + throw new Error('绑定的智能体已发生变化,请返回上一步确认。'); + if (account.health !== 'connected' || unsettled(account) || usableBlockers(account).length) + throw new Error('微信还没有准备好,请稍后重试完成绑定。'); + const activate = flowRef.current?.mode !== 'reconnect'; + if (!account.enabled && activate) { + const input = { channel_account_id: id, expected_revision: account.revision }; + await cloudAgentsApi.call('enableChannelAccount', { + ...input, + operation_id: operationId('enable', input), + }); + account = await verifyFinal(id, { target, enabled: true }); + } + if (account.health !== 'connected' || unsettled(account) || usableBlockers(account).length) + throw new Error('微信还没有准备好,请稍后重试完成绑定。'); + intents.current.delete('enable'); + return account; + }); + if (!alive.current || flowRef.current?.accountId !== id) return; + if (fresh) updateFlow({ ...flowRef.current, step: 'result' }); + else setConnectionFailed(true); }; - const setEnabled = async (account: CloudUserChannelAccount, enabled: boolean) => { - const key = enabled ? 'enable' : 'pause'; - const input = { channel_account_id: account.id, expected_revision: account.revision }; - const result = await run(key, () => enabled - ? cloudAgentsApi.call('enableChannelAccount', { ...input, operation_id: operationId(key, input) }) - : cloudAgentsApi.call('pauseChannelAccount', { ...input, operation_id: operationId(key, input) })); + const applyBindState = (result: CloudWechatBindState, accountId: string) => { + if (!alive.current || flowRef.current?.accountId !== accountId || flowRef.current.step !== 'connect') + return; + setQr(result); + setQrAccountId(accountId); + if (result.status === 'confirmed') { + setVerifyCode(''); + const identity = accountId + ':' + result.session_key; + if (!confirmedSessions.current.has(identity)) { + confirmedSessions.current.add(identity); + void finishBinding(accountId, flowRef.current.target); + } + } + }; + const startQr = async (account: CloudUserChannelAccount, force = false) => { + setVerifyCode(''); + setConnectionFailed(false); + const input = { channel_account_id: account.id, force }; + const result = await run('qr', account.id, () => + cloudAgentsApi.call('channelAccountWechatBindStart', { + ...input, + operation_id: operationId('qr', input), + }) + ); if (result) { - mergeAccount(result.channel); - if (result.channel.enabled === enabled) setNotice(enabled ? '微信绑定已启用。' : '已暂停,微信连接和绑定的智能体仍保留。'); - else setError('使用状态还没有确认,请刷新后再试。'); + intents.current.delete('qr'); + applyBindState(result, account.id); } - return result?.channel ?? null; }; - const completeBinding = async () => { - if (!selected || !selected.target_agent_slug) return; - const page = await run('ready', () => cloudAgentsApi.call('channelAccounts', {})); - if (!page) return; - const fresh = page.items.find(item => item.id === selected.id); - if (!fresh || fresh.target_agent_slug !== flow?.target) { setError('绑定的智能体已发生变化,请返回上一步确认。'); return; } - mergeAccount(fresh); - if (fresh.health !== 'connected' || fresh.blockers.length) { setError('微信还没有准备好,请刷新连接状态后再试。'); return; } - const account = fresh.enabled ? fresh : await setEnabled(fresh, true); - if (account?.enabled && account.health === 'connected' && !account.blockers.length) setFlow(current => current ? { ...current, step: 'result' } : null); - else if (account) setError('微信还没有准备好,请刷新连接状态后再试。'); + const chooseAgent = async () => { + const current = flowRef.current; + if ( + !current || + !agentsLoaded || + agentsError || + !agents.some((agent) => agent.slug === current.target) || + disabled + ) + return; + const result = await run('choose', current.accountId, async () => { + let account = current.accountId + ? accounts.find((item) => item.id === current.accountId) + : undefined; + if (current.accountId && !account) + throw new Error('这个微信绑定已发生变化,请关闭窗口并刷新列表。'); + if (!account) { + const input = { display_name: `微信 ${accounts.length + 1}` }; + const created = await cloudAgentsApi.call('createChannelAccount', { + ...input, + operation_id: operationId('create', input), + }); + account = created.channel; + if (!alive.current) return null; + updateFlow({ ...current, accountId: account.id }); + await readAccounts(); + intents.current.delete('create'); + if (!alive.current || flowRef.current?.accountId !== account.id) return null; + } + if (account.target_agent_slug !== current.target) account = await route(account, current.target); + return account; + }); + if (!result || !alive.current || !flowRef.current) return; + if (current.mode === 'switch' && selected?.target_agent_slug) { + setNotice('已更换智能体,微信连接和历史保留。'); + close(false); + } else { + updateFlow({ ...flowRef.current, step: 'connect' }); + if (result.health === 'connected') void finishBinding(result.id, current.target); + else void startQr(result); + } }; - const confirm = async () => { - if (!selected || !flow) return; - if (confirmation === 'switch' || confirmation === 'unlink') { - const target = confirmation === 'unlink' ? '' : flow.target; - const account = await route(selected, target); - if (!account) return; - setFlow(current => current ? { ...current, target } : null); - setNotice(target ? '已更换绑定的智能体,微信连接保留。' : '已解除智能体绑定,微信连接保留。'); - } else if (confirmation === 'disconnect') { + const setEnabled = async (account: CloudUserChannelAccount) => { + setMore(''); + const enabled = !account.enabled; + const key = enabled ? 'enable' : 'pause'; + const fresh = await run(key, account.id, async () => { + const input = { channel_account_id: account.id, expected_revision: account.revision }; + await cloudAgentsApi.call(enabled ? 'enableChannelAccount' : 'pauseChannelAccount', { + ...input, + operation_id: operationId(key, input), + }); + const result = await verifyFinal(account.id, { target: account.target_agent_slug, enabled }); + intents.current.delete(key); + return result; + }); + if (fresh && alive.current) + setNotice(enabled ? '已恢复自动回复。' : '已暂停自动回复,微信连接和智能体保留。'); + }; + const unbind = async () => { + if (!selected) return; + const fresh = await run('unbind', selected.id, async () => { const input = { channel_account_id: selected.id, expected_revision: selected.revision }; - const result = await run('disconnect', () => cloudAgentsApi.call('disconnectChannelAccount', { ...input, operation_id: operationId('disconnect', input) }), '微信连接已断开,再使用时需要扫码。'); - if (!result) return; - mergeAccount(result.channel); resetConnection(); + const expected = { target: null, enabled: false, disconnected: true }; + const result = await cloudAgentsApi.call('unbindChannelAccount', { + ...input, + operation_id: operationId('unbind', input), + }); + setCheck(selected.id, expected); + if ( + result.unbound !== true || + result.disconnected !== true || + !matches(result.channel, { target: null, enabled: false, disconnected: true }) + ) + throw new Error('解除结果还没有确认,请先确认上次操作。'); + const account = await verifyFinal(selected.id, { + target: null, + enabled: false, + disconnected: true, + }); + intents.current.delete('unbind'); + return account; + }); + if (fresh && alive.current) { + setNotice('微信绑定已解除,历史仍保留。'); + close(false); } - setConfirmation(null); }; const pollingId = flow?.step === 'connect' ? flow.accountId : ''; const pollingSession = qr?.session_key ?? ''; const pollingStatus = qr?.status ?? ''; useEffect(() => { - if (!pollingId || pollingId !== qrAccountId || !pollingSession || ['confirmed', 'failed', 'expired'].includes(pollingStatus)) return; + if ( + !pollingId || + pollingId !== qrAccountId || + !pollingSession || + ['confirmed', 'failed', 'expired'].includes(pollingStatus) || + recoveryBlocked + ) + return; let stopped = false; + let polling = false; const timer = window.setInterval(() => { - if (document.visibilityState === 'hidden') return; - void cloudAgentsApi.call('channelAccountWechatBindStatus', { channel_account_id: pollingId, session_key: pollingSession }) - .then(result => { if (!stopped && alive.current) applyBindState(result, pollingId); }) - .catch(failure => { if (!stopped && alive.current) setError(errorText(failure)); }); + if (document.visibilityState === 'hidden' || polling || busyRef.current) return; + polling = true; + void cloudAgentsApi + .call('channelAccountWechatBindStatus', { + channel_account_id: pollingId, + session_key: pollingSession, + }) + .then((result) => { + if (!stopped) applyBindState(result, pollingId); + }) + .catch((failure) => { + if (!stopped && alive.current) setError(errorText(failure)); + }) + .finally(() => { + polling = false; + }); }, 2000); - return () => { stopped = true; window.clearInterval(timer); }; - }, [pollingId, pollingSession, pollingStatus, qrAccountId, applyBindState]); + return () => { + stopped = true; + window.clearInterval(timer); + }; + // The flow ref identifies the current user-initiated connection without restarting a poll on unrelated renders. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pollingId, pollingSession, pollingStatus, qrAccountId, recoveryBlocked]); const verify = async () => { if (!selected || !qr || !verifyCode.trim()) return; - const input = { channel_account_id: selected.id, session_key: qr.session_key, verify_code: verifyCode.trim() }; - const result = await run('verify', () => cloudAgentsApi.call('channelAccountWechatBindVerification', { ...input, operation_id: operationId('verify', input) })); - if (result) applyBindState(result, selected.id); + const input = { + channel_account_id: selected.id, + session_key: qr.session_key, + verify_code: verifyCode.trim(), + }; + const result = await run('verify', selected.id, () => + cloudAgentsApi.call('channelAccountWechatBindVerification', { + ...input, + operation_id: operationId('verify', input), + }) + ); + if (result) { + intents.current.delete('verify'); + applyBindState(result, selected.id); + } }; - const connectionReady = selected?.health === 'connected' || selected?.id === connectedAccountId; - const selectedState = selected ? accountState(selected) : null; - const canActivate = connectionReady && Boolean(selected?.target_agent_slug) && !selected?.blockers.some(item => item !== 'login_required'); - const messages = <> - {error &&

{error}

} - {notice &&

{notice}

} - ; - const options = <> - - {flow?.target && !targetAgent && selected?.target_agent_slug === flow.target && } - {agents.map(agent => )} - ; - const agentRequiredHint =
-

还没有可连接的智能体

-

先创建一个智能体,配置完成后点击“开始使用”,就能连接到微信。

- -
; - - return
-
- void refresh().catch(failure => setError(errorText(failure)))} /> -
-
- -

连接到微信

选一个智能体,把它带到微信里。

-
-
- - -
-
- {!flow && messages} - {!loading && loaded && !agents.length && !flow && agentRequiredHint} - {loading ?
正在查看你的微信绑定…
- : !loaded ?

暂时没能读到微信绑定,请刷新后再试。

- : !accounts.length ? (agents.length > 0 &&
-

把你的智能体带到微信吧

绑定后,就可以回到微信和它聊聊。每个微信绑定一个智能体,也可以把多个微信交给同一个智能体。

- -
) :
- {accounts.map(account => { - const agent = agents.find(item => item.slug === account.target_agent_slug); - const state = accountState(account); - return
-

微信

{account.display_name}

{state.text}
-
- {account.target_agent_slug ? <>

绑定的智能体

{account.target_agent_name ?? agent?.name ?? '智能体暂不可用'}

- : <>

还没有绑定智能体

} -
- -
; - })} -
} + const feedback = ( + <> + {error && ( +

+ {error} +

+ )} + {agentsError && ( +

+ {agentsError}。已有微信仍可以暂停或解除绑定。 +

+ )} + {notice && ( +

+ {notice} +

+ )} + + ); + const agentHint = ( +
+

还没有可连接的智能体

+

先创建一个智能体,配置完成后点击“开始使用”,就能连接到微信。

+
- { if (!open) close(); }}> - event.preventDefault()} onEscapeKeyDown={event => { if (busy) event.preventDefault(); }}> - {flow?.mode === 'new' ? '添加微信' : '管理微信绑定'}{flow?.mode === 'new' ? '给微信选一个智能体,再扫码连接。' : selected?.display_name ?? '微信绑定'} - {flow && <> - {(flow.mode === 'new' || flow.step !== 'choose') &&
    {['选择智能体', '扫码连接', '完成绑定'].map((text, index) => { - const current = ['choose', 'connect', 'result'].indexOf(flow.step); - return
  1. {index < current ? : index + 1}{text}
  2. ; - })}
} - {messages} - {recoveryBlocked &&

有一次绑定操作的结果需要确认,请先关闭此窗口并处理页面上的恢复提醒。

} - {flow.step === 'choose' && <> - {flow.mode === 'manage' && selectedState &&
微信连接与绑定{selectedState.text}
} - {!agents.length ? agentRequiredHint - : } - {flow.mode === 'new' && !flow.accountId && } - {flow.mode === 'manage' && selected?.blockers.length ?

{selected.blockers.map(item => blockerText[item] ?? '连接暂时不可用,请刷新后重试').join(';')}

: null} - {flow.mode === 'new' || !selected?.target_agent_slug ? - :
- {flow.target !== selected.target_agent_slug && } -
- {!connectionReady && } - - + ); + return ( +
+
+ void refresh().catch((failure) => setError(errorText(failure)))} + /> +
+
+ + + +
+

+ 连接到微信 +

+

一个微信,一个智能体。

+
+
+
+ + +
+
+ {!flow && feedback} + {!loading && + accountsLoaded && + agentsLoaded && + !agentsError && + !agents.length && + !flow && + agentHint} + {loading ? ( +
+ + 正在查看你的微信绑定… +
+ ) : !accountsLoaded ? ( +
+ 暂时没能读到微信绑定,请刷新后再试。 +
+ ) : !accounts.length ? ( + agentsLoaded && + !agentsError && + agents.length > 0 && ( +
+ +

把你的智能体带到微信吧

+

选一个智能体,再用微信扫码,就可以开始聊天。

+ +
+ ) + ) : ( +
+ {accounts.map((account) => { + const agent = agents.find((item) => item.slug === account.target_agent_slug); + const uncertain = !recoveryLoaded || checkingIds.includes(account.id) || pendingFor(account.id); + const state = accountState(account, uncertain || busyAccount === account.id); + const needsScan = + account.target_agent_slug && + ['login_required', 'verification_required'].includes(account.health); + const blocked = disabled || uncertain; + const changeBlocked = + blocked || unsettled(account) || !agentsLoaded || Boolean(agentsError); + const canResume = + !unsettled(account) && + account.health === 'connected' && + Boolean(account.target_agent_slug) && + !usableBlockers(account).length; + return ( +
+
+
+

微信

+

+ {account.display_name} +

+
+ + {state.text} + +
+
+ {account.target_agent_slug ? ( + <> + +
+

当前智能体

+

+ {account.target_agent_name ?? agent?.name ?? '智能体暂不可用'} +

+
+ + ) : ( + <> + +

还没有绑定智能体

+ + )} +
+ {usableBlockers(account) + .filter((item) => blockerText[item]) + .map((item) => ( +

+ {blockerText[item]} +

+ ))} +
+ + +
+ {more === account.id && ( +
{ + if (event.key === 'Escape') setMore(''); + }} + > + {needsScan && ( + + )} + {account.target_agent_slug && ( + + )} + +
+ )} + {uncertain && ( + + )} +
+ ); + })} +
+ )} +
+ { + if (!value) close(); + }} + > + event.preventDefault()} + onEscapeKeyDown={(event) => { + if (busy) event.preventDefault(); + }} + > + + + {flow?.mode === 'unbind' + ? '解除微信绑定' + : flow?.mode === 'reconnect' + ? '重新连接微信' + : flow?.mode === 'switch' + ? '更换智能体' + : '添加微信'} + + + {flow?.mode === 'new' + ? '选一个智能体,再用微信扫码。' + : (selected?.display_name ?? '微信绑定')} + + + {feedback} + {recoveryBlocked && ( +

+ 上次操作的结果还需要确认。请关闭窗口,处理页面上的恢复提醒。 +

+ )} + {flow?.mode === 'unbind' ? ( + <> +

+ 解除后,这个微信将停止自动回复,当前任务会停止,并断开微信登录。智能体聊天历史仍保留,再使用需要重新扫码。 +

+ + + + ) : flow?.step === 'choose' ? ( + <> + {!agentsError && agentsLoaded && !agents.length ? ( + agentHint + ) : ( +
+ {agents.map((agent) => ( + + ))}
-

暂停会停止这个微信正在进行的任务,连接和绑定仍保留。

-
} - {flow.mode === 'manage' && selected && } - - } - {flow.step === 'connect' && selected && <> -

{selected.display_name}将使用

{targetAgent?.name ?? selected.target_agent_name}

- {connectionReady ?

微信已连接

完成绑定后,就可以回微信使用。

- :
-
{qr?.qrcode_url ? : busy === 'qr' ? : }
-

{qr ? qrLabels[qr.status] ?? '请使用微信扫码' : '请使用微信扫码'}

- {qr?.message &&

{qr.message}

} - {(!qr || ['expired', 'failed'].includes(qr.status)) && } -
} - {qr?.status === 'verification_required' &&
} - -
- } - {flow.step === 'result' && selected && <> -

绑定完成啦

{selected.display_name}已绑定{selected.target_agent_name ?? targetAgent?.name ?? '智能体'},现在可以回微信和它聊聊。

- - } - } - -
- setConfirmation(null)} /> -
; + )} + {flow.mode === 'switch' && selected?.target_agent_slug && ( +

+ 更换会停止当前任务,微信连接和历史保留。{!selected.enabled && '这个微信仍保持暂停。'} +

+ )} + + + + ) : flow?.step === 'connect' ? ( + <> + {qr?.status === 'confirmed' || selected?.health === 'connected' ? ( +
+ +

{busy ? '正在完成微信绑定…' : '微信登录已确认'}

+

+ {connectionFailed + ? '还没有完成绑定,请查看上方提示。' + : '正在确认智能体和自动回复状态。'} +

+
+ ) : ( +
+
+ {qr?.qrcode_url ? ( + + ) : busy === 'qr' ? ( + + ) : ( + + )} +
+

+ {qr ? (qrLabels[qr.status] ?? '请使用微信扫码') : '请使用微信扫码'} +

+ {qr?.message &&

{qr.message}

} + {(!qr || ['expired', 'failed'].includes(qr.status)) && ( + + )} +
+ )} + {qr?.status === 'verification_required' && ( +
+ + +
+ )} + {connectionFailed && ( + + )} + {checkingIds.includes(flow.accountId) && ( + + )} +
+ + +
+ + ) : flow?.step === 'result' ? ( + <> +
+ +

+ {flow.mode === 'reconnect' ? '微信重新连接成功' : '微信绑定成功'} +

+

+ {selected?.enabled + ? `现在可以回微信和${selected.target_agent_name ?? '智能体'}聊聊。` + : '微信已连接,自动回复仍保持暂停。'} +

+
+ + + ) : null} + + + + ); } diff --git a/src/pages/CloudAgents/CloudRecovery.tsx b/src/pages/CloudAgents/CloudRecovery.tsx index 91adc25e..9a6242ce 100644 --- a/src/pages/CloudAgents/CloudRecovery.tsx +++ b/src/pages/CloudAgents/CloudRecovery.tsx @@ -17,6 +17,7 @@ const labels: Record = { createChannelAccount: '添加微信账号', routeChannelAccount: '切换目标智能体', enableChannelAccount: '启用渠道', pauseChannelAccount: '暂停渠道', disconnectChannelAccount: '断开微信账号', channelAccountWechatBindStart: '微信扫码连接', channelAccountWechatUnbind: '解除微信登录', + unbindChannelAccount: '解除微信绑定', }; const retiredChannelOperations = new Set(['channelPolicy', 'createChannelPairing', 'channelCallers', 'revokeChannelCaller']); @@ -45,6 +46,9 @@ export function CloudRecovery({ pending, onResolved, triggerContainer }: { pendi if (response.requires_input) { setResult(null); setNotice('这次微信验证还需要验证码。请进入「渠道 → 微信」,选择对应账号后继续连接验证。'); + } else if (response.pending) { + setResult(null); + setNotice('这次操作还在确认中,请稍后再查看。'); } else if (response.retired) { setResult(null); setNotice('旧版联系人权限操作已移除,本机恢复记录已丢弃,微信账号的其他操作不受影响。'); diff --git a/tests/e2e/cloud-agent-channels.spec.ts b/tests/e2e/cloud-agent-channels.spec.ts index 4c21a10c..660fe9e2 100644 --- a/tests/e2e/cloud-agent-channels.spec.ts +++ b/tests/e2e/cloud-agent-channels.spec.ts @@ -1,6 +1,8 @@ import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; -test('guides an empty channel workspace from the sidebar back to My Agents', async ({ launchElectronApp }, testInfo) => { +test('guides an empty channel workspace from the sidebar back to My Agents', async ({ + launchElectronApp, +}, testInfo) => { const app = await launchElectronApp({ skipSetup: true }); try { const page = await getStableWindow(app); @@ -13,19 +15,61 @@ test('guides an empty channel workspace from the sidebar back to My Agents', asy Object.assign(globalThis, { cloudChannelEmptyFixtureCalls: calls }); const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } }); ipcMain.removeHandler('hostapi:fetch'); - ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string; body?: string }) => { - const path = request.path ?? ''; - if (path === '/api/auth/session/sync') return result({ success: true, session: { accessToken: 'ui-fixture', tokenType: 'Bearer', expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false } }); - if (path === '/api/auth/me') return result({ success: true, user: { username: 'creator', userId: 'creator', tenantId: null, deptId: null, authorities: [] }, moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } }); - if (path === '/api/works/user/agent-profile') return result({ success: true, profile: { display_name: '创作者', age: null, gender: null, avatar_url: null, share_age_with_agents: false, share_gender_with_agents: false, analysis_enabled: true, completed: true, version: 1, updated_at: '2026-10-13T00:00:00Z' } }); - if (!path.startsWith('/api/cloud-agents/')) return result({ success: true }); - const body = typeof request.body === 'string' ? JSON.parse(request.body) : request.body ?? {}; - calls.push({ method: request.method ?? 'GET', path, operation: body.operation }); - if (path.endsWith('/recovery')) return result({ recent: null, pending: [] }); - if (path.endsWith('/agents') || path.endsWith('/bootstrap')) return result({ agents: [], next_cursor: null }); - if (path.endsWith('/actions') && body.operation === 'channelAccounts') return result({ items: [] }); - throw new Error('Unexpected empty channel fixture request ' + path); - }); + ipcMain.handle( + 'hostapi:fetch', + async (_event, request: { path?: string; method?: string; body?: string }) => { + const path = request.path ?? ''; + if (path === '/api/auth/session/sync') + return result({ + success: true, + session: { + accessToken: 'ui-fixture', + tokenType: 'Bearer', + expiresAt: Date.now() + 3600000, + lastActiveAt: Date.now(), + canRefresh: false, + }, + }); + if (path === '/api/auth/me') + return result({ + success: true, + user: { + username: 'creator', + userId: 'creator', + tenantId: null, + deptId: null, + authorities: [], + }, + moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true }, + }); + if (path === '/api/works/user/agent-profile') + return result({ + success: true, + profile: { + display_name: '创作者', + age: null, + gender: null, + avatar_url: null, + share_age_with_agents: false, + share_gender_with_agents: false, + analysis_enabled: true, + completed: true, + version: 1, + updated_at: '2026-10-13T00:00:00Z', + }, + }); + if (!path.startsWith('/api/cloud-agents/')) return result({ success: true }); + const body = + typeof request.body === 'string' ? JSON.parse(request.body) : (request.body ?? {}); + calls.push({ method: request.method ?? 'GET', path, operation: body.operation }); + if (path.endsWith('/recovery')) return result({ recent: null, pending: [] }); + if (path.endsWith('/agents') || path.endsWith('/bootstrap')) + return result({ agents: [], next_cursor: null }); + if (path.endsWith('/actions') && body.operation === 'channelAccounts') + return result({ items: [] }); + throw new Error('Unexpected empty channel fixture request ' + path); + } + ); }); await page.goto(applicationUrl); await page.getByTestId('ai-module-option-cloud_agents').click(); @@ -34,36 +78,70 @@ test('guides an empty channel workspace from the sidebar back to My Agents', asy await expect(page).toHaveURL(/\/cloud-agents\/channels$/); const hint = page.getByRole('heading', { name: '还没有可连接的智能体', exact: true }); await expect(hint).toBeVisible(); - await expect(page.getByText('先创建一个智能体,配置完成后点击“开始使用”,就能连接到微信。', { exact: true })).toBeVisible(); + await expect( + page.getByText('先创建一个智能体,配置完成后点击“开始使用”,就能连接到微信。', { exact: true }) + ).toBeVisible(); await expect(page.getByRole('button', { name: '添加第一个微信', exact: true })).toHaveCount(0); await expect(page.getByRole('button', { name: '添加微信', exact: true })).toBeEnabled(); await page.screenshot({ path: testInfo.outputPath('wechat-no-agents-desktop.png'), fullPage: true }); await page.getByRole('button', { name: '添加微信', exact: true }).click(); const dialog = page.getByRole('dialog', { name: '添加微信', exact: true }); - await expect(dialog.getByRole('heading', { name: '还没有可连接的智能体', exact: true })).toBeVisible(); + await expect( + dialog.getByRole('heading', { name: '还没有可连接的智能体', exact: true }) + ).toBeVisible(); await expect(dialog.getByRole('button', { name: '下一步:扫码连接', exact: true })).toBeDisabled(); await dialog.getByRole('button', { name: '关闭', exact: true }).click(); - await app.evaluate(({ BrowserWindow }) => { const window = BrowserWindow.getAllWindows()[0]; window.setMinimumSize(800, 600); window.setSize(980, 680); }); + await app.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0]; + window.setMinimumSize(800, 600); + window.setSize(980, 680); + }); await expect(hint).toBeVisible(); - await expect.poll(() => page.getByTestId('main-content').evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1); + await expect + .poll(() => + page.getByTestId('main-content').evaluate((element) => element.scrollWidth - element.clientWidth) + ) + .toBeLessThanOrEqual(1); await page.screenshot({ path: testInfo.outputPath('wechat-no-agents-compact.png'), fullPage: true }); await page.getByRole('button', { name: '去我的智能体', exact: true }).click(); await expect(page).toHaveURL(/\/cloud-agents\?view=mine$/); await expect(page.getByTestId('cloud-agents-page')).toBeVisible(); - await expect(page.getByRole('heading', { name: '做一个属于你的 AI 助手吧', exact: true })).toBeVisible(); - await expect(sidebar.getByRole('button', { name: '我的智能体', exact: true })).toHaveAttribute('aria-current', 'page'); - const calls = await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelEmptyFixtureCalls: { method: string; operation?: string }[] }).cloudChannelEmptyFixtureCalls); - expect(calls.filter(call => call.operation).map(call => call.operation)).toEqual(['channelAccounts']); - expect(calls.every(call => call.method === 'GET' || call.operation === 'channelAccounts')).toBe(true); + await expect( + page.getByRole('heading', { name: '做一个属于你的 AI 助手吧', exact: true }) + ).toBeVisible(); + await expect(sidebar.getByRole('button', { name: '我的智能体', exact: true })).toHaveAttribute( + 'aria-current', + 'page' + ); + const calls = await app.evaluate( + () => + ( + globalThis as typeof globalThis & { + cloudChannelEmptyFixtureCalls: { method: string; operation?: string }[]; + } + ).cloudChannelEmptyFixtureCalls + ); + expect(calls.filter((call) => call.operation).map((call) => call.operation)).toEqual([ + 'channelAccounts', + ]); + expect(calls.every((call) => call.method === 'GET' || call.operation === 'channelAccounts')).toBe( + true + ); } finally { - await app.evaluate(({ BrowserWindow }) => { for (const window of BrowserWindow.getAllWindows()) window.destroy(); }).catch(() => undefined); + await app + .evaluate(({ BrowserWindow }) => { + for (const window of BrowserWindow.getAllWindows()) window.destroy(); + }) + .catch(() => undefined); await closeElectronApp(app); } }); -test('WeChat binding cards connect an account and switch its Agent through the channel workspace', async ({ launchElectronApp }, testInfo) => { +test('WeChat binding cards connect an account and switch its Agent through the channel workspace', async ({ + launchElectronApp, +}, testInfo) => { const app = await launchElectronApp({ skipSetup: true }); const agentSlug = 'ml-' + 'a'.repeat(32); const secondAgentSlug = 'ml-' + 'b'.repeat(32); @@ -77,69 +155,319 @@ test('WeChat binding cards connect an account and switch its Agent through the c const calls: { operation: string; input: unknown }[] = []; Object.assign(globalThis, { cloudChannelFixtureCalls: calls }); let verified = false; + let rejectUnbind = true; const slug = 'ml-' + 'a'.repeat(32); - const configuration = { model: 'test-model', tools: [], knowledges: [], mcps: [], skills: [], preload_skills: [], subagents: [], tool_approval_mode: 'default', max_execution_steps: 40, max_output_tokens: 4096, max_run_seconds: 600 }; - const draft = { slug, name: '我的创作助手', purpose: '整理选题与制作演示文稿', system_prompt: '使用简洁自然的中文', draft_revision: 2, updated_at: '2026-09-13T00:00:00Z', configuration, published_version: 1, enabled: true, archived: false }; + const configuration = { + model: 'test-model', + tools: [], + knowledges: [], + mcps: [], + skills: [], + preload_skills: [], + subagents: [], + tool_approval_mode: 'default', + max_execution_steps: 40, + max_output_tokens: 4096, + max_run_seconds: 600, + }; + const draft = { + slug, + name: '我的创作助手', + purpose: '整理选题与制作演示文稿', + system_prompt: '使用简洁自然的中文', + draft_revision: 2, + updated_at: '2026-09-13T00:00:00Z', + configuration, + published_version: 1, + enabled: true, + archived: false, + }; const secondSlug = 'ml-' + 'b'.repeat(32); - const secondDraft = { ...draft, slug: secondSlug, name: '资料整理助手', draft_revision: 1, published_version: 1 }; - const channel = { id: 'wechat-a', address: 'wechat:personal', display_name: '创作微信', target_agent_address: 'yuxi:makelore:' + slug, route_revision: 3, provider_generation: 'generation-a', binding_id: 'binding-a', enabled: true, status: 'active', worker_online: true, agent_slug: slug, published_version: 1, desired_state: 'enabled', sync_state: 'ready', health: 'connected', last_confirmed_at: '2026-09-13T01:00:00Z', revision: 3, blockers: [], access_mode: 'self_only', policy_revision: 0, target_agent_slug: slug, target_agent_name: draft.name, target_agent_published_version: 1 }; - const spare = { id: 'wechat-b', address: 'wechat:spare', display_name: '备用微信', target_agent_address: null, route_revision: 1, provider_generation: 'generation-b', binding_id: 'binding-b', enabled: false, status: 'paused', worker_online: true, agent_slug: null, published_version: null, desired_state: 'disabled', sync_state: 'ready', health: 'login_required', last_confirmed_at: null, revision: 1, blockers: ['target_required'], access_mode: 'self_only', policy_revision: 0, target_agent_slug: null, target_agent_name: null, target_agent_published_version: null }; + const secondDraft = { + ...draft, + slug: secondSlug, + name: '资料整理助手', + draft_revision: 1, + published_version: 1, + }; + const channel = { + id: 'wechat-a', + address: 'wechat:personal', + display_name: '创作微信', + target_agent_address: 'yuxi:makelore:' + slug, + route_revision: 3, + provider_generation: 'generation-a', + binding_id: 'binding-a', + enabled: true, + status: 'active', + worker_online: true, + agent_slug: slug, + published_version: 1, + desired_state: 'enabled', + sync_state: 'ready', + health: 'connected', + last_confirmed_at: '2026-09-13T01:00:00Z', + revision: 3, + blockers: [], + access_mode: 'self_only', + policy_revision: 0, + target_agent_slug: slug, + target_agent_name: draft.name, + target_agent_published_version: 1, + }; + const spare = { + id: 'wechat-b', + address: 'wechat:spare', + display_name: '备用微信', + target_agent_address: null, + route_revision: 1, + provider_generation: 'generation-b', + binding_id: 'binding-b', + enabled: false, + status: 'paused', + worker_online: true, + agent_slug: null, + published_version: null, + desired_state: 'disabled', + sync_state: 'ready', + health: 'login_required', + last_confirmed_at: null, + revision: 1, + blockers: ['target_required'], + access_mode: 'self_only', + policy_revision: 0, + target_agent_slug: null, + target_agent_name: null, + target_agent_published_version: null, + }; let channelAccounts = [channel, spare]; const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } }); ipcMain.removeHandler('hostapi:fetch'); - ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string; body?: string }) => { - const path = request.path ?? ''; - if (path === '/api/auth/session/sync') return result({ success: true, session: { accessToken: 'ui-fixture', tokenType: 'Bearer', expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false } }); - if (path === '/api/auth/me') return result({ success: true, user: { username: 'creator', userId: 'creator', tenantId: null, deptId: null, authorities: [] }, moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } }); - if (path === '/api/works/user/agent-profile') return result({ success: true, profile: { display_name: '创作者', age: null, gender: null, avatar_url: null, share_age_with_agents: false, share_gender_with_agents: false, analysis_enabled: true, completed: true, version: 1, updated_at: '2026-09-13T00:00:00Z' } }); - if (!path.startsWith('/api/cloud-agents/')) return result({ success: true }); - const body = typeof request.body === 'string' ? JSON.parse(request.body) : request.body ?? {}; - if (path.endsWith('/recovery')) return result({ recent: null, pending: [] }); - if (path.endsWith('/recovery/recent')) return result({ saved: true }); - if (!path.endsWith('/actions')) return result(path.endsWith('/agents') || path.endsWith('/bootstrap') ? { agents: [draft, secondDraft], next_cursor: null } : path.endsWith(secondSlug) ? secondDraft : draft); - const { operation, input = {} } = body; - calls.push({ operation, input }); - if (operation === 'threads') return result({ threads: [], next_offset: null }); - if (operation === 'history') return result({ thread_id: 'desktop-thread', messages: [], run: null, next_offset: null }); - if (operation === 'attachments' || operation === 'files') return result({ attachments: [], files: [] }); - if (operation === 'catalog') return result({ models: [{ id: 'test-model', name: '创作模型' }], resources: {}, pricing: null }); - if (operation === 'access') return result({ published_version: 1, enabled: true, versions: [], grants: [], applications: [], share_url: 'niancode://agents/' + slug }); - if (operation === 'budget') return result({ request_limit_points: '2.50', daily_limit_points: '10.00', daily_committed_points: '0.20' }); - if (operation === 'costs') return result({ items: [], next_offset: null, summary: { count: 0, settled_points: '0', pending_points: '0' } }); - if (operation === 'channelAccounts') return result({ items: channelAccounts }); - if (operation === 'createChannelAccount') { - const created = { ...spare, id: 'wechat-created', display_name: input.display_name, binding_id: 'binding-created' }; - channelAccounts.push(created); - return result({ channel: created }); + ipcMain.handle( + 'hostapi:fetch', + async (_event, request: { path?: string; method?: string; body?: string }) => { + const path = request.path ?? ''; + if (path === '/api/auth/session/sync') + return result({ + success: true, + session: { + accessToken: 'ui-fixture', + tokenType: 'Bearer', + expiresAt: Date.now() + 3600000, + lastActiveAt: Date.now(), + canRefresh: false, + }, + }); + if (path === '/api/auth/me') + return result({ + success: true, + user: { + username: 'creator', + userId: 'creator', + tenantId: null, + deptId: null, + authorities: [], + }, + moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true }, + }); + if (path === '/api/works/user/agent-profile') + return result({ + success: true, + profile: { + display_name: '创作者', + age: null, + gender: null, + avatar_url: null, + share_age_with_agents: false, + share_gender_with_agents: false, + analysis_enabled: true, + completed: true, + version: 1, + updated_at: '2026-09-13T00:00:00Z', + }, + }); + if (!path.startsWith('/api/cloud-agents/')) return result({ success: true }); + const body = + typeof request.body === 'string' ? JSON.parse(request.body) : (request.body ?? {}); + if (path.endsWith('/recovery')) return result({ recent: null, pending: [] }); + if (path.endsWith('/recovery/recent')) return result({ saved: true }); + if (!path.endsWith('/actions')) + return result( + path.endsWith('/agents') || path.endsWith('/bootstrap') + ? { agents: [draft, secondDraft], next_cursor: null } + : path.endsWith(secondSlug) + ? secondDraft + : draft + ); + const { operation, input = {} } = body; + calls.push({ operation, input }); + if (operation === 'threads') return result({ threads: [], next_offset: null }); + if (operation === 'history') + return result({ thread_id: 'desktop-thread', messages: [], run: null, next_offset: null }); + if (operation === 'attachments' || operation === 'files') + return result({ attachments: [], files: [] }); + if (operation === 'catalog') + return result({ + models: [{ id: 'test-model', name: '创作模型' }], + resources: {}, + pricing: null, + }); + if (operation === 'access') + return result({ + published_version: 1, + enabled: true, + versions: [], + grants: [], + applications: [], + share_url: 'niancode://agents/' + slug, + }); + if (operation === 'budget') + return result({ + request_limit_points: '2.50', + daily_limit_points: '10.00', + daily_committed_points: '0.20', + }); + if (operation === 'costs') + return result({ + items: [], + next_offset: null, + summary: { count: 0, settled_points: '0', pending_points: '0' }, + }); + if (operation === 'channelAccounts') return result({ items: channelAccounts }); + if (operation === 'createChannelAccount') { + const created = { + ...spare, + id: 'wechat-created', + display_name: input.display_name, + binding_id: 'binding-created', + }; + channelAccounts.push(created); + return result({ channel: created }); + } + if (operation === 'routeChannelAccount') { + const stale = { ...channelAccounts.find((item) => item.id === input.channel_account_id) }; + const target = + input.target_agent_slug === secondSlug + ? secondDraft + : input.target_agent_slug === slug + ? draft + : null; + channelAccounts = channelAccounts.map((item) => + item.id !== input.channel_account_id + ? item + : { + ...item, + revision: item.revision + 1, + enabled: input.enabled, + target_agent_slug: target?.slug ?? null, + target_agent_name: target?.name ?? null, + target_agent_published_version: target?.published_version ?? null, + agent_slug: target?.slug ?? null, + published_version: target?.published_version ?? null, + target_agent_address: target ? 'yuxi:makelore:' + target.slug : null, + blockers: target ? [] : ['target_required'], + } + ); + return result({ channel: stale, target_agent: target?.slug ?? null }); + } + if (operation === 'enableChannelAccount' || operation === 'pauseChannelAccount') { + const enabled = operation === 'enableChannelAccount'; + channelAccounts = channelAccounts.map((item) => + item.id !== input.channel_account_id + ? item + : { + ...item, + revision: item.revision + 1, + enabled, + desired_state: enabled ? 'enabled' : 'disabled', + } + ); + return result({ + channel: channelAccounts.find((item) => item.id === input.channel_account_id), + }); + } + if (operation === 'unbindChannelAccount') { + if (rejectUnbind) { + rejectUnbind = false; + return { + ok: true, + data: { + status: 409, + ok: false, + json: { + error: '解除暂时失败,请重试', + code: 'channel_operation_conflict', + commandOutcome: 'definitive_failure', + }, + }, + }; + } + channelAccounts = channelAccounts.map((item) => + item.id === input.channel_account_id + ? { + ...item, + health: 'login_required', + enabled: false, + desired_state: 'paused', + sync_state: 'ready', + target_agent_slug: null, + target_agent_name: null, + target_agent_address: '', + target_agent_published_version: null, + agent_slug: null, + published_version: null, + provider_generation: 'logged-out-generation', + revision: item.revision + 1, + } + : item + ); + return result({ + channel: channelAccounts.find((item) => item.id === input.channel_account_id), + disconnected: true, + unbound: true, + }); + } + if (operation === 'channelBindings') + return result({ + items: channelAccounts.filter((item) => item.target_agent_slug === input.slug), + available_accounts: [], + }); + if ( + operation === 'channelAccountWechatBindStart' || + operation === 'channelAccountWechatBindStatus' + ) + return result({ + session_key: 'qr-session', + status: verified ? 'confirmed' : 'verification_required', + qrcode_url: 'https://login.example.test/confirm?token=fixture', + message: '请确认微信验证码', + }); + if (operation === 'channelAccountWechatBindVerification') { + verified = true; + channelAccounts = channelAccounts.map((item) => + item.id === input.channel_account_id ? { ...item, health: 'connected' } : item + ); + return result({ session_key: 'qr-session', status: 'confirmed', message: '连接已确认' }); + } + if (operation === 'schedules') return result({ jobs: [] }); + if (operation === 'scheduleContext') + return result({ + version: 1, + enabled: true, + configuration, + result_destination: '任务历史', + payer: 'creator', + }); + throw new Error('Unexpected channel UI fixture operation ' + operation); } - if (operation === 'routeChannelAccount') { - const target = input.target_agent_slug === secondSlug ? secondDraft : input.target_agent_slug === slug ? draft : null; - channelAccounts = channelAccounts.map(item => item.id !== input.channel_account_id ? item : { ...item, revision: item.revision + 1, enabled: input.enabled, - target_agent_slug: target?.slug ?? null, target_agent_name: target?.name ?? null, target_agent_published_version: target?.published_version ?? null, - agent_slug: target?.slug ?? null, published_version: target?.published_version ?? null, target_agent_address: target ? 'yuxi:makelore:' + target.slug : null, - blockers: target ? [] : ['target_required'] }); - return result({ channel: channelAccounts.find(item => item.id === input.channel_account_id), target_agent: target?.slug ?? null }); - } - if (operation === 'enableChannelAccount' || operation === 'pauseChannelAccount') { - const enabled = operation === 'enableChannelAccount'; - channelAccounts = channelAccounts.map(item => item.id !== input.channel_account_id ? item : { ...item, revision: item.revision + 1, enabled, desired_state: enabled ? 'enabled' : 'disabled' }); - return result({ channel: channelAccounts.find(item => item.id === input.channel_account_id) }); - } - if (operation === 'disconnectChannelAccount') { - channelAccounts = channelAccounts.map(item => item.id === input.channel_account_id ? { ...item, health: 'login_required', enabled: false, revision: item.revision + 1 } : item); - return result({ channel: channelAccounts.find(item => item.id === input.channel_account_id), disconnected: true }); - } - if (operation === 'channelBindings') return result({ items: channelAccounts.filter(item => item.target_agent_slug === input.slug), available_accounts: [] }); - if (operation === 'channelAccountWechatBindStart' || operation === 'channelAccountWechatBindStatus') return result({ session_key: 'qr-session', status: verified ? 'confirmed' : 'verification_required', qrcode_url: 'https://login.example.test/confirm?token=fixture', message: '请确认微信验证码' }); - if (operation === 'channelAccountWechatBindVerification') { verified = true; channelAccounts = channelAccounts.map(item => item.id === input.channel_account_id ? { ...item, health: 'connected' } : item); return result({ session_key: 'qr-session', status: 'confirmed', message: '连接已确认' }); } - if (operation === 'schedules') return result({ jobs: [] }); - if (operation === 'scheduleContext') return result({ version: 1, enabled: true, configuration, result_destination: '任务历史', payer: 'creator' }); - throw new Error('Unexpected channel UI fixture operation ' + operation); - }); + ); }); await page.goto(applicationUrl); await page.getByTestId('ai-module-option-cloud_agents').click(); - await page.getByRole('article', { name: '我的创作助手', exact: true }).getByRole('button', { name: '和它聊聊', exact: true }).click(); + await page + .getByRole('article', { name: '我的创作助手', exact: true }) + .getByRole('button', { name: '和它聊聊', exact: true }) + .click(); await expect(page.getByRole('region', { name: '智能体对话', exact: true })).toBeVisible(); await expect(page.getByRole('heading', { name: '我的创作助手', exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: '微信对话', exact: true })).toHaveCount(0); @@ -151,7 +479,7 @@ test('WeChat binding cards connect an account and switch its Agent through the c const legacyCard = page.getByRole('article', { name: '创作微信微信', exact: true }); const spareCard = page.getByRole('article', { name: '备用微信微信', exact: true }); await expect(legacyCard.getByText('我的创作助手', { exact: true })).toBeVisible(); - await expect(spareCard.getByRole('button', { name: '继续绑定', exact: true })).toBeVisible(); + await expect(spareCard.getByRole('button', { name: '绑定智能体', exact: true })).toBeVisible(); await expect(page.getByRole('dialog')).toHaveCount(0); await expect(page.getByText('活动记录', { exact: true })).toHaveCount(0); await expect(page.getByRole('button', { name: '微信对话', exact: true })).toHaveCount(0); @@ -159,59 +487,225 @@ test('WeChat binding cards connect an account and switch its Agent through the c await page.getByRole('button', { name: '添加微信', exact: true }).click(); const adding = page.getByRole('dialog', { name: '添加微信', exact: true }); - await adding.getByLabel('选择智能体', { exact: true }).selectOption(agentSlug); - await adding.getByLabel('微信备注(可选)', { exact: true }).fill('新创作微信'); + await adding + .getByRole('radiogroup', { name: '选择智能体' }) + .getByText('我的创作助手', { exact: true }) + .click(); + await expect(adding.getByRole('radio', { name: '我的创作助手', exact: true })).toBeChecked(); await adding.getByRole('button', { name: '下一步:扫码连接', exact: true }).click(); - await expect(adding.getByRole('img', { name: '个人微信登录二维码', exact: true })).toHaveAttribute('src', /^data:image\//); - await expect.poll(async () => app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string; input: Record }[] }).cloudChannelFixtureCalls - .filter(call => call.operation === 'routeChannelAccount').some(call => call.input.channel_account_id === 'wechat-created' && call.input.target_agent_slug === 'ml-' + 'a'.repeat(32) && call.input.enabled === false))).toBe(true); - await expect(adding.getByRole('button', { name: '完成绑定并启用', exact: true })).toBeDisabled(); + await expect(adding.getByRole('img', { name: '个人微信登录二维码', exact: true })).toHaveAttribute( + 'src', + /^data:image\// + ); + await expect + .poll(async () => + app.evaluate(() => + ( + globalThis as typeof globalThis & { + cloudChannelFixtureCalls: { operation: string; input: Record }[]; + } + ).cloudChannelFixtureCalls + .filter((call) => call.operation === 'routeChannelAccount') + .some( + (call) => + call.input.channel_account_id === 'wechat-created' && + call.input.target_agent_slug === 'ml-' + 'a'.repeat(32) && + call.input.enabled === false + ) + ) + ) + .toBe(true); + await expect(adding.getByRole('button', { name: '完成绑定并启用', exact: true })).toHaveCount(0); await adding.getByLabel('微信验证码', { exact: true }).fill('123456'); await adding.getByRole('button', { name: '提交验证码', exact: true }).click(); await expect(adding.getByRole('img', { name: '个人微信登录二维码', exact: true })).toHaveCount(0); await expect(adding.getByLabel('微信验证码', { exact: true })).toHaveCount(0); - await expect(adding.getByText('微信已连接', { exact: true })).toBeVisible(); - expect(await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }).cloudChannelFixtureCalls.some(call => call.operation === 'enableChannelAccount'))).toBe(false); - await page.screenshot({ path: testInfo.outputPath('wechat-connection-confirmed.png'), fullPage: true }); - await adding.getByRole('button', { name: '完成绑定并启用', exact: true }).click(); + await expect(adding.getByRole('heading', { name: '微信绑定成功', exact: true })).toBeVisible(); + expect( + await app.evaluate( + () => + ( + globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] } + ).cloudChannelFixtureCalls.filter((call) => call.operation === 'enableChannelAccount').length + ) + ).toBe(1); + await page.screenshot({ + path: testInfo.outputPath('wechat-automatically-connected.png'), + fullPage: true, + }); await expect(adding.getByRole('button', { name: '完成', exact: true })).toBeVisible(); await adding.getByRole('button', { name: '完成', exact: true }).click(); - await expect(page.getByRole('article', { name: '新创作微信微信', exact: true }).getByText('我的创作助手', { exact: true })).toBeVisible(); + await expect( + page + .getByRole('article', { name: '微信 3微信', exact: true }) + .getByText('我的创作助手', { exact: true }) + ).toBeVisible(); - await legacyCard.getByRole('button', { name: '管理绑定', exact: true }).click(); - const managing = page.getByRole('dialog', { name: '管理微信绑定', exact: true }); - const qrStartsBeforeSwitch = await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }).cloudChannelFixtureCalls.filter(call => call.operation === 'channelAccountWechatBindStart').length); - await managing.getByLabel('绑定的智能体', { exact: true }).selectOption(secondAgentSlug); - await managing.getByRole('button', { name: '保存更换', exact: true }).click(); - const switching = page.getByRole('dialog', { name: '更换绑定的智能体', exact: true }); - await switching.getByRole('button', { name: /确认更换/ }).click(); - await expect.poll(async () => app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string; input: Record }[] }).cloudChannelFixtureCalls - .filter(call => call.operation === 'routeChannelAccount').some(call => call.input.channel_account_id === 'wechat-a' && call.input.target_agent_slug === 'ml-' + 'b'.repeat(32) && call.input.enabled === true))).toBe(true); - await expect(managing.getByText('已更换绑定的智能体,微信连接保留。', { exact: true })).toBeVisible(); - await expect(managing.getByLabel('绑定的智能体', { exact: true })).toHaveValue(secondAgentSlug); - expect(await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }).cloudChannelFixtureCalls.filter(call => call.operation === 'channelAccountWechatBindStart').length)).toBe(qrStartsBeforeSwitch); + await legacyCard.getByRole('button', { name: '更换智能体', exact: true }).click(); + const managing = page.getByRole('dialog', { name: '更换智能体', exact: true }); + const qrStartsBeforeSwitch = await app.evaluate( + () => + ( + globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] } + ).cloudChannelFixtureCalls.filter((call) => call.operation === 'channelAccountWechatBindStart') + .length + ); + await managing + .getByRole('radiogroup', { name: '选择智能体' }) + .getByText('资料整理助手', { exact: true }) + .click(); + await expect(managing.getByRole('radio', { name: '资料整理助手', exact: true })).toBeChecked(); + await expect(page.getByRole('dialog')).toHaveCount(1); + await page.screenshot({ path: testInfo.outputPath('wechat-agent-choice.png'), fullPage: true }); + await managing.getByRole('button', { name: '确认更换', exact: true }).click(); + await expect + .poll(async () => + app.evaluate(() => + ( + globalThis as typeof globalThis & { + cloudChannelFixtureCalls: { operation: string; input: Record }[]; + } + ).cloudChannelFixtureCalls + .filter((call) => call.operation === 'routeChannelAccount') + .some( + (call) => + call.input.channel_account_id === 'wechat-a' && + call.input.target_agent_slug === 'ml-' + 'b'.repeat(32) && + call.input.enabled === true + ) + ) + ) + .toBe(true); + await expect(managing).toHaveCount(0); + await expect(page.getByText('已更换智能体,微信连接和历史保留。', { exact: true })).toBeVisible(); + expect( + await app.evaluate( + () => + ( + globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] } + ).cloudChannelFixtureCalls.filter((call) => call.operation === 'channelAccountWechatBindStart') + .length + ) + ).toBe(qrStartsBeforeSwitch); await expect(page.getByText('活动记录', { exact: true })).toHaveCount(0); await expect(page.getByRole('button', { name: '微信对话', exact: true })).toHaveCount(0); await expect(page.getByText(/本人|受邀|邀请|使用范围|使用权限|授权调用者/)).toHaveCount(0); - await app.evaluate(({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0]; win.setMinimumSize(800, 600); win.setSize(980, 680); }); - await expect.poll(() => page.getByTestId('main-content').evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1); - await page.screenshot({ path: testInfo.outputPath('wechat-manage-binding-compact.png'), fullPage: true }); - await managing.getByRole('button', { name: '关闭', exact: true }).click(); - await expect(managing).toHaveCount(0); - await expect(legacyCard.getByText('资料整理助手', { exact: true })).toBeVisible(); - const finalCalls = await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string; input: Record }[] }).cloudChannelFixtureCalls); - expect(finalCalls.some(call => ['channelActivity', 'channelConversations', 'channelConversation', 'channelConversationControl', 'channelFiles', 'downloadChannelArtifact', 'channelPolicy', 'createChannelPairing', 'channelCallers', 'channelSelfCallers', 'revokeChannelCaller', 'submit', 'preview', 'resume'].includes(call.operation))).toBe(false); - expect(finalCalls.filter(call => call.operation === 'createChannelAccount')).toHaveLength(1); - expect(finalCalls.find(call => call.operation === 'createChannelAccount')?.input).toEqual({ display_name: '新创作微信', operation_id: expect.any(String) }); - expect(finalCalls.find(call => call.operation === 'channelAccountWechatBindVerification')?.input).toEqual({ channel_account_id: 'wechat-created', session_key: 'qr-session', verify_code: '123456', operation_id: expect.any(String) }); - expect(finalCalls.find(call => call.operation === 'enableChannelAccount')?.input).toEqual({ channel_account_id: 'wechat-created', expected_revision: 2, operation_id: expect.any(String) }); - expect(finalCalls.filter(call => call.operation === 'routeChannelAccount').map(call => call.input)).toEqual([ - { channel_account_id: 'wechat-created', target_agent_slug: agentSlug, expected_revision: 1, enabled: false, operation_id: expect.any(String) }, - { channel_account_id: 'wechat-a', target_agent_slug: secondAgentSlug, expected_revision: 3, enabled: true, operation_id: expect.any(String) }, - ]); + await app.evaluate(({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows()[0]; + win.setMinimumSize(800, 600); + win.setSize(980, 680); + }); + await expect + .poll(() => + page.getByTestId('main-content').evaluate((element) => element.scrollWidth - element.clientWidth) + ) + .toBeLessThanOrEqual(1); + await page.screenshot({ + path: testInfo.outputPath('wechat-binding-cards-compact.png'), + fullPage: true, + }); + await expect(legacyCard.getByText('资料整理助手', { exact: true })).toBeVisible(); + const finalCalls = await app.evaluate( + () => + ( + globalThis as typeof globalThis & { + cloudChannelFixtureCalls: { operation: string; input: Record }[]; + } + ).cloudChannelFixtureCalls + ); + expect( + finalCalls.some((call) => + [ + 'channelActivity', + 'channelConversations', + 'channelConversation', + 'channelConversationControl', + 'channelFiles', + 'downloadChannelArtifact', + 'channelPolicy', + 'createChannelPairing', + 'channelCallers', + 'channelSelfCallers', + 'revokeChannelCaller', + 'submit', + 'preview', + 'resume', + ].includes(call.operation) + ) + ).toBe(false); + expect(finalCalls.filter((call) => call.operation === 'createChannelAccount')).toHaveLength(1); + expect(finalCalls.find((call) => call.operation === 'createChannelAccount')?.input).toEqual({ + display_name: '微信 3', + operation_id: expect.any(String), + }); + expect( + finalCalls.find((call) => call.operation === 'channelAccountWechatBindVerification')?.input + ).toEqual({ + channel_account_id: 'wechat-created', + session_key: 'qr-session', + verify_code: '123456', + operation_id: expect.any(String), + }); + expect(finalCalls.find((call) => call.operation === 'enableChannelAccount')?.input).toEqual({ + channel_account_id: 'wechat-created', + expected_revision: 2, + operation_id: expect.any(String), + }); + expect( + finalCalls.filter((call) => call.operation === 'routeChannelAccount').map((call) => call.input) + ).toEqual([ + { + channel_account_id: 'wechat-created', + target_agent_slug: agentSlug, + expected_revision: 1, + enabled: false, + operation_id: expect.any(String), + }, + { + channel_account_id: 'wechat-a', + target_agent_slug: secondAgentSlug, + expected_revision: 3, + enabled: true, + operation_id: expect.any(String), + }, + ]); + await legacyCard.getByRole('button', { name: '更多操作', exact: true }).click(); + await page.screenshot({ path: testInfo.outputPath('wechat-binding-more-compact.png'), fullPage: true }); + await expect(legacyCard.getByRole('button', { name: '暂停自动回复', exact: true })).toBeVisible(); + await legacyCard.getByRole('button', { name: '解除微信绑定', exact: true }).click(); + const unbinding = page.getByRole('dialog', { name: '解除微信绑定', exact: true }); + await unbinding.getByRole('button', { name: '确认解除微信绑定', exact: true }).click(); + await expect(unbinding.getByRole('alert')).toHaveText(/解除暂时失败/); + await expect(page.getByRole('dialog')).toHaveCount(1); + await page.screenshot({ + path: testInfo.outputPath('wechat-unbind-visible-failure.png'), + fullPage: true, + }); + await unbinding.getByRole('button', { name: '确认解除微信绑定', exact: true }).click(); + await expect(unbinding).toHaveCount(0); + await expect(legacyCard.getByText('未绑定', { exact: true })).toBeVisible(); + await expect(legacyCard.getByRole('button', { name: '绑定智能体', exact: true })).toBeVisible(); + await expect(page.getByText('断开微信连接', { exact: true })).toHaveCount(0); + await page.screenshot({ + path: testInfo.outputPath('wechat-unbound-card-compact.png'), + fullPage: true, + }); + const unbindCalls = await app.evaluate(() => + ( + globalThis as typeof globalThis & { + cloudChannelFixtureCalls: { operation: string; input: Record }[]; + } + ).cloudChannelFixtureCalls.filter((call) => call.operation === 'unbindChannelAccount') + ); + expect(unbindCalls).toHaveLength(2); + expect(unbindCalls[0].input).toEqual(unbindCalls[1].input); } finally { - await app.evaluate(({ BrowserWindow }) => { for (const window of BrowserWindow.getAllWindows()) window.destroy(); }).catch(() => undefined); + await app + .evaluate(({ BrowserWindow }) => { + for (const window of BrowserWindow.getAllWindows()) window.destroy(); + }) + .catch(() => undefined); await closeElectronApp(app); } }); diff --git a/tests/unit/channel-accounts-page.test.tsx b/tests/unit/channel-accounts-page.test.tsx index 9e8fe991..ffbc6409 100644 --- a/tests/unit/channel-accounts-page.test.tsx +++ b/tests/unit/channel-accounts-page.test.tsx @@ -2,76 +2,168 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testi import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { ChannelAccounts } from '@/pages/CloudAgents/ChannelAccounts'; -import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudUserChannelAccount, type CloudWechatBindState } from '../../shared/cloud-agents'; +import { + EMPTY_CLOUD_CONFIGURATION, + type CloudAgentDraft, + type CloudPendingOperation, + type CloudUserChannelAccount, + type CloudWechatBindState, +} from '../../shared/cloud-agents'; -const api = vi.hoisted(() => ({ call: vi.fn(), list: vi.fn(), recovery: vi.fn(), resolvePending: vi.fn() })); +const api = vi.hoisted(() => ({ + call: vi.fn(), + list: vi.fn(), + recovery: vi.fn(), + resolvePending: vi.fn(), +})); vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api })); -vi.mock('@/pages/CloudAgents/CloudWechatQr', () => ({ CloudWechatQr: () => 个人微信登录二维码 })); - -const agentA: CloudAgentDraft = { slug: 'ml-' + 'a'.repeat(32), name: '写作助手', purpose: '', system_prompt: '', published_version: 2, draft_revision: 3, updated_at: '', configuration: EMPTY_CLOUD_CONFIGURATION, enabled: true }; -const agentB: CloudAgentDraft = { ...agentA, slug: 'ml-' + 'b'.repeat(32), name: '资料助手', published_version: 4, draft_revision: 5 }; -const legacy: CloudUserChannelAccount = { id: 'wechat-legacy', display_name: '原有微信', binding_id: 'binding-legacy', revision: 7, enabled: true, - status: 'active', health: 'connected', blockers: [], target_agent_slug: agentA.slug, target_agent_name: agentA.name, - target_agent_published_version: 2, provider_generation: 'generation-legacy' }; -const spare: CloudUserChannelAccount = { id: 'wechat-spare', display_name: '备用微信', binding_id: 'binding-spare', revision: 2, enabled: false, - status: 'paused', health: 'login_required', blockers: ['target_required'], target_agent_slug: null, target_agent_name: null, - target_agent_published_version: null, provider_generation: 'generation-spare' }; +vi.mock('@/pages/CloudAgents/CloudWechatQr', () => ({ + CloudWechatQr: () => 个人微信登录二维码, +})); +const agentA: CloudAgentDraft = { + slug: 'ml-' + 'a'.repeat(32), + name: '写作助手', + purpose: '', + system_prompt: '', + published_version: 2, + draft_revision: 3, + updated_at: '', + configuration: EMPTY_CLOUD_CONFIGURATION, + enabled: true, +}; +const agentB: CloudAgentDraft = { + ...agentA, + slug: 'ml-' + 'b'.repeat(32), + name: '资料助手', + published_version: 4, +}; +const legacy: CloudUserChannelAccount = { + id: 'wechat-legacy', + display_name: '原有微信', + binding_id: 'binding-legacy', + revision: 7, + enabled: true, + status: 'active', + health: 'connected', + blockers: [], + target_agent_slug: agentA.slug, + target_agent_name: agentA.name, + target_agent_published_version: 2, + sync_state: 'ready', +}; +const spare: CloudUserChannelAccount = { + ...legacy, + id: 'wechat-spare', + display_name: '备用微信', + revision: 2, + enabled: false, + status: 'paused', + health: 'login_required', + blockers: ['target_required'], + target_agent_slug: null, + target_agent_name: null, + target_agent_published_version: null, +}; let accounts: CloudUserChannelAccount[]; let agents: CloudAgentDraft[]; +let pending: CloudPendingOperation[]; let qrStatus: CloudWechatBindState['status']; let verifyStatus: CloudWechatBindState['status']; - const calls = (operation: string) => api.call.mock.calls.filter(([name]) => name === operation); -const writes = () => api.call.mock.calls.filter(([name]) => !['channelAccounts', 'channelAccountWechatBindStatus'].includes(name)); +const writes = () => + api.call.mock.calls.filter( + ([name]) => !['channelAccounts', 'channelAccountWechatBindStatus'].includes(name) + ); function LocationProbe() { const location = useLocation(); return {location.pathname + location.search}; } function renderPage(entry = '/cloud-agents/channels') { - return render( - } /> - 智能体列表} /> - ); + return render( + + + } /> + 智能体列表} /> + + + + ); } -async function addWechat(fakeTimers = false) { +async function card(name = legacy.display_name) { + return screen.findByRole('article', { name: name + '微信', exact: true }); +} +async function chooseExisting(name = legacy.display_name) { + const current = await card(name); + fireEvent.click(within(current).getByRole('button', { name: /更换智能体|绑定智能体/ })); + return screen.getByRole('dialog', { name: '更换智能体', exact: true }); +} +async function more(name = legacy.display_name) { + const current = await card(name); + fireEvent.click(within(current).getByRole('button', { name: '更多操作', exact: true })); + return current; +} +async function adding() { fireEvent.click(await screen.findByRole('button', { name: '添加微信', exact: true })); const dialog = screen.getByRole('dialog', { name: '添加微信', exact: true }); - fireEvent.change(within(dialog).getByLabelText('选择智能体', { exact: true }), { target: { value: agentA.slug } }); - fireEvent.change(within(dialog).getByLabelText('微信备注(可选)', { exact: true }), { target: { value: '创作微信' } }); - if (fakeTimers) vi.useFakeTimers(); - await act(async () => fireEvent.click(within(dialog).getByRole('button', { name: '下一步:扫码连接', exact: true }))); + fireEvent.click(within(dialog).getByRole('radio', { name: agentA.name })); + await act(async () => + fireEvent.click(within(dialog).getByRole('button', { name: '下一步:扫码连接', exact: true })) + ); return dialog; } -async function manage(name = legacy.display_name) { - const card = await screen.findByRole('article', { name: name + '微信', exact: true }); - fireEvent.click(within(card).getByRole('button', { name: /管理绑定|继续绑定/ })); - return screen.getByRole('dialog', { name: '管理微信绑定', exact: true }); -} function installMock() { api.call.mockImplementation(async (operation: string, input: Record) => { - if (operation === 'channelAccounts') return { items: accounts.map(item => ({ ...item })) }; + if (operation === 'channelAccounts') return { items: accounts.map((item) => ({ ...item })) }; if (operation === 'createChannelAccount') { - const channel = { ...spare, id: 'wechat-created', display_name: String(input.display_name), revision: 1 }; - accounts.push(channel); return { channel }; + const channel = { + ...spare, + id: 'wechat-created', + display_name: String(input.display_name), + revision: 1, + }; + accounts.push(channel); + return { channel: { ...channel } }; } - const account = accounts.find(item => item.id === input.channel_account_id); + const account = accounts.find((item) => item.id === input.channel_account_id); if (!account) throw new Error('Unknown fixture account'); if (operation === 'routeChannelAccount') { - const target = agents.find(item => item.slug === input.target_agent_slug); - Object.assign(account, { revision: account.revision + 1, target_agent_slug: target?.slug ?? null, - target_agent_name: target?.name ?? null, target_agent_published_version: target?.published_version ?? null, - enabled: input.enabled, blockers: target ? [] : ['target_required'] }); + const target = agents.find((item) => item.slug === input.target_agent_slug); + Object.assign(account, { + revision: account.revision + 1, + target_agent_slug: target?.slug ?? null, + target_agent_name: target?.name ?? null, + enabled: input.enabled, + blockers: input.enabled ? [] : ['binding_paused'], + }); return { channel: { ...account } }; } if (operation === 'enableChannelAccount' || operation === 'pauseChannelAccount') { - Object.assign(account, { revision: account.revision + 1, enabled: operation === 'enableChannelAccount' }); + const enabled = operation === 'enableChannelAccount'; + Object.assign(account, { + revision: account.revision + 1, + enabled, + blockers: enabled ? [] : ['binding_paused'], + }); return { channel: { ...account } }; } - if (operation === 'disconnectChannelAccount') { - Object.assign(account, { revision: account.revision + 1, health: 'login_required', enabled: false }); - return { channel: { ...account }, disconnected: true }; + if (operation === 'unbindChannelAccount') { + Object.assign(account, { + revision: account.revision + 1, + health: 'login_required', + enabled: false, + target_agent_slug: null, + target_agent_name: null, + blockers: ['target_required'], + }); + return { channel: { ...account }, unbound: true, disconnected: true }; } - if (['channelAccountWechatBindStart', 'channelAccountWechatBindStatus', 'channelAccountWechatBindVerification'].includes(operation)) { + if ( + [ + 'channelAccountWechatBindStart', + 'channelAccountWechatBindStatus', + 'channelAccountWechatBindVerification', + ].includes(operation) + ) { const status = operation === 'channelAccountWechatBindVerification' ? verifyStatus : qrStatus; if (status === 'confirmed') account.health = 'connected'; return { session_key: 'qr-' + account.id, status, qrcode_url: 'https://login.example.test/qr' }; @@ -81,393 +173,627 @@ function installMock() { } beforeEach(() => { vi.resetAllMocks(); - accounts = [{ ...legacy }, { ...spare }]; agents = [agentA, agentB]; - qrStatus = 'verification_required'; verifyStatus = 'pending'; + accounts = [{ ...legacy }, { ...spare }]; + agents = [agentA, agentB]; + pending = []; + qrStatus = 'verification_required'; + verifyStatus = 'pending'; api.list.mockImplementation(async () => ({ agents, next_cursor: null })); - api.recovery.mockResolvedValue({ recent: null, pending: [] }); + api.recovery.mockImplementation(async () => ({ recent: null, pending })); installMock(); }); -afterEach(() => { cleanup(); vi.useRealTimers(); }); +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); it.each([ - ['no Agents', []], - ['only drafts', [{ ...agentA, published_version: null }]], - ['only disabled Agents', [{ ...agentA, enabled: false }]], -] satisfies [string, CloudAgentDraft[]][])('guides a workspace with %s to My Agents without starting a binding', async (_name, unavailable) => { - agents = unavailable; accounts = []; + ['none', []], + ['drafts', [{ ...agentA, published_version: null }]], + ['disabled', [{ ...agentA, enabled: false }]], +] satisfies [string, CloudAgentDraft[]][])( + 'retains no-Agent guidance and makes no binding requests for %s Agents', + async (_name, unavailable) => { + agents = unavailable; + accounts = []; + renderPage(); + expect(await screen.findByRole('heading', { name: '还没有可连接的智能体' })).toBeVisible(); + expect(writes()).toEqual([]); + fireEvent.click(screen.getByRole('button', { name: '去我的智能体' })); + expect(await screen.findByText('智能体列表')).toBeVisible(); + expect(screen.getByTestId('location-path')).toHaveTextContent('/cloud-agents?view=mine'); + } +); +it('keeps one main action on each card and places pause/unbind in More', async () => { renderPage(); - expect(await screen.findByRole('heading', { name: '还没有可连接的智能体', exact: true })).toBeVisible(); - expect(screen.getByText('先创建一个智能体,配置完成后点击“开始使用”,就能连接到微信。', { exact: true })).toBeVisible(); - expect(screen.queryByRole('button', { name: '添加第一个微信', exact: true })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: '添加微信', exact: true })).toBeEnabled(); - fireEvent.click(screen.getByRole('button', { name: '去我的智能体', exact: true })); - expect(await screen.findByText('智能体列表', { exact: true })).toBeVisible(); - expect(screen.getByTestId('location-path')).toHaveTextContent('/cloud-agents?view=mine'); + const current = await card(); + expect(within(current).getByText('使用中')).toBeVisible(); + expect(within(current).getByText(agentA.name)).toBeVisible(); + expect(within(current).getByRole('button', { name: '更换智能体' })).toBeVisible(); + expect(within(current).queryByRole('button', { name: '解除微信绑定' })).not.toBeInTheDocument(); + const menu = await more(); + expect(within(menu).getByRole('button', { name: '暂停自动回复' })).toBeVisible(); + expect(within(menu).getByRole('button', { name: '解除微信绑定' })).toBeVisible(); + expect(screen.queryByText('断开微信连接')).not.toBeInTheDocument(); + expect(screen.queryByText('活动记录')).not.toBeInTheDocument(); expect(writes()).toEqual([]); }); - -it('uses the same preparation hint in the add dialog without duplicating it on the page', async () => { - agents = []; accounts = []; +it('uses one Agent-choice window and confirms a switch without a second dialog', async () => { renderPage(); - await screen.findByRole('heading', { name: '还没有可连接的智能体', exact: true }); - fireEvent.click(screen.getByRole('button', { name: '添加微信', exact: true })); - const dialog = screen.getByRole('dialog', { name: '添加微信', exact: true }); - expect(within(dialog).getByRole('heading', { name: '还没有可连接的智能体', exact: true })).toBeVisible(); - expect(within(dialog).getByText('先创建一个智能体,配置完成后点击“开始使用”,就能连接到微信。', { exact: true })).toBeVisible(); - expect(screen.getAllByRole('heading', { name: '还没有可连接的智能体', exact: true, hidden: true })).toHaveLength(1); - expect(within(dialog).getByRole('button', { name: '下一步:扫码连接', exact: true })).toBeDisabled(); - fireEvent.click(within(dialog).getByRole('button', { name: '去我的智能体', exact: true })); - expect(await screen.findByText('智能体列表', { exact: true })).toBeVisible(); - expect(screen.getByTestId('location-path')).toHaveTextContent('/cloud-agents?view=mine'); - expect(writes()).toEqual([]); -}); - -it('waits for the Agent read before showing a preparation hint', async () => { - accounts = []; - let finish!: (value: { agents: CloudAgentDraft[]; next_cursor: null }) => void; - api.list.mockImplementation(() => new Promise(resolve => { finish = resolve; })); - renderPage(); - expect(screen.getByText('正在查看你的微信绑定…', { exact: true })).toBeVisible(); - expect(screen.queryByRole('heading', { name: '还没有可连接的智能体', exact: true })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: '去我的智能体', exact: true })).not.toBeInTheDocument(); - await act(async () => finish({ agents: [], next_cursor: null })); - expect(await screen.findByRole('heading', { name: '还没有可连接的智能体', exact: true })).toBeVisible(); - expect(writes()).toEqual([]); -}); - -it('keeps existing WeChat cards manageable when no connectable Agent remains', async () => { - agents = []; - renderPage(); - expect(await screen.findByRole('heading', { name: '还没有可连接的智能体', exact: true })).toBeVisible(); - expect(screen.getByRole('article', { name: '原有微信微信', exact: true })).toBeVisible(); - expect(screen.getByRole('article', { name: '备用微信微信', exact: true })).toBeVisible(); - const dialog = await manage(); - expect(within(dialog).getByRole('button', { name: '暂停使用', exact: true })).toBeEnabled(); - expect(within(dialog).getByRole('button', { name: '解除绑定', exact: true })).toBeEnabled(); - expect(within(dialog).getByRole('button', { name: '断开微信连接', exact: true })).toBeEnabled(); - fireEvent.click(within(dialog).getByRole('button', { name: '暂停使用', exact: true })); - await waitFor(() => expect(calls('pauseChannelAccount')).toHaveLength(1)); - expect(accounts[0]).toMatchObject({ enabled: false, health: 'connected', target_agent_slug: agentA.slug }); - expect(calls('createChannelAccount')).toHaveLength(0); + const dialog = await chooseExisting(); + expect(within(dialog).getByRole('button', { name: '确认更换' })).toBeDisabled(); + fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name })); expect(calls('routeChannelAccount')).toHaveLength(0); + fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(calls('routeChannelAccount')).toHaveLength(1); + expect(calls('routeChannelAccount')[0][1]).toMatchObject({ + channel_account_id: legacy.id, + target_agent_slug: agentB.slug, + enabled: true, + expected_revision: 7, + }); + expect(await screen.findByText('已更换智能体,微信连接和历史保留。')).toBeVisible(); + expect(calls('channelAccountWechatBindStart')).toHaveLength(0); }); - -it('shows binding cards and never fetches or embeds conversation or activity panels', async () => { +it('switches a paused binding without enabling it', async () => { + accounts[0] = { ...legacy, enabled: false, blockers: ['binding_paused'] }; renderPage(); - const card = await screen.findByRole('article', { name: '原有微信微信', exact: true }); - expect(within(card).getByText(agentA.name, { exact: true })).toBeVisible(); - expect(within(card).getByRole('button', { name: '管理绑定', exact: true })).toBeVisible(); - expect(await screen.findByRole('article', { name: '备用微信微信', exact: true })).toBeVisible(); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(screen.queryByText('微信对话', { exact: true })).not.toBeInTheDocument(); - expect(screen.queryByText('活动记录', { exact: true })).not.toBeInTheDocument(); - expect(screen.queryByRole('heading', { name: '还没有可连接的智能体', exact: true })).not.toBeInTheDocument(); - expect(writes()).toEqual([]); - expect(api.call.mock.calls.every(([operation]) => operation === 'channelAccounts')).toBe(true); -}); - -it('offers only available published Agents and needs a selection before connecting', async () => { - agents.push({ ...agentA, slug: 'ml-' + 'c'.repeat(32), name: '尚未制作好', published_version: null }, - { ...agentA, slug: 'ml-' + 'd'.repeat(32), name: '已停用助手', enabled: false }); - renderPage(); - fireEvent.click(await screen.findByRole('button', { name: '添加微信', exact: true })); - const dialog = screen.getByRole('dialog', { name: '添加微信', exact: true }); - expect(within(dialog).getByRole('button', { name: '下一步:扫码连接', exact: true })).toBeDisabled(); - expect(within(dialog).queryByRole('option', { name: /尚未制作好|已停用助手/ })).not.toBeInTheDocument(); - expect(within(dialog).getByRole('option', { name: agentA.name, exact: true })).toBeInTheDocument(); - expect(writes()).toEqual([]); -}); - -it('creates, associates then requests a QR without enabling the channel', async () => { - renderPage(); const dialog = await addWechat(); - expect(await within(dialog).findByRole('img', { name: '个人微信登录二维码' })).toBeVisible(); - expect(writes().map(([operation]) => operation)).toEqual(['createChannelAccount', 'routeChannelAccount', 'channelAccountWechatBindStart']); - expect(calls('routeChannelAccount')[0][1]).toMatchObject({ channel_account_id: 'wechat-created', target_agent_slug: agentA.slug, expected_revision: 1, enabled: false }); - expect(accounts.find(item => item.id === 'wechat-created')).toMatchObject({ enabled: false }); + expect(await screen.findByText('已暂停')).toBeVisible(); + const dialog = await chooseExisting(); + fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name })); + fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(accounts[0].enabled).toBe(false); + expect(calls('routeChannelAccount')[0][1]).toMatchObject({ enabled: false }); expect(calls('enableChannelAccount')).toHaveLength(0); }); - -it('keeps a pending verification visible and cannot claim usable or enable before confirmation', async () => { - renderPage(); const dialog = await addWechat(); - const input = await within(dialog).findByLabelText('微信验证码', { exact: true }); - fireEvent.change(input, { target: { value: '123456' } }); - fireEvent.click(within(dialog).getByRole('button', { name: '提交验证码', exact: true })); - await waitFor(() => expect(calls('channelAccountWechatBindVerification')).toHaveLength(1)); - expect(within(dialog).getByRole('img', { name: '个人微信登录二维码' })).toBeVisible(); - expect(within(dialog).getByRole('button', { name: '完成绑定并启用', exact: true })).toBeDisabled(); - expect(calls('enableChannelAccount')).toHaveLength(0); -}); - -it('closes a confirmed QR, enables only with an explicit click and reports success afterward', async () => { - verifyStatus = 'confirmed'; - renderPage(); const dialog = await addWechat(); - fireEvent.change(await within(dialog).findByLabelText('微信验证码', { exact: true }), { target: { value: '123456' } }); - fireEvent.click(within(dialog).getByRole('button', { name: '提交验证码', exact: true })); - expect(await within(dialog).findByText('微信已连接', { exact: true })).toBeVisible(); - expect(within(dialog).queryByRole('img', { name: '个人微信登录二维码' })).not.toBeInTheDocument(); - expect(within(dialog).queryByLabelText('微信验证码')).not.toBeInTheDocument(); - expect(calls('enableChannelAccount')).toHaveLength(0); - fireEvent.click(within(dialog).getByRole('button', { name: '完成绑定并启用', exact: true })); - await waitFor(() => expect(calls('enableChannelAccount')).toHaveLength(1)); - expect(await within(dialog).findByRole('button', { name: '完成', exact: true })).toBeEnabled(); - fireEvent.click(within(dialog).getByRole('button', { name: '完成', exact: true })); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(accounts.find(item => item.id === 'wechat-created')).toMatchObject({ enabled: true, health: 'connected', target_agent_slug: agentA.slug }); -}); - -it('does not report binding success when enable fails after the QR is confirmed', async () => { - qrStatus = 'confirmed'; +it('shows a rejected switch inside the still-open choice dialog and preserves the old binding', async () => { const base = api.call.getMockImplementation()!; - api.call.mockImplementation(async (operation, input) => { if (operation === 'enableChannelAccount') throw new Error('启用失败'); return base(operation, input); }); - renderPage(); const dialog = await addWechat(); - fireEvent.click(await within(dialog).findByRole('button', { name: '完成绑定并启用', exact: true })); - expect(await within(dialog).findByText(/启用失败/)).toBeVisible(); - expect(within(dialog).queryByRole('button', { name: '完成', exact: true })).not.toBeInTheDocument(); - expect(accounts.find(item => item.id === 'wechat-created')?.enabled).toBe(false); + api.call.mockImplementation(async (op, input) => { + if (op === 'routeChannelAccount') throw new Error('渠道状态已变化'); + return base(op, input); + }); + renderPage(); + const dialog = await chooseExisting(); + fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name })); + fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' })); + expect(await within(dialog).findByRole('alert')).toHaveTextContent('渠道状态已变化'); + expect(within(dialog).getByRole('radio', { name: agentB.name })).toBeChecked(); + expect(accounts[0].target_agent_slug).toBe(agentA.slug); + expect(screen.getAllByRole('dialog')).toHaveLength(1); }); - -it('ends QR polling once connected and does not automatically enable', async () => { - renderPage(); const dialog = await addWechat(true); - expect(within(dialog).getByRole('img', { name: '个人微信登录二维码' })).toBeVisible(); - qrStatus = 'confirmed'; - await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); - expect(within(dialog).queryByRole('img', { name: '个人微信登录二维码' })).not.toBeInTheDocument(); - expect(within(dialog).getByRole('button', { name: '完成绑定并启用', exact: true })).toBeVisible(); - await act(async () => { await vi.advanceTimersByTimeAsync(6000); }); - expect(calls('channelAccountWechatBindStatus')).toHaveLength(1); +it('never overwrites a final read with a stale switch receipt', async () => { + const base = api.call.getMockImplementation()!; + api.call.mockImplementation(async (op, input) => { + if (op === 'routeChannelAccount') { + await base(op, input); + return { channel: { ...legacy } }; + } + return base(op, input); + }); + renderPage(); + const dialog = await chooseExisting(); + fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name })); + fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(within(await card()).getByText(agentB.name)).toBeVisible(); + expect(within(await card()).getByText('使用中')).toBeVisible(); +}); +it('pauses and resumes with authoritative state, ignoring a stale receipt', async () => { + const base = api.call.getMockImplementation()!; + api.call.mockImplementation(async (op, input) => { + if (op === 'pauseChannelAccount') { + await base(op, input); + return { channel: { ...legacy } }; + } + return base(op, input); + }); + renderPage(); + fireEvent.click(within(await more()).getByRole('button', { name: '暂停自动回复' })); + expect(await screen.findByText('已暂停')).toBeVisible(); + const menu = await more(); + expect(within(menu).getByRole('button', { name: '恢复自动回复' })).toBeEnabled(); + fireEvent.click(within(menu).getByRole('button', { name: '恢复自动回复' })); + expect(await screen.findByText('使用中')).toBeVisible(); + expect(calls('enableChannelAccount')[0][1]).toMatchObject({ expected_revision: 8 }); + expect(calls('routeChannelAccount')).toHaveLength(0); +}); +it('uses unified WeChat unbind after one explicit confirmation and preserves the account card', async () => { + renderPage(); + fireEvent.click(within(await more()).getByRole('button', { name: '解除微信绑定' })); + const dialog = screen.getByRole('dialog', { name: '解除微信绑定' }); + expect(within(dialog).getByText(/历史仍保留/)).toBeVisible(); + expect(calls('unbindChannelAccount')).toHaveLength(0); + fireEvent.click(within(dialog).getByRole('button', { name: '确认解除微信绑定' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(within(await card()).getByText('未绑定')).toBeVisible(); + expect(within(await card()).getByRole('button', { name: '绑定智能体' })).toBeVisible(); + expect(calls('unbindChannelAccount')[0][1]).toMatchObject({ + channel_account_id: legacy.id, + expected_revision: 7, + }); + expect(calls('disconnectChannelAccount')).toHaveLength(0); + expect(calls('routeChannelAccount')).toHaveLength(0); +}); +it('shows unbind errors in the current dialog and never reports success from an incomplete receipt', async () => { + const base = api.call.getMockImplementation()!; + api.call.mockImplementation(async (op, input) => + op === 'unbindChannelAccount' + ? { channel: { ...legacy }, unbound: false, disconnected: false } + : base(op, input) + ); + renderPage(); + fireEvent.click(within(await more()).getByRole('button', { name: '解除微信绑定' })); + const dialog = screen.getByRole('dialog', { name: '解除微信绑定' }); + fireEvent.click(within(dialog).getByRole('button', { name: '确认解除微信绑定' })); + expect(await within(dialog).findByRole('alert')).toHaveTextContent('解除结果还没有确认'); + expect(within(dialog).getByRole('button', { name: '确认解除微信绑定' })).toBeDisabled(); + expect(screen.queryByText('微信绑定已解除,历史仍保留。')).not.toBeInTheDocument(); + fireEvent.click(within(dialog).getByRole('button', { name: '取消' })); + expect(within(await card()).getByText('正在确认')).toBeVisible(); + expect(within(await card()).getByRole('button', { name: '更换智能体' })).toBeDisabled(); +}); +it('does not enable a pending or verification-required QR', async () => { + renderPage(); + const dialog = await adding(); + expect(await within(dialog).findByRole('img', { name: '个人微信登录二维码' })).toBeVisible(); + expect(calls('createChannelAccount')).toHaveLength(1); + expect(calls('routeChannelAccount')[0][1]).toMatchObject({ + target_agent_slug: agentA.slug, + enabled: false, + }); + expect(calls('enableChannelAccount')).toHaveLength(0); + expect(within(dialog).queryByRole('button', { name: '完成绑定并启用' })).not.toBeInTheDocument(); + fireEvent.change(within(dialog).getByLabelText('微信验证码'), { target: { value: '123456' } }); + await act(async () => fireEvent.click(within(dialog).getByRole('button', { name: '提交验证码' }))); + expect(await within(dialog).findByText('等待微信确认')).toBeVisible(); expect(calls('enableChannelAccount')).toHaveLength(0); }); - -it('changes an existing association only after confirmation and keeps its login and enabled state', async () => { - renderPage(); const dialog = await manage(); - fireEvent.change(within(dialog).getByLabelText('绑定的智能体', { exact: true }), { target: { value: agentB.slug } }); - fireEvent.click(within(dialog).getByRole('button', { name: '保存更换', exact: true })); - expect(calls('routeChannelAccount')).toHaveLength(0); - const confirmation = screen.getByRole('dialog', { name: '更换绑定的智能体', exact: true }); - fireEvent.click(within(confirmation).getByRole('button', { name: /确认更换/ })); - await waitFor(() => expect(calls('routeChannelAccount')).toHaveLength(1)); - expect(calls('routeChannelAccount')[0][1]).toMatchObject({ channel_account_id: legacy.id, target_agent_slug: agentB.slug, expected_revision: 7, enabled: true }); - expect(calls('createChannelAccount')).toHaveLength(0); - expect(calls('channelAccountWechatBindStart')).toHaveLength(0); - expect(accounts[0].health).toBe('connected'); +it('automatically enables once after genuine QR confirmation and final readback', async () => { + qrStatus = 'confirmed'; + renderPage(); + const dialog = await adding(); + expect(await within(dialog).findByRole('heading', { name: '微信绑定成功' })).toBeVisible(); + expect(calls('enableChannelAccount')).toHaveLength(1); + expect(calls('enableChannelAccount')[0][1]).toMatchObject({ expected_revision: 2 }); + expect(within(dialog).queryByRole('button', { name: '完成绑定并启用' })).not.toBeInTheDocument(); + expect(calls('channelAccounts').length).toBeGreaterThanOrEqual(5); }); - -it('lets independent WeChat accounts choose the same Agent without creating another slot', async () => { - accounts[1] = { ...spare, health: 'connected', target_agent_slug: agentB.slug, target_agent_name: agentB.name, target_agent_published_version: 4 }; - renderPage(); const dialog = await manage(spare.display_name); - fireEvent.change(within(dialog).getByLabelText('绑定的智能体', { exact: true }), { target: { value: agentA.slug } }); - fireEvent.click(within(dialog).getByRole('button', { name: '保存更换', exact: true })); - const confirmation = screen.getByRole('dialog', { name: '更换绑定的智能体', exact: true }); - fireEvent.click(within(confirmation).getByRole('button', { name: /确认更换/ })); - await waitFor(() => expect(calls('routeChannelAccount')).toHaveLength(1)); - expect(accounts.every(item => item.target_agent_slug === agentA.slug)).toBe(true); - expect(accounts[1].enabled).toBe(false); - expect(calls('createChannelAccount')).toHaveLength(0); - expect(calls('channelAccountWechatBindStart')).toHaveLength(0); +it('a confirmed status without authoritative login cannot enable or claim success', async () => { + const base = api.call.getMockImplementation()!; + api.call.mockImplementation(async (op, input) => + op === 'channelAccountWechatBindStart' ? { session_key: 'qr', status: 'confirmed' } : base(op, input) + ); + renderPage(); + const dialog = await adding(); + expect(await within(dialog).findByRole('alert')).toHaveTextContent('微信还没有准备好'); + expect(within(dialog).queryByRole('heading', { name: '微信绑定成功' })).not.toBeInTheDocument(); + expect(calls('enableChannelAccount')).toHaveLength(0); + expect(within(dialog).getByRole('button', { name: '重试完成绑定' })).toBeEnabled(); }); - -it('pauses and resumes an existing binding without clearing its target or login', async () => { - renderPage(); const dialog = await manage(); - fireEvent.click(within(dialog).getByRole('button', { name: '暂停使用', exact: true })); - expect(await within(dialog).findByRole('button', { name: '恢复使用', exact: true })).toBeEnabled(); - expect(accounts[0]).toMatchObject({ enabled: false, health: 'connected', target_agent_slug: agentA.slug }); - fireEvent.click(within(dialog).getByRole('button', { name: '恢复使用', exact: true })); - await within(dialog).findByRole('button', { name: '暂停使用', exact: true }); - expect(calls('pauseChannelAccount')[0][1]).toMatchObject({ channel_account_id: legacy.id, expected_revision: 7 }); - expect(calls('enableChannelAccount')[0][1]).toMatchObject({ channel_account_id: legacy.id, expected_revision: 8 }); - expect(calls('routeChannelAccount')).toHaveLength(0); - expect(calls('channelAccountWechatBindStart')).toHaveLength(0); -}); - -it('clears an association only after confirmation and preserves the WeChat login', async () => { - renderPage(); const dialog = await manage(); - fireEvent.click(within(dialog).getByRole('button', { name: '解除绑定', exact: true })); - expect(calls('routeChannelAccount')).toHaveLength(0); - const confirmation = screen.getByRole('dialog', { name: '解除智能体绑定', exact: true }); - fireEvent.click(within(confirmation).getByRole('button', { name: '确认解除', exact: true })); - await waitFor(() => expect(calls('routeChannelAccount')).toHaveLength(1)); - expect(calls('routeChannelAccount')[0][1]).toMatchObject({ channel_account_id: legacy.id, target_agent_slug: null, enabled: false, expected_revision: 7 }); - expect(accounts[0]).toMatchObject({ health: 'connected', target_agent_slug: null, enabled: false }); - expect(calls('disconnectChannelAccount')).toHaveLength(0); - expect(calls('channelAccountWechatUnbind')).toHaveLength(0); -}); - -it('disconnects only after confirmation and retains the selected Agent and account card', async () => { - renderPage(); const dialog = await manage(); - fireEvent.click(within(dialog).getByRole('button', { name: '断开微信连接', exact: true })); - expect(calls('disconnectChannelAccount')).toHaveLength(0); - const confirmation = screen.getByRole('dialog', { name: '断开微信连接', exact: true }); - fireEvent.click(within(confirmation).getByRole('button', { name: '确认断开', exact: true })); - await waitFor(() => expect(calls('disconnectChannelAccount')).toHaveLength(1)); - expect(accounts[0]).toMatchObject({ enabled: false, health: 'login_required', target_agent_slug: agentA.slug }); - expect(calls('routeChannelAccount')).toHaveLength(0); - await within(dialog).findByRole('button', { name: '扫码连接', exact: true }); -}); - -it('retries a failed association without creating another account and reuses the original request', async () => { +it('a failed automatic enable offers explicit retry without an automatic loop', async () => { + qrStatus = 'confirmed'; const base = api.call.getMockImplementation()!; let attempts = 0; - api.call.mockImplementation(async (operation, input) => { if (operation === 'routeChannelAccount' && ++attempts === 1) throw new Error('绑定未完成'); return base(operation, input); }); - renderPage(); const dialog = await addWechat(); - expect(await within(dialog).findByText('绑定未完成', { exact: true })).toBeVisible(); - expect(calls('createChannelAccount')).toHaveLength(1); - expect(calls('channelAccountWechatBindStart')).toHaveLength(0); - fireEvent.click(within(dialog).getByRole('button', { name: '下一步:扫码连接', exact: true })); + api.call.mockImplementation(async (op, input) => { + if (op === 'enableChannelAccount' && ++attempts === 1) throw new Error('启用暂时失败'); + return base(op, input); + }); + renderPage(); + const dialog = await adding(); + expect(await within(dialog).findByRole('alert')).toHaveTextContent('启用暂时失败'); + expect(calls('enableChannelAccount')).toHaveLength(1); + fireEvent.click(within(dialog).getByRole('button', { name: '重试完成绑定' })); + expect(await within(dialog).findByRole('heading', { name: '微信绑定成功' })).toBeVisible(); + expect(calls('enableChannelAccount')).toHaveLength(2); + expect(calls('enableChannelAccount')[0][1]).toEqual(calls('enableChannelAccount')[1][1]); +}); +it('stale enable receipts cannot conceal a still-disabled authoritative account', async () => { + qrStatus = 'confirmed'; + const base = api.call.getMockImplementation()!; + api.call.mockImplementation(async (op, input) => + op === 'enableChannelAccount' + ? { channel: { ...accounts.find((item) => item.id === input.channel_account_id), enabled: true } } + : base(op, input) + ); + renderPage(); + const dialog = await adding(); + expect(await within(dialog).findByRole('alert')).toHaveTextContent('操作结果还没有确认'); + expect(within(dialog).queryByRole('heading', { name: '微信绑定成功' })).not.toBeInTheDocument(); + fireEvent.click(within(dialog).getByRole('button', { name: '稍后继续' })); + const current = await card('微信 3'); + expect(within(current).getByText('正在确认')).toBeVisible(); + expect(within(current).queryByText('使用中')).not.toBeInTheDocument(); +}); +it('does not enable a different Agent or an offline account after QR confirmation', async () => { + qrStatus = 'confirmed'; + const base = api.call.getMockImplementation()!; + api.call.mockImplementation(async (op, input) => { + const result = await base(op, input); + if (op === 'channelAccountWechatBindStart') + accounts.find((item) => item.id === input.channel_account_id)!.target_agent_slug = agentB.slug; + return result; + }); + renderPage(); + const dialog = await adding(); + expect(await within(dialog).findByRole('alert')).toHaveTextContent('绑定的智能体已发生变化'); + expect(calls('enableChannelAccount')).toHaveLength(0); +}); +it('never auto-enables or replays old paused records on page entry or refresh', async () => { + accounts[0] = { ...legacy, enabled: false, blockers: ['binding_paused'] }; + renderPage(); + await screen.findByText('已暂停'); + fireEvent.click(screen.getByRole('button', { name: '刷新微信列表' })); + await waitFor(() => expect(calls('channelAccounts')).toHaveLength(2)); + expect(writes()).toEqual([]); +}); +it('reconnects an old paused binding while keeping automatic replies paused', async () => { + accounts[0] = { ...legacy, health: 'login_required', enabled: false, blockers: ['binding_paused'] }; + qrStatus = 'confirmed'; + renderPage(); + const current = await card(); + fireEvent.click(within(current).getByRole('button', { name: '重新扫码' })); + const dialog = screen.getByRole('dialog', { name: '重新连接微信' }); + expect(await within(dialog).findByRole('heading', { name: '微信重新连接成功' })).toBeVisible(); + expect(within(dialog).getByText('微信已连接,自动回复仍保持暂停。')).toBeVisible(); + expect(calls('enableChannelAccount')).toHaveLength(0); + expect(calls('routeChannelAccount')).toHaveLength(0); +}); +it('stops polling after a QR confirmation and deduplicates activation', async () => { + renderPage(); + await screen.findByRole('button', { name: '添加微信' }); + vi.useFakeTimers({ shouldAdvanceTime: true }); + const dialog = await adding(); + await within(dialog).findByRole('img', { name: '个人微信登录二维码' }); + qrStatus = 'confirmed'; + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(within(dialog).getByRole('heading', { name: '微信绑定成功' })).toBeVisible(); + await act(async () => { + await vi.advanceTimersByTimeAsync(6000); + }); + expect(calls('enableChannelAccount')).toHaveLength(1); + expect(calls('channelAccountWechatBindStatus')).toHaveLength(1); +}); +it('does not create an enable request after unmount during the authoritative QR read', async () => { + qrStatus = 'confirmed'; + const base = api.call.getMockImplementation()!; + let release: (() => void) | undefined; + api.call.mockImplementation(async (op, input) => { + if (op === 'channelAccounts' && calls('channelAccountWechatBindStart').length) + await new Promise((resolve) => { + release = resolve; + }); + return base(op, input); + }); + const view = renderPage(); + await adding(); + await waitFor(() => expect(release).toBeTypeOf('function')); + view.unmount(); + await act(async () => release!()); + expect(calls('enableChannelAccount')).toHaveLength(0); +}); +it('closing a pending QR does not later enable it or leave polling alive', async () => { + renderPage(); + const dialog = await adding(); + await within(dialog).findByRole('img', { name: '个人微信登录二维码' }); + vi.useFakeTimers(); + fireEvent.click(within(dialog).getByRole('button', { name: '稍后继续' })); + qrStatus = 'confirmed'; + await act(async () => { + await vi.advanceTimersByTimeAsync(6000); + }); + expect(calls('channelAccountWechatBindStatus')).toHaveLength(0); + expect(calls('enableChannelAccount')).toHaveLength(0); +}); +it('retries an unsuccessful new route with the same operation, without creating another account', async () => { + const base = api.call.getMockImplementation()!; + let attempts = 0; + api.call.mockImplementation(async (op, input) => { + if (op === 'routeChannelAccount' && ++attempts === 1) throw new Error('绑定未完成'); + return base(op, input); + }); + renderPage(); + const dialog = await adding(); + expect(await within(dialog).findByRole('alert')).toHaveTextContent('绑定未完成'); + fireEvent.click(within(dialog).getByRole('button', { name: '下一步:扫码连接' })); await within(dialog).findByRole('img', { name: '个人微信登录二维码' }); expect(calls('createChannelAccount')).toHaveLength(1); - expect(calls('routeChannelAccount')).toHaveLength(2); expect(calls('routeChannelAccount')[0][1]).toEqual(calls('routeChannelAccount')[1][1]); }); +it('represents a pending operation honestly and prevents conflicting changes until recovery', async () => { + pending = [ + { + id: 'unknown-pause', + operation: 'pauseChannelAccount', + input: { channel_account_id: legacy.id, expected_revision: 7, operation_id: 'original' }, + created_at: '', + }, + ]; + renderPage(); + const current = await card(); + expect(within(current).getByText('正在确认')).toBeVisible(); + expect(within(current).getByRole('button', { name: '更换智能体' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '添加微信' })).toBeDisabled(); + expect(writes()).toEqual([]); + pending = []; + accounts[0] = { ...legacy, enabled: false, blockers: ['binding_paused'] }; + fireEvent.click(within(current).getByRole('button', { name: '刷新确认' })); + expect(await screen.findByText('已暂停')).toBeVisible(); + expect(within(current).getByRole('button', { name: '更换智能体' })).toBeEnabled(); + expect(writes()).toEqual([]); +}); +it('does not paint local paused or degraded synchronization as usable', async () => { + accounts = [ + { ...legacy, enabled: false, desired_state: 'paused', blockers: ['binding_paused'] }, + { + ...spare, + target_agent_slug: agentA.slug, + health: 'connected', + enabled: true, + sync_state: 'degraded', + blockers: ['channel_sync_degraded'], + }, + ]; + renderPage(); + expect(await screen.findByText('已暂停')).toBeVisible(); + expect(within(await card(spare.display_name)).getByText('正在确认')).toBeVisible(); + expect(screen.queryByText('使用中')).not.toBeInTheDocument(); +}); +it('deep links open the single choice dialog and refresh preserves user choice', async () => { + renderPage('/cloud-agents/channels?account=' + legacy.id + '&target=' + agentB.slug); + const dialog = await screen.findByRole('dialog', { name: '更换智能体' }); + expect(within(dialog).getByRole('radio', { name: agentB.name })).toBeChecked(); + expect(screen.getByTestId('location-path')).not.toHaveTextContent('target='); + expect(writes()).toEqual([]); +}); +it.each(['accounts', 'agents'])( + 'keeps failed %s loading distinct from an empty list', + async (source) => { + if (source === 'accounts') api.call.mockRejectedValue(new Error('微信列表暂时不可用')); + else api.list.mockRejectedValue(new Error('智能体列表暂时不可用')); + renderPage(); + expect(await screen.findByRole('alert')).toHaveTextContent('暂时不可用'); + expect(screen.getByRole('button', { name: '添加微信' })).toBeDisabled(); + expect(screen.queryByRole('heading', { name: '把你的智能体带到微信吧' })).not.toBeInTheDocument(); + } +); -it('continues an unfinished account without creating another account slot', async () => { +it('locks changing an inconsistent local paused route without preventing safe unbind', async () => { + accounts = [{ ...legacy, desired_state: 'paused', enabled: true }]; + renderPage(); + const current = await card(); + expect(within(current).getByText('正在确认')).toBeVisible(); + expect(within(current).queryByText('使用中')).not.toBeInTheDocument(); + expect(within(current).getByRole('button', { name: '更换智能体' })).toBeDisabled(); + fireEvent.click(within(current).getByRole('button', { name: '更多操作' })); + expect(within(current).getByRole('button', { name: '解除微信绑定' })).toBeEnabled(); +}); + +it('loads all Agent pages and offers only published enabled targets', async () => { + api.list.mockImplementation(async (cursor) => + cursor + ? { + agents: [ + agentB, + { ...agentA, slug: 'ml-' + 'c'.repeat(32), name: '未发布', published_version: null }, + ], + next_cursor: null, + } + : { agents: [agentA], next_cursor: 'page-2' } + ); + renderPage(); + const dialog = await chooseExisting(); + expect(within(dialog).getByRole('radio', { name: agentA.name })).toBeVisible(); + expect(within(dialog).getByRole('radio', { name: agentB.name })).toBeVisible(); + expect(within(dialog).queryByRole('radio', { name: '未发布' })).not.toBeInTheDocument(); + expect(api.list).toHaveBeenNthCalledWith(2, 'page-2'); +}); + +it('keeps existing cards removable when no available Agent remains', async () => { + agents = []; + renderPage(); + const current = await more(); + fireEvent.click(within(current).getByRole('button', { name: '解除微信绑定' })); + const dialog = screen.getByRole('dialog', { name: '解除微信绑定' }); + expect(within(dialog).getByRole('button', { name: '确认解除微信绑定' })).toBeEnabled(); + expect(writes()).toEqual([]); +}); + +it.each(['expired', 'failed'] as const)( + 'offers an explicit QR refresh after %s without claiming success', + async (status) => { + qrStatus = status; + renderPage(); + const dialog = await adding(); + expect(await within(dialog).findByRole('button', { name: '刷新二维码' })).toBeEnabled(); + expect(calls('enableChannelAccount')).toHaveLength(0); + qrStatus = 'confirmed'; + fireEvent.click(within(dialog).getByRole('button', { name: '刷新二维码' })); + await within(dialog).findByRole('heading', { name: '微信绑定成功' }); + expect(calls('channelAccountWechatBindStart')[1][1]).toMatchObject({ force: true }); + expect(calls('enableChannelAccount')).toHaveLength(1); + } +); + +it('continues an existing unfinished account without creating another account slot', async () => { accounts = [{ ...spare, health: 'connected' }]; - renderPage(); const dialog = await manage(spare.display_name); - fireEvent.change(within(dialog).getByLabelText('绑定的智能体', { exact: true }), { target: { value: agentA.slug } }); - fireEvent.click(within(dialog).getByRole('button', { name: '下一步:扫码连接', exact: true })); - expect(await within(dialog).findByRole('button', { name: '完成绑定并启用', exact: true })).toBeEnabled(); + renderPage(); + const dialog = await chooseExisting(spare.display_name); + fireEvent.click(within(dialog).getByRole('radio', { name: agentA.name })); + fireEvent.click(within(dialog).getByRole('button', { name: '下一步:扫码连接' })); + await within(dialog).findByRole('heading', { name: '微信绑定成功' }); expect(calls('createChannelAccount')).toHaveLength(0); expect(calls('channelAccountWechatBindStart')).toHaveLength(0); - expect(calls('routeChannelAccount')[0][1]).toMatchObject({ channel_account_id: spare.id, target_agent_slug: agentA.slug }); + expect(calls('enableChannelAccount')).toHaveLength(1); }); -it('does not erase the old association or claim success after a rejected change', async () => { - const base = api.call.getMockImplementation()!; - api.call.mockImplementation(async (operation, input) => { if (operation === 'routeChannelAccount') throw new Error('渠道状态已变化'); return base(operation, input); }); - renderPage(); const dialog = await manage(); - fireEvent.change(within(dialog).getByLabelText('绑定的智能体', { exact: true }), { target: { value: agentB.slug } }); - fireEvent.click(within(dialog).getByRole('button', { name: '保存更换', exact: true })); - fireEvent.click(within(screen.getByRole('dialog', { name: '更换绑定的智能体', exact: true })).getByRole('button', { name: '确认更换', exact: true })); - await screen.findByText('渠道状态已变化', { exact: true }); - expect(accounts[0].target_agent_slug).toBe(agentA.slug); - expect(within(dialog).getByLabelText('绑定的智能体', { exact: true })).toHaveValue(agentB.slug); - expect(screen.queryByText('已更换绑定的智能体,微信连接保留。')).not.toBeInTheDocument(); -}); - - -it.each(['failed', 'expired'] as const)('cannot enable an unsuccessful %s QR connection and can explicitly refresh it', async (status) => { - qrStatus = status; - renderPage(); const dialog = await addWechat(); - expect(await within(dialog).findByRole('button', { name: '刷新二维码', exact: true })).toBeEnabled(); - expect(within(dialog).getByRole('button', { name: '完成绑定并启用', exact: true })).toBeDisabled(); - expect(calls('enableChannelAccount')).toHaveLength(0); +it('does not enable an offline Worker after confirmed QR login', async () => { qrStatus = 'confirmed'; - fireEvent.click(within(dialog).getByRole('button', { name: '刷新二维码', exact: true })); - expect(await within(dialog).findByText('微信已连接', { exact: true })).toBeVisible(); - expect(calls('channelAccountWechatBindStart')[1][1]).toMatchObject({ channel_account_id: 'wechat-created', force: true }); + const base = api.call.getMockImplementation()!; + api.call.mockImplementation(async (op, input) => { + const result = await base(op, input); + if (op === 'channelAccountWechatBindStart') + accounts.find((item) => item.id === input.channel_account_id)!.blockers = ['worker_offline']; + return result; + }); + renderPage(); + const dialog = await adding(); + expect(await within(dialog).findByRole('alert')).toHaveTextContent('微信还没有准备好'); expect(calls('enableChannelAccount')).toHaveLength(0); }); -it('does not call a blocked or unconfirmed existing account ready to use', async () => { - accounts = [{ ...legacy, health: 'unknown' }, { ...legacy, id: spare.id, display_name: spare.display_name, blockers: ['worker_offline'] }]; +it('recovers the original uncertain operation through the existing recovery control', async () => { + pending = [ + { + id: 'original-unbind-record', + operation: 'unbindChannelAccount', + input: { channel_account_id: legacy.id, operation_id: 'original-op', expected_revision: 7 }, + created_at: '2026-10-13T00:00:00Z', + }, + ]; + api.resolvePending.mockImplementation(async (id) => { + expect(id).toBe('original-unbind-record'); + Object.assign(accounts[0], { target_agent_slug: null, enabled: false, health: 'login_required' }); + pending = []; + return { result: { unbound: true, disconnected: true }, operation: 'unbindChannelAccount' }; + }); renderPage(); - const unknown = await screen.findByRole('article', { name: '原有微信微信', exact: true }); - const blocked = screen.getByRole('article', { name: '备用微信微信', exact: true }); - expect(within(unknown).queryByText('可以使用', { exact: true })).not.toBeInTheDocument(); - expect(within(blocked).queryByText('可以使用', { exact: true })).not.toBeInTheDocument(); - expect(calls('enableChannelAccount')).toHaveLength(0); + await card(); + fireEvent.click(screen.getByRole('button', { name: '待处理记录(1)' })); + const recovery = screen.getByRole('dialog', { name: '待处理记录' }); + fireEvent.click(within(recovery).getByRole('button', { name: '再试一次' })); + await within(recovery).findByText('解除微信绑定的结果已确认。'); + fireEvent.click(within(recovery).getByRole('button', { name: '返回列表' })); + expect(within(await card()).getByText('未绑定')).toBeVisible(); + expect(api.resolvePending).toHaveBeenCalledWith('original-unbind-record', false); + expect(writes()).toEqual([]); }); - -it.each(['accounts', 'agents'])('keeps adding unavailable after a failed initial %s read instead of showing a false empty list', async source => { - agents = []; - const message = source === 'accounts' ? '微信列表暂时不可用' : '智能体列表暂时不可用'; - if (source === 'accounts') api.call.mockRejectedValue(new Error(message)); - else api.list.mockRejectedValue(new Error(message)); +it('retains an uncertain badge when final route synchronization is degraded', async () => { + const base = api.call.getMockImplementation()!; + api.call.mockImplementation(async (op, input) => { + const result = await base(op, input); + if (op === 'routeChannelAccount') accounts[0].sync_state = 'degraded'; + return result; + }); renderPage(); - await screen.findByText(message, { exact: true }); - expect(screen.getByRole('button', { name: '添加微信', exact: true })).toBeDisabled(); - expect(screen.queryByText('还没有微信绑定', { exact: true })).not.toBeInTheDocument(); - expect(screen.queryByRole('heading', { name: '还没有可连接的智能体', exact: true })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: '去我的智能体', exact: true })).not.toBeInTheDocument(); - installMock(); - api.list.mockImplementation(async () => ({ agents, next_cursor: null })); - fireEvent.click(screen.getByRole('button', { name: '刷新微信列表', exact: true })); - expect(await screen.findByRole('article', { name: '原有微信微信', exact: true })).toBeVisible(); - expect(screen.getByRole('button', { name: '添加微信', exact: true })).toBeEnabled(); + const dialog = await chooseExisting(); + fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name })); + fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' })); + expect(await within(dialog).findByRole('alert')).toHaveTextContent('操作结果还没有确认'); + expect(screen.queryByText('已更换智能体,微信连接和历史保留。')).not.toBeInTheDocument(); + fireEvent.click(within(dialog).getByRole('button', { name: '关闭' })); + expect(within(await card()).getByText('正在确认')).toBeVisible(); }); -it('allows the optional WeChat remark to stay empty', async () => { +it('ignores an older manual refresh that arrives after the final switch read', async () => { + const base = api.call.getMockImplementation()!; + const original = accounts.map((account) => ({ ...account })); + let releaseRefresh: (() => void) | undefined; + api.call.mockImplementation(async (op, input) => { + if (op === 'channelAccounts' && calls('channelAccounts').length === 2) { + await new Promise((resolve) => { + releaseRefresh = resolve; + }); + return { items: original }; + } + return base(op, input); + }); renderPage(); - fireEvent.click(await screen.findByRole('button', { name: '添加微信', exact: true })); - const dialog = screen.getByRole('dialog', { name: '添加微信', exact: true }); - fireEvent.change(within(dialog).getByLabelText('选择智能体', { exact: true }), { target: { value: agentA.slug } }); - fireEvent.click(within(dialog).getByRole('button', { name: '下一步:扫码连接', exact: true })); - await within(dialog).findByRole('img', { name: '个人微信登录二维码' }); - expect(calls('createChannelAccount')).toHaveLength(1); - expect(calls('createChannelAccount')[0][1].display_name).toMatch(/\S/); + await card(); + fireEvent.click(screen.getByRole('button', { name: '刷新微信列表' })); + await waitFor(() => expect(releaseRefresh).toBeTypeOf('function')); + const dialog = await chooseExisting(); + fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name })); + fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(within(await card()).getByText(agentB.name)).toBeVisible(); + await act(async () => releaseRefresh!()); + expect(within(await card()).getByText(agentB.name)).toBeVisible(); + expect(within(await card()).queryByText(agentA.name)).not.toBeInTheDocument(); }); - -it('completes a reconnected already enabled account without sending another enable request', async () => { - accounts = [{ ...legacy, health: 'login_required' }]; qrStatus = 'confirmed'; - renderPage(); const dialog = await manage(); - fireEvent.click(within(dialog).getByRole('button', { name: '扫码连接', exact: true })); - expect(await within(dialog).findByText('微信已连接', { exact: true })).toBeVisible(); - fireEvent.click(within(dialog).getByRole('button', { name: '完成绑定并启用', exact: true })); - expect(await within(dialog).findByRole('button', { name: '完成', exact: true })).toBeVisible(); - expect(calls('enableChannelAccount')).toHaveLength(0); - expect(calls('createChannelAccount')).toHaveLength(0); +it('preserves owned cards and safe pause/unbind when the initial Agent listing fails', async () => { + api.list.mockRejectedValue(new Error('智能体列表暂时不可用')); + renderPage(); + const current = await card(); + expect(within(current).getByText(agentA.name)).toBeVisible(); + expect(within(current).getByRole('button', { name: '更换智能体' })).toBeDisabled(); + const menu = await more(); + expect(within(menu).getByRole('button', { name: '暂停自动回复' })).toBeEnabled(); + expect(within(menu).getByRole('button', { name: '解除微信绑定' })).toBeEnabled(); + fireEvent.click(within(menu).getByRole('button', { name: '解除微信绑定' })); + const dialog = screen.getByRole('dialog', { name: '解除微信绑定' }); + fireEvent.click(within(dialog).getByRole('button', { name: '确认解除微信绑定' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(within(await card()).getByText('未绑定')).toBeVisible(); + expect(calls('unbindChannelAccount')).toHaveLength(1); expect(calls('routeChannelAccount')).toHaveLength(0); }); -it.each(['channelAccountWechatBindStart', 'channelAccountWechatBindVerification'])('blocks new slots and QR actions while %s has an uncertain result', async operation => { - accounts = [{ ...legacy, health: 'login_required' }]; - api.recovery.mockResolvedValue({ recent: null, pending: [{ id: 'uncertain-wechat', operation, input: { channel_account_id: legacy.id }, created_at: '2026-10-12T00:00:00Z' }] }); - renderPage('/cloud-agents/channels?account=' + legacy.id); - const dialog = await screen.findByRole('dialog', { name: '管理微信绑定', exact: true }); - expect(screen.getByRole('button', { name: '添加微信', exact: true, hidden: true })).toBeDisabled(); - expect(within(dialog).getByRole('button', { name: '扫码连接', exact: true })).toBeDisabled(); - fireEvent.click(within(dialog).getByRole('button', { name: '扫码连接', exact: true })); - expect(calls('channelAccountWechatBindStart')).toHaveLength(0); - expect(calls('createChannelAccount')).toHaveLength(0); - expect(calls('enableChannelAccount')).toHaveLength(0); -}); - -it('uses the latest account state when enabling after QR confirmation', async () => { - qrStatus = 'confirmed'; renderPage(); const dialog = await addWechat(); - await within(dialog).findByText('微信已连接', { exact: true }); - const created = accounts.find(item => item.id === 'wechat-created')!; - created.revision = 77; - fireEvent.click(within(dialog).getByRole('button', { name: '完成绑定并启用', exact: true })); - await within(dialog).findByRole('button', { name: '完成', exact: true }); - expect(calls('enableChannelAccount')[0][1]).toMatchObject({ channel_account_id: 'wechat-created', expected_revision: 77 }); -}); - -it('does not enable when the authoritative account has become blocked after QR confirmation', async () => { - qrStatus = 'confirmed'; renderPage(); const dialog = await addWechat(); - await within(dialog).findByText('微信已连接', { exact: true }); - accounts.find(item => item.id === 'wechat-created')!.blockers = ['worker_offline']; - fireEvent.click(within(dialog).getByRole('button', { name: '完成绑定并启用', exact: true })); - expect(await within(dialog).findByText('微信还没有准备好,请刷新连接状态后再试。', { exact: true })).toBeVisible(); - expect(calls('enableChannelAccount')).toHaveLength(0); - expect(within(dialog).queryByRole('button', { name: '完成', exact: true })).not.toBeInTheDocument(); -}); - -it('does not silently enable another Agent if the association changes while scanning', async () => { - qrStatus = 'confirmed'; renderPage(); const dialog = await addWechat(); - await within(dialog).findByText('微信已连接', { exact: true }); - const created = accounts.find(item => item.id === 'wechat-created')!; - Object.assign(created, { target_agent_slug: agentB.slug, target_agent_name: agentB.name }); - fireEvent.click(within(dialog).getByRole('button', { name: '完成绑定并启用', exact: true })); - expect(await within(dialog).findByText('绑定的智能体已发生变化,请返回上一步确认。', { exact: true })).toBeVisible(); - expect(calls('enableChannelAccount')).toHaveLength(0); - expect(within(dialog).queryByRole('button', { name: '完成', exact: true })).not.toBeInTheDocument(); -}); - - -it('does not show successful enable or completed binding when the enable receipt remains disabled', async () => { - qrStatus = 'confirmed'; +it('does not let an older refresh erase a new uncertain operation and unlock the card', async () => { const base = api.call.getMockImplementation()!; - api.call.mockImplementation(async (operation, input) => { - if (operation === 'enableChannelAccount') return { channel: { ...accounts.find(item => item.id === input.channel_account_id)!, enabled: false } }; - return base(operation, input); + const original = accounts.map((account) => ({ ...account })); + let releaseRefresh: (() => void) | undefined; + api.recovery.mockImplementation(async () => ({ recent: null, pending: [...pending] })); + api.call.mockImplementation(async (op, input) => { + if (op === 'channelAccounts' && calls('channelAccounts').length === 2) { + await new Promise((resolve) => { + releaseRefresh = resolve; + }); + return { items: original }; + } + if (op === 'routeChannelAccount') { + pending = [ + { + id: 'new-uncertain-route', + operation: 'routeChannelAccount', + input: { ...input }, + created_at: '2026-10-13T00:00:00Z', + }, + ]; + throw new Error('还没有确认切换结果'); + } + return base(op, input); }); - renderPage(); const dialog = await addWechat(); - fireEvent.click(await within(dialog).findByRole('button', { name: '完成绑定并启用', exact: true })); - await within(dialog).findByText('微信还没有准备好,请刷新连接状态后再试。', { exact: true }); - expect(calls('enableChannelAccount')).toHaveLength(1); - expect(within(dialog).queryByRole('heading', { name: '绑定完成啦', exact: true })).not.toBeInTheDocument(); - expect(within(dialog).queryByRole('button', { name: '完成', exact: true })).not.toBeInTheDocument(); - expect(screen.queryByText('微信绑定已启用。', { exact: true })).not.toBeInTheDocument(); - expect(accounts.find(item => item.id === 'wechat-created')?.enabled).toBe(false); + renderPage(); + await card(); + fireEvent.click(screen.getByRole('button', { name: '刷新微信列表' })); + await waitFor(() => expect(releaseRefresh).toBeTypeOf('function')); + const dialog = await chooseExisting(); + fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name })); + fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' })); + expect(await within(dialog).findByRole('alert')).toHaveTextContent('还没有确认切换结果'); + await within(dialog).findByText('上次操作的结果还需要确认。请关闭窗口,处理页面上的恢复提醒。'); + fireEvent.click(within(dialog).getByRole('button', { name: '关闭' })); + expect(within(await card()).getByText('正在确认')).toBeVisible(); + await act(async () => releaseRefresh!()); + expect(within(await card()).getByText('正在确认')).toBeVisible(); + expect(within(await card()).getByRole('button', { name: '更换智能体' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '待处理记录(1)' })).toBeVisible(); + expect(calls('routeChannelAccount')).toHaveLength(1); +}); + +it('keeps an uncertain operation locked when its recovery read is also unavailable', async () => { + const base = api.call.getMockImplementation()!; + api.recovery + .mockResolvedValueOnce({ recent: null, pending: [] }) + .mockRejectedValue(new Error('恢复状态暂时不可用')); + api.call.mockImplementation(async (op, input) => { + if (op === 'routeChannelAccount') throw new Error('服务器响应还没有收到'); + return base(op, input); + }); + renderPage(); + const dialog = await chooseExisting(); + fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name })); + fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' })); + expect(await within(dialog).findByRole('alert')).toHaveTextContent('服务器响应还没有收到'); + await waitFor(() => expect(within(dialog).getByRole('button', { name: '确认更换' })).toBeDisabled()); + fireEvent.click(within(dialog).getByRole('button', { name: '关闭' })); + expect(within(await card()).getByText('正在确认')).toBeVisible(); + expect(within(await card()).getByRole('button', { name: '更换智能体' })).toBeDisabled(); + expect(calls('routeChannelAccount')).toHaveLength(1); }); diff --git a/tests/unit/cloud-agents-main.test.ts b/tests/unit/cloud-agents-main.test.ts index dc1b8139..ab9991ca 100644 --- a/tests/unit/cloud-agents-main.test.ts +++ b/tests/unit/cloud-agents-main.test.ts @@ -592,6 +592,68 @@ describe('Main cloud Agents boundary', () => { .toBe('/api/makelore/channel-operations/op-route'); }); + it('limits unified WeChat unbinding to its owned account and revision contract', () => { + const plan = operationPlan({ operation: 'unbindChannelAccount', input: { + channel_account_id: 'wechat-1', operation_id: input.operation_id, expected_revision: 2, + target_agent_slug: draft.slug, access_token: 'private', + } }); + expect(plan.path).toBe('/api/makelore/channel-accounts/wechat-1/unbind'); + expect(plan.method).toBe('POST'); + expect(plan.body).toEqual({ operation_id: input.operation_id, expected_revision: 2 }); + expect(plan.project({ channel: { id: 'wechat-1', desired_state: 'paused', sync_state: 'ready', access_token: 'private' }, + unbound: true, disconnected: true, wechat: { status: 'empty', token: 'private' } })).toEqual({ + channel: { id: 'wechat-1', desired_state: 'paused', sync_state: 'ready' }, + unbound: true, disconnected: true, wechat: { status: 'empty' }, + }); + for (const invalid of [ + { operation_id: input.operation_id, expected_revision: 2 }, + { channel_account_id: 'wechat-1', expected_revision: 2 }, + { channel_account_id: 'wechat-1', operation_id: input.operation_id }, + { channel_account_id: '../other', operation_id: input.operation_id, expected_revision: 2 }, + ]) expect(() => operationPlan({ operation: 'unbindChannelAccount', input: invalid })).toThrow(); + }); + + it.each([ + { health: 'connected' }, { enabled: true }, { target_agent_slug: draft.slug }, { id: 'another-account' }, + ])('keeps unified unbinding pending when a success response contains unfinished account state %o', async unfinished => { + const channel = { id: 'wechat-1', enabled: false, target_agent_slug: null, health: 'login_required', ...unfinished }; + const fetchImpl = vi.fn().mockResolvedValueOnce(session()) + .mockResolvedValueOnce(json({ channel, unbound: true, disconnected: true })); + const module = moduleFor(fetchImpl); + const original = { channel_account_id: 'wechat-1', operation_id: input.operation_id, expected_revision: 2 }; + await expect(module.execute({ operation: 'unbindChannelAccount', input: original })).rejects.toMatchObject({ + code: 'channel_operation_unconfirmed', commandOutcome: 'unknown', + }); + expect((await module.recovery()).pending).toEqual([expect.objectContaining({ + operation: 'unbindChannelAccount', input: original, + })]); + }); + + it('requires compound completion and retries the same unbind intent after a partial success response', async () => { + const channel = { id: 'wechat-1', enabled: false, target_agent_slug: null, health: 'login_required' }; + const fetchImpl = vi.fn().mockResolvedValueOnce(session()) + .mockResolvedValueOnce(json({ channel, disconnected: true })) + .mockResolvedValueOnce(json({ channel, unbound: true, disconnected: true })); + const module = moduleFor(fetchImpl); + const original = { channel_account_id: 'wechat-1', operation_id: input.operation_id, expected_revision: 2 }; + await expect(module.execute({ operation: 'unbindChannelAccount', input: original })).rejects.toMatchObject({ + code: 'channel_operation_unconfirmed', + }); + const pending = (await module.recovery()).pending[0]; + expect(await module.resolvePending({ id: pending.id })).toMatchObject({ + operation: 'unbindChannelAccount', input: original, result: { channel, unbound: true, disconnected: true }, + }); + expect((await module.recovery()).pending).toEqual([]); + expect(fetchImpl.mock.calls.slice(1).map(call => [call[0], JSON.parse(call[1].body)])).toEqual([ + ['https://agents.example.test/api/makelore/channel-accounts/wechat-1/unbind', { + operation_id: input.operation_id, expected_revision: 2, + }], + ['https://agents.example.test/api/makelore/channel-accounts/wechat-1/unbind', { + operation_id: input.operation_id, expected_revision: 2, + }], + ]); + }); + it('recovers a completed WeChat verification from its receipt without replaying the code', async () => { const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response')) .mockResolvedValueOnce(json({ operation_id: input.operation_id, status: 'completed', result: { channel: { id: 'channel-1' } } })); diff --git a/tests/unit/cloud-recovery.test.tsx b/tests/unit/cloud-recovery.test.tsx index 97e1c188..bc843cca 100644 --- a/tests/unit/cloud-recovery.test.tsx +++ b/tests/unit/cloud-recovery.test.tsx @@ -184,6 +184,21 @@ it('offers only explicit discard for retired operations and explains the retired expect(api.resolvePending).toHaveBeenCalledWith(retired.id, false); }); +it('keeps a channel operation visibly pending without claiming a confirmed result', async () => { + const operation: CloudPendingOperation = { id: 'unbind:original', operation: 'unbindChannelAccount', + input: { channel_account_id: 'wechat-1', operation_id: 'original-unbind', expected_revision: 2 }, + created_at: '2026-10-13T04:00:00Z' }; + api.resolvePending.mockResolvedValue({ operation: operation.operation, pending: true, result: { status: 'pending' } }); + render(); + const dialog = await openRecords(); + expect(within(dialog).getByRole('article', { name: '解除微信绑定' })).toBeVisible(); + fireEvent.click(within(dialog).getByRole('button', { name: '再试一次' })); + expect(await within(dialog).findByRole('status')).toHaveTextContent('这次操作还在确认中'); + expect(within(dialog).queryByText(/结果已确认/)).not.toBeInTheDocument(); + expect(within(dialog).getByRole('article', { name: '解除微信绑定' })).toBeVisible(); + expect(resolved).toHaveBeenCalledOnce(); +}); + it('renders no entry or dialog when there are no pending records or retained results', () => { render(); expect(screen.queryByRole('button')).not.toBeInTheDocument();