GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-05-09-wechat-uia-d-frontend-tray-e2e.md

1608 lines
60 KiB
Markdown
Raw Normal View History

2026-05-20 21:39:12 +08:00
# 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<string, any>;
credential?: Record<string, any> | 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<Framework>(); });
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
<el-form-item label="频道类型" prop="type">
<el-select v-model="drawer.form.type" style="width: 100%" :disabled="drawer.isEdit">
<el-option v-for="item in options.types" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<div class="form-hint">UIA(本地代理)需本机已装 PC 微信并登录;ClawBot 通过扫码登录 iLink。</div>
</el-form-item>
<el-form-item label="绑定 Agent" :prop="drawer.form.type === 'weixin' ? 'agentId' : undefined">
<el-select v-model="drawer.form.agentId" style="width: 100%" filterable clearable>
<el-option v-for="item in options.agents" :key="item.id" :label="item.label" :value="item.id" />
</el-select>
<div v-if="drawer.form.type === 'weixin-uia'" class="form-hint">
UIA 渠道可不填:每个群可独立绑定 agent (在群聊管理里配置)。此处是默认 agent。
</div>
</el-form-item>
<!-- ClawBot 专属字段 -->
<template v-if="drawer.form.type === 'weixin'">
<el-form-item label="机器人昵称" prop="botAlias">
<el-input v-model="drawer.form.botAlias" placeholder="bot 在群里显示的昵称" clearable />
<div class="form-hint">必填项,否则群聊 "@机器人" 策略无法识别。</div>
</el-form-item>
</template>
<!-- UIA 专属字段 -->
<template v-if="drawer.form.type === 'weixin-uia'">
<el-form-item label="微信号 wxid" prop="wxid" required>
<el-input v-model="drawer.form.wxid" placeholder="如:wxid_abc123 (在 PC 微信 设置→关于 查看)" clearable />
<div class="form-hint">必填。同一 wxid 只能绑一个 UIA 渠道。handshake 时 bridge 按此 wxid 匹配本频道并回填 nickname/版本。</div>
</el-form-item>
<el-form-item label="机器人别名">
<el-input v-model="drawer.form.botAlias" placeholder="选填:群里 @ 触发时的别名(默认用微信昵称)" clearable />
</el-form-item>
<el-form-item label="默认回复身份">
<el-radio-group v-model="drawer.form.replyIdentity">
<el-radio value="silent">隐形(直接发内容)</el-radio>
<el-radio value="ai_prefix">【AI 助手】前缀</el-radio>
</el-radio-group>
</el-form-item>
<el-alert type="info" :closable="false" show-icon
title="请确保 Tray 已启动 WeChat Bridge,且 PC 微信已登录。handshake 成功后 nickname 与微信版本会自动回填。"
/>
</template>
```
- [ ] **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<string, any>,
},
});
```
- [ ] **Step 3: 修改 handleSave 组装 payload + rules**
rules 动态加:
```ts
const rules = computed<FormRules>(() => ({
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
<div class="channel-card__meta">
<el-tag size="small" type="success">
{{ item.type === 'weixin-uia' ? '微信本地代理' : '微信' }}
</el-tag>
<el-tag size="small" :type="statusTagType(item.loginStatus)">
{{ loginStatusLabel(item.loginStatus) }}
</el-tag>
<el-tag size="small" :type="item.status === 1 ? 'primary' : 'info'">
{{ item.status === 1 ? '启用' : '禁用' }}
</el-tag>
<el-tag
v-if="(item.groupTotal ?? 0) > 0"
size="small" type="info"
:class="{ 'group-badge--inactive': (item.groupEnabled ?? 0) === 0 }"
>
群聊 {{ item.groupEnabled ?? 0 }}/{{ item.groupTotal }}
</el-tag>
<el-tag v-if="item.type === 'weixin-uia' && item.bridgeOnline === false" size="small" type="danger">
Bridge 离线
</el-tag>
<el-tag v-if="item.type === 'weixin-uia' && item.wechatVersion && !item.profileName" size="small" type="danger">
微信版本不兼容
</el-tag>
</div>
```
卡片 body 区加 UIA 专属信息:
```vue
<div class="channel-card__body">
<div class="info-row" v-if="item.type === 'weixin-uia'">
<span class="label">微信号</span>
<span class="value">{{ item.wxid || '-' }} ({{ item.nickname || '未知' }})</span>
</div>
<div class="info-row" v-if="item.type === 'weixin-uia'">
<span class="label">微信版本</span>
<span class="value">{{ item.wechatVersion || '-' }}<span v-if="item.profileName" class="form-hint">(profile: {{ item.profileName }})</span></span>
</div>
<!-- 原有 info-row 保留 -->
<div class="info-row">
<span class="label">绑定 Agent</span>
<span class="value">{{ agentLabel(item) }}</span>
</div>
...
</div>
```
卡片 footer 按 type 分流按钮:
```vue
<div class="channel-card__footer">
<el-button link type="primary" @click="handleEdit(item)">编辑</el-button>
<el-button
v-if="item.type === 'weixin'"
link type="success" @click="openQrDialog(item)"
>ClawBot 扫码登录</el-button>
<el-button
link type="primary" @click="handleManageGroups(item)"
:disabled="item.type === 'weixin-uia' && item.bridgeOnline === false"
>群聊管理</el-button>
<!-- 其它按钮保留 -->
</div>
```
- [ ] **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
<channel-group-panel
v-model="groupPanel.visible"
:channel-id="groupPanel.channelId"
:channel-name="groupPanel.channelName"
:channel-type="groupPanel.channelType"
:agent-id="groupPanel.agentId"
/>
```
- [ ] **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<AgentChannelInfo | null> {
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
<el-drawer v-model="visible" :title="`群聊管理 · ${channelName}`" size="720px" @opened="loadList">
<div class="group-panel" v-loading="loading">
<!-- 待审批横幅 -->
<el-alert
v-if="pendingCount > 0"
type="warning" show-icon :closable="false"
:title="`新发现 ${pendingCount} 个群(待审批)`"
>
<template #default>
<el-button link size="small" @click="statusFilter = 'pending'">仅看待审批</el-button>
<el-button v-if="statusFilter !== null" link size="small" @click="statusFilter = null">显示全部</el-button>
</template>
</el-alert>
<div class="group-panel__header">
<div>共发现 {{ list.length }} 个群 · 已启用 {{ enabledCount }} 个 · 已否决 {{ ignoredCount }} 个</div>
<el-button size="small" @click="loadList">刷新</el-button>
</div>
...
</div>
</el-drawer>
```
- [ ] **Step 2: 修改 triggerMode radio 只 2 档**
```vue
<el-radio-group v-model="group._pendingTrigger.mode">
<el-radio value="at_mention">@机器人 (默认)</el-radio>
<el-radio value="all">所有消息 (agent 自行判断相关性)</el-radio>
</el-radio-group>
<!-- 删除 prefix 选项和 prefix input -->
```
**向下兼容**:如果现有记录的 `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
<div class="group-card__actions" v-if="group.status === 0">
<el-button size="small" type="primary" @click="handleToggle(group, true)">启用监听</el-button>
<el-button size="small" @click="handleIgnore(group)">忽略</el-button>
<el-button size="small" @click="jumpToChat(group)">查看对话记录</el-button>
</div>
<div class="group-card__actions" v-else-if="group.status === 1">
<el-button size="small" @click="jumpToChat(group)">查看对话记录</el-button>
<el-button size="small" @click="openArchive(group)">查看归档</el-button>
<el-button size="small" type="primary" @click="handleSavePolicy(group)">保存策略</el-button>
</div>
<div class="group-card__actions" v-else-if="group.status === -1">
<el-button size="small" @click="handleUnignore(group)">恢复到待审批</el-button>
</div>
```
- [ ] **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
<div class="group-panel__header">
<div>
共发现 {{ list.length }} · 已启用 {{ enabledCount }} · 待审批 {{ pendingCount }}
<el-button v-if="ignoredCount > 0 && statusFilter !== 'ignored'" link size="small"
@click="statusFilter = 'ignored'"
>查看已忽略 {{ ignoredCount }}</el-button>
<el-button v-if="statusFilter !== null" link size="small"
@click="statusFilter = null"
>返回默认视图</el-button>
</div>
<el-button size="small" @click="loadList">刷新</el-button>
</div>
```
模板里 `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
<template v-if="props.channelType === 'weixin-uia'">
<div class="group-card__form-row">
<label>绑定 Agent</label>
<el-select v-model="group._pendingBoundAgentId" clearable placeholder="未选 → 使用频道默认 Agent" style="width: 280px">
<el-option v-for="agent in agentOptions" :key="agent.id" :label="agent.label" :value="agent.id" />
</el-select>
</div>
<div class="group-card__form-row">
<label>回复身份</label>
<el-radio-group v-model="group._pendingIdentity">
<el-radio value="follow">跟随频道</el-radio>
<el-radio value="silent">隐形</el-radio>
<el-radio value="ai_prefix">【AI 助手】前缀</el-radio>
</el-radio-group>
</div>
</template>
```
- [ ] **Step 4: 加载 agentOptions**
`loadList()` 里调 `GET /admin/netaclaw/agent_channel/options` 拿 agents 列表,或新增独立端点。
```ts
const agentOptions = ref<Array<{ id: number; label: string }>>([]);
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
<template>
<el-drawer v-model="visible" :title="`归档 · ${roomName}`" size="640px" @opened="loadList">
<div class="archive-panel" v-loading="loading">
<div class="archive-panel__filter">
<el-radio-group v-model="filter" @change="loadList">
<el-radio-button value="all">全部</el-radio-button>
<el-radio-button value="accepted">已接纳</el-radio-button>
<el-radio-button value="rejected">被拒绝</el-radio-button>
</el-radio-group>
<el-button size="small" @click="loadList">刷新</el-button>
</div>
<div v-if="list.length === 0" class="archive-panel__empty">暂无记录</div>
<div v-for="row in list" :key="row.id" class="archive-row">
<div class="archive-row__head">
<span class="sender">{{ row.senderName }}</span>
<span class="time">{{ formatTime(row.receivedAt) }}</span>
<el-tag size="small" :type="row.triggerAccepted ? 'success' : 'info'">
{{ row.triggerAccepted ? '已接纳' : '未接纳' }}
</el-tag>
<el-tag v-if="!row.triggerAccepted && row.triggerReason" size="small" type="warning">
{{ row.triggerReason }}
</el-tag>
</div>
<div class="archive-row__body">
<div v-if="row.msgType === 'text' || row.msgType === 'quote'">{{ row.content }}</div>
<img v-else-if="row.msgType === 'image' && row.attachmentPath"
:src="imageUrl(row.attachmentPath)" class="archive-img" />
<div v-else class="archive-row__meta">[{{ row.msgType }}] {{ row.content }}</div>
<div v-if="row.atList" class="archive-row__meta">@{{ JSON.parse(row.atList).join(', @') }}</div>
<div v-if="row.quotedRef" class="archive-row__meta">
引用 {{ JSON.parse(row.quotedRef).senderName }}: {{ JSON.parse(row.quotedRef).preview }}
</div>
</div>
<div class="archive-row__footer">
<el-button v-if="row.sessionEntryId" size="small" link @click="jumpToChat(row)">跳转对话</el-button>
<el-button size="small" link disabled>标记为有价值(v2)</el-button>
</div>
</div>
</div>
</el-drawer>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import { config } from '/@/config';
import { useBase } from '/$/base';
interface ArchiveRow {
id: number;
roomId: string;
msgId: string;
senderName: string;
msgType: string;
content: string | null;
attachmentPath: string | null;
atList: string | null;
quotedRef: string | null;
receivedAt: string;
triggerAccepted: 0 | 1;
triggerReason: string | null;
sessionEntryId: string | null;
}
const props = defineProps<{
modelValue: boolean;
channelId: number | null;
roomId: string | null;
roomName: string;
}>();
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void }>();
const { user } = useBase();
const router = useRouter();
const visible = computed({
get: () => props.modelValue,
set: v => emit('update:modelValue', v),
});
const list = ref<ArchiveRow[]>([]);
const loading = ref(false);
const filter = ref<'all' | 'accepted' | 'rejected'>('all');
async function loadList() {
if (!props.channelId || !props.roomId) return;
loading.value = true;
try {
const resp = await fetch(`${config.baseUrl}/admin/netaclaw/wechat_archive/list`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: user.token || '' },
body: JSON.stringify({
channelId: props.channelId,
roomId: props.roomId,
acceptedOnly: filter.value === 'accepted',
rejectedOnly: filter.value === 'rejected',
limit: 200,
}),
});
const data = await resp.json();
list.value = data?.data ?? [];
} finally {
loading.value = false;
}
}
function imageUrl(attachmentPath: string): string {
// attachmentPath 是绝对本地路径 dataDir/wechat-uploads/<cid>/<ym>/<hash>.ext
// backend 把 dataDir/wechat-uploads 挂在 /wechat-uploads 静态路由(Task 2.5 实现)
const rel = attachmentPath.replace(/^.*wechat-uploads[\\/]/, '');
return `${config.baseUrl}/wechat-uploads/${rel.replace(/\\/g, '/')}`;
}
function formatTime(iso: string): string {
try { return new Date(iso).toLocaleString(); } catch { return iso; }
}
function jumpToChat(row: ArchiveRow) {
if (!row.sessionEntryId) return;
router.push({
path: '/agent/chat',
query: { sessionId: row.sessionEntryId.split(':').slice(0, 4).join(':') },
});
}
watch(() => props.modelValue, v => { if (v) loadList(); });
</script>
<style scoped lang="scss">
.archive-panel { padding: 12px; }
.archive-panel__filter { display: flex; gap: 8px; margin-bottom: 12px; }
.archive-row { border: 1px solid #ebeef5; border-radius: 6px; padding: 8px; margin-bottom: 8px; }
.archive-row__head { display: flex; gap: 8px; align-items: center; margin-bottom: 4px; }
.archive-row__body { padding: 4px 0; }
.archive-row__meta { color: #909399; font-size: 12px; }
.archive-img { max-width: 200px; max-height: 200px; border-radius: 4px; }
.sender { font-weight: bold; }
.time { color: #909399; font-size: 12px; }
</style>
```
- [ ] **Step 2: channel-group-panel 集成"查看归档"入口**
`channel-group-panel.vue` 模板里加:
```vue
<wechat-archive-panel
v-model="archivePanel.visible"
:channel-id="archivePanel.channelId"
:room-id="archivePanel.roomId"
:room-name="archivePanel.roomName"
/>
```
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:<cid>:weixin:<senderId>`
- 群:`channel:<cid>:weixin:group:<roomId>`
加 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
<div class="msg-bubble">
<div v-if="sessionKind !== 'other'" class="msg-badge">
<el-tag size="small" :type="sessionKind === 'group' ? 'warning' : 'primary'">
{{ sessionKind === 'group' ? '群' : 'DM' }}
</el-tag>
</div>
...
</div>
```
位置精确放在哪视现有模板结构而定;给的位置是示意,实施者按现有 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 最后:依赖前三者