# WeChat UIA Phase D+E+F · 前端 + Tray + 端到端 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 让 UIA 渠道对用户可见可用:前端 channel-management 支持 `weixin-uia` type、`channel-group-panel` 支持每群绑定 agent / 回复身份 / 待审批横幅 / 忽略按钮、新增 `wechat-archive-panel` 归档查看抽屉;Neta.Tray 拉起 `bridge.exe` + 生命周期管理 + 菜单扩展;安装包把 `bridge.exe` 打入 `{installDir}/bin/bridge/`;最后跑端到端手工验证 14 条清单。本 plan 完成后 Neta UIA 渠道 MVP 完整可交付。 **Architecture:** 前端用已有 Pinia + Element Plus 栈,不引入新依赖;`channel-management.vue` 的 drawer 按 `form.type` 动态渲染字段;`channel-group-panel.vue` 扩展现有抽屉 UI(**不重建**),新增 `wechat-archive-panel.vue` 作为"归档记录"二级抽屉。Tray 端新增 `BridgeProcessManager.cs` 镜像 `BackendProcessManager.cs` 的模式,`TrayApplicationContext` 加 bridge 子菜单 + 健康轮询。安装包侧改动 `build-windows-installer.js` 脚本,额外 `dotnet publish` bridge.exe 并复制到安装目录。端到端 14 条沿用 spec `## 验证 · 端到端手工验证` 章节逐项执行。 **Tech Stack:** Vue 3 / Element Plus 2 / Pinia / TypeScript / .NET 8 (Tray + Bridge) / Node.js (installer 脚本)。 **Spec:** `docs/superpowers/specs/2026-05-09-wechat-uia-channel-design.md`(Spec 已按架构审查回改到最新版本) **前置依赖:** Plan A / Plan B / Plan C 必须**全部合并**才能完整跑端到端。前端 Phase D 可以提前实施(只要 Plan C 的 controller API 存在);Tray Phase E 要等 Plan A/B 的 bridge.exe 能本地启动。 **关键约束:** - **`channel-group-panel.vue` 增量扩展**——不重建。现有 `_pendingTrigger` 结构已经在渲染 `prefix` 按钮,本 plan 把它 hide 掉(UI 隐藏,不删除数据结构,保留历史兼容)。 - **`type=weixin-uia` 卡片渲染差异**:隐藏 ClawBot 扫码按钮、显示 Bridge 连接状态、显示 `微信 PC 版本` + 版本不兼容 tag、不显示 loginStatus='pending' 状态(UIA 不走扫码)。 - **UIA channel 创建时必填 wxid**——虽然 spec 原本写"bridge 自动识别",实际 handshake 按 wxid 查 channel,**用户不填 wxid 则永远对接不上**。Plan D Task 3 drawer 要求 UIA 必填 wxid(用户在 PC 微信"设置 → 关于 → 微信号"可看到);handshake 时 bridge 把 nickname/wechatVersion 回填 credential(架构师审查 D3)。 - **新建 UIA channel 时 wxid 唯一性校验**:前端 drawer 提交前先查 `/admin/netaclaw/agent_channel/page` 看同 wxid 是否已存在 UIA channel;存在则直接拒绝(呼应 Plan C S8 决策)。 - **bridge backend-url 来自业务端口,不是控制端口**:Tray 要把 `_lastStatus.Url`(backend koa 业务端口,默认 8003)传给 bridge,而不是 `ControlBaseUrl`(Tray 控制端口)。两者不同 HTTP server(架构师审查 D1/D2)。 - **bridge 端口让系统分配**:`TcpListener(Loopback, 0)` 绑 0 让 OS 分配一个空闲端口,避免随机端口冲突导致 bridge exit 8(架构师审查 D6)。 - **Tray 启动后等 /health 200 再认定 bridge 就绪**:最多重试 20 次 × 500ms(10s 总超时),符合 spec"等 GET bridge/health 返回 200"的精确要求,不用硬延迟(架构师审查 D-Spec-2)。 - **Tray 启动顺序**:`EnsureBackendAttachedAsync` 等 `/status.ready` → `StartBridgeIfNeededAsync` 拉起 bridge → poll `/health` 直到 200 或超时。bridge 进程按 `BackendProcessManager` 同样做 alive 轮询 + 3 次自愈重启。 - **`/upload/wechat-uploads` 静态映射**:backend config 需加新 staticFile prefix,把 `dataDir/wechat-uploads` 挂出来,归档面板才能显示图片(架构师审查 D4)。 - **bridgeOnline 字段暴露**:Plan C `WeixinUiaService.getLastHealth` 已经记录,Task 2.5 补一个 Task 在 `agent_channel.page()` 里 enrich 返回给前端(架构师审查 D5)。 - **安装包改动最小**:不改安装器 UI / 用户数据目录布局,只加 bridge.exe 复制 + 快捷方式可选。 - **`/diag` 前端只读展示**:不做"一键重启 bridge",交给 Tray 菜单处理。 - **群聊管理默认隐藏"已忽略"群**:status=-1 状态的群不出现在默认视图,通过单独开关查看(架构师审查 D8)。 - **端到端 checklist 按 spec 14 条完整跑**,每条打勾并留截图/日志证据;失败项建 issue 跟进但不阻塞 plan 标记完成。 - **不实现**(留 v2):归档"标记为有价值 → 转存 MySQL 业务表"(UI 先预留 placeholder 按钮,点击无动作)、跨群人物志、语音转文字。 --- ## 文件结构 ### 新增 | 文件 | 责任 | |---|---| | `packages/frontend/src/modules/agent/components/wechat-archive-panel.vue` | 归档查看抽屉,roomId 过滤 / triggerAccepted 筛选 / 图片预览 / 分页 | | `packages/frontend/src/modules/agent/composables/useUiaChannelValidation.ts` | wxid 唯一性前端校验 + bridge 在线态 composable | | `packages/backend/src/modules/netaclaw/controller/admin/wechat_archive.ts` | 归档 admin REST `/list` | | `packages/backend/test/modules/netaclaw/controller/admin_wechat_archive.test.ts` | admin controller 集成 | | `packages/windows-tray/Neta.Tray/BridgeProcessManager.cs` | bridge.exe 进程生命周期 (参照 BackendProcessManager) | | `packages/windows-tray/Neta.Tray/BridgeHealthPoller.cs` | 每 30s GET /health,连续 2 次失败则通知 backend 标记 disconnected (v2 做) / 更新 Tray 状态 (本 plan 做) | | `packages/windows-tray/Neta.Tray.Tests/BridgeProcessManagerTests.cs` | bridge 进程管理单测 | ### 修改 | 文件 | 改动 | |---|---| | `packages/frontend/src/modules/agent/views/channel-management.vue` | drawer 按 type 动态字段、UIA 卡片渲染差异、wxid 唯一性校验 | | `packages/frontend/src/modules/agent/components/channel-group-panel.vue` | `triggerMode` 只 2 档、每群 bind agent + replyIdentityOverride 编辑、待审批横幅、忽略按钮、查看归档入口 | | `packages/frontend/src/modules/agent/views/chat.vue` | 消息气泡左上角加 "DM / 群" 小标识(按 sessionId pattern) | | `packages/frontend/src/modules/agent/store/chat.ts` | `buildFallbackTitle` 已支持,核查不动 | | `packages/frontend/src/modules/agent/types/index.d.ts` | `AgentChannelInfo` 加 `type`/`bridgeOnline`/`wechatVersion`/`profileName` 字段 | | `packages/windows-tray/Neta.Tray/TrayApplicationContext.cs` | 新增 bridge 子菜单(状态/重启/查看日志)、启动后若有 UIA channel 则拉起 bridge | | `packages/windows-tray/Neta.Tray/BackendProcessManager.cs` | 无改动(保持 ProcessStartInfo 约定一致) | | `packages/backend/scripts/build-windows-installer.js`(若存在) | 在 backend 产物外,额外 `dotnet publish` bridge.exe 并复制到 `{installDir}/bin/bridge/` | --- ## Phase 1 · 前端类型 + 归档 admin controller ### Task 1: 前端类型扩展 **Files:** - Modify: `packages/frontend/src/modules/agent/types/index.d.ts` - [ ] **Step 1: 追加字段** ```ts export interface AgentChannelInfo { id?: number; name: string; type: 'weixin' | 'weixin-uia'; agentId?: number | null; agentName?: string | null; description?: string; config?: Record; credential?: Record | null; loginStatus?: 'pending' | 'connected' | 'disconnected' | 'error'; lastError?: string | null; lastConnectedAt?: string | null; status?: 0 | 1; createTime?: string; updateTime?: string; /** Plan C 新增 */ groupTotal?: number; groupEnabled?: number; /** Plan D 新增 — UIA 渠道专用 */ bridgeOnline?: boolean; wechatVersion?: string | null; profileName?: string | null; wxid?: string | null; nickname?: string | null; } export interface AgentGroupItem { id: number; channelId: number; roomId: string; roomName: string | null; status: -1 | 0 | 1; triggerMode: 'at_mention' | 'all' | 'prefix'; // prefix 仅兼容存量,前端不显示 triggerPrefix: string | null; boundAgentId: number | null; // Plan C 新增 replyIdentityOverride: 'silent' | 'ai_prefix' | null; // Plan C 新增 firstSeenAt: string | null; lastSeenAt: string | null; lastActiveAt: string | null; } ``` - [ ] **Step 2: Commit** ```bash git add packages/frontend/src/modules/agent/types/index.d.ts git commit -m "feat(agent-fe): extend types for UIA channel + group bindings" ``` --- ### Task 2: admin controller `/admin/netaclaw/wechat_archive/list` **Files:** - Create: `packages/backend/src/modules/netaclaw/controller/admin/wechat_archive.ts` - Test: `packages/backend/test/modules/netaclaw/controller/admin_wechat_archive.test.ts` - [ ] **Step 1: 写失败测试** ```ts import { close, createApp, createHttpRequest } from '@midwayjs/mock'; import type { Framework } from '@midwayjs/koa'; describe('POST /admin/netaclaw/wechat_archive/list', () => { let app: any; beforeAll(async () => { app = await createApp(); }); afterAll(async () => { await close(app); }); it('returns 400 when channelId / roomId missing', async () => { const r = await createHttpRequest(app) .post('/admin/netaclaw/wechat_archive/list') .send({}); expect(r.body.code).toBe(1003); }); it('returns empty list for unknown room', async () => { const r = await createHttpRequest(app) .post('/admin/netaclaw/wechat_archive/list') .send({ channelId: 999, roomId: 'nonexistent' }); expect(r.body.code).toBe(1000); expect(r.body.data).toEqual([]); }); it('respects acceptedOnly filter', async () => { // 前置 — 构造 archive 数据的 mock 略,后续在 service 层再补充 const r = await createHttpRequest(app) .post('/admin/netaclaw/wechat_archive/list') .send({ channelId: 1, roomId: 'r-1', acceptedOnly: true }); expect(r.body.code).toBe(1000); expect(Array.isArray(r.body.data)).toBe(true); }); }); ``` - [ ] **Step 2: 实现** ```ts import { Provide, Inject, Controller, Post, Body } from '@midwayjs/core'; import { WechatArchiveService } from '../../service/wechat_archive.js'; @Provide() @Controller('/admin/netaclaw/wechat_archive') export class NetaClawWechatArchiveAdminController { @Inject() archiveService: WechatArchiveService; @Post('/list') async list(@Body() body: { channelId: number; roomId: string; acceptedOnly?: boolean; rejectedOnly?: boolean; limit?: number; }) { if (!body?.channelId || !body?.roomId) { return { code: 1003, message: 'channelId and roomId required' }; } const data = this.archiveService.listByRoom(body.channelId, body.roomId, { limit: body.limit, acceptedOnly: body.acceptedOnly, rejectedOnly: body.rejectedOnly, }); return { code: 1000, data }; } } ``` - [ ] **Step 3: 测试通过** Expected:`Tests: 3 passed`。 - [ ] **Step 4: Commit** ```bash git add packages/backend/src/modules/netaclaw/controller/admin/wechat_archive.ts \ packages/backend/test/modules/netaclaw/controller/admin_wechat_archive.test.ts git commit -m "feat(netaclaw): add admin /wechat_archive/list" ``` --- ### Task 2.5: Backend 加 /wechat-uploads 静态服务映射 **Files:** - Modify: `packages/backend/src/config/config.default.ts` > 架构师审查 D4:归档面板要渲染图片,需要 backend 把 `dataDir/wechat-uploads` 暴露成 HTTP 静态资源。 - [ ] **Step 1: 修改 staticFile.dirs** ```ts import { resolveDataDir } from '../comm/data-dir'; // 在 staticFile.dirs 里追加: wechatUploads: { prefix: '/wechat-uploads', dir: path.join(resolveDataDir(), 'wechat-uploads'), }, ``` - [ ] **Step 2: 手工验证** 启 backend,浏览器访问 `http://localhost:8003/wechat-uploads/` 应返回 403/404 (目录不存在但路由已注册)。Plan A/B/C 实施后此目录会被 bridge 自动创建。 - [ ] **Step 3: Commit** ```bash git add packages/backend/src/config/config.default.ts git commit -m "feat(netaclaw): serve /wechat-uploads from dataDir for archive image preview" ``` > 注意 Task 8 的 `imageUrl` 路径拼接也要随之同步调整——从 `/upload/wechat-uploads/...` 改为 `/wechat-uploads/...`。 --- ### Task 2.6: agent_channel.page() enrich bridgeOnline / wechatVersion / profileName **Files:** - Modify: `packages/backend/src/modules/netaclaw/service/agent_channel.ts` (`page()` 方法) > 架构师审查 D5:前端卡片需要 `bridgeOnline` 字段,Plan C `WeixinUiaService.getLastHealth` 已记录,这里暴露给前端。 - [ ] **Step 1: 修改 page() enrich 逻辑** ```ts async page(params: { page?: number; size?: number; keyWord?: string; type?: string; loginStatus?: string }) { const { page = 1, size = 20, keyWord, type, loginStatus } = params; // ...原查询逻辑不变... const enriched = list.map(item => { const base: any = { ...item, groupTotal: groupStats.get(item.id)?.total ?? 0, groupEnabled: groupStats.get(item.id)?.enabled ?? 0, }; if (item.type === 'weixin-uia') { const last = this.weixinUiaService.getLastHealth(item.id); base.bridgeOnline = last?.ok ?? false; base.wechatVersion = (item.credential as any)?.wechatVersion ?? null; base.profileName = (item.credential as any)?.profileName ?? null; base.wxid = (item.credential as any)?.wxid ?? null; base.nickname = (item.credential as any)?.nickname ?? null; } return base; }); return { list: enriched, pagination: { page, size, total } }; } ``` - [ ] **Step 2: 测试追加 (agent_channel.test.ts)** ```ts it('page() enriches UIA channel with bridgeOnline from WeixinUiaService', async () => { const { service, weixinUiaService } = setupMocks({ type: 'weixin-uia' }); jest.spyOn(weixinUiaService, 'getLastHealth').mockReturnValue({ ok: true, at: Date.now() }); // ... 构造 repo mock 返回一条 UIA channel ... const res = await service.page({}); expect(res.list[0]).toMatchObject({ bridgeOnline: true }); }); ``` - [ ] **Step 3: Commit** ```bash git add packages/backend/src/modules/netaclaw/service/agent_channel.ts \ packages/backend/test/modules/netaclaw/service/agent_channel.test.ts git commit -m "feat(netaclaw): enrich page() with bridgeOnline/wechatVersion for UIA" ``` --- ## Phase 2 · channel-management.vue 扩展 ### Task 3: drawer 按 type 动态渲染 + UIA 字段 **Files:** - Modify: `packages/frontend/src/modules/agent/views/channel-management.vue` - [ ] **Step 1: 修改 drawer template** 把现有 drawer 表单里 agentId / botAlias 等字段用 `v-if="drawer.form.type === 'weixin'"` / `v-if="drawer.form.type === 'weixin-uia'"` 分组。加 UIA 专用字段: ```vue
UIA(本地代理)需本机已装 PC 微信并登录;ClawBot 通过扫码登录 iLink。
UIA 渠道可不填:每个群可独立绑定 agent (在群聊管理里配置)。此处是默认 agent。
``` - [ ] **Step 2: 修改 drawer.form 默认结构** ```ts const drawer = reactive({ visible: false, isEdit: false, form: { id: null as number | null, name: '', type: 'weixin' as 'weixin' | 'weixin-uia', agentId: null as number | null, description: '', botAlias: '', wxid: '', // 新增:UIA 渠道必填 replyIdentity: 'silent' as 'silent' | 'ai_prefix', config: {} as Record, }, }); ``` - [ ] **Step 3: 修改 handleSave 组装 payload + rules** rules 动态加: ```ts const rules = computed(() => ({ name: [{ required: true, message: '请输入频道名称', trigger: 'blur' }], type: [{ required: true, message: '请选择频道类型', trigger: 'change' }], agentId: drawer.form.type === 'weixin' ? [{ required: true, message: '请选择要绑定的 Agent', trigger: 'change' }] : [], wxid: drawer.form.type === 'weixin-uia' ? [{ required: true, message: 'UIA 渠道必须填写 wxid', trigger: 'blur' }] : [], botAlias: drawer.form.type === 'weixin' ? [{ required: true, message: 'ClawBot 渠道必须填写机器人昵称', trigger: 'blur' }] : [], })); ``` handleSave: ```ts const payload: any = { id: drawer.form.id, name: drawer.form.name, type: drawer.form.type, agentId: drawer.form.agentId, description: drawer.form.description, config: { ...drawer.form.config, group: { ...(drawer.form.config.group || {}), botAlias: drawer.form.botAlias.trim() || null, replyIdentity: drawer.form.type === 'weixin-uia' ? drawer.form.replyIdentity : undefined, }, }, }; if (drawer.form.type === 'weixin-uia') { payload.credential = { ...(drawer.form.config.credential || {}), wxid: drawer.form.wxid.trim(), }; } ``` - [ ] **Step 4: 核对 loadData 填回** `handleEdit(item)` 时把 `item.config?.group?.botAlias` 和 `replyIdentity` 填回 form。 - [ ] **Step 5: 手工冒烟** ```bash cd packages/frontend && pnpm dev ``` 打开 `http://localhost:9000/#/agent/channel-management`,点新建 → 切 type 下拉 → 字段切换正确。 - [ ] **Step 6: Commit** ```bash git add packages/frontend/src/modules/agent/views/channel-management.vue git commit -m "feat(agent-fe): channel drawer dynamic fields by type" ``` --- ### Task 4: UIA 渠道卡片渲染差异 + bridge 状态 tag **Files:** - Modify: `packages/frontend/src/modules/agent/views/channel-management.vue` - [ ] **Step 1: 修改卡片渲染** 卡片头部 meta 区加 UIA 分支: ```vue
{{ item.type === 'weixin-uia' ? '微信本地代理' : '微信' }} {{ loginStatusLabel(item.loginStatus) }} {{ item.status === 1 ? '启用' : '禁用' }} 群聊 {{ item.groupEnabled ?? 0 }}/{{ item.groupTotal }} Bridge 离线 微信版本不兼容
``` 卡片 body 区加 UIA 专属信息: ```vue
微信号 {{ item.wxid || '-' }} ({{ item.nickname || '未知' }})
微信版本 {{ item.wechatVersion || '-' }}(profile: {{ item.profileName }})
绑定 Agent {{ agentLabel(item) }}
...
``` 卡片 footer 按 type 分流按钮: ```vue ``` - [ ] **Step 2: 修改 handleManageGroups 调用 channel 的 type 传递** `groupPanel` 加 `channelType` 字段: ```ts const groupPanel = reactive({ visible: false, channelId: null as number | null, channelName: '', agentId: null as number | null, channelType: 'weixin' as 'weixin' | 'weixin-uia', // 新增 }); function handleManageGroups(item: AgentChannelInfo) { groupPanel.channelId = item.id ?? null; groupPanel.channelName = item.name; groupPanel.agentId = item.agentId ?? null; groupPanel.channelType = item.type; groupPanel.visible = true; } ``` 模板里: ```vue ``` - [ ] **Step 3: 手工冒烟 + Commit** ```bash git add packages/frontend/src/modules/agent/views/channel-management.vue git commit -m "feat(agent-fe): UIA-aware channel card (bridge status + wechat version)" ``` --- ### Task 5: UIA wxid 唯一性前端校验 **Files:** - Create: `packages/frontend/src/modules/agent/composables/useUiaChannelValidation.ts` - Modify: `packages/frontend/src/modules/agent/views/channel-management.vue`(handleSave 调用校验) - [ ] **Step 1: 实现 composable** ```ts import { config } from '/@/config'; import { useBase } from '/$/base'; import type { AgentChannelInfo } from '../types/index.d'; export function useUiaChannelValidation() { const { user } = useBase(); async function findOtherUiaChannelWithWxid( wxid: string, excludeChannelId?: number | null, ): Promise { if (!wxid) return null; const resp = await fetch(`${config.baseUrl}/admin/netaclaw/agent_channel/page`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: user.token || '' }, body: JSON.stringify({ type: 'weixin-uia', size: 200 }), }); const data = await resp.json(); const list = (data?.data?.list ?? []) as AgentChannelInfo[]; return list.find(c => (c.credential as any)?.wxid === wxid && c.id !== excludeChannelId, ) ?? null; } return { findOtherUiaChannelWithWxid }; } ``` - [ ] **Step 2: 新建 UIA 渠道时,由于 wxid 在 handshake 前未知,仅提示用户 "确保同一微信号不要绑多个 UIA 渠道"** 新建时无法硬校验(wxid 要 handshake 才知道);编辑时 credential 里已有 wxid,可以校验重复。改 `handleEdit` / `handleSave`: ```ts async function handleSave() { await formRef.value?.validate(); saving.value = true; try { // UIA 编辑场景:如果改了 wxid(理论上不会,但防呆),校验不与别的 UIA channel 冲突 if (drawer.form.type === 'weixin-uia' && drawer.form.id) { const existingWxid = (drawer.form.config?.credential as any)?.wxid; if (existingWxid) { const { findOtherUiaChannelWithWxid } = useUiaChannelValidation(); const clash = await findOtherUiaChannelWithWxid(existingWxid, drawer.form.id); if (clash) { ElMessage.error(`wxid ${existingWxid} 已绑定到频道 "${clash.name}",同一微信号只能绑一个 UIA 渠道`); return; } } } // ...原 save 逻辑 } finally { saving.value = false; } } ``` - [ ] **Step 3: Commit** ```bash git add packages/frontend/src/modules/agent/composables/useUiaChannelValidation.ts \ packages/frontend/src/modules/agent/views/channel-management.vue git commit -m "feat(agent-fe): validate wxid ↔ UIA channel uniqueness" ``` --- ## Phase 3 · channel-group-panel.vue 扩展 ### Task 6: triggerMode 收敛到 2 档 + 待审批横幅 + 忽略按钮 **Files:** - Modify: `packages/frontend/src/modules/agent/components/channel-group-panel.vue` - [ ] **Step 1: 修改 template 头部加横幅 + 筛选** ```vue
共发现 {{ list.length }} 个群 · 已启用 {{ enabledCount }} 个 · 已否决 {{ ignoredCount }} 个
刷新
...
``` - [ ] **Step 2: 修改 triggerMode radio 只 2 档** ```vue @机器人 (默认) 所有消息 (agent 自行判断相关性) ``` **向下兼容**:如果现有记录的 `triggerMode === 'prefix'`,前端把它展示为 "prefix (已弃用,请切换)",保存时强制改为 `at_mention`。 ```ts function normalizeTriggerMode(m: string): 'at_mention' | 'all' { if (m === 'at_mention' || m === 'all') return m; return 'at_mention'; // prefix / 未知值 fallback } ``` - [ ] **Step 3: 加"启用 / 忽略"按钮区(针对 status=0 的群)** ```vue
启用监听 忽略 查看对话记录
查看对话记录 查看归档 保存策略
恢复到待审批
``` - [ ] **Step 4: 实现 handleIgnore / handleUnignore** ```ts async function handleIgnore(group: GroupItem) { await apiPost('/admin/netaclaw/agent_channel_group/toggle', { id: group.id, status: -1 }); await loadList(); ElMessage.success('已忽略'); } async function handleUnignore(group: GroupItem) { await apiPost('/admin/netaclaw/agent_channel_group/toggle', { id: group.id, status: 0 }); await loadList(); } ``` - [ ] **Step 5: 加 computed** ```ts const pendingCount = computed(() => list.value.filter(g => g.status === 0).length); const ignoredCount = computed(() => list.value.filter(g => g.status === -1).length); const enabledCount = computed(() => list.value.filter(g => g.status === 1).length); // 三档筛选:pending / enabled / ignored / null=默认(不含 ignored) const statusFilter = ref<'pending' | 'enabled' | 'ignored' | null>(null); const visibleList = computed(() => { if (statusFilter.value === 'pending') return list.value.filter(g => g.status === 0); if (statusFilter.value === 'enabled') return list.value.filter(g => g.status === 1); if (statusFilter.value === 'ignored') return list.value.filter(g => g.status === -1); // 默认视图排除已忽略的,避免首次打开被大量 -1 记录干扰 return list.value.filter(g => g.status !== -1); }); ``` 横幅 + 头部统计要加"显示已忽略"切换: ```vue
共发现 {{ list.length }} · 已启用 {{ enabledCount }} · 待审批 {{ pendingCount }} 查看已忽略 {{ ignoredCount }} 返回默认视图
刷新
``` 模板里 `v-for="group in list"` 改为 `v-for="group in visibleList"`。 - [ ] **Step 6: Commit** ```bash git add packages/frontend/src/modules/agent/components/channel-group-panel.vue git commit -m "feat(agent-fe): triggerMode 2-way + approval banner + ignore button" ``` --- ### Task 7: 每群绑定 agent + 回复身份覆盖 **Files:** - Modify: `packages/frontend/src/modules/agent/components/channel-group-panel.vue` - [ ] **Step 1: 修改 GroupItem 类型加字段** ```ts interface GroupItem { id: number; channelId: number; roomId: string; roomName: string | null; status: number; triggerMode: string; triggerPrefix: string | null; boundAgentId: number | null; // 新增 replyIdentityOverride: 'silent' | 'ai_prefix' | null; // 新增 firstSeenAt: string | null; lastSeenAt: string | null; lastActiveAt: string | null; _pendingTrigger: { mode: string; prefix: string }; _pendingBoundAgentId: number | null; // 新增(编辑态) _pendingIdentity: 'follow' | 'silent' | 'ai_prefix'; // 新增(编辑态) } ``` - [ ] **Step 2: 加 props `channelType` + 有条件渲染** ```ts const props = defineProps<{ modelValue: boolean; channelId: number | null; channelName: string; channelType: 'weixin' | 'weixin-uia'; // 新增 agentId: number | null; }>(); ``` - [ ] **Step 3: 在每群卡片里加表单字段(仅 UIA 渠道显示)** ```vue ``` - [ ] **Step 4: 加载 agentOptions** 在 `loadList()` 里调 `GET /admin/netaclaw/agent_channel/options` 拿 agents 列表,或新增独立端点。 ```ts const agentOptions = ref>([]); async function loadAgentOptions() { const resp = await apiGet('/admin/netaclaw/agent_channel/options'); agentOptions.value = (resp.data?.agents ?? []).map((a: any) => ({ id: a.id, label: a.label || a.name, })); } ``` - [ ] **Step 5: 修改 handleSavePolicy 保存三个值** ```ts async function handleSavePolicy(group: GroupItem) { // 1. 触发策略 const mode = normalizeTriggerMode(group._pendingTrigger.mode); await apiPost('/admin/netaclaw/agent_channel_group/updatePolicy', { id: group.id, triggerMode: mode, triggerPrefix: null, }); // 2. bound agent await apiPost('/admin/netaclaw/agent_channel_group/setBoundAgent', { id: group.id, agentId: group._pendingBoundAgentId, }); // 3. 回复身份 const identity = group._pendingIdentity === 'follow' ? null : group._pendingIdentity; await apiPost('/admin/netaclaw/agent_channel_group/setReplyIdentity', { id: group.id, value: identity, }); await loadList(); ElMessage.success('已保存'); } ``` - [ ] **Step 6: loadList 里填 `_pendingBoundAgentId` / `_pendingIdentity`** ```ts for (const g of list.value) { g._pendingTrigger = { mode: normalizeTriggerMode(g.triggerMode), prefix: g.triggerPrefix || '' }; g._pendingBoundAgentId = g.boundAgentId; g._pendingIdentity = g.replyIdentityOverride ?? 'follow'; } ``` - [ ] **Step 7: 手工冒烟 + Commit** ```bash git add packages/frontend/src/modules/agent/components/channel-group-panel.vue git commit -m "feat(agent-fe): per-group agent binding + reply identity override" ``` --- ## Phase 4 · 归档查看抽屉 ### Task 8: wechat-archive-panel.vue **Files:** - Create: `packages/frontend/src/modules/agent/components/wechat-archive-panel.vue` - Modify: `packages/frontend/src/modules/agent/components/channel-group-panel.vue`(加"查看归档"按钮 → 打开此 panel) - [ ] **Step 1: 新建 panel 组件** ```vue ``` - [ ] **Step 2: channel-group-panel 集成"查看归档"入口** 在 `channel-group-panel.vue` 模板里加: ```vue ``` script 里: ```ts import WechatArchivePanel from './wechat-archive-panel.vue'; const archivePanel = reactive({ visible: false, channelId: null as number | null, roomId: null as string | null, roomName: '', }); function openArchive(group: GroupItem) { archivePanel.channelId = group.channelId; archivePanel.roomId = group.roomId; archivePanel.roomName = group.roomName || group.roomId; archivePanel.visible = true; } ``` Task 6 里的"查看归档"按钮已经绑定到 `openArchive`。 - [ ] **Step 3: 手工冒烟** 需要先有 Plan A+B+C 跑起来产生归档数据。若尚未实施,只验证 "空列表 / API 能调通"。 - [ ] **Step 4: Commit** ```bash git add packages/frontend/src/modules/agent/components/wechat-archive-panel.vue \ packages/frontend/src/modules/agent/components/channel-group-panel.vue git commit -m "feat(agent-fe): add wechat archive viewer panel" ``` --- ### Task 9: agent chat.vue 消息气泡加 DM/群 标识 **Files:** - Modify: `packages/frontend/src/modules/agent/views/chat.vue` - [ ] **Step 1: sessionId pattern 检测** sessionId 格式: - DM:`channel::weixin:` - 群:`channel::weixin:group:` 加 computed: ```ts const sessionKind = computed<'dm' | 'group' | 'other'>(() => { const sid = currentSessionId.value || ''; if (/^channel:\d+:weixin:group:/.test(sid)) return 'group'; if (/^channel:\d+:weixin:/.test(sid)) return 'dm'; return 'other'; }); ``` - [ ] **Step 2: 在消息气泡左上角加小 tag** ```vue
{{ sessionKind === 'group' ? '群' : 'DM' }}
...
``` 位置精确放在哪视现有模板结构而定;给的位置是示意,实施者按现有 class 结构接入。 - [ ] **Step 3: Commit** ```bash git add packages/frontend/src/modules/agent/views/chat.vue git commit -m "feat(agent-fe): show DM/group badge on chat message bubbles" ``` --- ## Phase 5 · Neta.Tray · BridgeProcessManager ### Task 10: BridgeProcessManager **Files:** - Create: `packages/windows-tray/Neta.Tray/BridgeProcessManager.cs` - Test: `packages/windows-tray/Neta.Tray.Tests/BridgeProcessManagerTests.cs` > 镜像 `BackendProcessManager` 的 API 形状,保证 TrayApplicationContext 同样模式调用。 - [ ] **Step 1: 写失败测试** ```csharp using Xunit; using Neta.Tray; public class BridgeProcessManagerTests { [Fact] public void BuildBridgeStartInfo_passes_cli_args() { var info = BridgeProcessManager.BuildBridgeStartInfo( @"C:\Program Files\Neta\bin\bridge\bridge.exe", traySecret: "sec-123", backendUrl: "http://127.0.0.1:7071", dataDir: @"C:\ProgramData\Neta", port: 7702); Assert.Equal(@"C:\Program Files\Neta\bin\bridge\bridge.exe", info.FileName); Assert.Contains("--tray-secret", info.ArgumentList); Assert.Contains("sec-123", info.ArgumentList); Assert.Contains("--backend-url", info.ArgumentList); Assert.Contains("http://127.0.0.1:7071", info.ArgumentList); Assert.Contains("--data-dir", info.ArgumentList); Assert.Contains(@"C:\ProgramData\Neta", info.ArgumentList); Assert.Contains("--bridge-port", info.ArgumentList); Assert.Contains("7702", info.ArgumentList); Assert.False(info.UseShellExecute); Assert.True(info.CreateNoWindow); } } ``` - [ ] **Step 2: 实现** ```csharp using System.Diagnostics; namespace Neta.Tray; public sealed class BridgeProcessManager { public static ProcessStartInfo BuildBridgeStartInfo( string bridgeExePath, string traySecret, string backendUrl, string dataDir, int port) { var info = new ProcessStartInfo(bridgeExePath) { UseShellExecute = false, CreateNoWindow = true, WorkingDirectory = Path.GetDirectoryName(bridgeExePath)!, }; info.ArgumentList.Add("--tray-secret"); info.ArgumentList.Add(traySecret); info.ArgumentList.Add("--backend-url"); info.ArgumentList.Add(backendUrl); info.ArgumentList.Add("--data-dir"); info.ArgumentList.Add(dataDir); info.ArgumentList.Add("--bridge-port"); info.ArgumentList.Add(port.ToString()); return info; } public Process Start(string bridgeExePath, string traySecret, string backendUrl, string dataDir, int port) => Process.Start(BuildBridgeStartInfo(bridgeExePath, traySecret, backendUrl, dataDir, port)) ?? throw new InvalidOperationException("bridge.exe 启动失败"); public bool IsBridgeProcessAlive(int pid) { try { var p = Process.GetProcessById(pid); return !p.HasExited; } catch { return false; } } public void KillProcess(int pid) { try { var p = Process.GetProcessById(pid); if (!p.HasExited) { p.Kill(true); p.WaitForExit(5000); } } catch { } } public void WaitForExit(int pid, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { if (!IsBridgeProcessAlive(pid)) return; Thread.Sleep(200); } } } ``` - [ ] **Step 3: 测试通过 + Commit** ```bash dotnet test packages/windows-tray/Neta.Tray.Tests --filter "FullyQualifiedName~BridgeProcessManagerTests" git add packages/windows-tray/Neta.Tray/BridgeProcessManager.cs \ packages/windows-tray/Neta.Tray.Tests/BridgeProcessManagerTests.cs git commit -m "feat(tray): add BridgeProcessManager" ``` --- ### Task 11: TrayApplicationContext 集成 bridge 子菜单 + 拉起 **Files:** - Modify: `packages/windows-tray/Neta.Tray/TrayApplicationContext.cs` - [ ] **Step 1: 加 bridge 字段** ```csharp private readonly BridgeProcessManager _bridgeManager; private Process? _bridgeProcess; private int _bridgePort; public TrayApplicationContext() : this(new BackendProcessManager(), new StatusClient(new HttpClient()), new BridgeProcessManager()) { } internal TrayApplicationContext( BackendProcessManager processManager, StatusClient statusClient, BridgeProcessManager bridgeManager) { _processManager = processManager; _statusClient = statusClient; _bridgeManager = bridgeManager; // ... 原构造逻辑 } ``` - [ ] **Step 2: 菜单加"微信桥接"子菜单** ```csharp var bridgeMenu = new ToolStripMenuItem("微信桥接"); bridgeMenu.DropDownItems.Add("状态", null, (_, _) => ShowBridgeStatus()); bridgeMenu.DropDownItems.Add("重启桥接", null, WrapAsync(RestartBridgeAsync)); bridgeMenu.DropDownItems.Add("查看日志", null, (_, _) => OpenBridgeLogs()); _notifyIcon.ContextMenuStrip.Items.Insert(4, bridgeMenu); // 插到"打开日志目录"前面 ``` - [ ] **Step 3: EnsureBackendAttachedAsync 之后拉 bridge** ```csharp private async Task EnsureBackendAttachedAsync() { // ... 原实现 ... if (_lastStatus is not null) { MarkRunning(); // 只有拿到 _lastStatus.Url (backend 业务端口) 才能给 bridge 正确 backend-url await StartBridgeIfNeededAsync(); } } private static int PickFreeLoopbackPort() { // 让 OS 分配一个可用 loopback 端口,避免随机端口冲突 var listener = new System.Net.Sockets.TcpListener( System.Net.IPAddress.Loopback, 0); listener.Start(); var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); return port; } private async Task StartBridgeIfNeededAsync() { if (_bridgeProcess is not null && !_bridgeProcess.HasExited) return; if (_runtime is null || _lastStatus is null) return; var bridgeExe = Path.Combine(AppContext.BaseDirectory, "bridge", "bridge.exe"); if (!File.Exists(bridgeExe)) { _notifyIcon.BalloonTipText = "bridge.exe 未安装;UIA 渠道不可用"; _notifyIcon.ShowBalloonTip(3000); return; } // 架构师审查 D6:OS 分配空闲端口,而不是随机挑 _bridgePort = PickFreeLoopbackPort(); try { // 架构师审查 D1/D2:bridge 要调的是 backend 的**业务端口**(_lastStatus.Url), // 不是 Tray 控制端口(_runtime.ControlBaseUrl)。两者是 backend 两个不同的 HTTP server。 var backendBusinessUrl = _lastStatus.Url.TrimEnd('/'); var dataDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Neta"); _bridgeProcess = _bridgeManager.Start( bridgeExe, _runtime.ControlSecret, // 和 backend 共用的 tray-secret backendBusinessUrl, dataDir, _bridgePort); } catch (Exception ex) { ShowError($"bridge 启动失败: {ex.Message}"); return; } // 架构师审查 D-Spec-2:spec 要求"等 /health 200",轮询而不是硬 delay using var probe = new HttpClient { Timeout = TimeSpan.FromSeconds(1) }; for (var i = 0; i < 20; i++) { if (_bridgeProcess.HasExited) { ShowError($"bridge 启动后立即退出 (exit code {_bridgeProcess.ExitCode})"); return; } try { using var req = new HttpRequestMessage( HttpMethod.Get, $"http://127.0.0.1:{_bridgePort}/health"); req.Headers.Add("x-neta-tray-secret", _runtime.ControlSecret); using var resp = await probe.SendAsync(req); if (resp.IsSuccessStatusCode) return; } catch { /* 还没起来,继续等 */ } await Task.Delay(500); } ShowError("bridge 启动超过 10 秒仍未就绪"); } ``` - [ ] **Step 4: 实现 RestartBridgeAsync / ShowBridgeStatus / OpenBridgeLogs** ```csharp private async Task RestartBridgeAsync() { if (_bridgeProcess is { HasExited: false }) { _bridgeManager.KillProcess(_bridgeProcess.Id); _bridgeProcess = null; } await StartBridgeIfNeededAsync(); } private void ShowBridgeStatus() { var alive = _bridgeProcess is { HasExited: false }; _notifyIcon.BalloonTipTitle = "微信桥接"; _notifyIcon.BalloonTipText = alive ? $"运行中 (pid={_bridgeProcess!.Id}, port={_bridgePort})" : "未运行"; _notifyIcon.ShowBalloonTip(3000); } private void OpenBridgeLogs() { var logDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Neta", "logs"); if (Directory.Exists(logDir)) Process.Start(new ProcessStartInfo(logDir) { UseShellExecute = true }); } ``` - [ ] **Step 5: ExitAllAsync 关闭 bridge** ```csharp private async Task ExitAllAsync() { if (_bridgeProcess is { HasExited: false }) { _bridgeManager.KillProcess(_bridgeProcess.Id); } await StopBackendAsync(); _notifyIcon.Visible = false; _notifyIcon.Dispose(); ExitThread(); } ``` - [ ] **Step 6: Commit** ```bash git add packages/windows-tray/Neta.Tray/TrayApplicationContext.cs git commit -m "feat(tray): start/monitor bridge.exe via BridgeProcessManager" ``` --- ## Phase 6 · 安装包打入 bridge.exe ### Task 12: build-windows-installer 脚本扩展 **Files:** - Modify: `packages/backend/scripts/build-windows-installer.js`(若存在) > 若脚本不存在或在别处,定位到实际 installer 构建流程并同步改动。 - [ ] **Step 1: 在 backend publish 后加 bridge publish 步骤** 伪代码: ```js // 在现有 dotnet publish tray + backend.exe 步骤之后 const bridgeOut = path.join(stage, 'bin', 'bridge'); fs.mkdirSync(bridgeOut, { recursive: true }); await exec([ 'dotnet', 'publish', path.join(repoRoot, 'packages/windows-tray/Neta.WeChatBridge/Neta.WeChatBridge.csproj'), '-c', 'Release', '-r', 'win-x64', '--self-contained=false', '-o', bridgeOut, ]); ``` - [ ] **Step 2: 安装器把 `bin/bridge/` 作为子目录加入最终 msi/exe 制品** 具体按 installer 框架(NSIS / WiX / Inno Setup) 加 file group。若现在脚本已经通配 `*.exe`,确保包含 `bin/bridge/bridge.exe` 路径即可。 - [ ] **Step 3: 本地构建一次验证产物存在** ```bash cd packages/backend && node scripts/build-windows-installer.js ls dist-windows/.../bin/bridge/bridge.exe ``` Expected:文件存在、`--version` 能运行。 - [ ] **Step 4: Commit** ```bash git add packages/backend/scripts/build-windows-installer.js git commit -m "build(installer): bundle bridge.exe in windows installer" ``` --- ## Phase 7 · 端到端手工验证 ### Task 13: Spec 14 条 E2E checklist > 这个 Task 无代码,全部是手工验证。完整走一遍 spec 末尾"端到端手工验证(部署到 Windows 测试机)"节 14 条。**每条验证完画 ✅,失败项单独开 issue**,不阻塞本 plan 合并。 **前置条件:** - Plan A + B + C + D 全部代码合并 - Windows 测试机装 PC 微信 3.9.11.17 并登录测试号 - Neta Windows 安装包本地构建并安装 - 后端 MySQL 测试库 ready **Checklist:** - [ ] **E2E-01**: 启动 Neta Tray,后端 + bridge 都拉起,前端频道页访问正常 - [ ] **E2E-02**: 创建 UIA channel(type=weixin-uia),handshake 后 wxid/nickname 自动填充 - [ ] **E2E-03**: 测试群里任一成员说 "hello",bridge 切窗采集 → 前端群聊管理"待审批"横幅出现该群 - [ ] **E2E-04**: 点"启用监听" + 选 at_mention + 填 botAlias=小神 - [ ] **E2E-05**: 群里发 `@小神 你好` → agent 回复 → bot 在群里发回复 → 发送方是测试号自己 - [ ] **E2E-06**: 切到 all 模式 → 群里随便说 → agent 通过 system prompt 判定相关性 → 不相关时返回 [SKIP] 不发 - [ ] **E2E-07**: 群里发图 → SQLite 落归档 → 前端归档抽屉能预览到图片 - [ ] **E2E-08**: 关掉 PC 微信 → bridge `/health` 失败 → channel 状态变 disconnected - [ ] **E2E-09**: 重开 PC 微信 → bridge 自愈 → channel 重连 - [ ] **E2E-10**: 故意装 PC 微信 4.x(不在白名单)→ bridge 启动失败 → tray 气泡通知"版本不兼容" - [ ] **E2E-11**: 一个微信号同时绑 ClawBot + UIA channel:DM 走 ClawBot / 群走 UIA,不重复响应 - [ ] **E2E-12**: 删 UIA channel → group 表级联清;SQLite 归档保留(按决策不级联删) - [ ] **E2E-13**: 群被改名 → 视为新群,又出现在"待审批"横幅 - [ ] **E2E-14**: 群里回复 UIA bot 的消息(包含"引用") → 前端对话页的 user message 能看到"[被引用: ... 原文...]"结构化上下文 每条通过后在 PR 评论 / issue tracker 里勾选并附截图/日志。失败项记录到 `docs/superpowers/followups/2026-05-09-uia-e2e-failures.md`。 - [ ] **Step 1: 执行完整 checklist** 按上 14 条逐项跑,做好记录。 - [ ] **Step 2: 写验证报告** ```bash mkdir -p docs/superpowers/followups cat > docs/superpowers/followups/2026-05-09-uia-e2e-report.md <<'EOF' # UIA MVP E2E 验证报告 · 2026-05-09 | # | 项目 | 状态 | 备注 | |---|---|---|---| | E2E-01 | Tray 启动全链路 | ✅ | | | ... | | | | ## 失败项 follow-up (如有) EOF ``` - [ ] **Step 3: Commit** ```bash git add docs/superpowers/followups/2026-05-09-uia-e2e-report.md git commit -m "docs(uia): e2e manual verification report" ``` --- ## 自检 (Self-Review) **1. Spec 覆盖:** | Spec 章节 | 覆盖 Task | |---|---| | "前端 UX 调整 · 频道管理页" | Task 3 + Task 4 | | "频道管理页 · 动态表单字段" | Task 3 | | "频道管理页 · UIA 卡片 bridge 状态 tag" | Task 4 | | "wxid ↔ UIA channel 唯一性" | Task 5 | | "群聊管理抽屉 · 触发策略 2 档" | Task 6 | | "群聊管理抽屉 · 待审批横幅 + 忽略按钮" | Task 6 | | "群聊管理抽屉 · 每群绑定 agent + 回复身份覆盖" | Task 7 | | "agent 对话页 · DM/群气泡小标识" | Task 9 | | "归档查看页 · wechat-archive-panel" | Task 8 | | "Neta.Tray 菜单扩展" | Task 11 | | "BridgeProcessManager" | Task 10 | | "Tray 启动顺序 · 先 backend 再 bridge" | Task 11 | | "崩溃自愈"(基础) | Task 11(最多 3 次通过 StartBridgeIfNeeded 隐式;完整 alive 轮询 + 指数退避留 v2) | | "安装包分发 · bridge.exe 打入 {installDir}/bin/bridge/" | Task 12 | | "端到端手工验证 14 条" | Task 13 | **Spec 中未覆盖(明确留 v2):** - 崩溃自愈完整的 3 次重启 + 30s 间隔 + tray 气泡 — Task 11 当前只做"需要时拉起",没有 alive 轮询定时器。可接受,v2 再补 - 归档"标记为有价值 → 转存 MySQL 业务表" — UI 按钮 disabled 占位 - 语音/视频/跨群人物志 — spec 明确 v2 **2. Placeholder 扫描:** Task 8 的"标记为有价值"按钮 disabled,这是 spec 明确的 v2 兑现项,不是 plan 内部占位。 **3. 类型一致性:** - 前端 `AgentGroupItem.triggerMode` 类型包含 `'prefix'` 是为向下兼容,UI 仅写 `at_mention` / `all`,保存时 normalize。 - `AgentGroupItem.boundAgentId` / `replyIdentityOverride` 字段与 Plan C `NetaClawAgentChannelGroupEntity` 完全一致。 - Tray `BridgeProcessManager.BuildBridgeStartInfo` 的参数顺序 (exe, traySecret, backendUrl, dataDir, port) 与 Plan A `BridgeRuntimeInfo.Parse` 接受的 CLI 参数对齐。 **4. 跨 Plan 衔接契约:** - Plan A CLI 参数 `--tray-secret / --backend-url / --data-dir / --bridge-port` → Task 10 `BridgeProcessManager` 传递一致 - Plan C `/admin/netaclaw/wechat_archive/list` → Task 8 前端调用一致 - Plan C `/admin/netaclaw/agent_channel_group/setBoundAgent` / `setReplyIdentity` → Task 7 前端调用一致 --- ## 架构师交叉审查 (2026-05-09) 本 plan 初稿后做了一轮系统架构师审查,发现 7 个问题 + 1 处 spec 表述问题,全部直接修复到位: | # | 严重度 | 问题 | 修复 | |---|---|---|---| | D1/D2 | 🔴 高 | bridge backend-url 传 Tray 控制端口而非业务端口 | Task 11 用 `_lastStatus.Url.TrimEnd('/')` 作为 backend-url;等 `_lastStatus` 设置好才启 bridge | | D3 | 🔴 高 | UIA 创建时不填 wxid → handshake 永远查不到 channel | Task 3 drawer 加 `wxid` 必填字段 + rules 动态校验;spec 表格"扫码登录"行改为"wxid 手填 必填" | | D4 | 🟠 中 | 归档面板 imageUrl 假设 `/upload/wechat-uploads` 路由存在,实际没 mapping | 新增 Task 2.5: backend config 加 `/wechat-uploads` 静态前缀指向 `dataDir/wechat-uploads` | | D5 | 🟠 中 | 前端卡片用 `bridgeOnline` 字段但 `page()` 不返回 | 新增 Task 2.6: `agent_channel.page()` enrich `bridgeOnline` / `wechatVersion` / `profileName` / `wxid` / `nickname` | | D6 | 🟠 中 | bridge 端口随机取 [49152,65535) 可能冲突 → exit 8 | Task 11 改 `PickFreeLoopbackPort` (用 `TcpListener(Loopback,0)` 让 OS 分配) | | D-Spec-2 | 🟠 中 | spec 要求"等 /health 200",plan 只 `Task.Delay(1500)` | Task 11 改为轮询 `/health` 最多 20 × 500ms,非 2xx 就再等 | | D8 | 🟡 低 | 已忽略群默认显示会打扰用户 | Task 6 `visibleList` 默认过滤 status=-1;单独按钮"查看已忽略" | | D-Spec-3 | 📝 spec | 表格"扫码登录 ❌(bridge 自动识别)"歧义 | Spec 改为"wxid 手填 必填;handshake 时 bridge 按此匹配并回填 nickname/wechatVersion" | --- ## Execution Handoff Plan D 写作 + 架构师审查 + 回改全部完成,保存至 `docs/superpowers/plans/2026-05-09-wechat-uia-d-frontend-tray-e2e.md`。 前置依赖:Plan A + Plan B + Plan C 全部合并。 **4 份 plan 全部就位,每份都经过架构师审查 + spec 同步修正。可选执行路径:** - Plan C (Backend) 最快跑出可验证的端到端:controller + ingestUiaInbound + mock bridge POST 模拟 → TDD 完整 - Plan A (Bridge 骨架) 最独立:不依赖其它 plan 合并,可立刻本地跑 - Plan B 依赖 Plan A + 需要 Plan C handshake 响应才能端到端 - Plan D 最后:依赖前三者