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 ?? '智能体'}聊聊。`}