AI 智能输入框解决方案【前端】

2,027 阅读7分钟

AI 智能输入框解决方案【前端】

在现代前端开发中,智能输入框已成为诸多应用场景中提升用户体验的关键组件。例如,当用户输入内容时可以自动提示相关词汇,并高亮显示命中的关键词,进一步增强可读性和交互体验。本文介绍了一种基于 contenteditable 属性的 AI 智能输入框解决方案,帮助实现高亮显示、鼠标悬停展示词汇信息等功能,并针对开发过程中遇到的技术难题提供解决方案。

功能展示

20241101-174902.gif

核心功能

本解决方案的核心功能包括:

  1. 基于 HTML 标签的 contenteditable 属性,创建可编辑区域。
  2. 在用户输入的过程中实时匹配关键词,并将命中的关键词高亮显示。
  3. 鼠标悬停在高亮关键词上时展示关键词附加信息。

实现思路

通过 contenteditable 属性设置输入框的可编辑区域,结合 JavaScript 和 react-contenteditable 组件监听用户的输入,发送到服务端,经过工程和大模型的处理返回高亮tokens色块和元素index,通过样式处理回填到输入框中。核心流程如下:

  1. 输入监听:通过 inputkeyup 事件监听用户的输入行为。
  2. 关键词匹配:根据api返回的命中关键词,高亮色块包裹回填到输入框元素内。
  3. 信息展示:通过 mouseover 事件监听高亮关键词的悬停状态,展示该词的类型和同义词。

遇到的问题及解决方案

在开发中,主要遇到了以下几个技术问题:

1. 高亮色块展示遮挡问题 和 鼠标在高亮色块上输入时带出色块内文字

问题描述:使用hover和overflow: hidden;控制色块的显隐会被遮挡,更严重的是用户将光标移动到高亮区域后进行输入时,可能会误带出高亮色块内部的文字。

色块遮挡.gif

解决方案:将色块hover需要显示的信息放到色块的data-content属性中,结合onmouseenter事件用js控制色块hover的交互和内容信息,同时解决色块因overflow: hidden;的遮挡和输入时带出文字的问题。

const newColorText = ({ word, title, type, offset, synonym }: Token) => {
  const colorMap: { [key: string]: string } = {
    general: '#00000040',
    dimension_value: '#CFF7F2',
    dimension: '#CFF7F2',
    indicator: '#E0E9FF',
    intent: '#F2D2FF',
    time: '#E0E9FF',
  };

  return `<div 
      class='cutword' 
      data-title='${title}' 
      data-content='${JSON.stringify(synonym)}' 
      data-offset='${offset.join(',')}' 
      style='background-color: ${colorMap[type]};' 
      onmouseenter="const popover = document.createElement('div'); 
        popover.className = 'cm-cutword-popover-input'; 
        popover.setAttribute('contenteditable', 'false'); 
        popover.style.position = 'absolute'; 
        popover.style.top = (this.getBoundingClientRect().bottom + window.scrollY + 10) + 'px'; 
        popover.style.left = (this.getBoundingClientRect().left + window.scrollX) + 'px'; 
        popover.innerHTML = '<div class=\\'cm-cutword-popover-input-title\\'>类型:${title}</div><div class=\\'cm-cutword-popover-input-content\\'>同义词:${synonym.join(
    ',',
  )}</div>'; 
        document.body.appendChild(popover); 
        this.addEventListener('mouseleave', () => document.querySelectorAll('.cm-cutword-popover-input').forEach((el) => el.remove()), { once: true });"
    >${word}</div>`;
};

2. 接口竞态问题

问题描述:由于关键词信息需要从接口异步获取,存在竞态问题,即可能在后续输入发生前,异步接口返回的信息才生效。

解决方案:竞态问题出现的原因是无法保证异步操作的完成会按照他们开始时同样的顺序,那么如何解决竞态问题呢? 当发出新的请求时,取消掉上次请求。 XHR请求可以使用about取消,如果是fetch或者axios可以使用AbortController或者利用axios 的 CancelToken API 取消请求。

除了取消请求,我们还可以给「请求标记 id」的方式来忽略上次请求。

具体思路是:

利用全局变量记录最新一次的请求 id 在发请求前,生成唯一 id 标识该次请求 在请求回调中,判断 id 是否是最新的 id,如果不是,则忽略该请求的回调

还有一些其他的解决方式,比如rxjs的竞态处理等,我使用的是取消请求,代码如下:

  import { isCancel } from 'axios';

  const cutController = useRef<AbortController | null>(null);
  const cutApi = useCallback(
    async ({ question }: { question: string }) => {
      // 每次发出新请求时,先取消之前的请求
      if (cutController.current) {
        cutController.current.abort(); // 取消上一个请求
      }

      // 为新请求创建新的 AbortController
      cutController.current = new AbortController();

      postCutWords(
        {
          app_id,
          question,
        },
        {
          signal: cutController.current.signal,
        },
      )
        .then((res) => {
          // xxxx
          cutLoadingRef.current = false;
        })
        .catch((error) => {
          if (isCancel(error)) {
            console.log('切词-请求已取消');
          } else {
            console.error('请求失败:', error);
          }
        });
    },
    [app_id],
  );

3. 输入过程中光标位置问题

问题描述:当光标不在输入框末尾时,每次输入结束后光标都会跳转到输入框末尾。

光标.gif

解决方案:出现这个问题的原因是每次请求api返回切词结果再赋值到输入框内都会触发render,从而光标位置变更到输入框末尾。解决问题的核心是 在发送请求更新输入框内容前记录光标的绝对位置(需要处理兄弟节点位置),复写内容后用 Range 恢复光标(需要处理兄弟节点位置),避免光标跳至末尾

记录当前光标位置

  1. 检查光标是否在输入框内
const selection = window.getSelection();
const isFocusedChildNode = inputEl && selection?.focusNode?.parentNode?.parentNode === inputEl;
const isFocusedInput = isFocusedChildNode ||
  (inputEl && selection?.focusNode?.nodeType === 3 && selection?.focusNode?.parentNode === inputEl);
  1. 计算光标在输入框中的绝对位置
const inputLastCursorPosition = selection && inputEl && isFocusedInput ? (() => {
  let prev = selection?.focusNode?.previousSibling || selection?.focusNode?.parentNode?.previousSibling;
  let pos = window.getSelection()?.focusOffset || 0;
  while (prev) {
    pos += prev.textContent?.length || 0;
    prev = prev.previousSibling;
  }
  return pos;
})() : -1;

恢复光标位置

  1. 初始化 Range,遍历子节点找到精确的光标位置
const range = (() => {
  let range = document.createRange();
  range.selectNode(inputEl);
  range.setStart(inputEl, 0);

  let pos = 0;
  const stack: Array<HTMLElement | ChildNode> = [inputEl];
  while (stack.length > 0) {
    const current = stack.pop() as HTMLElement;

    if (current.nodeType === Node.TEXT_NODE) {
      const len = current.textContent?.length || 0;
      if (pos + len >= inputLastCursorPosition) {
        const _pos = inputLastCursorPosition - pos;
        range.setStart(current, _pos);
        range.setEnd(current, _pos);
        return range;
      }
      pos += len;
    } else if (
      current.childNodes &&
      current.childNodes.length > 0
    ) {
      for (
        let i = current.childNodes.length - 1;
        i >= 0;
        i--
      ) {
        stack.push(current.childNodes[i]);
      }
    }
  }

  const _pos = inputEl.childNodes.length;
  range.setStart(inputEl, _pos);
  range.setEnd(inputEl, _pos);

  return range;
})();
  1. 应用 RangeSelection
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);

4. contenteditable 元素的 <br> 问题

问题描述:在输入框中点击删除键直至删除最后一个字符时,回车会生成 <br> 标签,这个标签会影响后续的输入。如下动图所示,我用输入法输入了“是”,然后点击了一下删除键,再用输入法输入“是”,就变成了图中的情况。

br.gif

解决方案:查看dom元素分析,当我点击删除键删除了“是”这个字后,输入框内自动生成了一个<br>元素。

image.png

通过Google我发现,<div> 是一个块级元素,当它为空时,由于缺少内容支持,默认会收缩到零高度。如果是 contenteditable 状态,浏览器会自动插入 <br> 以保持元素的交互性和高度,使用户可以直接点击编辑。最终解决方案是: 使用内联元素 <span> 而非 <div>,设置 display: inline-block 来确保 span 具备设置宽高的能力,避免 <br> 的插入。

总结

通过使用 contenteditable 和高亮关键字匹配,可以实现一个简单而高效的 AI 智能输入框。然而在实际应用中,仍然会遇到光标位置、遮挡、异步数据处理等问题,这些细节往往影响用户体验。在开发过程中,关注并优化这些细节,将极大提升输入框的响应和交互效果。

希望本文的解决方案能够对遇到类似需求的开发者提供帮助,也欢迎交流改进思路!