# RFC 138: AI 对话 @ 提及功能前端实现方案 > **状态**:Draft > **创建日期**:2026-01-30 > **作者**:Jointo AI Team --- ## 目录 1. [功能概述](#功能概述) 2. [用户体验设计](#用户体验设计) 3. [提及识别方案](#提及识别方案) 4. [组件设计](#组件设计) 5. [状态管理](#状态管理) 6. [API 集成](#api-集成) 7. [键盘交互](#键盘交互) 8. [性能优化](#性能优化) 9. [测试策略](#测试策略) --- ## 功能概述 ### 什么是 @ 提及功能? 用户在 AI 对话输入框中输入 `@` 符号,可以快速引用当前上下文中的角色、场景、道具、视频等资源作为参考图,提交给 AI 生成时使用。 ### 核心特性 - **实时自动补全**:输入 `@` 后实时显示匹配的资源列表 - **键盘导航**:支持 ↑↓ 选择、Enter 确认、ESC 取消 - **智能识别**:自动识别提及的结束位置(空格、标点等) - **视觉反馈**:高亮显示提及标记,显示缩略图 - **标准格式**:插入 Markdown 风格的提及标记 ### 技术栈 - **React 19** + **TypeScript** - **Zustand**:状态管理 - **TanStack Query**:数据请求 - **Tailwind CSS**:样式 - **shadcn/ui**:UI 组件 --- ## 用户体验设计 ### 交互流程 ``` 用户输入 "@" ↓ 显示自动补全下拉菜单 ↓ 用户输入 "张" 进行搜索 ↓ 实时过滤显示匹配的资源 ↓ 用户按 ↓ 选择 "张三" ↓ 用户按 Enter 确认 ↓ 如果有多个标签,显示标签选择器 ↓ 用户选择 "少年" 标签 ↓ 自动插入 "@[张三-少年](character:...)" + 空格 ↓ 用户继续输入 "在花果山" ↓ 最终文本:"@[张三-少年](character:...) 在花果山" ``` 完整的前端实现方案文档已创建,包含: - 提及识别方案(解决 `@张三在花果山` 的识别问题) - 组件设计 - 状态管理 - API 集成 - 键盘交互 - 性能优化 - 测试策略 详见:`docs/client/rfcs/138-ai-conversation-mention-frontend.md` ### 视觉设计 #### 自动补全下拉菜单 ``` ┌─────────────────────────────────────┐ │ 🔍 搜索: 张 │ ├─────────────────────────────────────┤ │ [图] 张三 (角色) · 2 个标签 │ ← 选中状态(蓝色背景) │ [图] 张飞 (角色) │ │ [图] 张家界 (场景) · 3 个标签 │ ├─────────────────────────────────────┤ │ ↑↓ 选择 · Enter 确认 · ESC 取消 │ └─────────────────────────────────────┘ ``` --- ## 提及识别方案 ### 核心问题 **用户输入 `@张三在花果山`,如何识别 `张三` 是一个完整的提及?** ### 推荐方案:混合方案 ✅ **核心思路**: 1. **优先使用自动补全**:用户选择后自动插入完整标记 2. **支持手动输入**:遇到结束符(空格、标点)时结束 3. **后端容错**:如果没有完整标记,尝试模糊匹配 ### 结束符定义 ```typescript const END_CHARS = [ ' ', // 空格 ',', ',', // 逗号 '。', '.', // 句号 '?', '?', // 问号 '!', '!', // 感叹号 ':', ':', // 冒号 ';', ';', // 分号 '\n' // 换行 ]; ``` ### 识别示例 ```typescript "@张三 在花果山" → 识别 "张三" ✅ (空格结束) "@张三,在花果山" → 识别 "张三" ✅ (逗号结束) "@张三在花果山" → 识别 "张三在花果山" ⚠️ (无结束符,需要自动补全) "@[张三-少年](...) 在" → 已完成 ✅ (包含右括号) ``` ### 提及上下文检测 ```typescript // types/mention.ts interface MentionContext { startIndex: number; // @ 的位置 keyword: string; // @ 后面的搜索关键词 } // utils/mention.ts export function detectMentionContext( text: string, cursorPos: number ): MentionContext | null { const textBeforeCursor = text.slice(0, cursorPos); // 1. 找到最后一个 @ 的位置 const lastAtIndex = textBeforeCursor.lastIndexOf('@'); if (lastAtIndex === -1) return null; // 2. 提取 @ 后面的文本 const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1); // 3. 检查是否已经是完整的提及标记(包含右括号) if (textAfterAt.includes(')')) return null; // 4. 检查是否遇到结束符 const END_CHARS = [' ', ',', ',', '。', '.', '?', '?', '!', '!', ':', ':', ';', ';', '\n']; if (END_CHARS.some(char => textAfterAt.includes(char))) return null; // 5. 返回搜索关键词 return { startIndex: lastAtIndex, keyword: textAfterAt }; } ``` --- ## 组件设计 ### 类型定义 ```typescript // types/mention.ts export interface MentionResource { type: 'character' | 'scene' | 'prop' | 'video' | 'audio'; element_id: string; element_name: string; has_tags: boolean; tags?: MentionTag[]; default_resource?: ResourceInfo; } export interface MentionTag { tag_id: string; tag_label: string; resources: ResourceInfo[]; } export interface ResourceInfo { resource_id: string; resource_url: string; thumbnail_url: string; width: number; height: number; } ``` ### MentionInput 组件 ```typescript // components/ai/MentionInput.tsx import { useState, useRef } from 'react'; import { MentionAutocomplete } from './MentionAutocomplete'; import { useMentionableResources } from '@/hooks/api/useMentionableResources'; import { detectMentionContext } from '@/utils/mention'; import type { MentionResource, MentionTag } from '@/types/mention'; interface MentionInputProps { conversationId: string; value: string; onChange: (value: string) => void; placeholder?: string; disabled?: boolean; } export function MentionInput({ conversationId, value, onChange, placeholder = "输入 @ 来引用角色、场景、道具等资源...", disabled = false }: MentionInputProps) { const textareaRef = useRef(null); const [showAutocomplete, setShowAutocomplete] = useState(false); const [cursorPosition, setCursorPosition] = useState(0); const [searchKeyword, setSearchKeyword] = useState(''); const [selectedIndex, setSelectedIndex] = useState(0); // 获取可提及的资源 const { data: resources, isLoading } = useMentionableResources( conversationId, searchKeyword, { enabled: showAutocomplete } ); // 监听输入 const handleInput = (e: React.ChangeEvent) => { const newValue = e.target.value; const cursorPos = e.target.selectionStart; onChange(newValue); setCursorPosition(cursorPos); // 检测提及上下文 const mentionContext = detectMentionContext(newValue, cursorPos); if (mentionContext) { setSearchKeyword(mentionContext.keyword); setShowAutocomplete(true); setSelectedIndex(0); } else { setShowAutocomplete(false); } }; // 键盘导航 const handleKeyDown = (e: React.KeyboardEvent) => { if (!showAutocomplete || !resources?.length) return; switch (e.key) { case 'Escape': setShowAutocomplete(false); e.preventDefault(); break; case 'ArrowDown': setSelectedIndex(prev => prev < resources.length - 1 ? prev + 1 : prev ); e.preventDefault(); break; case 'ArrowUp': setSelectedIndex(prev => prev > 0 ? prev - 1 : prev); e.preventDefault(); break; case 'Enter': if (resources[selectedIndex]) { handleSelectResource(resources[selectedIndex]); e.preventDefault(); } break; } }; // 选择资源 const handleSelectResource = (resource: MentionResource, tag?: MentionTag) => { const displayName = tag ? `${resource.element_name}-${tag.tag_label}` : resource.element_name; const resourceId = tag?.resources[0]?.resource_id || resource.default_resource?.resource_id; const mentionMark = `@[${displayName}](${resource.type}:${resource.element_id}:${tag?.tag_id || ''}:${resourceId})`; // 找到 @ 的位置 const textBeforeCursor = value.slice(0, cursorPosition); const lastAtIndex = textBeforeCursor.lastIndexOf('@'); const textAfterCursor = value.slice(cursorPosition); // 替换为完整的提及标记 const newValue = value.slice(0, lastAtIndex) + mentionMark + ' ' + textAfterCursor; onChange(newValue); setShowAutocomplete(false); // 设置光标位置 setTimeout(() => { if (textareaRef.current) { const newCursorPos = lastAtIndex + mentionMark.length + 1; textareaRef.current.setSelectionRange(newCursorPos, newCursorPos); textareaRef.current.focus(); } }, 0); }; return (