Ctrl K
ComunidadGuías…

docs/plan/2026-09-18-agent-experience-dual-mode-design.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Agent 最佳实践经验 · 双形态展示/编辑技术方案

> 状态:已实现  
> 日期:2026-07-18  
> 范围:仅管理后台 `admin`(Agent 编辑弹窗「最佳实践」Tab)  
> 前置:`docs/plan/2026-07-09-agent-experience-design.md`(数据模型与注入链路,已上线)

---

## 1. 背景与目标

现有「最佳实践」Tab 采用纵向卡片列表:每条经验一张卡片,内含 2 行 textarea、启用开关、上移/下移/删除按钮。运营侧反馈:

1. **界面太分散**:几十条经验时一屏可见内容非常有限。
2. **启用/停用开关、上移/下移按钮多余**,操作噪音大。
3. **跨 Agent 复制经验麻烦**:只能一条一条复制粘贴。
4. **无法同时编辑多条经验**:希望有文本编辑器方式,一次性批量编辑。

目标:将最佳实践经验模块改为**双形态展示/编辑**——表格形态管单条,文本形态管批量;两种形态是同一份数据的两个视图,可互相切换。

### 1.1 非目标

| 项 | 说明 |
|----|------|
| 后端 / DB / API 变更 | `agent_experiences` 表、`enabled` 字段、嵌套全量同步、300 字校验全部保持原样 |
| `enabled` 字段下线 | 功能保留;文本形态用 `#` 前缀表达停用 |
| 经验共享库 / 跨 Agent 导入选择器 | 本期不做;跨 Agent 复制 = 文本形态剪贴板 |
| 推荐问题 Tab 同构改造 | 本期不动;若双形态跑顺,后续可照搬 |
| 桌面端 / Subagent / Prompt 注入链路 | 不涉及 |

---

## 2. 现状

| 层级 | 现状 |
|------|------|
| UI | `admin/src/views/agent/AgentFormDialog.vue`「最佳实践」Tab:卡片列表 + 每条 `textarea`(rows=2, maxlength=300) + `el-switch` 启停 + 上移/下移/删除 + 「+ 添加经验」 |
| 表单模型 | `form.experiences: ExperienceFormItem[]`,字段 `_key / id / content / sortOrder / enabled` |
| 提交 | `POST/PUT /agents` 嵌套 `experiences` 全量同步;编辑带 id,创建/复制不带 id |
| 后端校验 | `content` trim 后 1~300 字;`enabled` 缺省 true;`sortOrder` 缺省按数组下标 |
| Prompt 注入 | 仅 `enabled=1` 按 `sort_order` 升序拼入 `## 最佳实践经验`,列表项 `- {content}` |

痛点根因:卡片形态纵向堆叠、每条操作按钮多,信息密度低;无批量编辑视图;启停与调序都绑在单条卡片上。

---

## 3. 方案总览

### 3.1 核心结构

```
form.experiences: ExperienceFormItem[]   ← 唯一数据源(有序数组)
        │
        ├── 表格形态视图(管理单条:增删改、拖拽调序、点状态启停)
        │
        └── 文本形态视图(批量管理:一行一条,# = 停用)
```

- Tab 内顶部增加分段切换「表格 | 文本」,同一时间只渲染一个编辑器。
- 切换形态时:当前编辑器 → 解析/校验 → 重建 `form.experiences` → 渲染目标形态。
- 提交逻辑完全复用现有 payload 映射,不改接口契约。

### 3.2 改动文件

| 文件 | 改动 |
|------|------|
| `admin/src/views/agent/AgentFormDialog.vue` | 「最佳实践」Tab 重构为双形态;移除卡片列表与上移下移 |
| `admin` 依赖(可选) | 若用 `sortablejs` 实现行拖拽则新增依赖;否则原生 HTML5 DnD,零依赖(实现时按现有依赖定,见 §8) |

其余文件(API、后端、`AgentListView.vue`、推荐问题 Tab)**不改**。

---

## 4. 表格形态设计

### 4.1 结构

紧凑表格,列定义:

| 列 | 宽度建议 | 内容 |
|----|----------|------|
| 拖拽 handle | ~32px | `⠿` 图标,整行可拖(仅拖拽热区,不触发输入) |
| 序号 | ~48px | 1-based,随拖拽/增删实时刷新 |
| 正文 | 弹性 | 单行 `el-input`,`maxlength=300`,行内编辑 |
| 状态 | ~72px | 可点击标签:启用 / 停用;点击切换 `enabled` |
| 操作 | ~56px | 删除(link danger) |

示意:

```
⠿  │ #  │ 正文(单行输入)              │ 状态     │ 操作
⠿  │ 1  │ CPU 持续过高时优先查最近发布…  │ [启用]   │ 删除
⠿  │ 2  │ 暂不建议轻易重启…              │ [停用]   │ 删除
                    [+ 添加经验]
```

### 4.2 交互规则

- **增**:「+ 添加经验」追加到数组末尾,`enabled: true`,`sortOrder` 提交时按数组下标重算(沿用现有逻辑)。
- **删**:移除该行,后续序号自动重排(现有 `removeExperience` 逻辑保留)。
- **改正文**:单行输入,实时 `v-model` 到 `content`;粘贴含换行时将 `\n` / `\r\n` 压成空格(见 §6 单行约束)。
- **调序**:行拖拽(handle 触发),拖拽结束后按可视顺序重写数组顺序与 `sortOrder`。**不再提供上移/下移按钮**。
- **启停**:点击状态标签在 启用↔停用 间切换;停用行整行增加灰显样式(正文与序号降透明度,状态标签高亮「停用」)。
- **无独立「启用开关 + 操作行」**:原 `el-switch` 与上移下移按钮从 DOM 移除。

### 4.3 密度与高度

- 弹窗宽度维持 `760px`。
- 最佳实践 Tab 的滚动区高度可适当提高(现为 `min(440px, 55vh)` 的统一 Tab 高度;可对该 Tab 单独给更高值,如 `min(560px, 65vh)`,实现时以不挤压 footer 按钮为准)。
- 表格行高压到 ~40–44px,一屏可见行数显著多于原卡片形态。

---

## 5. 文本形态设计

### 5.1 编码约定

一行一条经验:

```text
CPU 持续过高时,优先检查最近是否发布、流量是否突增。
排障时不要轻易建议重启,先确认是否有明确的资源泄漏或死锁证据。
# 这条经验暂缓,先保留原文
```

| 规则 | 说明 |
|------|------|
| 分行 | `\n` 分隔;一条经验不跨行(单行约束见 §6) |
| 停用 | 行首为 `#`(`#` 后允许紧跟空格)= 停用;解析时剥掉 `#` 与**至多一个**后续空格,剩余为 `content` |
| 启用 | 非 `#` 开头 = 启用,整行 trim 后为 `content` |
| 空行 | 仅空白的行在解析时**忽略**(不产生经验、不报错) |
| 正文 `#` | **约定正文不得以 `#` 开头**(保存校验拒绝,见 §6);否则与停用前缀碰撞 |

### 5.2 双向转换

**表格 → 文本**(进入文本形态,或从表格切走时生成草稿):

```
对 form.experiences 按当前顺序:
  enabled ? content : '# ' + content
以 '\n' join → textarea.value
```

**文本 → 表格**(点击切回表格):

```
按 '\n' 拆行 → 逐行:
  忽略空行
  若以 '#' 开头:enabled=false;content = line.replace(/^# ?/, '')
  否则:enabled=true;content = line.trim()
校验(见 §5.3)全部通过后:
  重建 form.experiences(保留已有 id 的匹配策略见 §5.4)
  切换视图到表格
校验失败:
  ElMessage 提示行号与原因;停留在文本形态,不切换、不重建
```

两种形态互斥编辑,切换即整表重建,不存在双工冲突。

### 5.3 文本形态校验

切回表格前逐条检查,任一失败则阻止切换并提示**行号**(按原始文本行号,忽略的空行不计入「第 N 条」时,提示文案用「第 N 行」对齐用户在 textarea 里看到的行号):

1. 剥前缀 + trim 后 `content` 为空 → 「第 N 行:内容为空」(理论上空行已忽略;仅当行是 `#` 或 `# ` 这类只有前缀时触发)。
2. `content.length > 300` → 「第 N 行:超过 300 字(当前 X 字)」。
3. `content` 以 `#` 开头(原文就是 `##foo` 这类,剥掉一个 `# ` 后仍以 `#` 开,或原文 `#` 后无空格导致前缀识别歧义的边界)→ 「第 N 行:正文不能以 # 开头」。

实现细节:`#foo`(`#` 后无空格)**按停用处理**,剥掉 `#` 得 `foo`(更宽容,避免用户漏空格报错);若剥完仍以 `#` 开头则拒绝。`content` 原文(非前缀)校验「不得以 `#` 开头」在表格形态保存路径同样执行,保证两条路径一致。

### 5.4 id 保留(编辑模式全量同步)

编辑模式下 `PUT` 带 `id` 才能正确做「未出现的 id → 物理删除」同步。文本切回表格重建数组时:

- **不做**基于 content 的模糊匹配(改字就丢 id,导致误删+重建,审计时间戳抖动)。
- 采用**稳定映射**:切换前记录当前数组的 id 序列;文本解析后的第 i 条尝试继承「原第 i 条」的 id——但这在增删行时会错位。

更稳妥的做法(推荐):**文本形态仅在「切换进入时」从表格/数组生成;「切回时」的重建采用位置对齐 + 内容相同的行优先复用 id**:

1. 解析得到 `parsed: { content, enabled }[]`。
2. 与切换前的 `previous` 列表做一次对齐:
   - 先标记 `previous` 中 content+enabled 完全相等且未被占用的条目,把其 `id` 赋给对应 parsed 条目(按出现顺序一一配对)。
   - 剩余 parsed 条目 `id = null`(新增)。
   - 未配对的 previous 条目自然消失(提交时删除)。
3. 这样「只改顺序 / 只加 `#` / 中间插入几行」都能尽量保住 id;整段重写正文则按新增+删除同步,数据上仍是全量覆盖,行为与手改表格再保存一致。

> 说明:后端全量同步本就不依赖 id 稳定才能正确(无 id → 插,有 id → 更新,缺失 → 删)。id 对齐只是为了减少无意义的 delete+insert。实现可按上述对齐,若为简化也可接受「文本切回后 id 全部置 null 再按顺序继承原 id 列表」——两者提交结果等价,优先实现简单版:**按顺序继承原数组同下标的 id,新尾部为 null,多出的原 id 自然丢弃**。最终采用顺序继承(与表格拖拽后 id 跟随条目移动的直觉一致:拖拽时 id 必须跟着 content 走,文本模式同理——顺序继承仅在「用户未增删行只改字」时完美;增删行时与后端全量语义仍正确)。实现时以「数组元素携带 id,转换只重排/追加/截断,不主动洗掉已有 id」为准。

### 5.5 文本编辑器

- `el-input type="textarea"`,` autos` 或固定占满 Tab 高度(`height: 100%`)。
- 等宽字体可选(`font-family: monospace`)便于对齐扫视;非强制。
- 顶部或 placeholder 给一行提示:`一行一条;# 开头表示停用`。
- 不强制语法高亮、不做行号 gutter(原生 textarea 即可;行号仅在校验报错文案中出现)。

---

## 6. 单行约束(跨形态一致)

正文**必须单行**:

| 路径 | 规则 |
|------|------|
| 表格输入 | `el-input` 单行;粘贴/输入中的 `\n` 替换为空格(`update` 时 `content = value.replace(/\s*\n+\s*/g, ' ')`) |
| 文本形态 | 结构即一行一条;解析按 `\n` 拆,天然不允许多行正文 |
| 保存前校验 | 沿用 `validateExperiences`:非空、≤300;追加「不得以 `#` 开头」 |
| 后端 | 不变(后端不禁止换行,但前端已保证不会提交换行) |

原因:Prompt 按 `- {content}` 拼 Markdown 列表,正文含换行会破坏列表结构;文本形态的行协议也依赖单行。

---

## 7. 校验与提交

### 7.1 校验时机

| 时机 | 动作 |
|------|------|
| 文本 → 表格切换 | §5.3 全量校验,失败阻止切换 |
| 表单提交 `handleSubmit` | 复用 `validateExperiences()`:trim 非空、≤300、**新增** `#` 开头拒绝;失败跳到 experience Tab |
| 表格行内 | `maxlength=300` 硬限制 + 提交兜底 |

### 7.2 提交 payload

与现状完全一致,无字段增减:

```json
{
  "experiences": [
    { "id": 12, "content": "...", "sortOrder": 0, "enabled": true },
    { "id": null, "content": "...", "sortOrder": 1, "enabled": false }
  ]
}
```

`enabled` 照常传递;复制/创建模式不带 id(现有 `mapExperiences(..., keepId)` 逻辑不动)。

---

## 8. 行拖拽实现选型

| 方案 | 说明 | 取舍 |
|------|------|------|
| `sortablejs`(推荐) | 成熟、与 Vue 集成示例多;`onEnd` 里 splice 重排数组 | 新增 ~10KB 依赖 |
| 原生 HTML5 DnD | 零依赖;`draggable` + `dragstart/dragover/drop` | 实现与边界(与输入框焦点、移动端)略繁 |

实现时先查 `admin/package.json` 是否已有可复用拖拽库;没有则**优先 `sortablejs`**(后台为桌面管理场景,依赖可接受)。拖拽手柄列 `draggable`,正文输入区域不参与 drag,避免打字误拖。

---

## 9. UI 草图

### 9.1 Tab 顶部切换

```
最佳实践
┌─────────────────────────────────────────────┐
│  [ 表格 ]  [ 文本 ]                    (右对齐或左置分段) │
├─────────────────────────────────────────────┤
│  (当前形态编辑区,占满剩余高度,内部滚动)              │
└─────────────────────────────────────────────┘
```

用 `el-segmented`(Element Plus ≥2.7)或两个 `el-button` + `type=primary` 互斥样式均可,以项目现有组件习惯为准。

### 9.2 文本形态

```
┌─────────────────────────────────────────────┐
│ 一行一条;# 开头表示停用                      │
│┌───────────────────────────────────────────┐│
││ CPU 持续过高时,优先检查最近是否发布…        ││
││ 排障时不要轻易建议重启…                     ││
││ # 这条经验暂缓,先保留原文                   ││
││                                           ││
││                                           ││
│└───────────────────────────────────────────┘│
└─────────────────────────────────────────────┘
```

---

## 10. 边界与风险

| # | 风险 | 处理 |
|---|------|------|
| 1 | 正文以 `#` 开头与停用前缀碰撞 | 前端保存校验拒绝;文档/placeholder 说明约定 |
| 2 | `#` 后无空格的宽容解析 | `#foo` → 停用,content=`foo`;剥完仍 `#` 开头则拒绝 |
| 3 | 文本切回导致 id 错位 | 元素级携带 id + 转换时顺序继承;后端全量语义兜底(无 id 插入、缺失删除) |
| 4 | 拖拽与行内输入抢事件 | 仅 handle 可拖;`filter: input` |
| 5 | 粘贴多行进表格输入 | 输入时换行压空格,一条始终一行 |
| 6 | 文本形态直接提交(若允许不切回就保存) | **不允许**:提交前若处于文本形态,先强制走一遍 §5.3 解析重建,失败则跳转并阻止提交 |
| 7 | 推荐问题 Tab 仍是旧卡片 | 本期有意保留,文档已声明非目标 |

**提交时处于文本形态**:`handleSubmit` 开头增加——若 `mode === 'text'`,先执行「文本 → 数组」转换,校验失败则 `activeTab` 保持 experience 并提示,返回;通过后再走原 `validateExperiences` 与 payload 组装。避免「文本里改了没切回表格 → 保存的是旧数组」。

---

## 11. 实现步骤

| 步骤 | 内容 | 产出 |
|------|------|------|
| 1 | Tab 内加形态切换状态 `experienceView: 'table' \| 'text'`;进入弹窗默认表格 | 骨架 |
| 2 | 表格形态:`el-table` 替换卡片列表;单行输入、状态列、删除、添加;去掉上移下移与 switch | 表格可用 |
| 3 | 文本 ↔ 数组转换函数 `experiencesToText` / `textToExperiences` + §5.3 校验 | 转换纯函数 |
| 4 | 切换交互与失败提示;文本 textarea 高度占满 | 双形态打通 |
| 5 | `handleSubmit` 兼容文本形态(§10-6);`validateExperiences` 增加 `#` 开头拒绝 | 提交闭环 |
| 6 | 行拖拽(sortablejs 或原生) | 调序 |
| 7 | 单行换行压制、停用行灰显、Tab 高度微调 | 打磨 |
| 8 | 回归:编辑/创建/复制三模式、300 字、空列表、全停用不注入(手测 + 现有 Playwright 若涉及则更新) | 验收 |

---

## 12. 验收标准

1. 最佳实践 Tab 提供「表格 / 文本」切换;同一时间仅一个编辑器;数据同源。
2. 表格:可增、删、行内改正文、拖拽调序、点状态启停;**无**上移下移按钮、**无**独立 `el-switch` 行;停用行灰显。
3. 文本:一行一条;`#`(及 `# `)前缀 = 停用;空行忽略;与表格双向转换结果一致(round-trip 稳定:表格 → 文本 → 表格,内容与启停、顺序不变)。
4. 文本含超长行 / 仅 `#` 空条目 / 非法 `#` 开头正文时,切回表格被阻止并提示行号。
5. 处于文本形态直接点保存:先解析校验,通过则以文本内容提交,失败则阻止并留在文本形态。
6. 正文始终单行;提交 payload 字段与改造前一致;300 字前后端约束不放松。
7. 跨 Agent:Agent A 文本全选复制 → Agent B 文本粘贴 → 保存,B 获得相同内容与启停状态(新 id)。
8. 创建 / 编辑 / 复制三模式行为与改造前一致(复制仍去 id)。
9. 后端零改动;无启用经验时 Prompt 不含「最佳实践经验」区块(现有行为回归)。

---

## 13. 决策摘要

| # | 决策 |
|---|------|
| 形态 | 表格(单条)+ 文本(批量),同源互斥,分段切换 |
| `enabled` | 字段与后端保留;表格点状态列切换;文本 `#` 前缀 = 停用 |
| 调序 | 表格行拖拽;文本靠剪切/粘贴行;删除上移下移按钮 |
| 跨 Agent 复制 | 仅文本剪贴板;不做导入器、共享库 |
| 正文 | 强制单行;禁止以 `#` 开头 |
| 提交 | 复用嵌套 `experiences` 全量同步;文本形态提交前强制解析 |
| 范围 | 仅 `AgentFormDialog.vue` 最佳实践 Tab;推荐问题、后端、DB 不动 |
| 拖拽库 | 优先 `sortablejs`,无现成依赖时新增;否则原生 DnD |