- 看板: 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·简洁·扩展性·高内聚低耦合·单一真值来源)
18 KiB
输入框列表预览与自动编号 — 交互设计
日期: 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./-/*/+后无任何视觉反馈(纯文本),与飞书"行首即成型"的手感差距明显。 - 回车续写不补号:按 Shift+Enter 换行后,下一行不会自动带
2./-。 - 空列表项无退出:清空某行内容后标记
1.残留,需手工删。 - 更深度的是"自动重排号"(中间插一项后续号重排)完全缺失。
二、目标与验收口径
| 目标 | 验收口径(可实测) |
|---|---|
| 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 渲染。
数据结构(仅内存,不落库):
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 不渗透):
.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.inputHintzh: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(带开关) |
六、拍板点(需人定,附建议)
- Enter 语义: 保留"Enter=发送,Shift+Enter=换行并续列表"(建议,零回归、不违
aiChat.inputHint与handleKeydown:906既有契约) vs 改为"列表态下 Enter 续行、Cmd/Ctrl+Enter 才发送"(更贴飞书,但变更已固化的发送习惯,置信成本高)。 - 渲染档位: 先做档一 轻量、档二 WYSIWYG 单独立项(建议) vs 直接上档二 contenteditable 富文本。档二会击穿 IME/
@span/粘贴图/autoResize 全链,工作量与回归面远超本需求。 - 范围边界: 标记仅
\d+.+-(建议起步) vs 含)、*、+、嵌套缩进、以及是否做自动重排号(renumber 改用户文本,单独开关)。 - 视觉形态: 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(同一目录写法)。