From c953126284eb81a3ac446c2213441db24e23929c Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Sun, 20 Sep 2026 10:36:29 +0800 Subject: [PATCH] Close WeChat QR flow when binding is confirmed --- .../20260920-wechat-qr-completion-29ea4d7c.md | 53 ++++++++++++++ README.md | 2 +- src/pages/CloudAgents/ChannelAccounts.tsx | 36 +++++++--- tests/e2e/cloud-agent-channels.spec.ts | 12 +++- tests/unit/channel-accounts-page.test.tsx | 71 ++++++++++++++++++- 5 files changed, 160 insertions(+), 14 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260920-wechat-qr-completion-29ea4d7c.md diff --git a/.project-docs/30-worklog/tasks/20260920-wechat-qr-completion-29ea4d7c.md b/.project-docs/30-worklog/tasks/20260920-wechat-qr-completion-29ea4d7c.md new file mode 100644 index 0000000..598dcb2 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260920-wechat-qr-completion-29ea4d7c.md @@ -0,0 +1,53 @@ +# Task: Complete WeChat binding interaction after QR scan + +## Identity + +- Task ID: 20260920-wechat-qr-completion-29ea4d7c +- Mode: Feature +- Branch: codex/20260920-wechat-qr-completion-29ea4d7c-wechat-qr-completion +- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260920-wechat-qr-completion-29ea4d7c +- Base commit: e04787763b68574798c07ee2ff14f86607d4642a +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Make WeChat QR binding complete visibly in ChannelAccounts: confirmed binding removes the QR/verification controls, refreshes the account, and guides the next step. +- Cover supported bind start, status polling, and verification responses with focused UI regressions and the existing Electron fixture. + +## Intent And Constraints + +- User reports the QR remains after successfully binding WeChat. Only a confirmed bind session should close it; an existing connected account must still be able to reconnect. +- Preserve Main-owned Host contracts, operation recovery, scan identity, route selection and explicit enabling. No automatic routing or enabling. +- Concurrent Task Gate and Planning Gate passed. Isolated from e047877 so the previous authorized sharing removal remains included; no merge, deployment, or cleanup in scope. + +## Outcome + +- Reproduced the exact symptom with a confirmed Host bind-status response: QR image remained visible. The existing handler stored confirmed state and stopped polling without closing the QR UI. +- Start, verification, and poll responses now share the confirmed transition: clear QR/session UI and verification input, stop polling, and show an account-scoped green connection confirmation. +- The confirmation guides unassigned accounts to select/save a target and enable the channel; routed paused accounts to enable; active accounts back to WeChat. These remain explicit user actions. +- Pending verification is described as submitted and awaiting confirmation. Reconnecting an already connected account keeps its new QR until that session confirms; account switching clears completion feedback. +- Confirmed polling refresh failures are caught and displayed instead of escaping as unhandled rejections. +- Updated README and existing Electron channel fixture; reviewed source diff and actual desktop screenshot. No debug instrumentation or temporary harness added. + +## Verification + +- Red reproduction: pnpm exec vitest run tests/unit/channel-accounts-page.test.tsx -t 'closes the QR' --maxWorkers=1 failed because the confirmed QR image remained. This small deterministic reproduction directly identified the missing terminal UI transition; multi-hypothesis instrumentation was unnecessary. +- Pinned pnpm 10.33.4 frozen dependency install: passed; lockfile unchanged. +- Focused unit suite (channel-accounts-page, cloud-channel-panel, cloud-channel-conversations): 24 tests passed. Includes poll/start/verification confirmation, pending verification, account switching, reconnect display, stopped polling, and no automatic routing/enabling. +- pnpm run typecheck and scoped ESLint (changed UI/unit/E2E files): passed. +- pnpm run build:vite: passed. +- Electron cloud-agent-channels.spec.ts: 1 passed. Verified QR and verification controls automatically disappear, target/enable guidance progresses, and existing channel routing/conversations/file saving remain functional. +- Visually inspected test-results/cloud-agent-channels-own-W-43513-ry-in-the-desktop-workspace/wechat-binding-completed.png. +- git diff --check and task-aware check_doc_drift.py: passed. Unit/Electron Host fixtures verify desktop state transitions; actual production WeChat login is not performed. + +## Follow-ups + +- Commit is based on previous sharing removal e047877. Neither change has been merged or deployed by this task. Shipping the updated client is required for the user to see this interaction. + +## Promotion Candidates + +- Targets: .project-docs/10-decisions/ADR-2026-09-11-personal-cloud-agents.md (User-Owned WeChat Accounts) and .project-docs/30-worklog/current-state.md. +- Proposal: confirmed binding closes transient QR/verification UI and shows account-scoped completion and next-step guidance; submitting verification alone does not indicate success. +- Evidence: direct failing reproduction, passing 24-test focused suite and Electron scenario, inspected screenshot. +- Future impact: preserve visible completion when extending binding/reconnect flows. No semantic conflict with accepted scan identity or route rules; human confirmation already supplied by this interaction request. diff --git a/README.md b/README.md index f0bb743..5334e0f 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。 - Agents 侧栏的「渠道」统一管理个人微信账号。每个账号由唯一扫码微信身份连接,可以在发布智能体前先扫码,之后选择或更换目标智能体;多个微信账号可以使用同一个已发布智能体,切换目标不需要重新扫码。账号可单独启用、暂停、重新连接或断开,不提供联系人邀请或授权调用者管理。Agent 的「使用与接入」展示已关联渠道并提供定位入口,保留应用 API 管理,不再提供指定用户授权和分享链接。选中账号的二级详情保留运行/投递活动、失败文件补发和微信对话;对话可审批、停止、新开一段并保存实际产物,不同账号的会话相互隔离。自动任务可明确选择把结果发送到已经开始对话的微信账号,暂停保留目标;渠道触发的聊天等应计费模型调用仍由智能体创建者支付,费用统计包含微信渠道。渠道需要配套版本的 AgentBus Core/微信 Adapter、Yuxi 和 Works Square,桌面用户无需填写服务凭据。Main 保管操作恢复记录,重开界面不会自动重发绑定或执行请求;验证码不写入恢复记录,已移除的联系人权限操作记录只能丢弃。应用 API 管理独立于微信渠道账号管理。 + Agents 侧栏的「渠道」统一管理个人微信账号。每个账号由唯一扫码微信身份连接,可以在发布智能体前先扫码,之后选择或更换目标智能体;多个微信账号可以使用同一个已发布智能体,切换目标不需要重新扫码。账号可单独启用、暂停、重新连接或断开,不提供联系人邀请或授权调用者管理。扫码或验证码流程确认连接成功后,二维码与验证码输入自动收起,显示连接成功及选择目标、启用渠道或返回微信对话的下一步提示;仅提交验证码不视为已连接。Agent 的「使用与接入」展示已关联渠道并提供定位入口,保留应用 API 管理,不再提供指定用户授权和分享链接。选中账号的二级详情保留运行/投递活动、失败文件补发和微信对话;对话可审批、停止、新开一段并保存实际产物,不同账号的会话相互隔离。自动任务可明确选择把结果发送到已经开始对话的微信账号,暂停保留目标;渠道触发的聊天等应计费模型调用仍由智能体创建者支付,费用统计包含微信渠道。渠道需要配套版本的 AgentBus Core/微信 Adapter、Yuxi 和 Works Square,桌面用户无需填写服务凭据。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 8f561d9..c3b0c08 100644 --- a/src/pages/CloudAgents/ChannelAccounts.tsx +++ b/src/pages/CloudAgents/ChannelAccounts.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { ArrowLeft, Loader2, MessageCircle, Pause, Play, Plus, QrCode, RefreshCw, Unplug, X } from 'lucide-react'; +import { ArrowLeft, CheckCircle2, Loader2, MessageCircle, Pause, Play, Plus, QrCode, RefreshCw, Unplug, X } from 'lucide-react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -48,6 +48,7 @@ export function ChannelAccounts() { 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([]); @@ -75,7 +76,7 @@ export function ChannelAccounts() { useEffect(() => { if (!selected) return; setTargetSlug(params.get('target') ?? selected.target_agent_slug ?? ''); - setQr(null); setQrAccountId(''); setVerifyCode(''); setConfirmDisconnect(false); + setQr(null); setQrAccountId(''); setConnectedAccountId(''); setVerifyCode(''); setConfirmDisconnect(false); const next = new URLSearchParams(params); next.set('account', selected.id); next.delete('target'); @@ -121,21 +122,30 @@ export function ChannelAccounts() { channel_account_id: selected.id, operation_id: operationId(), expected_revision: selected.revision, }), enabled ? '渠道已启用。' : '渠道已暂停,账号连接仍保留。'); }; + 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; + setConnectedAccountId(''); setVerifyCode(''); 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) { - setQr(result as CloudWechatBindState); setQrAccountId(selected.id); + applyBindState(result as CloudWechatBindState, selected.id); } }; 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) setQr(result as CloudWechatBindState); + }), '验证码已提交,请等待微信确认。'); + if (result && typeof result === 'object' && 'session_key' in result) applyBindState(result as CloudWechatBindState, selected.id); }; const pollingAccountId = selected?.id ?? ''; const pollingSessionKey = qr?.session_key ?? ''; @@ -148,18 +158,18 @@ export function ChannelAccounts() { void cloudAgentsApi.call('channelAccountWechatBindStatus', { channel_account_id: pollingAccountId, session_key: pollingSessionKey }) .then(result => { if (stopped || !alive.current) return; - setQr(result); - if (result.status === 'confirmed') void refresh(); + 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)); }); }, 2000); return () => { stopped = true; window.clearInterval(timer); }; - }, [pollingAccountId, pollingSessionKey, pollingStatus, qrAccountId, refresh]); + }, [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); + setConfirmDisconnect(false); setQr(null); setConnectedAccountId(''); }; return
@@ -201,6 +211,14 @@ export function ChannelAccounts() { : } + {connectedAccountId === selected.id &&
+
} {qr && qrAccountId === selected.id &&
{qr.qrcode_url ? : }

{label(qr.status)}

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

{qr.message &&

{qr.message}

}
diff --git a/tests/e2e/cloud-agent-channels.spec.ts b/tests/e2e/cloud-agent-channels.spec.ts index ba95208..b784945 100644 --- a/tests/e2e/cloud-agent-channels.spec.ts +++ b/tests/e2e/cloud-agent-channels.spec.ts @@ -95,7 +95,7 @@ test('own WeChat conversation approval and generated PPT delivery in the desktop expect(calls.find(call => call.operation === 'channelConversationControl')?.input).toMatchObject({ session_id: 'self-session', run_id: 'channel-run', action: 'resume', decision: { decisions: [{ type: 'approve' }] } }); expect(calls.find(call => call.operation === 'downloadChannelArtifact')?.input).toEqual({ session_id: 'self-session', path: '/outputs/创作建议.pptx' }); expect(calls.some(call => call.operation === 'resume' || call.operation === 'submit')).toBe(false); - await page.getByRole('button', { name: '发布与访问', exact: true }).click(); + await page.getByRole('button', { name: '使用与接入', exact: true }).click(); const linked = page.getByRole('region', { name: '已关联渠道', exact: true }); await expect(linked.getByText('创作微信', { exact: true })).toBeVisible(); await expect(page.getByRole('region', { name: '个人微信渠道', exact: true })).toHaveCount(0); @@ -115,12 +115,20 @@ test('own WeChat conversation approval and generated PPT delivery in the desktop 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); - if (await page.getByRole('button', { name: '关闭二维码', exact: true }).count()) await page.getByRole('button', { name: '关闭二维码', exact: true }).click(); + 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.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 }); const qrStartsBeforeSwitch = await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }).cloudChannelFixtureCalls.filter(call => call.operation === 'channelAccountWechatBindStart').length); diff --git a/tests/unit/channel-accounts-page.test.tsx b/tests/unit/channel-accounts-page.test.tsx index 3d2f48c..9b92c86 100644 --- a/tests/unit/channel-accounts-page.test.tsx +++ b/tests/unit/channel-accounts-page.test.tsx @@ -1,6 +1,6 @@ -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; -import { beforeEach, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { ChannelAccounts } from '@/pages/CloudAgents/ChannelAccounts'; const api = vi.hoisted(() => ({ call: vi.fn(), list: vi.fn(), recovery: vi.fn(), resolvePending: vi.fn() })); @@ -33,6 +33,73 @@ beforeEach(() => { }); }); +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' }; + } + return base(operation, input); + }); + 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); +}); + +it.each(['channelAccountWechatBindStart', 'channelAccountWechatBindVerification'])('handles confirmed %s for an already routed account', async confirmedOperation => { + 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' }; + 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();