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

60 KiB
Raw Blame History

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.readyStartBridgeIfNeededAsync 拉起 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 AgentChannelInfotype/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: 追加字段

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
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: 写失败测试

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: 实现
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
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
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
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 逻辑
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)
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
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 专用字段:

      <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 默认结构
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 动态加:

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:

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?.botAliasreplyIdentity 填回 form。

  • Step 5: 手工冒烟
cd packages/frontend && pnpm dev

打开 http://localhost:9000/#/agent/channel-management,点新建 → 切 type 下拉 → 字段切换正确。

  • Step 6: Commit
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 分支:

      <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 专属信息:

      <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 分流按钮:

      <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 传递

groupPanelchannelType 字段:

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;
}

模板里:

<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
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

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:

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
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 头部加横幅 + 筛选

  <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 档
        <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

function normalizeTriggerMode(m: string): 'at_mention' | 'all' {
  if (m === 'at_mention' || m === 'all') return m;
  return 'at_mention'; // prefix / 未知值 fallback
}
  • Step 3: 加"启用 / 忽略"按钮区(针对 status=0 的群)
        <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
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
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);
});

横幅 + 头部统计要加"显示已忽略"切换:

<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
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 类型加字段

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 + 有条件渲染
const props = defineProps<{
  modelValue: boolean;
  channelId: number | null;
  channelName: string;
  channelType: 'weixin' | 'weixin-uia';     // 新增
  agentId: number | null;
}>();
  • Step 3: 在每群卡片里加表单字段(仅 UIA 渠道显示)
        <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 列表,或新增独立端点。

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 保存三个值
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
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
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 组件

<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 模板里加:

<wechat-archive-panel
  v-model="archivePanel.visible"
  :channel-id="archivePanel.channelId"
  :room-id="archivePanel.roomId"
  :room-name="archivePanel.roomName"
/>

script 里:

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
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:

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
<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
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: 写失败测试
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: 实现
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
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 字段

    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: 菜单加"微信桥接"子菜单
        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
    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
    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
    private async Task ExitAllAsync()
    {
        if (_bridgeProcess is { HasExited: false })
        {
            _bridgeManager.KillProcess(_bridgeProcess.Id);
        }
        await StopBackendAsync();
        _notifyIcon.Visible = false;
        _notifyIcon.Dispose();
        ExitThread();
    }
  • Step 6: Commit
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 步骤

伪代码:

// 在现有 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: 本地构建一次验证产物存在
cd packages/backend && node scripts/build-windows-installer.js
ls dist-windows/.../bin/bridge/bridge.exe

Expected:文件存在、--version 能运行。

  • Step 4: Commit
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: 写验证报告
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
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 最后:依赖前三者