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