diff --git a/.project-docs/30-worklog/tasks/20261013-wechat-bindings-ui-f827c13a.md b/.project-docs/30-worklog/tasks/20261013-wechat-bindings-ui-f827c13a.md new file mode 100644 index 00000000..668c3305 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261013-wechat-bindings-ui-f827c13a.md @@ -0,0 +1,47 @@ +# Task: Simplify WeChat Agent binding and reliable channel state + +## Identity + +- Task ID: 20261013-wechat-bindings-ui-f827c13a +- Mode: Feature +- Branch: codex/20261013-wechat-bindings-ui-f827c13a-wechat-bindings +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-wechat-bindings-ui-f827c13a +- Base commit: 0fa6f8cca07c687ed1d9944267e87d950b1373ae +- Owner: codex +- Status: In Progress + +## Scope + +- Implement the approved one-WeChat/one-Agent flow: a card with one main action, a single Agent-choice dialog, pause in More, and unified WeChat unbinding that stops work, clears the Agent and logs out without deleting history. +- Automatically finish explicit user-initiated binding after confirmed QR login and authoritative enable/readback. Add typed Main-owned unbind operation paired with Yuxi. +- Fix stale mutation-result overwrites, visible confirmation failure feedback and honest uncertain state. Preserve channel-empty guidance, ownership/credentials/recovery and unrelated modules. + +## Intent And Constraints + +- Official check/start/status passed; task ID, feature mode, absolute worktree, branch and base match Identity. All 36 peer Scope/Intent/Promotion sections reviewed read-only; legacy placeholder scopes remain unknown without a conflicting channel dependency. Existing channel-empty hint is integrated and retained. +- Planning Gate Passed after entry/memory-index/own record, positioning, integrated state, accepted personal-cloud ADR, architecture, domain/success criteria, evidence/reflection/commitments were loaded. User explicitly supersedes independent unlink UI and extra manual enable confirmation. Shared canonical docs remain read-only. +- Yuxi peer task implements a new POST /channel-accounts/{id}/unbind; account ownership, operation receipts and final state must be authoritative. No live WeChat changes, paid model calls, credentials reads, app restart, production deployment, primary adoption/merge/push or peer cleanup. +- Plan: agree compound contract; implement typed transport and streamlined page; focused unit/real Electron-window fixture checks then type/lint/build; independent review and task-aware documentation check; save reviewable commits with paired-release limitations. +- User requested stopping planning-with-files; that workflow is no longer used. This record contains only the repository task outcome and integration evidence required by the existing project-docs workflow. + +## Outcome + +- Completed and independently reviewed. The client now exposes one card per owned WeChat, a single Agent-choice dialog, pause/resume in More, and compound unbinding. Main journals the original unbind request and only accepts a verified compound final response. QR completion only enables within the explicitly initiated binding flow; reconnecting a paused account preserves pause. +- Agent-list failures preserve safe management of successfully loaded owned accounts; target choice stays disabled. Separate account/read and refresh/recovery guards prevent delayed reads from overwriting a newer mutation result or its unknown-operation lock. Recovery-read failure keeps all conflicting changes blocked until refreshed. The companion backend also fences retained legacy disconnect replay and rejects stale revisions before any native stop or provider logout. + +## Verification + +- Final client check: `corepack pnpm exec vitest run tests/unit/channel-accounts-page.test.tsx tests/unit/cloud-agents-main.test.ts tests/unit/cloud-recovery.test.tsx tests/unit/cloud-channel-panel.test.tsx tests/unit/cloud-channel-conversations.test.tsx tests/unit/cloud-agents-page.test.tsx --maxWorkers=4`: 214 passed across six files (41 page tests). Includes stale-read/metadata races, partial compound responses, preserved original recovery intent, explicit QR completion, paused reconnect and unavailable-target safe stopping. +- Final client `corepack pnpm run typecheck`, changed-file ESLint and `corepack pnpm run build:vite`: passed. Full `corepack pnpm run lint:check`: passed with eight pre-existing warnings outside modified files. Vite reported existing Browserslist age/dynamic-import/chunk-size warnings. Product UI source SHA256: `37c6b37a3f895154a99656b3fe41e6f6fee04e71d9b33245e3238e8a04bab3ce`. +- Final isolated Electron E2E `cloud-agent-channels.spec.ts`: two scenarios passed in 6.1 seconds against the completed Vite build. Covered explicit QR completion, one-confirmation Agent replacement, pause/resume and compound unbind, including failure feedback inside the active dialog and 980×680 viewport handling. Agent and root inspected synthetic-fixture screenshots; no live WeChat or model calls were made. The first older-bundle E2E attempt is not final verification evidence. +- Fresh independent reviewer re-read the complete final client/backend diff and returned paired PASS with no remaining material findings. Independent client page/Main/recovery: 158 passed; backend service/lifecycle: 59 passed (final service SHA prefix `d6809619`). +- Companion Yuxi task `20261013-channel-unbind-yx-a43d91c8`: focused 101 passed (83 unit plus 18 actual HTTP/PostgreSQL tests with Core/WeChat/Redis substitutes), prior to the last legacy stale-revision guard. Initial full non-slow unit run: 2295 passed, 1 skipped, 2 failed because existing baseline scripts are indexed non-executable. No unrelated script-mode repair is included; final numbers are owned by the companion task. + +## Follow-ups + +- Paired backend and client release is required. No primary checkout changes, merge/push, live WeChat actions, app runtime switch or production deployment is included. +- Actual online Core/WeChat/Worker/Redis behavior requires release-environment validation. Tests use isolated Electron fixtures and actual local HTTP/PostgreSQL with upstream substitutes; no database migration or new configuration was introduced. + +## Promotion Candidates + +- After integration, the current product docs/accepted channel ADR should describe compound unbinding and automatic completion of explicitly initiated QR binding, superseding the earlier independent unlink and extra manual enable flow. Canonical memory remains unchanged in this feature task. diff --git a/README.md b/README.md index 45f3b925..4dd118f8 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,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 1be67387..e18787e0 100644 --- a/electron/services/cloud-agents.ts +++ b/electron/services/cloud-agents.ts @@ -54,6 +54,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: '微信会话不存在或已关闭', @@ -423,9 +424,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 8a2506fd..23325089 100644 --- a/src/pages/CloudAgents/CloudRecovery.tsx +++ b/src/pages/CloudAgents/CloudRecovery.tsx @@ -16,6 +16,7 @@ const labels: Record = { createChannelAccount: '添加微信账号', routeChannelAccount: '切换目标智能体', enableChannelAccount: '启用渠道', pauseChannelAccount: '暂停渠道', disconnectChannelAccount: '断开微信账号', channelAccountWechatBindStart: '微信扫码连接', channelAccountWechatUnbind: '解除微信登录', + unbindChannelAccount: '解除微信绑定', }; const retiredChannelOperations = new Set(['channelPolicy', 'createChannelPairing', 'channelCallers', 'revokeChannelCaller']); @@ -43,6 +44,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 02a7a56b..12bc82e3 100644 --- a/tests/unit/cloud-recovery.test.tsx +++ b/tests/unit/cloud-recovery.test.tsx @@ -151,6 +151,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();