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