You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
23 KiB
23 KiB
RFC 138: AI 对话 @ 提及功能前端实现方案
状态:Draft
创建日期:2026-01-30
作者:Jointo AI Team
目录
功能概述
什么是 @ 提及功能?
用户在 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 取消 │
└─────────────────────────────────────┘
提及识别方案
核心问题
用户输入 @张三在花果山,如何识别 张三 是一个完整的提及?
推荐方案:混合方案 ✅
核心思路:
- 优先使用自动补全:用户选择后自动插入完整标记
- 支持手动输入:遇到结束符(空格、标点)时结束
- 后端容错:如果没有完整标记,尝试模糊匹配
结束符定义
const END_CHARS = [
' ', // 空格
',', ',', // 逗号
'。', '.', // 句号
'?', '?', // 问号
'!', '!', // 感叹号
':', ':', // 冒号
';', ';', // 分号
'\n' // 换行
];
识别示例
"@张三 在花果山" → 识别 "张三" ✅ (空格结束)
"@张三,在花果山" → 识别 "张三" ✅ (逗号结束)
"@张三在花果山" → 识别 "张三在花果山" ⚠️ (无结束符,需要自动补全)
"@[张三-少年](...) 在" → 已完成 ✅ (包含右括号)
提及上下文检测
// 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
};
}
组件设计
类型定义
// 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 组件
// 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<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>) => {
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 (
<div className="relative">
<textarea
ref={textareaRef}
value={value}
onChange={handleInput}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
className="w-full p-3 border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={4}
/>
{showAutocomplete && (
<MentionAutocomplete
resources={resources || []}
searchKeyword={searchKeyword}
selectedIndex={selectedIndex}
isLoading={isLoading}
onSelect={handleSelectResource}
onClose={() => setShowAutocomplete(false)}
/>
)}
</div>
);
}
MentionAutocomplete 组件
// components/ai/MentionAutocomplete.tsx
import { useState } from 'react';
import type { MentionResource, MentionTag } from '@/types/mention';
interface MentionAutocompleteProps {
resources: MentionResource[];
searchKeyword: string;
selectedIndex: number;
isLoading: boolean;
onSelect: (resource: MentionResource, tag?: MentionTag) => void;
onClose: () => void;
}
export function MentionAutocomplete({
resources,
searchKeyword,
selectedIndex,
isLoading,
onSelect,
onClose
}: MentionAutocompleteProps) {
const [showTagSelector, setShowTagSelector] = useState(false);
const [selectedResource, setSelectedResource] = useState<MentionResource | null>(null);
const handleResourceClick = (resource: MentionResource) => {
if (resource.has_tags && resource.tags && resource.tags.length > 1) {
// 如果有多个标签,显示标签选择器
setSelectedResource(resource);
setShowTagSelector(true);
} else if (resource.has_tags && resource.tags && resource.tags.length === 1) {
// 如果只有一个标签,直接选择
onSelect(resource, resource.tags[0]);
} else {
// 如果没有标签,直接选择
onSelect(resource);
}
};
const handleTagSelect = (tag: MentionTag) => {
if (selectedResource) {
onSelect(selectedResource, tag);
setShowTagSelector(false);
}
};
// 高亮搜索关键词
const highlightKeyword = (text: string) => {
if (!searchKeyword) return text;
const regex = new RegExp(`(${searchKeyword})`, 'gi');
const parts = text.split(regex);
return parts.map((part, index) =>
regex.test(part) ? (
<mark key={index} className="bg-yellow-200">{part}</mark>
) : (
<span key={index}>{part}</span>
)
);
};
if (isLoading) {
return (
<div className="absolute z-10 mt-1 w-full bg-white border rounded-lg shadow-lg p-4">
<div className="text-center text-gray-500">加载中...</div>
</div>
);
}
return (
<div className="absolute z-10 mt-1 w-full bg-white border rounded-lg shadow-lg max-h-96 overflow-y-auto">
{!showTagSelector ? (
<>
{resources.length === 0 ? (
<div className="p-4 text-center text-gray-500">
没有找到匹配的资源
</div>
) : (
<div className="p-2">
{resources.map((resource, index) => (
<div
key={resource.element_id}
onClick={() => handleResourceClick(resource)}
className={`flex items-center gap-3 p-2 rounded cursor-pointer ${
index === selectedIndex ? 'bg-blue-50' : 'hover:bg-gray-100'
}`}
>
<img
src={resource.default_resource?.thumbnail_url || resource.tags?.[0]?.resources[0]?.thumbnail_url}
alt={resource.element_name}
className="w-12 h-12 object-cover rounded"
/>
<div className="flex-1">
<div className="font-medium">
{highlightKeyword(resource.element_name)}
</div>
<div className="text-sm text-gray-500">
{resource.type === 'character' && '角色'}
{resource.type === 'scene' && '场景'}
{resource.type === 'prop' && '道具'}
{resource.has_tags && resource.tags && ` · ${resource.tags.length} 个标签`}
</div>
</div>
</div>
))}
</div>
)}
<div className="p-2 border-t text-xs text-gray-500 bg-gray-50">
<div>↑↓ 选择 · Enter 确认 · ESC 取消</div>
</div>
</>
) : (
<div className="p-2">
<div className="text-sm text-gray-500 mb-2 px-2">
选择 {selectedResource?.element_name} 的标签:
</div>
{selectedResource?.tags?.map((tag) => (
<div
key={tag.tag_id}
onClick={() => handleTagSelect(tag)}
className="flex items-center gap-3 p-2 hover:bg-gray-100 rounded cursor-pointer"
>
<img
src={tag.resources[0]?.thumbnail_url}
alt={tag.tag_label}
className="w-12 h-12 object-cover rounded"
/>
<div>
<div className="font-medium">{tag.tag_label}</div>
<div className="text-sm text-gray-500">
{tag.resources.length} 张图片
</div>
</div>
</div>
))}
<button
onClick={() => setShowTagSelector(false)}
className="mt-2 text-sm text-blue-600 hover:text-blue-800 px-2"
>
← 返回
</button>
</div>
)}
</div>
);
}
状态管理
使用 TanStack Query
// hooks/api/useMentionableResources.ts
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import type { MentionResource } from '@/types/mention';
export function useMentionableResources(
conversationId: string,
search?: string,
options?: { enabled?: boolean }
) {
return useQuery<MentionResource[]>({
queryKey: ['mentionable-resources', conversationId, search],
queryFn: async () => {
const params = new URLSearchParams();
if (search) params.append('search', search);
const response = await apiClient.get(
`/api/v1/ai/conversations/${conversationId}/mentionable-resources?${params}`
);
return response.data.resources;
},
enabled: options?.enabled ?? true,
staleTime: 5 * 60 * 1000, // 5 分钟缓存
});
}
发送消息
// hooks/api/useConversationMessages.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
export function useSendMessage(conversationId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (content: string) => {
const response = await apiClient.post(
`/api/v1/ai/conversations/${conversationId}/messages`,
{ content } // 只发送文本,后端会自动解析提及
);
return response.data;
},
onSuccess: () => {
// 刷新消息列表
queryClient.invalidateQueries({
queryKey: ['conversation-messages', conversationId]
});
}
});
}
API 集成
API 客户端
// lib/api-client.ts
import axios from 'axios';
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
headers: {
'Content-Type': 'application/json'
}
});
// 请求拦截器:添加认证 token
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 响应拦截器:统一错误处理
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// 未授权,跳转到登录页
window.location.href = '/login';
}
return Promise.reject(error);
}
);
键盘交互
支持的快捷键
| 按键 | 功能 | 说明 |
|---|---|---|
@ |
触发自动补全 | 显示可提及的资源列表 |
↑ |
上一个 | 选择上一个资源 |
↓ |
下一个 | 选择下一个资源 |
Enter |
确认选择 | 插入选中的资源 |
ESC |
取消 | 关闭自动补全 |
性能优化
1. 防抖搜索
// hooks/useDebouncedValue.ts
import { useState, useEffect } from 'react';
export function useDebouncedValue<T>(value: T, delay: number = 300): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
// 在 MentionInput 组件中使用
const debouncedKeyword = useDebouncedValue(searchKeyword, 300);
const { data: resources } = useMentionableResources(
conversationId,
debouncedKeyword, // 使用防抖后的关键词
{ enabled: showAutocomplete }
);
2. 缓存策略
// TanStack Query 缓存配置
export function useMentionableResources(
conversationId: string,
search?: string
) {
return useQuery({
queryKey: ['mentionable-resources', conversationId, search],
queryFn: fetchResources,
staleTime: 5 * 60 * 1000, // 5 分钟内不重新请求
cacheTime: 10 * 60 * 1000, // 10 分钟后清除缓存
});
}
3. 虚拟滚动(可选)
如果资源列表很长,可以使用虚拟滚动:
import { useVirtualizer } from '@tanstack/react-virtual';
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: resources.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 60, // 每项高度
overscan: 5
});
测试策略
单元测试
// __tests__/utils/mention.test.ts
import { describe, it, expect } from 'vitest';
import { detectMentionContext } from '@/utils/mention';
describe('detectMentionContext', () => {
it('应该识别正在输入的提及', () => {
const result = detectMentionContext('生成一张 @张', 8);
expect(result).toEqual({ startIndex: 6, keyword: '张' });
});
it('应该忽略已完成的提及', () => {
const result = detectMentionContext(
'生成一张 @[张三-少年](character:...) 在',
30
);
expect(result).toBeNull();
});
it('应该在遇到空格时返回 null', () => {
const result = detectMentionContext('生成一张 @张三 在', 10);
expect(result).toBeNull();
});
it('应该在遇到逗号时返回 null', () => {
const result = detectMentionContext('生成一张 @张三,在', 10);
expect(result).toBeNull();
});
});
集成测试
// __tests__/components/MentionInput.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MentionInput } from '@/components/ai/MentionInput';
const queryClient = new QueryClient();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
describe('MentionInput', () => {
it('应该在输入 @ 时显示自动补全', async () => {
const onChange = vi.fn();
render(
<MentionInput
conversationId="test-id"
value=""
onChange={onChange}
/>,
{ wrapper }
);
const textarea = screen.getByPlaceholderText(/输入 @ 来引用/);
fireEvent.change(textarea, { target: { value: '@' } });
await waitFor(() => {
expect(screen.getByText(/加载中/)).toBeInTheDocument();
});
});
it('应该支持键盘导航', async () => {
const onChange = vi.fn();
render(
<MentionInput
conversationId="test-id"
value="@张"
onChange={onChange}
/>,
{ wrapper }
);
const textarea = screen.getByPlaceholderText(/输入 @ 来引用/);
// 按下箭头键
fireEvent.keyDown(textarea, { key: 'ArrowDown' });
fireEvent.keyDown(textarea, { key: 'Enter' });
await waitFor(() => {
expect(onChange).toHaveBeenCalled();
});
});
});
使用示例
完整的对话页面
// pages/ConversationPage.tsx
import { useState } from 'react';
import { MentionInput } from '@/components/ai/MentionInput';
import { useSendMessage } from '@/hooks/api/useConversationMessages';
interface ConversationPageProps {
conversationId: string;
}
export function ConversationPage({ conversationId }: ConversationPageProps) {
const [message, setMessage] = useState('');
const sendMessage = useSendMessage(conversationId);
const handleSend = async () => {
if (!message.trim()) return;
try {
const result = await sendMessage.mutateAsync(message);
console.log('消息已发送:', result);
console.log('后端解析的提及:', result.metadata?.mentions);
console.log('参考图 URL:', result.metadata?.reference_images);
// 清空输入
setMessage('');
} catch (error) {
console.error('发送失败:', error);
}
};
return (
<div className="p-4">
<div className="mb-4">
<h2 className="text-xl font-bold">AI 对话</h2>
</div>
<MentionInput
conversationId={conversationId}
value={message}
onChange={setMessage}
/>
<button
onClick={handleSend}
disabled={!message.trim() || sendMessage.isPending}
className="mt-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
>
{sendMessage.isPending ? '发送中...' : '发送'}
</button>
</div>
);
}
相关文档
状态:Draft
创建日期:2026-01-30
作者:Jointo AI Team