GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-05-09-wechat-uia-d-frontend-tray-e2e.md
2026-05-20 21:39:12 +08:00

1608 lines
60 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 最后:依赖前三者