From 12d856cd60277bcc35670b0dc65dc64de8b4fe48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 12:06:38 +0800 Subject: [PATCH] feat: keep WeChat replies enabled and remove unbound cards locally --- .../20261014-wechat-always-reply-4c92e7a1.md | 47 ++ README.md | 2 +- shared/cloud-agents.ts | 1 + src/pages/CloudAgents/ChannelAccounts.tsx | 292 +++++--- tests/e2e/cloud-agent-channels.spec.ts | 181 ++++- tests/unit/channel-accounts-page.test.tsx | 687 +++++++++++++++--- 6 files changed, 1029 insertions(+), 181 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20261014-wechat-always-reply-4c92e7a1.md diff --git a/.project-docs/30-worklog/tasks/20261014-wechat-always-reply-4c92e7a1.md b/.project-docs/30-worklog/tasks/20261014-wechat-always-reply-4c92e7a1.md new file mode 100644 index 00000000..0c071cb3 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261014-wechat-always-reply-4c92e7a1.md @@ -0,0 +1,47 @@ +# Task: Remove user pause controls and keep bound WeChat replies enabled + +## Identity + +- Task ID: 20261014-wechat-always-reply-4c92e7a1 +- Mode: Feature +- Branch: codex/20261014-wechat-always-reply-4c92e7a1-wechat-always-reply +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261014-wechat-always-reply-4c92e7a1 +- Base commit: d0451e9a9dc85288149376a55ecf2153ba3facb4 +- Owner: codex +- Status: Planning + +## Scope + +- Add authorized device-only removal of safely unbound cards, scoped by tenant/user and bound revision; keep cloud identities/history intact and make rebindings visible again. Remove the WeChat user pause/resume feature. Completed binding, target switch and reconnect keep replies enabled; safely restore healthy old disabled bindings without bypassing original-operation, identity, logout, target or connectivity protections. + +## Intent And Constraints + +- User explicitly reverses the previous manual-pause policy: “不要这个功能,微信永远都可以回复。” This authorizes restoring ready valid old bindings, not changing unavailable Agents or misreporting service availability. Stop using a channel through existing unbind. Persistent constraint: no Yuxi/AgentBus edits; no planning-with-files workflow. +- Official Concurrent/Planning Gate Passed: task4c92e7a1/feature/planning/worktree/branch/base d0451e9 match; primary occupied dirty remains read-only. Independent reviewer65peer records/195sections and incremental changes found no semantic conflict. Earlier pause/preserve-paused ADR/task prose is superseded by current direct human instruction; canonical docs stay read-only. +- Project Context Loaded: read AGENTS; startup/memory/planning/own record; positioning, integrated state, decisions/auth004/personal cloud ADR, architecture/module/data flow, business/success/evidence/reflection/commitment indexes and community boundary. Main owns authentication and original-operation journals; Core/Yuxi persist route state. Source base is prior full trialRenderer including previous copy and homepage sidebar. +- Source plan: remove pause/resume controls and paused-success copy; logged-in target switches request enabled=true, new/unlogged-in associations stay safely disabled until login; reconnect now enables after fresh readiness; serially restore eligible old bindings after fresh accounts/catalog/recovery checks, stopping on failure and keeping the original new enable operation ID. Do not mutate a pending old pause/route body's identity. No restore during an edit/scan/unknown result, failed read or owner logout. +- Verification: update prior copy fixtures from committed a837c4f, extend policy/negative/race/recovery cases, run typecheck/scoped lint/focused unit/full default Vite/isolated Electron. Live delivery must use latest actual runtime rather than overwrite it. +- Latest runtime has advanced to work-entry40930aec/Main93092/Host58003, with Main manifest metadata temporarily stale and peer awaiting manual login/final verification. Preserve this actual Main and wait for truthful final source/manifest before delivery. Unconditional backend-wide auto-recovery while client is closed remains a colleague policy concern; no source code here can guarantee service uptime. + +- Added user steering: unbound cards must support deletion; read-only assessment found no backend delete/archive interface. User explicitly chose device-only removal. Fresh original-operation, account revision, logout and authenticated owner checks precede persisting the local preference; it never deletes cloud identity or history. + +## Outcome + +- Implemented always-enabled valid bindings, no pause/resume UI, enabled connected target switches and reconnect completion, guarded serial convergence of healthy legacy disabled accounts. Actual online presence is mandatory for automatic restoration; original operation IDs/journals remain unchanged and unknown results stop the batch. Zero-write preflights can retry after a later refresh; changed targets require manual refresh. +- Implemented local card deletion only after confirmed unbind/logout, with explicit device-only confirmation, scoped tenant/user revision preferences, persistence across refresh/reopen, rematerialization after rebind/revision change, fresh owner/recovery/revision/logout checks and visible storage failure. Unbound but still connected/unknown accounts retain safe unbind. No Yuxi/AgentBus or Main backend edits; DTO only declares the existing optional worker_online response. +- README synchronized. Source complete and independently reviewed; immutable latest-runtime delivery/native verification pending. + +## Verification + +- Focused Page unit tests 108/108 passed, including enabled-policy, serial old-record restoration, zero-write same-revision offline recovery, legacy management journals, unknown write identity retention, owner/tenant changes, deletion cancel/persistence/storage failure/rebind and fresh-state denials. +- Typecheck and scoped ESLint passed before final fixture correction; final typecheck/lint rerun in progress. Full default build:vite passed for Renderer/Main/Preload/Utility. +- Isolated Electron empty-workspace scenario passed. Main interaction scenario initially failed because the mock reused the first QR session for reconnect; source correctly deduplicated it. Test now assigns the second scan a fresh session, scoped scenario passed 1/1 in7.3s. No product dedup change. Both scenarios now passed;16 screenshots produced, root visually inspected deletion confirmation and post-removal/reload list without clipping. +- Independent final source review PASS at Page SHA2565f418bb424b7a26cb815023fab5c0351210a9c0106835d51268b3ea5032de490. Project-doc ownership drift passed. Runtime delivery will preserve complete Renderer ec07c67 and actual Main40930/PID93092/Host58003, whose peer source/artifacts and fresh acceptance receipts are verified. + +## Follow-ups + +- None recorded. + +## Promotion Candidates + +- None recorded. diff --git a/README.md b/README.md index d7feb64d..69c0e51f 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。 - Agents 侧栏的「渠道」按每个微信一张卡展示备注、当前智能体与使用状态;一个微信同时只绑定一个智能体,多个微信可以使用同一智能体。常用操作是“更换智能体”,在同一个选择窗口中确认;暂停自动回复和解除微信绑定放在“更多操作”。暂停停止当前任务并保留登录、绑定和历史;更换保留登录与原启停状态;解除微信绑定是完整操作,停止任务、清除目标并退出微信登录,历史保留,再使用需扫码。掉线显示重新扫码入口,暂停显示继续回复,未知或未同步状态显示正在确认,不宣称可用。添加流程为选择已开始使用的智能体、扫码、自动完成绑定;只有这次用户发起的绑定在扫码确认、目标正确、启用成功且最终状态回读可用后才显示完成,不因页面打开、旧记录或验证码提交自动启用。无可用智能体时说明创建和“开始使用”的前提,加载失败不冒充空态,已有微信仍可管理或解绑。Main 持有凭据与原操作恢复;解除需要配套 Yuxi 用户级 `/channel-accounts/{id}/unbind`,旧后端不会通过改按钮文案或组合未确认操作假装完成。变更后以最新读取状态为准,未完成操作保留原身份供显式恢复;失败在当前窗口可见,结果未知不自动重放。服务端显示微信退出仍在处理中时,禁止新的连接、启停和绑定变更,保留刷新与原操作恢复;管理写入前再次读取当前状态。客户端已知目标智能体停用时明确显示不可用,并禁止恢复自动回复,仍可更换目标或安全暂停、解绑。旧请求无法安全恢复、账号关系不一致或目标不可用时提示先核对,保留原记录并避免盲目重试;账号关系或目标智能体问题可显式刷新状态后再次确认原操作,刷新本身不重放操作;“不再提醒”只移除本机提醒,不取消服务端仍在处理的操作。最新 Yuxi 的退出保护条件和持久恢复由服务端负责,发布需配套 Core/Adapter 与 Yuxi business=13、knowledge=2 数据库版本。渠道页不展示微信聊天或活动,既有历史、费用、内容隔离与应用 API 保持原边界。实际微信身份及重复扫码识别仍由云端负责,不从备注推测。 + Agents 侧栏的「渠道」按每个微信一张卡展示备注、当前智能体与使用状态;一个微信同时只绑定一个智能体,多个微信可以使用同一智能体。绑定有效且服务正常时始终开启回复,不提供暂停或恢复开关;更换智能体保留登录与历史,完成重新登录后也开启回复。旧的关闭记录在页面可见时,重新核对登录、在线服务、可用目标、同步状态及新旧渠道操作恢复记录后串行开启;服务离线、退出待确认、目标不可用和结果未知时不自动写入。常用操作是“更换智能体”;“更多操作”内可以解除微信绑定,停止任务、清除目标并退出微信登录,历史保留,再使用需扫码。确认解绑后的卡片可选择“删除微信卡片”,只从这台设备的当前账号列表移除,刷新和重开后仍保留删除结果;其他设备及云端账号和聊天历史不受影响,同一微信重新绑定后再次显示。本机偏好按账号与租户隔离,删除前再次读取云端状态并检查原操作,保存失败时保留卡片并显示原因。添加流程为选择已开始使用的智能体、扫码、自动完成绑定;扫码确认、目标正确、启用成功且最终状态回读可用后才显示完成。服务离线如实说明,不把扫码成功当作回复可用;未知或未同步状态显示“状态待确认”。无可用智能体时说明创建和“开始使用”的前提,加载失败不冒充空态,已有微信仍可解绑。Main 持有凭据与原操作恢复;解除需要配套 Yuxi 用户级 `/channel-accounts/{id}/unbind`,旧后端不会通过改按钮文案或组合未确认操作假装完成。变更以最新读取状态为准,未完成操作保留原身份供显式恢复;失败在当前窗口可见,结果未知不自动重放。微信退出仍在处理中时,禁止新的连接和绑定变更,保留刷新与原操作恢复。旧请求无法安全恢复、账号关系不一致或目标不可用时提示先核对;“不再提醒”只移除本机提醒,不取消服务端仍在处理的操作。最新 Yuxi 的退出保护条件和持久恢复由服务端负责,发布需配套 Core/Adapter 与 Yuxi business=13、knowledge=2 数据库版本。渠道页不展示微信聊天或活动,既有历史、费用、内容隔离与应用 API 保持原边界。实际微信身份及重复扫码识别仍由云端负责,不从备注推测。 - `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧项目栏沿用 Agents 的图标徽章与文字入口,新建、切换和项目操作菜单集中管理 Workspace。工作区统一项目标题、设计版本与制作状态,创作对话和作品区支持拖动分栏、键盘调宽及单栏展开;对话以 Markdown 呈现,底部输入器保留草稿、发送状态与 Enter / Shift+Enter 快捷键。右侧通过“当前作品 / 历史作品”切换,在大图或视频预览旁编辑唯一制作方案、确认费用和制作;历史作品可回到工作区查看,切换面板保留未保存的制作参数。紧凑窗口以“创作对话 / 作品与方案”切换面板,通过项目抽屉管理 Workspace。图片支持适应窗口、原始尺寸及 Esc 关闭,视频支持预览和保存到电脑。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。 - `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 diff --git a/shared/cloud-agents.ts b/shared/cloud-agents.ts index aef4e919..3668754f 100644 --- a/shared/cloud-agents.ts +++ b/shared/cloud-agents.ts @@ -216,6 +216,7 @@ export interface CloudUserChannelAccount { enabled: boolean; status: string; health: string; + worker_online?: boolean; blockers: string[]; target_agent_slug: string | null; target_agent_name: string | null; diff --git a/src/pages/CloudAgents/ChannelAccounts.tsx b/src/pages/CloudAgents/ChannelAccounts.tsx index 42480f58..d7a999ae 100644 --- a/src/pages/CloudAgents/ChannelAccounts.tsx +++ b/src/pages/CloudAgents/ChannelAccounts.tsx @@ -6,11 +6,10 @@ import { Link2, Loader2, MoreHorizontal, - Pause, - Play, Plus, QrCode, RefreshCw, + Trash2, Unplug, } from 'lucide-react'; import { useNavigate, useSearchParams } from 'react-router-dom'; @@ -25,6 +24,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; +import { useAuthStore, type AuthUser } from '@/stores/auth'; import type { CloudAgentDraft, CloudPendingOperation, @@ -36,13 +36,30 @@ import { CloudRecovery } from './CloudRecovery'; import { CloudWechatQr } from './CloudWechatQr'; type Flow = { - mode: 'new' | 'switch' | 'reconnect' | 'unbind'; + mode: 'new' | 'switch' | 'reconnect' | 'unbind' | 'delete'; step: 'choose' | 'connect' | 'result'; accountId: string; target: string; }; type Expected = { target: string | null; enabled: boolean; disconnected?: boolean }; const errorText = (error: unknown) => (error instanceof Error ? error.message : '操作没有完成,请重试'); +const isChannelManagementOperation = (operation: string) => + /channelaccount|channelbinding|^wechat/iu.test(operation); +function cardPreferenceKey(user: AuthUser | null) { + return user?.userId + ? 'makelore.wechat-removed-cards:' + JSON.stringify([user.tenantId, user.userId]) + : ''; +} +function readRemovedCards(key: string): Record { + if (!key) return {}; + try { + const value: unknown = JSON.parse(localStorage.getItem(key) ?? '{}'); + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return Object.fromEntries(Object.entries(value).filter(([, revision]) => + typeof revision === 'number' && Number.isSafeInteger(revision) && revision >= 0 + )); + } catch { return {}; } +} const usableBlockers = (account: CloudUserChannelAccount) => account.blockers.filter((item) => item !== 'binding_paused'); const matches = (account: CloudUserChannelAccount | undefined, expected: Expected) => @@ -83,6 +100,9 @@ const unsettled = (account: CloudUserChannelAccount) => account.blockers.includes('channel_sync_degraded') || (['paused', 'disabled'].includes(account.desired_state ?? '') && account.enabled) || (account.desired_state === 'enabled' && !account.enabled); +const canRemoveCard = (account: CloudUserChannelAccount) => + !account.target_agent_slug && !account.enabled && account.health === 'login_required' && + account.sync_state === 'ready' && !unsettled(account); function accountState(account: CloudUserChannelAccount, uncertain: boolean, targetDisabled = false) { if (uncertain || unsettled(account)) return { text: '状态待确认', tone: 'bg-[#fff0d8] text-[#876033]' }; if (!account.target_agent_slug) return { text: '未绑定', tone: 'bg-[#f0edf8] text-[#75618e]' }; @@ -95,7 +115,7 @@ function accountState(account: CloudUserChannelAccount, uncertain: boolean, targ return { text: '状态待确认', tone: 'bg-[#fff0d8] text-[#876033]' }; return account.enabled ? { text: '使用中', tone: 'bg-[#e7f4ec] text-[#3f7458]' } - : { text: '已暂停', tone: 'bg-[#f0edf8] text-[#75618e]' }; + : { text: '绑定待完成', tone: 'bg-[#f0edf8] text-[#75618e]' }; } function bindingReadinessMessage(account: CloudUserChannelAccount) { if (unsettled(account)) return '微信使用状态尚未确认,请刷新状态后再继续。'; @@ -106,6 +126,20 @@ function bindingReadinessMessage(account: CloudUserChannelAccount) { ? blockerMessage(blocker, account) : '暂时无法完成微信绑定,请检查连接后再继续。'; } +function canRestoreReplies(account: CloudUserChannelAccount, agents: CloudAgentDraft[]) { + const target = agents.find((agent) => agent.slug === account.target_agent_slug); + return Boolean( + !account.enabled && + account.health === 'connected' && + account.worker_online === true && + account.sync_state === 'ready' && + !unsettled(account) && + !usableBlockers(account).length && + target?.published_version && + target.enabled !== false && + target.archived !== true + ); +} async function loadAgents(): Promise { const items: CloudAgentDraft[] = []; let cursor: string | null = null; @@ -119,9 +153,21 @@ async function loadAgents(): Promise { export function ChannelAccounts() { const navigate = useNavigate(); + const user = useAuthStore((state) => state.user); + const cardStorageKey = cardPreferenceKey(user); + const [removedCards, setRemovedCards] = useState(() => ({ + key: cardStorageKey, revisions: readRemovedCards(cardStorageKey), + })); + useEffect(() => { + setRemovedCards({ key: cardStorageKey, revisions: readRemovedCards(cardStorageKey) }); + }, [cardStorageKey]); const [params, setParams] = useSearchParams(); const initial = useRef({ account: params.get('account') ?? '', target: params.get('target') ?? '' }); const [accounts, setAccounts] = useState([]); + const visibleAccounts = accounts.filter((account) => + removedCards.key !== cardStorageKey || + removedCards.revisions[account.id] !== account.revision || !canRemoveCard(account) + ); const [agents, setAgents] = useState([]); const agentsRef = useRef([]); const availableAgents = agents.filter( @@ -152,25 +198,30 @@ export function ChannelAccounts() { const readGeneration = useRef(0); const ownerPending = useRef(false); const refreshGeneration = useRef(0); + const replyRestoreAttempts = useRef(new Set()); + const replyRestorePreflightSkipped = useRef(new Set()); + const replyRestoreStopped = useRef(false); + const replyPolling = useRef(false); + const [restoringReplies, setRestoringReplies] = useState(false); const [checkingIds, setCheckingIds] = useState([]); const selected = accounts.find((account) => account.id === flow?.accountId); - const recoveryBlocked = pending.some((item) => /channelaccount/iu.test(item.operation)); + const recoveryBlocked = pending.some((item) => isChannelManagementOperation(item.operation)); const ownerLogoutPending = accounts.some((account) => account.blockers.includes('wechat_disconnect_pending') ); - const disabled = Boolean(busy) || recoveryBlocked || !recoveryLoaded || ownerLogoutPending; + const disabled = Boolean(busy) || restoringReplies || recoveryBlocked || !recoveryLoaded || ownerLogoutPending; const pendingFor = (id: string) => pending.some( - (item) => /channelaccount/iu.test(item.operation) && item.input.channel_account_id === id + (item) => isChannelManagementOperation(item.operation) && item.input.channel_account_id === id ); const updateFlow = (next: Flow | null) => { flowRef.current = next; setFlow(next); }; - const setCheck = (id: string, expected: Expected) => { + const setCheck = useCallback((id: string, expected: Expected) => { checks.current.set(id, expected); setCheckingIds([...checks.current.keys()]); - }; + }, []); const readAccounts = useCallback(async () => { const generation = ++readGeneration.current; const page = await cloudAgentsApi.call('channelAccounts', {}); @@ -192,7 +243,12 @@ export function ChannelAccounts() { } return page.items; }, []); - const refresh = useCallback(async () => { + const refresh = useCallback(async (retryReplyRestore = false) => { + if (!busyRef.current) replyRestorePreflightSkipped.current.clear(); + if (retryReplyRestore && !busyRef.current) { + replyRestoreStopped.current = false; + replyRestoreAttempts.current.clear(); + } const generation = ++refreshGeneration.current; const [accountResult, agentResult, recoveryResult] = await Promise.allSettled([ readAccounts(), @@ -245,15 +301,15 @@ export function ChannelAccounts() { // Consume the deep link once; refresh must preserve the user's selection. // eslint-disable-next-line react-hooks/exhaustive-deps }, [accounts, loading]); - const operationId = (key: string, input: unknown) => { + const operationId = useCallback((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 run = async (key: string, accountId: string, task: () => Promise): Promise => { + }, []); + const run = useCallback(async (key: string, accountId: string, task: () => Promise): Promise => { if (busyRef.current || recoveryBlocked || !recoveryLoaded || ownerPending.current) return null; busyRef.current = true; const generation = ++refreshGeneration.current; @@ -293,7 +349,7 @@ export function ChannelAccounts() { setBusyAccount(''); } } - }; + }, [readAccounts, recoveryBlocked, recoveryLoaded]); const resetConnection = () => { setQr(null); setQrAccountId(''); @@ -331,25 +387,25 @@ export function ChannelAccounts() { setParams(next, { replace: true }); if (mode === 'reconnect') void startQr(account); }; - const verifyFinal = async (id: string, expected: Expected) => { + const verifyFinal = useCallback(async (id: string, expected: Expected) => { setCheck(id, expected); const fresh = (await readAccounts()).find((item) => item.id === id); if (!matches(fresh, expected)) throw new Error('操作结果还没有确认,请刷新状态后再试。'); return fresh!; - }; + }, [readAccounts, setCheck]); const route = async (account: CloudUserChannelAccount, target: string) => { if (ownerPending.current) throw new Error('微信退出还在确认中,请先处理原来的操作。'); const input = { channel_account_id: account.id, target_agent_slug: target, expected_revision: account.revision, - enabled: account.enabled, + enabled: account.health === 'connected', }; await cloudAgentsApi.call('routeChannelAccount', { ...input, operation_id: operationId('route', input), }); - const fresh = await verifyFinal(account.id, { target, enabled: account.enabled }); + const fresh = await verifyFinal(account.id, { target, enabled: input.enabled }); intents.current.delete('route'); return fresh; }; @@ -371,18 +427,17 @@ export function ChannelAccounts() { if (ownerPending.current) throw new Error('微信退出还在确认中,请先处理原来的操作。'); if (agentsRef.current.some((agent) => agent.slug === target && agent.enabled === false)) throw new Error('当前智能体已停用,请更换智能体后再使用。'); - const activate = flowRef.current?.mode !== 'reconnect'; - if (!account.enabled && activate) { + if (!account.enabled) { const input = { channel_account_id: id, expected_revision: account.revision }; await cloudAgentsApi.call('enableChannelAccount', { ...input, - operation_id: operationId('enable', input), + operation_id: operationId('enable:' + id, input), }); account = await verifyFinal(id, { target, enabled: true }); } if (account.health !== 'connected' || unsettled(account) || usableBlockers(account).length) throw new Error(bindingReadinessMessage(account)); - intents.current.delete('enable'); + intents.current.delete('enable:' + id); return account; }); if (!alive.current || flowRef.current?.accountId !== id) return; @@ -460,30 +515,6 @@ export function ChannelAccounts() { else void startQr(result); } }; - const setEnabled = async (account: CloudUserChannelAccount) => { - setMore(''); - const enabled = !account.enabled; - if ( - enabled && - agentsRef.current.some( - (agent) => agent.slug === account.target_agent_slug && agent.enabled === false - ) - ) - return; - const key = enabled ? 'enable' : 'pause'; - const fresh = await run(key, account.id, async () => { - const input = { channel_account_id: account.id, expected_revision: account.revision }; - await cloudAgentsApi.call(enabled ? 'enableChannelAccount' : 'pauseChannelAccount', { - ...input, - operation_id: operationId(key, input), - }); - const result = await verifyFinal(account.id, { target: account.target_agent_slug, enabled }); - intents.current.delete(key); - return result; - }); - if (fresh && alive.current) - setNotice(enabled ? '已恢复自动回复。' : '已暂停自动回复,微信连接和智能体保留。'); - }; const unbind = async () => { if (!selected) return; const fresh = await run('unbind', selected.id, async () => { @@ -513,6 +544,96 @@ export function ChannelAccounts() { close(false); } }; + const removeCard = async () => { + if (!selected || !cardStorageKey || !canRemoveCard(selected)) return; + const result = await run('remove-card', selected.id, async () => { + const recovery = await cloudAgentsApi.recovery(); + if (!alive.current) return false; + setPending(recovery.pending); + if (recovery.pending.some((item) => isChannelManagementOperation(item.operation))) + throw new Error('上次微信操作还没有确认,请先处理后再删除卡片。'); + const observed = await readAccounts(); + const account = observed.find((item) => item.id === selected.id); + if (!alive.current || cardPreferenceKey(useAuthStore.getState().user) !== cardStorageKey) + return false; + if (ownerPending.current || !account || account.revision !== selected.revision || !canRemoveCard(account)) + throw new Error('这个微信的状态已变化,请刷新并确认解绑后再删除卡片。'); + const revisions = { ...readRemovedCards(cardStorageKey), [account.id]: account.revision }; + try { localStorage.setItem(cardStorageKey, JSON.stringify(revisions)); } + catch { throw new Error('卡片没有删除:暂时无法保存到这台设备,请重试。'); } + setRemovedCards({ key: cardStorageKey, revisions }); + return true; + }); + if (result && alive.current) { + setNotice('微信卡片已从这台设备移除,聊天历史保留。'); + close(false); + } + }; + useEffect(() => { + if ( + loading || !accountsLoaded || !agentsLoaded || agentsError || + !recoveryLoaded || recoveryBlocked || ownerLogoutPending || + busy || busyRef.current || restoringReplies || flow || more || + checkingIds.length || replyRestoreStopped.current + ) return; + const candidate = accounts.find((account) => + canRestoreReplies(account, agents) && + !replyRestoreAttempts.current.has(`${account.id}:${account.revision}:${account.target_agent_slug}`) && + !replyRestorePreflightSkipped.current.has(`${account.id}:${account.revision}:${account.target_agent_slug}`) + ); + if (!candidate) return; + // A zero-write preflight can retry after the next read without spinning in this render. + replyRestorePreflightSkipped.current.add(`${candidate.id}:${candidate.revision}:${candidate.target_agent_slug}`); + setRestoringReplies(true); + void run('restore-replies', candidate.id, async () => { + const [catalog, recovery] = await Promise.all([loadAgents(), cloudAgentsApi.recovery()]); + if (!alive.current || flowRef.current) return { restored: false }; + agentsRef.current = catalog; + setAgents(catalog); + setPending(recovery.pending); + setRecoveryLoaded(true); + if (recovery.pending.some((item) => isChannelManagementOperation(item.operation))) + return { restored: false }; + const observed = await readAccounts(); + const account = observed.find((item) => item.id === candidate.id); + if (!account || account.target_agent_slug !== candidate.target_agent_slug) + return { restored: false, stop: true }; + if ( + !alive.current || flowRef.current || ownerPending.current || + observed.some((item) => item.blockers.includes('wechat_disconnect_pending')) || + !canRestoreReplies(account, catalog) + ) return { restored: false }; + const key = 'enable:' + account.id; + const input = { channel_account_id: account.id, expected_revision: account.revision }; + replyRestoreAttempts.current.add(`${account.id}:${account.revision}:${account.target_agent_slug}`); + await cloudAgentsApi.call('enableChannelAccount', { + ...input, + operation_id: operationId(key, input), + }); + await verifyFinal(account.id, { target: account.target_agent_slug, enabled: true }); + intents.current.delete(key); + return { restored: true }; + }).then((result) => { + if (!result || result.stop) replyRestoreStopped.current = true; + }).finally(() => { + if (alive.current) setRestoringReplies(false); + }); + }, [accounts, accountsLoaded, agents, agentsLoaded, agentsError, recoveryLoaded, + recoveryBlocked, ownerLogoutPending, busy, restoringReplies, flow, more, + checkingIds, loading, run, readAccounts, operationId, verifyFinal]); + useEffect(() => { + if (flow || recoveryBlocked || ownerLogoutPending || !accounts.some((account) => + account.target_agent_slug && (!account.enabled || usableBlockers(account).length) + )) return; + const timer = window.setInterval(() => { + if (document.visibilityState === 'hidden' || busyRef.current || replyPolling.current) return; + replyPolling.current = true; + void refresh() + .catch((failure) => { if (alive.current) setError(errorText(failure)); }) + .finally(() => { replyPolling.current = false; }); + }, 10000); + return () => window.clearInterval(timer); + }, [accounts, flow, recoveryBlocked, ownerLogoutPending, refresh]); const pollingId = flow?.step === 'connect' ? flow.accountId : ''; const pollingSession = qr?.session_key ?? ''; const pollingStatus = qr?.status ?? ''; @@ -586,7 +707,7 @@ export function ChannelAccounts() { role="alert" className="rounded-2xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm text-destructive" > - {agentsError}。已有微信仍可以暂停或解除绑定。 + {agentsError}。已有微信仍可以解除绑定。

)} {ownerLogoutPending && ( @@ -604,7 +725,7 @@ export function ChannelAccounts() { variant="ghost" size="sm" disabled={Boolean(busy)} - onClick={() => void refresh().catch((failure) => setError(errorText(failure)))} + onClick={() => void refresh(true).catch((failure) => setError(errorText(failure)))} > 刷新确认 @@ -639,7 +760,7 @@ export function ChannelAccounts() {
void refresh().catch((failure) => setError(errorText(failure)))} + onResolved={() => void refresh(true).catch((failure) => setError(errorText(failure)))} />
@@ -659,7 +780,7 @@ export function ChannelAccounts() { size="sm" aria-label="刷新微信列表" disabled={Boolean(busy)} - onClick={() => void refresh().catch((failure) => setError(errorText(failure)))} + onClick={() => void refresh(true).catch((failure) => setError(errorText(failure)))} > @@ -699,7 +820,7 @@ export function ChannelAccounts() {
暂时没能读到微信绑定,请刷新后再试。
- ) : !accounts.length ? ( + ) : !visibleAccounts.length ? ( agentsLoaded && !agentsError && availableAgents.length > 0 && ( @@ -725,7 +846,7 @@ export function ChannelAccounts() { className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3" aria-label="我的微信绑定" > - {accounts.map((account) => { + {visibleAccounts.map((account) => { const agent = agents.find((item) => item.slug === account.target_agent_slug); const uncertain = !recoveryLoaded || checkingIds.includes(account.id) || pendingFor(account.id); @@ -744,12 +865,6 @@ export function ChannelAccounts() { const blocked = disabled || uncertain; const changeBlocked = blocked || unsettled(account) || !agentsLoaded || Boolean(agentsError); - const canResume = - !targetDisabled && - !unsettled(account) && - account.health === 'connected' && - Boolean(account.target_agent_slug) && - !usableBlockers(account).length; return (
)} - {account.target_agent_slug && ( - - )} - + } + {!account.target_agent_slug && }
)} {uncertain && ( @@ -879,7 +988,7 @@ export function ChannelAccounts() { variant="ghost" className="mt-2 text-xs" disabled={Boolean(busy)} - onClick={() => void refresh().catch((failure) => setError(errorText(failure)))} + onClick={() => void refresh(true).catch((failure) => setError(errorText(failure)))} > 刷新确认 @@ -905,7 +1014,9 @@ export function ChannelAccounts() { > - {flow?.mode === 'unbind' + {flow?.mode === 'delete' + ? '删除微信卡片' + : flow?.mode === 'unbind' ? '解除微信绑定' : flow?.mode === 'reconnect' ? '重新连接微信' @@ -925,7 +1036,24 @@ export function ChannelAccounts() { 上次操作的结果还需要确认。请关闭窗口,处理页面上的恢复提醒。

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

+ 删除后,这张卡片将从这台设备的微信列表移除。其他设备的记录和聊天历史保留;以后重新绑定时会再次显示。 +

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

解除后,这个微信将停止自动回复,当前任务会停止,并断开微信登录。智能体聊天历史仍保留,再使用需要重新扫码。 @@ -979,7 +1107,7 @@ export function ChannelAccounts() { )} {flow.mode === 'switch' && selected?.target_agent_slug && (

- 更换会停止当前任务,微信连接和历史保留。{!selected.enabled && '这个微信仍保持暂停。'} + 更换会停止当前任务,微信连接和历史保留。连接正常后将使用新的智能体回复。

)} @@ -1115,9 +1243,7 @@ export function ChannelAccounts() { {flow.mode === 'reconnect' ? '微信重新连接成功' : '微信绑定成功'}

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