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

RFC 138: AI 对话 @ 提及功能前端实现方案

状态:Draft
创建日期:2026-01-30
作者:Jointo AI Team


目录

  1. 功能概述
  2. 用户体验设计
  3. 提及识别方案
  4. 组件设计
  5. 状态管理
  6. 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. 后端容错:如果没有完整标记,尝试模糊匹配

结束符定义

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