文档: 看板销账与登记 + 设计档六份 + 索引补录

- 看板: 907 补注/909~941 登记与销账(含 929 模型停用根因与 4 处次生缺口、931 队列丢失、933/935/937 新需求、939 上下文瓶颈清单、941 C-r0 行为变更待拍板)
- 归档: 全量完成态条目/段落 + 只读核验销账 4 段迁 todo归档/2026-10-10.md(机械搬运零措辞改动,未完成项零丢失)
- 设计档: 输入框列表预览与自动编号 / 定时消息与待发箱持久化(统一 ai_outbox 底座) / 对话内定时延时任务 / 消息渲染管线重构(P0-P2 分期) / 上下文分层组装器落地方案(记账基点单一真值,与并行写者职责划清) / FileTree 大目录方案(推荐后端分页,否决虚拟滚动) / u-relay 运维手册
- 索引与事实修正: docs/INDEX crate 数 8→13 + 四子树补录 25 条 + 专项设计补录 27 条;纠正「项目无独立 ARCHITECTURE.md」等过时声明;统一 bun 口径
- CLAUDE.md: 新增「命名与代码风格」节(变量/方法名精简+准确;DRY·简洁·扩展性·高内聚低耦合·单一真值来源)
This commit is contained in:
lxy
2026-10-10 08:09:25 +08:00
parent b37279f3a2
commit 8df4e33656
14 changed files with 1747 additions and 129 deletions
@@ -0,0 +1,230 @@
# 输入框列表预览与自动编号 — 交互设计
> 日期: 2026-10-10 | 状态: 设计稿(待拍板) | 需求锚点: `docs/todo.md:701` 条目 933 | 勘察: 只读实测(文件:行已核)
> 目标一句话: ChatInput 输入 `1. ` / `- ` 后,输入框内呈现"列表已就绪"的反馈(高亮/预览)并支持回车续写补号,对齐飞书消息输入框的顺手感,而当前是纯文本无反馈。
> 边界: 仅设计,不改码;不涉及后端/协议/落库。
---
## 一、现状勘察(锚点实测)
### 1.1 输入框本体
| 件 | 锚点 | 现状 |
|----|------|------|
| 编辑器 | `src/components/ai/ChatInput.vue:20-35` | 单个纯 `<textarea class="ai-input" v-model="inputText">`,`rows="1"`,无富文本、无 overlay、无 mirror。事件: `@keydown/@beforeinput/@input/@paste/@dragover/@drop` |
| 键处理 | `ChatInput.vue:814-910` | `handleKeydown` 一条优先级长链:IME 守卫(817)→ Ctrl+R(822)→ 编辑态 Esc(829)→ 技能 Esc(836)→ chip Esc(843)→ 技能导航 ↑↓/Enter/Tab(850-872)→ 技能无匹配 Enter 拦截(875)→ mentions 导航 ↑↓/Enter/Tab(882-905)→ **Enter 发送(906-909)** |
| 输入态互斥 | `ChatInput.vue:418-432` | `watch(inputText)`: 以 `/` 开头 → `skillOpen`;否则 `detectMentionTrigger()`。二者互斥(427-431) |
| `/` 判据 | `ChatInput.vue:408` | `skillQuery = inputText.startsWith('/') ? slice(1) : ''` —— **仅识别"整串以 `/` 开头"**,行首列表标记 `1. ` 天然不与之撞 |
| `@` 判据 | `ChatInput.vue:439-467` | 从光标向前找最近 `@`,要求其前一字符为空/空白;遇空白即停 |
| 程序化改文 | `ChatInput.vue:509-532` | 已有成熟范式: `beforeinput` 快照旧文 + `input` 时公共前后缀差分定位编辑区间 → `shiftMentionSpans` 平移/丢弃 `pendingMentionSpans`。注释明确"程序赋值不派发 input 事件"(508-509) |
| 高度自适应 | `ChatInput.vue:916-922` | `autoResize()`: `height='auto'` → `min(scrollHeight, INPUT_MAX_HEIGHT=200)` |
| 光标定位 | `ChatInput.vue:801-811` | `selectMention` 里 `setSelectionRange` + 同步重算高度,是既有的"程序化插字+回光标"样板 |
| 样式 | `ChatInput.vue:1201-1215` | `.ai-input { flex:1; font-size:13px; line-height:1.5; font-family:var(--df-font-sans); resize:vertical; height:20px; max-height:200px; overflow-y:auto }`。外层 `.ai-input-wrap`(1177) `position:relative` + `display:flex; flex-wrap:wrap` |
### 1.2 既有浮层/提及基础设施(可复用)
| 件 | 锚点 | 复用点 |
|----|------|--------|
| 技能浮层 | `SkillMention.vue:43-62` | `.ai-skill-popover` 绝对定位浮层 |
| `@` 浮层 | `MentionPopover.vue:39-64` | 同款 `.ai-skill-popover`,仅加组标题 |
| 浮层定位 | `src/styles/components.css:254` | `.ai-skill-popover { position:absolute; bottom:calc(100% + 4px); left:0; right:0; max-height:240px; overflow-y:auto; z-index:20 }` —— 浮层锚在输入框**上方**,输入框整体透明底(`.ai-input { background:transparent }`) |
| 已选技能/图片预览 | `ChatInput.vue:16-19` | 输入框上方已有 chip/预览条先例(ImageInput),说明"输入框上方加一条反馈带"是既有形态 |
### 1.3 发送与渲染链(证明"发送侧零改动")
| 件 | 锚点 | 现状 |
|----|------|------|
| 发送 | `ChatInput.vue:924-1046` | `handleSend` 取 `inputText.trim()` 原样透传 `store.sendMessage(text, ...)`,不做任何 markdown 预处理 |
| 渲染器 | `src/composables/useMarkdown.ts:115-132` | `marked.setOptions({ breaks:true, gfm:true })` —— **有序/无序列表本就渲染为 `<ol>/<ul>`** |
| 列表样式 | `src/styles/ai-md.css:8-9` | `.ai-md ul, .ai-md ol { margin:4px 0; padding-left:20px }` / `.ai-md li { margin:2px 0; line-height:1.5 }` |
| 文本预处理先例 | `useMarkdown.ts:239-299` `wrapNakedDiff` | marked.parse 前做文本预处理的既有范式;其守卫1(纯 `-` 列表不误包成 diff)说明**已考虑"`- ` 是普通 markdown 列表"的语义** |
**结论**: 消息落地后列表渲染链完整,本需求是**纯输入侧体验缺口**——输入阶段无任何"这是列表"的反馈、无回车续写、无自动补号。
### 1.4 缺口清单
1. 输入 `1. ` / `- ` / `* ` / `+ ` 后无任何视觉反馈(纯文本),与飞书"行首即成型"的手感差距明显。
2. 回车续写不补号:按 Shift+Enter 换行后,下一行不会自动带 `2. ` / `- `。
3. 空列表项无退出:清空某行内容后标记 `1. ` 残留,需手工删。
4. 更深度的是"自动重排号"(中间插一项后续号重排)完全缺失。
---
## 二、目标与验收口径
| 目标 | 验收口径(可实测) |
|------|-------------------|
| G1 触发识别 | 行首匹配 `^(≤3 空格)(\d+\. )` / `^(≤3 空格)([-*+] )` 即判定为列表项;` ``` ` 围栏内不触发 |
| G2 回车续写补号 | 在非空列表项行末按 **Shift+Enter** → 新行自动前缀正确序号(`n` → `n+1`)或同型符号(`- `→`- `),光标落新标记后 |
| G3 空项退出 | 空列表项行(仅标记+空格)按 **Shift+Enter** 或 **末位退格** → 标记被移除,退出列表态,不产生新项 |
| G4 视觉反馈 | 列表行标记(`1.` / `- `)在输入框内获得区别于正文的样式(高亮/等宽/色块);无法在纯 textarea 内着色时,以"输入框上方列表预览条"替代(见 §3.3) |
| G5 不打架 | `/` 技能联想、`@` 提及、编辑态、IME 组词、代码围栏优先级正确;开启本能力后既有行为零回归 |
| G6 发送不变 | 发送文本仍为含字面标记的原文,消息气泡经 marked 渲染为列表——发送链/后端/落库零改动 |
---
## 三、设计方案
### 3.1 总体判断(先说结论)
**纯 `<textarea>` 无法在"框内"渲染真正的列表项**(圆点/编号缩进)——textarea 只能显示等宽字符流,无法对子串应用 bullet + hanging indent。因此"框内所见即所得"只有两条路:
| 档 | 实现 | 能力 | 成本/风险 |
|----|------|------|-----------|
| **档一 · 轻量** | 保留 textarea + 逻辑层(识别/补号/退出)+ 可选 mirror 高亮 | 补号、退出、标记高亮(字面仍为 `1.`,但高亮显示) | 低-中;mirror 需 textarea 文字透明化,有选区可见性坑 |
| **档二 · 完整** | contenteditable 富文本编辑器(真 `<ol>/<ul>`) | 真 WYSIWYG、可嵌套/拖动 | 高;替换 textarea,击穿 IME/`@`span/粘贴图/autoResize 全部既有机制 |
**建议先做档一**,档二单独立项(见 §6 拍板点 2)。理由:档一达成飞书体验的 ~90%(续写补号是主力价值),且完全不碰已验证多年的输入链;档二是输入层重写,风险与工作量不成比例。
### 3.2 逻辑层设计(档一核心,必做)
抽 `src/composables/ai/useListContinuation.ts`(纯函数,可单测),不改 textarea 渲染。
**数据结构**(仅内存,不落库):
```ts
export interface ListContext {
kind: 'ordered' | 'unordered'
indent: string // 行首缩进(≤3 空格,保留)
marker: string // 字面标记,如 "3. " / "- "
number?: number // ordered 的序号(含 . 或 ) 后解析)
bullet?: '-' | '*' | '+'// unordered 的符号字符
content: string // 标记后的正文(可空)
lineStart: number // 该行在 inputText 中的起始偏移
markerEnd: number // 标记结束偏移(标记后第一个正文字符)
}
/** 判定给定文本 + 光标所处行的列表语境;围栏内/无匹配返回 null */
export function analyzeListContext(text: string, caret: number): ListContext | null
/** 续写:返回应插入的标记串(ordered → `${n+1}. `;unordered → 同型) */
export function nextMarker(ctx: ListContext): string
/** (可选增强)重排:给定文本,把连续 ordered 段重编为 1,2,3…;返回新文本 + 需平移偏移 */
export function renumberOrdered(text: string): { text: string; renumbered: boolean }
```
**触发判据(实现细节)**:
- 有序: `/^(\s{0,3})(\d{1,9})[.)]\s(.*)$/`(兼容 `.` 与 `)`;markdown 允许 1-9 数字 + `.`/`)`)
- 无序: `/^(\s{0,3})([-*+])\s(.*)$/`
- **围栏守卫**: 从文本第 0 行扫到当前行前,统计 `^\s{0,3}(```|~~~)` 出现次数,奇数 → 处于代码围栏内 → 返回 `null`(不在围栏内把 `- 1.` 之类误判为列表)。复用 `useMarkdown.wrapNakedDiff` 同款 `_FENCE_OPEN_RE` 思路。
- 光标必须落在当前行(用 `\n` 计数定位行号),避免"光标在别行却对当前行操作"。
**续写流程(Shift+Enter)**: 在 `handleKeydown` 里新增分支(位置见 §3.4):
```
Shift+Enter 且非 skillOpen/mentionOpen/编辑态:
ctx = analyzeListContext(inputText, caret)
if !ctx: return(交默认换行)
if ctx.content.trim() === '':
// 空项 → 退出列表:删除整行标记,留下空行
preventDefault; 删除 [lineStart, markerEnd) 区间;
shiftMentionSpans(旧, 新); 回光标; return
preventDefault
insert = '\n' + ctx.indent + nextMarker(ctx)
在 caret 处插入 insert; 光标置插入末
shiftMentionSpans(旧, 新) // 复用 509-532,列表插字同样会平移其后的 @span
autoResize(); updateMirror()
```
> 注意: 程序化赋值不触发 `input` 事件(`ChatInput.vue:508-509` 已注明),故 `shiftMentionSpans` 必须**显式手动调用**(不能依赖 `onInputText`),与 `selectMention` 的处理保持一致。
**退格退出(B3)**: 在 `handleKeydown` 增 `Backspace` 分支:光标在行末且当前行为空列表项(`^(\s*)([-*+]|\d+[.)])\s$`)→ preventDefault,删标记,退出列表。
**自动重排号(G-增强,可选)**: 默认**不做**。true "自动编号" 需在中间插入/删除项后重写后续行号(`1. 2. 3.` → 插一行后 `1. 2. 3. 4.`),会改动用户已输入文本 → 触碰光标/`@`span 偏移。若做,须走完整"旧文差分 → 重排 → 重新定位光标与 span",成本与回归面显著上升,建议单独开关(见 §6 拍板点 3)。
### 3.3 视觉反馈设计(三选一,见拍板点 4)
| 形态 | 实现 | 优 | 劣 |
|------|------|----|----|
| **V0 零视觉** | 只做补号/退出 | 零渲染风险,一文件 | 不满足"而非纯文本"的观感诉求 |
| **V1 mirror 高亮(推荐目标)** | 在 textarea 下垫一层等宽 mirror div,渲染原文(转义),把列表标记包成 `<span class="ai-list-marker">` 高亮;textarea 文字设 `color:transparent; caret-color:var(--df-text)`,可见字符来自 mirror;同步 `scrollTop`/高度 | 字面仍为 `1.` 但**框内可见高亮 + 悬挂缩进**,最接近飞书 | 需精确对齐 font/line-height(13px/1.5)、尾行高度、`resize:vertical`、滚动;**选区可见性坑**:透明文字下 `::selection` 只剩底色、字形不可见,需 `textarea::selection{ background:… }` 兜且体验打折 |
| **V2 预览条** | 输入框上方(ImageInput 同位置范式)一条折叠/淡显预览,复用 `useMarkdown.renderMd(inputText)`(已 DOMPurify 净化)渲染当前输入 | 复用既有渲染单例,零新解析器;能看到真实序号/圆点 | 非"框内",占垂直空间,与飞书形态有差 |
**建议**: 先 V0 落地 G1-G3/G5/G6(最小可用,零渲染风险),再把 **V1 作为独立增强步**单独评估(透明化对选区/无障碍的影响需实测);V2 作为可选辅助保留。不建议一步到位 V1,因其 textarea 透明化是全局输入体验改动,面比"列表"本身大。
若采用 V1,样式落 `src/styles/components.css`(与 `.ai-skill-popover` 同处,因 scoped 不渗透):
```css
.ai-input-field { position: relative; flex: 1; display: flex; } /* 新包裹层,仅包 textarea */
.ai-input-mirror {
position: absolute; inset: 0; overflow: hidden; pointer-events: none;
font: inherit; font-size: 13px; line-height: 1.5; font-family: var(--df-font-sans);
white-space: pre-wrap; word-break: break-word; color: var(--df-text);
padding: 0; /* 与 textarea 完全一致,靠 .ai-input-wrap 的 6px 8px 内边距 */
}
.ai-list-marker { color: var(--df-accent); font-weight: 600; }
```
> mirror 文本必须用 `document.createTextNode` 逐段拼接(**禁用 v-html 直插原始输入**,防 XSS);仅对已确定的标记区间包 span。若日后想直接渲染整段 markdown 预览,只能走 V2 的 `renderMd`(已 `_purify.sanitize`,189-208)。
### 3.4 与既有机制的优先级(冲突落地)
新增分支在 `handleKeydown`(814-910)的**精确插入点**:
```
IME 守卫(817) → Ctrl+R(822) → 编辑态 Esc(829) → 技能 Esc/chip(836-848)
→ 技能导航(850-872)/技能无匹配 Enter(875)
→ mention 导航(882-905)
→ 【新增】Backspace 空项退出 · 【新增】Shift+Enter 列表续写 ← 放在此处
→ Enter 发送(906-909)
```
- **`/` 技能**: `skillOpen` 仅当"整串以 `/` 开头"(408/423),列表标记在行首,首行不可能是 `/`;续行也不影响 `startsWith` 判定 → **不撞**。守卫:续写分支前置 `!skillOpen`。
- **`@` 提及**: 续写时若光标处 popover 未开,insert 后调 `shiftMentionSpans` 平移其后的 span;若 popover 开着(`mentionOpen`),用户意图是选实体,续写分支前置 `!mentionOpen`。
- **代码围栏 ` ``` `**: 由 `analyzeListContext` 的围栏守卫直接排除(§3.2),`- 1.` 在围栏内不触发,与 `wrapNakedDiff` 守卫3一致。
- **IME/isComposing(817)**: 续写分支位于其后,组词中的 Shift+Enter 不误触。
- **Enter 发送不变**: 本设计**不改 Enter 发送语义**——续写绑定 Shift+Enter(换行链),Enter 仍发送。为最小改动。若要与飞书"Enter 续行"完全一致需改语义,见拍板点 1。
### 3.5 UI 形态与文案
- 无新浮层;V1 高亮走 mirror,V2 预览条若采用则置 `.ai-input-wrap` 之后、`EnrichmentPanel`(`ChatInput.vue:113-121`)之前。
- 提示语: 可选在 `.ai-input-hint`(122,`aiChat.inputHint` zh:159)追加"列表续行 Shift+Enter",或单独加一行淡提示。新增 i18n key 落 `src/i18n/zh-CN/aiChat.ts` + `src/i18n/en/aiChat.ts`(现有 key 区见 130-165)。
---
## 四、分期与最小可用
| 期 | 范围 | 交付 | 依赖 |
|----|------|------|------|
| **P1(最小可用)** | 逻辑层 + V0 | `useListContinuation.ts`(analyze/nextMarker)+ ChatInput 接线(Shift+Enter 续写、Backspace 空项退出、围栏守卫)+ 开关 | 单测覆盖判定/续写/围栏 |
| **P2(视觉)** | V1 mirror 高亮(或 V2 预览条) | 框内标记高亮 + 悬挂缩进;样式入 components.css | P1 稳定 + 选区/无障碍实测通过 |
| **P3(增强,可选)** | 自动重排号(renumber) | 中间插/删项后重编序号;独立开关 | P1/P2 无回归 |
**最小可用定义**: P1 完成即视为需求达标可发布——触发识别 + 续写补号 + 空项退出三件事齐,发送侧零改动。视觉(V1/V2)与重排(P3)为增量。
---
## 五、影响面(新能力引入评估纪律 · 五维)
| 维度 | 判定 |
|------|------|
| **兼容性** | 纯前端输入侧行为,**无协议/落库/迁移**。既有草稿(`convDrafts`)、编辑态回填、队列回填文本语义不变(仍是含字面标记的 markdown 原文)。旧会话/历史消息不受影响(渲染链本就支持列表)。**PASS** |
| **破坏性** | 增分支只拦 Shift+Enter/Backspace 的**特定空项/列表场景**,其余键行为不变;Enter 发送语义**不变**(拍板点 1 若选改才涉及行为变更,且可逆)。无不可逆操作。**PASS(低)** |
| **安全面** | 无新端点/命令执行/路径。**关键**: mirror/预览渲染用户输入时必须转义——mirror 用 textNode 逐段拼接;若用 v-html 走 `renderMd`(已 `DOMPurify.sanitize`,`useMarkdown.ts:201`)。禁裸 `v-html=原始输入`。新增攻击面为零,仅为既有 XSS 防护纪律的又一次适用。**PASS** |
| **资源面** | 每次键入一次纯字符串扫描(O(行数),典型 <100 行)一次可选 mirror DOM 更新。无需 rAF/防抖(P3 重排号若开,是 O(n) 文本改写,仍可控)。无磁盘/日志/内存增量。**PASS** |
| **回退开关** | 设 appSettings 键(沿用现网 `df-ai-streaming-md` / `df-ai-auto-execute-mode` 范式,`useAppSettingsStore().get<boolean>('df-ai-list-input', false)`),默认**关**再灰度;单点置 false 即完全回退到现状 textarea 行为,零残留。**PASS(带开关)** |
---
## 六、拍板点(需人定,附建议)
1. **Enter 语义**: 保留"Enter=发送,Shift+Enter=换行并续列表"(**建议**,零回归、不违 `aiChat.inputHint` 与 `handleKeydown:906` 既有契约) vs 改为"列表态下 Enter 续行、Cmd/Ctrl+Enter 才发送"(更贴飞书,但变更已固化的发送习惯,置信成本高)。
2. **渲染档位**: 先做**档一 轻量**、档二 WYSIWYG 单独立项(**建议**) vs 直接上档二 contenteditable 富文本。档二会击穿 IME/`@`span/粘贴图/autoResize 全链,工作量与回归面远超本需求。
3. **范围边界**: 标记仅 `\d+. ` + `- `(**建议**起步) vs 含 `)`、`*`、`+`、嵌套缩进、以及是否做**自动重排号**(renumber 改用户文本,单独开关)。
4. **视觉形态**: V0 零视觉先行 → V1 mirror 框内高亮(**建议路径**,但需实测 textarea 透明化的选区/无障碍影响) vs 直接 V1 vs 用 V2 输入框上方预览条替代。(此项与拍板点 2 联动:若否决档二,则 V1 是"框内感"的唯一可达形态。)
---
## 附:实施落点清单(供实施会话直接取用)
| 文件 | 改动 |
|------|------|
| `src/composables/ai/useListContinuation.ts` | 新增:`analyzeListContext` / `nextMarker` / 围栏守卫 /(可选)`renumberOrdered` + 单测 |
| `src/components/ai/ChatInput.vue` | `handleKeydown`(814-910)插入 Shift+Enter/Backspace 分支;`<script>` 引入 composable;V1 时包一层 `.ai-input-field` + `<div class="ai-input-mirror">` 并同步 scroll/height;读开关 `df-ai-list-input` |
| `src/styles/components.css` | V1:`.ai-input-field` / `.ai-input-mirror` / `.ai-list-marker` |
| `src/i18n/zh-CN/aiChat.ts` + `src/i18n/en/aiChat.ts` | 可选:续行提示文案 |
> 参照档: `无人值守长任务-设计-2026-10-04.md`(结构/详略) · `上下文三层记忆-设计-2026-10-04.md` · `跨会话召回最小闭环-设计-2026-10-04.md`(同一目录写法)。