From d823ddb8fa06657ae71f3f618769791a6aa3ad21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Mon, 12 Oct 2026 21:20:28 +0800 Subject: [PATCH] Simplify WeChat binding cards and guided setup --- .../20261012-wechat-channel-cards-a76f390d.md | 77 +++ README.md | 2 +- src/pages/CloudAgents/ChannelAccounts.tsx | 418 +++++++++------ tests/e2e/cloud-agent-channels.spec.ts | 88 ++-- tests/unit/channel-accounts-page.test.tsx | 486 ++++++++++++++---- 5 files changed, 768 insertions(+), 303 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20261012-wechat-channel-cards-a76f390d.md diff --git a/.project-docs/30-worklog/tasks/20261012-wechat-channel-cards-a76f390d.md b/.project-docs/30-worklog/tasks/20261012-wechat-channel-cards-a76f390d.md new file mode 100644 index 00000000..32e8bb37 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261012-wechat-channel-cards-a76f390d.md @@ -0,0 +1,77 @@ +# Task: Simplify WeChat channel binding and update combined trial + +## Identity + +- Task ID: 20261012-wechat-channel-cards-a76f390d +- Mode: Feature +- Branch: codex/20261012-wechat-channel-cards-a76f390d-wechat-channel-cards +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261012-wechat-channel-cards-a76f390d +- Base commit: ad1d2f261d5810ec79a5e88da58209673a1e7b8d +- Owner: codex +- Status: Planning + +## Scope + +- Simplify the WeChat channel page into account cards and an explicit choose-Agent → scan → confirmed binding flow. Remove channel chat/activity panels from this configuration surface. Combine all prior approved Agent refinements in the current temporary application. + +## Intent And Constraints + +- Product-language communication; user approved implementation and the combined temporary-app update. Preserve one current Agent target per account, many accounts per Agent, account isolation, Main-owned authentication/operations, creator billing and cloud history. No real WeChat binding/model calls for verification. Cloud changes remain with the user's teammate; do not invent physical-WeChat identity or duplicate detection absent from the current contract. +- Latest base includes prior simplified creation/configuration, cards-first entry, chat polish and manual-only sidebar. Preserve all of them. Keep other local tasks/read-only source, installed app and user profile intact; runtime replacement is restricted to the known temporary app/artifacts. User's new update instruction supersedes earlier code-only constraints. + +## Project Context Loaded + +Task context: +- ID: 20261012-wechat-channel-cards-a76f390d; Feature; owner codex. +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261012-wechat-channel-cards-a76f390d. +- Branch: codex/20261012-wechat-channel-cards-a76f390d-wechat-channel-cards. +- Base commit: ad1d2f261d5810ec79a5e88da58209673a1e7b8d. +- Bundled check/start/status passed, owner/reservation and HEAD match. 38 peers read only: 37 concrete scopes, one old undefined child-language record; no blocking conflict. Prior Agent changes form a single ancestry chain. + +Read: +- AGENTS.md, startup order/memory index/planning gate, own record, positioning and integrated current state, decision index, system overview/community collaboration. +- Business rules/success criteria, evidence/reflection/commitments indexes and personal-cloud-agents ADR; each peer's scope, constraints and promotion candidates. +- ChannelAccounts, typed channel contract, current channel checks, CloudAvatar/QR/dialog components; read-only Yuxi channel/identity/lifecycle contract. Existing temporary app runtime evidence and current CUA accessibility state. +- Design skill database search; adapt progressive disclosure, contrast/focus and soft cards to existing cream/pastel/orange style, without new fonts/themes. + +Relevant understanding: +- Project is an AI creation client for students/creators, excludes courses; Main/WS/Yuxi retain auth, billing, resources and run authority. +- User confirms one WeChat → one current Agent, many WeChats → one Agent, no WeChat transcript in channels. Existing scalar account target supports this relationship; only own published/enabled Agents are eligible. +- Old integrated UI descriptions include channel history tabs; this user-approved configuration simplification takes precedence. Feature task proposes canonical updates only. +- Pause preserves login/target but stops ongoing channel work; switch/clear stops old work and delivery. Clear target disables while retaining login. Disconnect also signs out but retains target/history. Present unlink as cancelling the Agent association, with an explicit confirmation; do not conflate it with logout. +- The current client/cloud projection has no verified real WeChat identity or repeated-scan lookup. Preserve recovery/conflict/error feedback and reuse existing account records for management; true duplicate-scan enforcement requires the cloud owner, not guessed names. +- Runtime currently uses Entry Trial PID 63739 / Host 62867, with formal app separately running. CUA confirms channels open and saved settings. Only renderer changed since that live source; compare final Main artifact hashes before choosing a renderer-only refresh that preserves login. Otherwise build/probe an isolated shell with rollback and manual login. +- Files: ChannelAccounts, focused channel tests, README, own task record, task-specific ignored runtime artifacts. No canonical or sibling source writes. +- Gates passed; no unresolved product conflict. Real WeChat and paid model behavior remain outside fixture evidence. + +Gate result: +- Passed. + +## Plan + +1. Implement pastel account cards and accessible add/manage dialogs with explicit confirmed connection and activation, safe switch/pause/unlink, loading/empty/error/recovery states. Remove channel transcript/activity UI from the configuration page. +2. Verify cardinality, existing/new account flows, QR completion, failures/recovery, keyboard/compact layouts and inherited Agent pages; run typecheck/scoped lint/build/Electron checks. +3. Save the reviewed result, bind a verified runtime update, preserve live login when identical Main artifacts allow it, and display the updated channel page. Record actual runtime evidence and limits. + +## Outcome + +- Implemented account cards, optional account remark, choose-Agent → scan → explicit activation and binding receipt. Management supports target replacement, pause/resume, confirmed unlink and separate disconnect. One scalar target per account and multiple accounts per Agent are retained. Channel configuration no longer renders or requests chat/activity panels. +- Uses authoritative account refresh after QR confirmation and before activation; checks current target/revision/blockers and activation receipts. Failed route keeps the created account identity; pending recovery blocks conflicting actions; successful mutations followed by read failures are not replayed. Initial loading/read failure is distinct from an empty account list. +- README and focused checks match the final page. Current ancestry includes all earlier approved Agent creation/configuration, direct-use, cards-first entry, chat polish and manual-only sidebar/icon changes. Renderer deployment is pending actual application verification; no cloud mutations performed. + +## Verification + +- Focused channel unit checks: 27 passed, including no false completion/success when enable returns enabled=false; creation/route failure identity; connection confirmation, recovery, stale target/revision, switch/pause/unlink/disconnect and no history requests. +- Final production build passed. Production Electron fixture: both channel binding and combined Agent scenarios passed (2 tests, 10.3s). Final screenshots reviewed: readable pastel cards, compact management dialog and correctly placed Select arrow; inherited creation/configuration/sidebar/chat checks remain passing. Fixtures use isolated synthetic data, not the real user's cloud state. +- Final post-review typecheck, scoped ESLint and whitespace checks passed. Independent final code review found no remaining necessary fixes. +- Live temporary app inspected through CUA: logged in, saved Agent settings, empty message composer. Formal installed app remains separate and untouched. Runtime update will verify identical Main/preload/utility bytes and preserve its process before reloading the page; visible result will be recorded after confirmation. + +## Follow-ups + +- Cloud owner must confirm real WeChat identity/repeated-scan behavior. Current account DTO does not expose a verified identity, so this client never guesses duplicates from account remarks. Real WeChat/paid model behavior is not established by fixture checks. +- Complete combined temporary-app refresh and record visible UI evidence before declaring this task ready. + +## Promotion Candidates + +- Target: integrated current state and personal-cloud-agents channel UI description. Proposal: channel configuration consists of account cards and choose-Agent/scan/explicit-enable management; no chat/activity surface. Evidence: user's explicit approval, focused fixture checks and reviewed screenshots. Future impact: prevents reintroducing conversation management into configuration. Semantic conflict: older integrated descriptions include channel history tabs; this approved UI simplification supersedes that presentation while preserving cloud history/billing. Human confirmation: already obtained for product direction; canonical promotion still requires a separate integration task. +- Target: channel contract/commitments. Proposal: confirm stable real-WeChat identity and repeated-scan resolution with the cloud owner before claiming physical-account uniqueness. Evidence: current scalar target gives one Agent per account record but DTO lacks physical identity. Future impact: prevents misleading duplicate detection. Human confirmation/cloud contract required; no client-side guessed identity. diff --git a/README.md b/README.md index 967c88e0..aae8ad06 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。 - Agents 侧栏的「渠道」统一管理个人微信账号。每个账号由唯一扫码微信身份连接,可以在发布智能体前先扫码,之后选择或更换目标智能体;多个微信账号可以使用同一个已发布智能体,切换目标不需要重新扫码。账号可单独启用、暂停、重新连接或断开,不提供联系人邀请或授权调用者管理。扫码或验证码流程确认连接成功后,二维码与验证码输入自动收起,显示连接成功及选择目标、启用渠道或返回微信对话的下一步提示;仅提交验证码不视为已连接。智能体配置页不再展示「使用与接入」导航;侧栏「渠道」承担微信账号管理,不再提供指定用户授权和分享链接。选中账号的二级详情保留运行/投递活动、失败文件补发和微信对话;对话可审批、停止、新开一段并保存实际产物,不同账号的会话相互隔离。渠道触发的聊天等应计费模型调用仍由智能体创建者支付,费用统计包含微信渠道。渠道需要配套版本的 AgentBus Core/微信 Adapter、Yuxi 和 Works Square,桌面用户无需填写服务凭据。Main 保管操作恢复记录,重开界面不会自动重发绑定或执行请求;验证码不写入恢复记录,已移除的联系人权限操作记录只能丢弃。应用 API 管理独立于微信渠道账号管理。 + Agents 侧栏的「渠道」用于把自己的智能体接到微信。每个微信一张卡,展示备注、绑定的智能体头像/名称和是否可用;一个账号同时只有一个目标,多个账号可以使用同一智能体。添加流程为选择自己已开始使用的智能体、扫码连接、明确完成绑定并启用;备注可选,扫码确认后收起二维码与验证码,未确认或启用失败不显示完成。管理已有账号不会重新创建账号;更换目标保留微信登录与原启停状态,解除智能体绑定清空目标并停用但保留微信登录,暂停保留绑定/登录并停止该渠道当前任务,断开微信连接则需要重新扫码。更换、解除和断开有明确确认,历史保留。渠道配置页不展示微信对话或活动,不读取这些列表;聊天在微信中进行,既有云端历史、文件、审批、费用和账号隔离合同不变。页面保留加载失败重试、未确认操作恢复,创建后绑定失败可继续使用原账号,重试沿用原操作身份;仅添加/扫码不自动启用。实际微信身份与重复扫码识别仍由云端负责,客户端不以备注推测同一微信。渠道触发的应计费模型调用仍由智能体创建者支付,Main 持有认证与恢复;用户无需填写服务凭据。应用 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/src/pages/CloudAgents/ChannelAccounts.tsx b/src/pages/CloudAgents/ChannelAccounts.tsx index c3b0c083..95dde3f9 100644 --- a/src/pages/CloudAgents/ChannelAccounts.tsx +++ b/src/pages/CloudAgents/ChannelAccounts.tsx @@ -1,34 +1,42 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { ArrowLeft, CheckCircle2, Loader2, MessageCircle, Pause, Play, Plus, QrCode, RefreshCw, Unplug, X } from 'lucide-react'; +import { ArrowRight, Check, CheckCircle2, ChevronLeft, Link2, Loader2, 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 { cloudAgentsApi } from '@/lib/cloud-agents-api'; import type { CloudAgentDraft, CloudPendingOperation, CloudUserChannelAccount, CloudWechatBindState } from '../../../shared/cloud-agents'; -import { CloudChannelPanel } from './CloudChannelPanel'; +import { CloudAvatar } from './CloudAvatar'; import { CloudRecovery } from './CloudRecovery'; import { CloudWechatQr } from './CloudWechatQr'; -const statusText: Record = { - connected: '已连接', active: '运行中', enabled: '已启用', paused: '已暂停', disabled: '已暂停', - login_required: '需要扫码', offline: '暂时离线', provisioning: '准备中', unknown: '状态未知', - verification_required: '需要验证码', pending: '处理中', confirmed: '已连接', expired: '已过期', failed: '失败', -}; +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: '渠道状态同步异常', + target_required: '还没有绑定智能体', target_unavailable: '绑定的智能体暂时不能使用,请选择另一个', + publish_required: '请先在智能体页面完成配置并开始使用', login_required: '需要重新扫码连接微信', + worker_offline: '微信连接暂时不可用,请稍后刷新', channel_sync_degraded: '连接状态正在确认,请稍后刷新', }; -const label = (value?: string | null) => statusText[value ?? 'unknown'] ?? value ?? '状态未知'; -const errorText = (error: unknown) => error instanceof Error ? error.message : '渠道操作失败,请重试'; -const operationId = () => crypto.randomUUID(); - -async function loadPublishedAgents(): Promise { +const qrLabels: Record = { + 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]' }; +} +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))); + items.push(...page.agents.filter(agent => Boolean(agent.published_version) && agent.enabled !== false)); cursor = page.next_cursor; } while (cursor); return items; @@ -37,199 +45,301 @@ async function loadPublishedAgents(): Promise { export function ChannelAccounts() { const navigate = useNavigate(); const [params, setParams] = useSearchParams(); + const initialAccount = useRef(params.get('account') ?? ''); + const initialTarget = useRef(params.get('target') ?? ''); const [accounts, setAccounts] = useState([]); const [agents, setAgents] = useState([]); - const [selectedId, setSelectedId] = useState(params.get('account') ?? ''); - const [targetSlug, setTargetSlug] = useState(params.get('target') ?? ''); - const [newName, setNewName] = useState(''); - const [creating, setCreating] = useState(false); + const [loading, setLoading] = useState(true); + const [loaded, setLoaded] = useState(false); + const [flow, setFlow] = useState(null); + const [confirmation, setConfirmation] = useState(null); const [busy, setBusy] = useState(''); + const busyRef = useRef(false); 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 [confirmDisconnect, setConfirmDisconnect] = useState(false); 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 selected = useMemo(() => accounts.find(item => item.id === selectedId) ?? null, [accounts, selectedId]); const refresh = useCallback(async () => { - const [accountPage, published, recovery] = await Promise.all([ - cloudAgentsApi.call('channelAccounts', {}), - loadPublishedAgents(), - cloudAgentsApi.recovery(), + const [accountPage, available, recovery] = await Promise.all([ + cloudAgentsApi.call('channelAccounts', {}), loadAvailableAgents(), cloudAgentsApi.recovery(), ]); if (!alive.current) return; - setAccounts(accountPage.items); - setAgents(published); - setPending(recovery.pending); - setSelectedId(current => accountPage.items.some(item => item.id === current) ? current : accountPage.items[0]?.id ?? ''); + setAccounts(accountPage.items); setAgents(available); setPending(recovery.pending); setLoaded(true); }, []); - useEffect(() => { alive.current = true; - void refresh().catch(error => { if (alive.current) setError(errorText(error)); }); + void refresh().catch(failure => { if (alive.current) setError(errorText(failure)); }) + .finally(() => { if (alive.current) setLoading(false); }); return () => { alive.current = false; }; }, [refresh]); useEffect(() => { - if (!selected) return; - setTargetSlug(params.get('target') ?? selected.target_agent_slug ?? ''); - setQr(null); setQrAccountId(''); setConnectedAccountId(''); setVerifyCode(''); setConfirmDisconnect(false); - const next = new URLSearchParams(params); - next.set('account', selected.id); - next.delete('target'); + if (loading || !initialAccount.current) return; + const account = accounts.find(item => item.id === initialAccount.current); + 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'); setParams(next, { replace: true }); - // Query params are consumed once; account changes use authoritative account state. + // Consume deep-link selection once; live refreshes must not replace user input. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selected?.id]); + }, [accounts, loading]); - const run = async (key: string, task: () => Promise, success: string) => { - if (busy) return null; - setBusy(key); setError(''); setNotice(''); + 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 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(''); try { const result = await task(); - await refresh(); + 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; } catch (failure) { if (alive.current) setError(errorText(failure)); + await cloudAgentsApi.recovery().then(state => { if (alive.current) setPending(state.pending); }).catch(() => undefined); return null; } finally { - if (alive.current) setBusy(''); + busyRef.current = false; if (alive.current) setBusy(''); } }; - const create = async () => { - if (!newName.trim()) return; - const result = await run('create', () => cloudAgentsApi.call('createChannelAccount', { - operation_id: operationId(), display_name: newName.trim(), - }), '微信账号已添加,可以先扫码连接,再选择目标智能体。'); - const channel = (result as { channel?: CloudUserChannelAccount } | null)?.channel; - if (channel) { setSelectedId(channel.id); setCreating(false); setNewName(''); } + const resetConnection = () => { setQr(null); setQrAccountId(''); setConnectedAccountId(''); setVerifyCode(''); }; + const close = () => { + 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 }); }; - const saveRoute = async () => { - if (!selected) return; - await run('route', () => cloudAgentsApi.call('routeChannelAccount', { - channel_account_id: selected.id, operation_id: operationId(), target_agent_slug: targetSlug || null, - expected_revision: selected.revision, enabled: targetSlug ? selected.enabled : false, - }), targetSlug ? '目标智能体已更新,微信连接保持不变。' : '已取消目标智能体,微信连接保持不变。'); - }; - const setEnabled = async (enabled: boolean) => { - if (!selected) return; - const operation = enabled ? 'enableChannelAccount' : 'pauseChannelAccount'; - await run(operation, () => cloudAgentsApi.call(operation, { - channel_account_id: selected.id, operation_id: operationId(), expected_revision: selected.revision, - }), enabled ? '渠道已启用。' : '渠道已暂停,账号连接仍保留。'); + 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 applyBindState = useCallback((result: CloudWechatBindState, accountId: string) => { if (result.status === 'confirmed') { - setQr(null); setQrAccountId(''); setVerifyCode(''); setNotice(''); setError(''); - setConnectedAccountId(accountId); - } else { - setQr(result); setQrAccountId(accountId); setConnectedAccountId(''); - } - }, []); - const startQr = async (force: boolean) => { - if (!selected) return; + 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', { - channel_account_id: selected.id, operation_id: operationId(), force, - }), '请使用个人微信扫码。'); - if (result && typeof result === 'object' && 'session_key' in result) { - applyBindState(result as CloudWechatBindState, selected.id); + ...input, operation_id: operationId('qr', input), + })); + if (result && alive.current) applyBindState(result, account.id); + }; + 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 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 verify = async () => { - if (!selected || !qr || qrAccountId !== selected.id || !verifyCode.trim()) return; - const result = await run('verify', () => cloudAgentsApi.call('channelAccountWechatBindVerification', { - channel_account_id: selected.id, operation_id: operationId(), session_key: qr.session_key, verify_code: verifyCode.trim(), - }), '验证码已提交,请等待微信确认。'); - if (result && typeof result === 'object' && 'session_key' in result) applyBindState(result as CloudWechatBindState, selected.id); + 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) })); + if (result) { + mergeAccount(result.channel); + if (result.channel.enabled === enabled) setNotice(enabled ? '微信绑定已启用。' : '已暂停,微信连接和绑定的智能体仍保留。'); + else setError('使用状态还没有确认,请刷新后再试。'); + } + return result?.channel ?? null; }; - const pollingAccountId = selected?.id ?? ''; - const pollingSessionKey = qr?.session_key ?? ''; + 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 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 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(); + } + setConfirmation(null); + }; + const pollingId = flow?.step === 'connect' ? flow.accountId : ''; + const pollingSession = qr?.session_key ?? ''; const pollingStatus = qr?.status ?? ''; useEffect(() => { - if (!pollingAccountId || !pollingSessionKey || qrAccountId !== pollingAccountId || ['confirmed', 'failed', 'expired'].includes(pollingStatus)) return; + if (!pollingId || pollingId !== qrAccountId || !pollingSession || ['confirmed', 'failed', 'expired'].includes(pollingStatus)) return; let stopped = false; const timer = window.setInterval(() => { if (document.visibilityState === 'hidden') return; - void cloudAgentsApi.call('channelAccountWechatBindStatus', { channel_account_id: pollingAccountId, session_key: pollingSessionKey }) - .then(result => { - if (stopped || !alive.current) return; - applyBindState(result, pollingAccountId); - if (result.status === 'confirmed') void refresh().catch(failure => { if (alive.current) setError(errorText(failure)); }); - }).catch(failure => { if (!stopped && alive.current) setError(errorText(failure)); }); + 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)); }); }, 2000); return () => { stopped = true; window.clearInterval(timer); }; - }, [pollingAccountId, pollingSessionKey, pollingStatus, qrAccountId, refresh, applyBindState]); - const disconnect = async () => { - if (!selected) return; - await run('disconnect', () => cloudAgentsApi.call('disconnectChannelAccount', { - channel_account_id: selected.id, operation_id: operationId(), expected_revision: selected.revision, - }), '微信账号已断开。'); - setConfirmDisconnect(false); setQr(null); setConnectedAccountId(''); + }, [pollingId, pollingSession, pollingStatus, qrAccountId, applyBindState]); + 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 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 => )} + ; - return
- void refresh()} /> -
-

渠道

微信

-

每个微信账号独立连接,可随时选择或更换它服务的智能体。

- -
- {creating &&
- - - -
} - {error &&
{error}
} - {notice &&

{notice}

} - {!accounts.length ?
-

还没有微信账号

即使还没有发布智能体,也可以先添加账号并扫码连接。

-
:
- - {selected &&
-

{selected.display_name}

微信连接:{label(selected.health)} · 渠道路由:{selected.enabled ? '已启用' : '已暂停'}

-
- {selected.blockers.length > 0 &&

{selected.blockers.map(item => blockerText[item] ?? item).join(';')}

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

连接到微信

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

-
- {selected.health !== 'connected' ? - : } - {selected.enabled ? - : } - +
+ +
- {connectedAccountId === selected.id &&
-
+ {!flow && messages} + {loading ?
正在查看你的微信绑定…
+ : !loaded ?

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

+ : !accounts.length ?
+

把你的智能体带到微信吧

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

+ +
:
+ {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 ?? '智能体暂不可用'}

+ : <>

还没有绑定智能体

} +
+ +
; + })}
} - {qr && qrAccountId === selected.id &&
-
{qr.qrcode_url ? : }
-

{label(qr.status)}

扫码只负责账号连接,目标智能体可稍后选择。

{qr.message &&

{qr.message}

}
-
- {qr.status === 'verification_required' && <> setVerifyCode(event.target.value)} placeholder="输入微信验证码" />} -
} - {confirmDisconnect &&

确认断开这个微信账号?

历史活动和会话仍保留;再次使用需要重新扫码。

} - {selected.target_agent_slug && selected.binding_id ?
- :

选择已发布的目标智能体后,可在这里查看活动记录和微信对话。

-
} -
} -
} + + { 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 ?

先准备一个可以使用的智能体,再把它带到微信里。

+ : } + {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 && } + + +
+

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

+
} + {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)} />
; } diff --git a/tests/e2e/cloud-agent-channels.spec.ts b/tests/e2e/cloud-agent-channels.spec.ts index 60a0191e..c9ec06ae 100644 --- a/tests/e2e/cloud-agent-channels.spec.ts +++ b/tests/e2e/cloud-agent-channels.spec.ts @@ -47,6 +47,11 @@ test('own WeChat conversation approval and generated PPT delivery in the desktop 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 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, @@ -60,7 +65,10 @@ test('own WeChat conversation approval and generated PPT delivery in the desktop 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') return result({ channel: { ...channelAccounts.find(item => item.id === input.channel_account_id), health: 'login_required', enabled: false }, disconnected: true }); + 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 === 'channelConversations') return result({ items: [session], next_offset: null }); if (operation === 'channelSelfCallers') return result({ items: [{ ...session, paired_ws_account_id: 'creator', revoked_at: null }] }); @@ -97,48 +105,58 @@ test('own WeChat conversation approval and generated PPT delivery in the desktop expect(calls.some(call => call.operation === 'resume' || call.operation === 'submit')).toBe(false); await page.getByTestId('cloud-agent-editor').getByRole('button', { name: '我的智能体', exact: true }).click(); await page.getByRole('button', { name: '渠道', exact: true }).click(); - await expect(page.getByRole('heading', { name: '微信', exact: true })).toBeVisible(); - await expect(page.getByRole('button', { name: /创作微信.*我的创作助手/ })).toBeVisible(); - await expect(page.getByRole('button', { name: /备用微信.*未分配智能体/ })).toBeVisible(); - await page.screenshot({ path: testInfo.outputPath('wechat-account-list.png'), fullPage: true }); - await page.getByRole('button', { name: /备用微信.*未分配智能体/ }).click(); - const spareDetail = page.getByRole('article', { name: '备用微信账号详情', exact: true }); - await spareDetail.getByRole('button', { name: '扫码连接', exact: true }).click(); - await expect(page.getByRole('img', { name: '个人微信登录二维码', exact: true })).toHaveAttribute('src', /^data:image\//); - await page.getByLabel('微信验证码', { exact: true }).scrollIntoViewIfNeeded(); - await page.screenshot({ path: testInfo.outputPath('wechat-unassigned-qr-and-target.png'), fullPage: true }); - await page.getByLabel('微信验证码', { exact: true }).fill('123456'); - await page.getByRole('button', { name: '提交验证码', exact: true }).click(); - await expect.poll(async () => app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string; input: unknown }[] }).cloudChannelFixtureCalls.some(call => call.operation === 'channelAccountWechatBindVerification'))).toBe(true); - await expect(page.getByRole('img', { name: '个人微信登录二维码', exact: true })).toHaveCount(0); - await expect(page.getByLabel('微信验证码', { exact: true })).toHaveCount(0); - await expect(spareDetail.getByRole('status')).toContainText('微信已连接'); - await expect(spareDetail.getByRole('status')).toContainText('选择目标智能体'); - await spareDetail.getByRole('status').scrollIntoViewIfNeeded(); - await page.screenshot({ path: testInfo.outputPath('wechat-binding-completed.png'), fullPage: true }); - await spareDetail.getByLabel('目标智能体', { exact: true }).selectOption(agentSlug); - await spareDetail.getByRole('button', { name: '保存目标', exact: true }).click(); + await expect(page.getByRole('heading', { name: '连接到微信', exact: true })).toBeVisible(); + 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(page.getByRole('dialog')).toHaveCount(0); + await expect(page.getByText('活动记录', { exact: true })).toHaveCount(0); + await expect(page.getByRole('button', { name: '微信对话', exact: true })).toHaveCount(0); + const callsBeforeConfig = await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }).cloudChannelFixtureCalls.length); + await page.screenshot({ path: testInfo.outputPath('wechat-binding-cards.png'), fullPage: true }); + + 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('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-b' && call.input.target_agent_slug === 'ml-' + 'a'.repeat(32)))).toBe(true); - await expect(page.getByRole('button', { name: /备用微信.*我的创作助手/ })).toBeVisible(); - await expect(spareDetail.getByRole('status')).toContainText('点击“启用”'); - await spareDetail.getByRole('button', { name: '启用', exact: true }).click(); - await expect(spareDetail.getByRole('status')).toContainText('返回微信与智能体对话'); - await page.getByRole('button', { name: /创作微信.*我的创作助手/ }).click(); - const legacyDetail = page.getByRole('article', { name: '创作微信账号详情', exact: true }); + .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 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('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 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 legacyDetail.getByLabel('目标智能体', { exact: true }).selectOption(secondAgentSlug); - await legacyDetail.getByRole('button', { name: '保存目标', exact: true }).click(); + 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); expect(await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }).cloudChannelFixtureCalls.filter(call => call.operation === 'channelAccountWechatBindStart').length)).toBe(qrStartsBeforeSwitch); - await expect(page.getByRole('button', { name: '活动记录', exact: true })).toBeVisible(); - await expect(page.getByRole('button', { name: '微信对话', exact: true })).toBeVisible(); + 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-account-detail-compact.png'), fullPage: true }); - expect(await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }).cloudChannelFixtureCalls.some(call => ['channelPolicy', 'createChannelPairing', 'channelCallers', 'revokeChannelCaller'].includes(call.operation)))).toBe(false); + await page.screenshot({ path: testInfo.outputPath('wechat-manage-binding-compact.png'), fullPage: true }); + const finalCalls = await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }).cloudChannelFixtureCalls); + expect(finalCalls.slice(callsBeforeConfig).some(call => ['channelActivity', 'channelConversations', 'channelConversation', 'channelPolicy', 'createChannelPairing', 'channelCallers', 'revokeChannelCaller'].includes(call.operation))).toBe(false); + expect(finalCalls.filter(call => call.operation === 'createChannelAccount')).toHaveLength(1); + } finally { 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 9b92c867..e25e3bb9 100644 --- a/tests/unit/channel-accounts-page.test.tsx +++ b/tests/unit/channel-accounts-page.test.tsx @@ -1,134 +1,394 @@ -import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { MemoryRouter } 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'; 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 = { slug: 'ml-' + 'a'.repeat(32), name: '写作助手', published_version: 2, draft_revision: 3 }; -const agentB = { slug: 'ml-' + 'b'.repeat(32), name: '资料助手', published_version: 4, draft_revision: 5 }; -const legacy = { - id: 'wechat-legacy', display_name: '原有微信', binding_id: 'binding-legacy', revision: 7, enabled: true, +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 = { - id: 'wechat-spare', display_name: '备用微信', binding_id: 'binding-spare', revision: 2, enabled: false, + 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', -}; + target_agent_published_version: null, provider_generation: 'generation-spare' }; +let accounts: CloudUserChannelAccount[]; +let agents: CloudAgentDraft[]; +let qrStatus: CloudWechatBindState['status']; +let verifyStatus: CloudWechatBindState['status']; -beforeEach(() => { - vi.clearAllMocks(); - api.list.mockResolvedValue({ agents: [agentA, agentB], next_cursor: null }); - api.recovery.mockResolvedValue({ recent: null, pending: [] }); +const calls = (operation: string) => api.call.mock.calls.filter(([name]) => name === operation); +const writes = () => api.call.mock.calls.filter(([name]) => !['channelAccounts', 'channelAccountWechatBindStatus'].includes(name)); +function renderPage(entry = '/cloud-agents/channels') { return render(); } +async function addWechat(fakeTimers = false) { + 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 }))); + 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: [legacy, spare] }; - if (operation === 'channelAccountWechatBindStart') return { session_key: 'qr-spare', status: 'verification_required', qrcode_url: 'https://login.example.test/qr' }; - if (operation === 'routeChannelAccount') return { channel: { ...spare, target_agent_slug: input.target_agent_slug } }; - if (operation === 'channelActivity') return { items: [], next_offset: null }; - throw new Error(`Unexpected operation ${operation}`); - }); -}); - -afterEach(() => vi.useRealTimers()); - -it('closes the QR after confirmed polling and shows the next step for an unassigned account', async () => { - const base = api.call.getMockImplementation()!; - let connected = false; - api.call.mockImplementation(async (operation, input) => { - if (operation === 'channelAccounts') return { items: [connected ? { ...spare, health: 'connected' } : spare] }; - if (operation === 'channelAccountWechatBindStatus') { - connected = true; - return { session_key: 'qr-spare', status: 'confirmed', qrcode_url: 'https://login.example.test/qr' }; + 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 }; } - return base(operation, input); + 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'] }); + return { channel: { ...account } }; + } + if (operation === 'enableChannelAccount' || operation === 'pauseChannelAccount') { + Object.assign(account, { revision: account.revision + 1, enabled: operation === 'enableChannelAccount' }); + return { channel: { ...account } }; + } + if (operation === 'disconnectChannelAccount') { + Object.assign(account, { revision: account.revision + 1, health: 'login_required', enabled: false }); + return { channel: { ...account }, disconnected: true }; + } + 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' }; + } + throw new Error('Unexpected fixture operation ' + operation); }); - render(); - const detail = await screen.findByRole('article', { name: '备用微信账号详情' }); - vi.useFakeTimers(); - await act(async () => fireEvent.click(within(detail).getByRole('button', { name: '扫码连接' }))); - expect(screen.getByRole('img', { name: '个人微信登录二维码' })).toBeVisible(); - await act(async () => vi.advanceTimersByTimeAsync(2000)); - expect(screen.queryByRole('img', { name: '个人微信登录二维码' })).not.toBeInTheDocument(); - expect(screen.queryByLabelText('微信验证码')).not.toBeInTheDocument(); - expect(screen.getByRole('status')).toHaveTextContent('微信已连接'); - expect(screen.getByRole('status')).toHaveTextContent('选择目标智能体'); - expect(within(detail).getByRole('button', { name: '重新连接' })).toBeEnabled(); - await act(async () => vi.advanceTimersByTimeAsync(6000)); - expect(api.call.mock.calls.filter(([operation]) => operation === 'channelAccountWechatBindStatus')).toHaveLength(1); - expect(api.call.mock.calls.some(([operation]) => operation === 'routeChannelAccount' || operation === 'enableChannelAccount')).toBe(false); +} +beforeEach(() => { + vi.resetAllMocks(); + accounts = [{ ...legacy }, { ...spare }]; agents = [agentA, agentB]; + qrStatus = 'verification_required'; verifyStatus = 'pending'; + api.list.mockImplementation(async () => ({ agents, next_cursor: null })); + api.recovery.mockResolvedValue({ recent: null, pending: [] }); + installMock(); +}); +afterEach(() => { cleanup(); vi.useRealTimers(); }); + +it('shows binding cards and never fetches or embeds conversation or activity panels', async () => { + 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(writes()).toEqual([]); + expect(api.call.mock.calls.every(([operation]) => operation === 'channelAccounts')).toBe(true); }); -it.each(['channelAccountWechatBindStart', 'channelAccountWechatBindVerification'])('handles confirmed %s for an already routed account', async confirmedOperation => { +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(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'; + 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); +}); + +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); + 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('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('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 () => { + 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 })); + 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('continues an 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(); + 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 }); +}); + +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); + 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 }); + 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'] }]; + 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); +}); + + +it('keeps adding unavailable after a failed initial read instead of showing a false empty account list', async () => { + api.call.mockRejectedValue(new Error('微信列表暂时不可用')); + renderPage(); + await screen.findByText('微信列表暂时不可用', { exact: true }); + expect(screen.getByRole('button', { name: '添加微信', exact: true })).toBeDisabled(); + expect(screen.queryByText('还没有微信绑定', { exact: true })).not.toBeInTheDocument(); + installMock(); + 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(); +}); + +it('allows the optional WeChat remark to stay empty', async () => { + 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/); +}); + + +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); + 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'; const base = api.call.getMockImplementation()!; api.call.mockImplementation(async (operation, input) => { - if (operation === confirmedOperation) return { session_key: 'qr-spare', status: 'confirmed', qrcode_url: 'https://login.example.test/qr' }; + if (operation === 'enableChannelAccount') return { channel: { ...accounts.find(item => item.id === input.channel_account_id)!, enabled: false } }; return base(operation, input); }); - render(); - const detail = await screen.findByRole('article', { name: '原有微信账号详情' }); - fireEvent.click(within(detail).getByRole('button', { name: '重新连接' })); - if (confirmedOperation === 'channelAccountWechatBindVerification') { - expect(await screen.findByRole('img', { name: '个人微信登录二维码' })).toBeVisible(); - fireEvent.change(screen.getByLabelText('微信验证码'), { target: { value: '123456' } }); - fireEvent.click(screen.getByRole('button', { name: '提交验证码' })); - } - expect(await screen.findByText('微信已连接', { exact: true })).toBeVisible(); - expect(screen.queryByRole('img', { name: '个人微信登录二维码' })).not.toBeInTheDocument(); - expect(screen.queryByLabelText('微信验证码')).not.toBeInTheDocument(); - expect(within(detail).getByRole('status')).toHaveTextContent('返回微信与智能体对话'); - fireEvent.click(screen.getByRole('button', { name: /备用微信.*未分配智能体/ })); - expect(screen.queryByText('微信已连接', { exact: true })).not.toBeInTheDocument(); -}); - -it('keeps the QR while verification is pending instead of claiming the connection is confirmed', async () => { - const base = api.call.getMockImplementation()!; - api.call.mockImplementation(async (operation, input) => { - if (operation === 'channelAccountWechatBindVerification') return { session_key: 'qr-spare', status: 'pending', qrcode_url: 'https://login.example.test/qr' }; - return base(operation, input); - }); - render(); - const detail = await screen.findByRole('article', { name: '备用微信账号详情' }); - fireEvent.click(within(detail).getByRole('button', { name: '扫码连接' })); - fireEvent.change(await screen.findByLabelText('微信验证码'), { target: { value: '123456' } }); - fireEvent.click(screen.getByRole('button', { name: '提交验证码' })); - expect(await screen.findByText('验证码已提交,请等待微信确认。')).toBeVisible(); - expect(screen.getByRole('img', { name: '个人微信登录二维码' })).toBeVisible(); - expect(screen.queryByText('微信已连接', { exact: true })).not.toBeInTheDocument(); -}); - -it('shows legacy and unassigned accounts and can connect before choosing an Agent', async () => { - render(); - expect(await screen.findByText('原有微信')).toBeVisible(); - expect(screen.getAllByText('备用微信')).toHaveLength(2); - const detail = await screen.findByRole('article', { name: '备用微信账号详情' }); - expect(within(detail).getByText('尚未选择目标智能体')).toBeVisible(); - fireEvent.click(within(detail).getByRole('button', { name: '扫码连接' })); - expect(await within(detail).findByRole('img', { name: '个人微信登录二维码' })).toBeVisible(); - expect(api.call).toHaveBeenCalledWith('channelAccountWechatBindStart', expect.objectContaining({ channel_account_id: 'wechat-spare' })); -}); - -it('routes two independent accounts to the same Agent without starting a new QR login', async () => { - render(); - const spareDetail = await screen.findByRole('article', { name: '备用微信账号详情' }); - fireEvent.change(within(spareDetail).getByLabelText('目标智能体'), { target: { value: agentA.slug } }); - fireEvent.click(within(spareDetail).getByRole('button', { name: '保存目标' })); - await waitFor(() => expect(api.call).toHaveBeenCalledWith('routeChannelAccount', expect.objectContaining({ - channel_account_id: 'wechat-spare', target_agent_slug: agentA.slug, enabled: false, - }))); - expect(api.call.mock.calls.some(([operation]) => operation === 'channelAccountWechatBindStart')).toBe(false); -}); - -it('switches an enabled legacy account target while preserving its enabled connection', async () => { - render(); - const detail = await screen.findByRole('article', { name: '原有微信账号详情' }); - fireEvent.change(within(detail).getByLabelText('目标智能体'), { target: { value: agentB.slug } }); - fireEvent.click(within(detail).getByRole('button', { name: '保存目标' })); - await waitFor(() => expect(api.call).toHaveBeenCalledWith('routeChannelAccount', expect.objectContaining({ - channel_account_id: 'wechat-legacy', target_agent_slug: agentB.slug, expected_revision: 7, enabled: true, - }))); - expect(api.call.mock.calls.some(([operation]) => String(operation).includes('WechatBindStart'))).toBe(false); + 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); });