1608 lines
60 KiB
Markdown
1608 lines
60 KiB
Markdown
# 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 最后:依赖前三者
|