npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

mentionly

v2.0.0

Published

Lightweight Vue 3 mention input component for AI chat scenarios

Readme

mentionly

tests npm downloads llms.txt

面向 AI 聊天场景的 mention 输入:一个框架无关的 contenteditable 引擎 (@mentionly/core),加上开箱即用的 Vue 3 组件与 React / Svelte 的 headless 适配层。

原子 mention 实体、异步与分页数据源、输入法(IME)处理与序列化 —— core 零运行时依赖。

在线演示 | English | 1.x 迁移指南

给 AI agent 的文档

为编程 agent 准备的文档:

  • llms.txt:安装方式、输出格式,以及 agent 最容易写错的接入规则。
  • llms-full.txt:所有包的 README、迁移指南和完整示例组件合成的一个文件。
  • 每个包都随包带一份与已安装版本一致的 llms.txt:node_modules/mentionly/llms.txt 或 node_modules/@mentionly/<name>/llms.txt。

包一览

| 包 | 是什么 | 安装 | peer 依赖 | |----|--------|------|-----------| | @mentionly/core | 框架无关引擎:触发检测、异步/分页数据源、DOM 序列化、键盘与 IME 处理。同时提供 ./ai-sdk 与 ./mcp 转换子路径。 | npm install @mentionly/core | 无 | | @mentionly/vue | 薄 Vue 3 适配层:useMention composable,以及开箱即用的 MentionInput / MentionList 组件。 | npm install @mentionly/vue | vue ^3.3.0 | | @mentionly/react | React headless hook(useMention),基于 useSyncExternalStore。不发布组件。 | npm install @mentionly/react | react >=18 | | @mentionly/svelte | Svelte 5 headless 适配层:createMention() 与 use:mention action。不发布组件。 | npm install @mentionly/svelte | svelte ^5.0.0 | | mentionly | 转发包:重新导出 @mentionly/vue,并附带 mentionly/style.css。1.x 用户可无缝升级。 | npm install mentionly | vue ^3.3.0 |

5 个包统一版本发布。

快速开始(Vue)

<script setup lang="ts">
import { ref } from 'vue'
import { MentionInput } from 'mentionly'
import type { MentionTrigger, Part } from 'mentionly'
import 'mentionly/style.css'

const inputRef = ref<InstanceType<typeof MentionInput>>()

const triggers: MentionTrigger[] = [
  {
    char: '@',
    items: [
      { id: 'u1', label: 'Alice' },
      { id: 'u2', label: 'Bob' },
    ],
    // toData() 在选中时执行一次,结果会持久化到 mention 上
    toData: (item) => ({ kind: 'user', userId: item.id }),
  },
]

function onSubmit(parts: Part[]) {
  // [
  //   { type: 'text', text: '请检查 ' },
  //   { type: 'mention', trigger: '@', id: 'u1', label: 'Alice',
  //     data: { kind: 'user', userId: 'u1' } },
  // ]
  api.sendMessage(parts)
}
</script>

<template>
  <MentionInput
    ref="inputRef"
    :triggers="triggers"
    placeholder="输入消息... 试试 @"
    @submit="onSubmit"
  />
</template>

@mentionly/vue 的 API 完全一致 —— 把导入换成 import { MentionInput } from '@mentionly/vue' 和 import '@mentionly/vue/style.css' 即可。

Headless 模式(Vue)

useMention() 给你引擎与 DOM 事件处理器,编辑器与下拉列表由你自己渲染。

<script setup lang="ts">
import { useMention } from 'mentionly'

const {
  editorRef, isOpen, filteredItems, activeIndex, ids,
  select, handlers, loadMore, getParts, clear, loading,
} = useMention({
  triggers: [
    {
      char: '@',
      items: (query, page) => searchUsers(query, page), // 异步 + 分页
      pagination: { pageSize: 20 },
      debounce: 200,
      toData: (item) => ({ kind: 'user', userId: item.id }),
    },
  ],
})

function send() {
  const parts = getParts()
  api.sendMessage(parts)
  clear()
}
</script>

<template>
  <div class="my-chat-input">
    <!-- 编辑器元素上的 role="combobox" 与 aria-* 由 core 设置 -->
    <div ref="editorRef" contenteditable v-on="handlers" />

    <ul v-if="isOpen" :id="ids.listbox" role="listbox">
      <li
        v-for="(item, index) in filteredItems"
        :id="ids.option(index)"
        :key="item.id"
        role="option"
        :aria-selected="index === activeIndex"
        @mousedown.prevent="select(item)"
      >
        {{ item.label }}
      </li>
    </ul>

    <button @click="send">发送</button>
  </div>
</template>

列表根元素与候选项必须使用 ids.listbox / ids.option(index) —— core 会在编辑器上写 aria-controls / aria-activedescendant 指向它们。

快速开始(React)

import { useMemo } from 'react'
import { useMention } from '@mentionly/react'
import type { MentionTrigger } from '@mentionly/react'

const USERS = [
  { id: 'u1', label: 'Alice' },
  { id: 'u2', label: 'Bob' },
]

export function Composer() {
  // ⚠ `triggers` 必须保持稳定引用:模块级常量或 useMemo()。
  // 每次渲染都新建数组会让 hook 调用 setOptions(),从而关闭列表并作废在途请求。
  // 开发模式下连续 3 次渲染引用都在变化时会打印 warning。
  const triggers = useMemo<MentionTrigger[]>(
    () => [{ char: '@', items: USERS, toData: (item) => ({ userId: item.id }) }],
    [],
  )

  const { ref, state, ids, select, getParts, clear } = useMention({ triggers })

  return (
    <>
      {/* 这个元素的内部 DOM 完全由 core 管理:不要往里渲染 React 子节点 */}
      <div ref={ref} contentEditable data-placeholder="Type @ to mention" />

      {state.isOpen && (
        <ul id={ids.listbox} role="listbox">
          {state.filteredItems.map((item, index) => (
            <li
              key={item.id}
              id={ids.option(index)}
              role="option"
              aria-selected={index === state.activeIndex}
              onMouseDown={(e) => e.preventDefault()}
              onClick={() => select(item)}
            >
              {item.label}
            </li>
          ))}
        </ul>
      )}
    </>
  )
}

useMention() 返回:callback ref(挂到编辑器上)、core 快照 state、无障碍 ids, 以及 select / loadMore / close / getParts / getPlainText / clear / setContent / focus / insertMention 和底层 core。

一个完整、可直接复制的组件(键盘提交、高亮项滚动入可视区、分页、错误状态)在 examples/react/src/MentionInput.tsx。把这个文件连同 MentionInput.css 拷进你的项目,改掉 mi-* 类名即可。

快速开始(Svelte 5)

<script lang="ts">
  import { createMention } from '@mentionly/svelte'

  const USERS = [
    { id: 'u1', label: 'Alice' },
    { id: 'u2', label: 'Bob' },
  ]

  const mention = createMention({
    triggers: [
      { char: '@', items: USERS, toData: (item) => ({ userId: item.id }) },
    ],
  })
</script>

<!-- core 会绑定到这个元素,并在其上设置 role="combobox" 与 aria-* -->
<div contenteditable use:mention.mention></div>

{#if mention.state.isOpen}
  <ul id={mention.ids.listbox} role="listbox">
    {#each mention.state.filteredItems as item, index (item.id)}
      <li
        id={mention.ids.option(index)}
        role="option"
        aria-selected={index === mention.state.activeIndex}
        onmousedown={(e) => e.preventDefault()}
        onclick={() => mention.select(item)}
      >
        {item.label}
      </li>
    {/each}
  </ul>
{/if}

createMention() 返回 state(core 快照的 $state 镜像)、ids、mention(action)、 core、setOptions,以及与其他适配层一致的方法集合。一个完整、可直接复制的组件在 examples/svelte/src/MentionInput.svelte。

输出格式

引擎把编辑器 DOM 解析为与框架无关的 Part[]:

import type { Part, TextPart, MentionPart } from '@mentionly/core'

interface TextPart {
  type: 'text'
  text: string
}

interface MentionPart<T = unknown> {
  type: 'mention'
  trigger: string   // '@'、'#'、'/' ...
  id: string
  label: string
  data?: T          // 选中时 trigger.toData(item) 的返回值
}

type Part<T = unknown> = TextPart | MentionPart<T>
core.getParts()      // Part[]
core.getPlainText()  // 'hello @Alice' —— 文本 + 每个 mention 的 trigger + label
  • toData(item) 在条目被选中时执行一次,结果直接持久化在 mention 节点上;之后 getParts() 会把它作为 data 返回,所以即使原始条目已经不存在,mention 依然可以正确序列化。
  • getParts() 会规范化 DOM:合并相邻文本、把默认插入在 mention 后的不换行空格(NBSP) 转成普通空格,并去掉首尾空白与空文本片段。
  • setContent(parts) 从 Part[](或 1.x 的 ContentPart[],见迁移指南) 还原编辑器,因此 setContent(getParts()) 可以往返。

与 AI / Agent 生态互操作

两个转换器都是 @mentionly/core 的子路径导出,且只使用结构类型,因此 core 保持零依赖。

Vercel AI SDK —— @mentionly/core/ai-sdk

import { toUIMessageParts, fromUIMessageParts } from '@mentionly/core/ai-sdk'

const uiParts = toUIMessageParts(core.getParts())
// [{ type: 'text', text: 'hi ' },
//  { type: 'data-mention', data: { trigger: '@', id: 'u1', label: 'Alice' } }]

const parts = fromUIMessageParts(message.parts) // 只读 text 与 data-*,其余分支跳过
  • toUIMessageParts(parts, { dataPartName }) —— data part 名默认 'mention'。
  • fromUIMessageParts(uiParts, { dataPartName }) —— 传 dataPartName 时只转换该名字的 data part;省略时接受任意 data-* 且负载包含 trigger / id / label 的 part。
  • 两者互为逆运算:fromUIMessageParts(toUIMessageParts(x)) 与 x 深等。

服务端注意:AI SDK 的 convertToModelMessages 默认会丢弃 user message 里的 data part, 模型看不到 mention。需要显式传 convertDataPart,把 mention 转成模型能读的内容 (返回 undefined 表示忽略该 part):

import { convertToModelMessages, type UIMessage } from 'ai'
import type { MentionDataPayload } from '@mentionly/core/ai-sdk'

type MyMessage = UIMessage<unknown, { mention: MentionDataPayload }>

const modelMessages = await convertToModelMessages<MyMessage>(messages, {
  convertDataPart: (part) => {
    if (part.type === 'data-mention') {
      return { type: 'text', text: `@${part.data.label}(${part.data.id})` }
    }
    return undefined
  },
})

MCP —— @mentionly/core/mcp

import { toMCPContent } from '@mentionly/core/mcp'

toMCPContent(core.getParts())
// [{ type: 'text', text: 'see ' },
//  { type: 'resource_link', uri: 'file:///a.ts', name: 'a.ts' }]
  • text part 变成 { type: 'text', text };相邻的文本块(包括没有 uri 的 mention 退化成的文本) 会合并成一个。
  • data 里有字符串 uri 的 mention 变成 { type: 'resource_link', uri, name: label } (name 取 mention 的 label)。
  • 默认从 part.data?.uri 取 uri。可用 getUri(part) / getMimeType(part) 从别处读取; 没有 uri 的 mention 退化为文本 trigger + label。

特性

  • 多触发字符 —— @、#、/ 或任意自定义字符。
  • 原子 mention 实体 —— contenteditable="false" 的 span,不可分割、可样式化。
  • 一个引擎 + 三个适配层 —— 逻辑都在 @mentionly/core;Vue 提供组件,React / Svelte 为 headless。
  • 异步数据源 —— 防抖、竞态保护与 error 状态。
  • 后端分页 —— 数据源收到 { offset, limit },滚动或键盘导航到底时自动加载下一页; 返回 { items, hasMore } 可显式告知是否还有下一页。
  • 命令模式 —— /斜杠 命令触发回调而不插入实体。
  • IME 兼容 —— 正确处理中文 / 日文 / 韩文输入法组词。
  • 序列化与反序列化 —— getParts() / setContent() 用于提交与编辑已发消息。
  • Teleport 下拉列表(Vue) —— 下拉列表默认 teleport 到 <body>,避免被 overflow: hidden 裁剪。
  • 光标跟随弹窗 —— popupMode="cursor" 让弹窗跟随光标;popupScrollBehavior 控制滚动时的 行为(reposition / close / ignore)。
  • 词边界感知 —— 默认 allowMidWord: false 时,触发符紧跟 ASCII 单词字符不会打开列表, [email protected] 这类邮箱保持原样;空白、中文、标点之后照常触发。
  • core 零运行时依赖 —— @mentionly/core 不带任何依赖;适配层只把 Vue / React / Svelte 作为 peer 依赖。

触发器配置

interface MentionTrigger {
  char: string                    // 触发字符
  mode?: 'inline' | 'command'     // 默认 'inline'
  allowMidWord?: boolean          // 是否允许触发符紧跟 ASCII 单词字符,默认 false
  items: MentionItem[]            // 静态数组
    | ((query: string, page?: { offset: number; limit: number })   // 或函数
        => MentionItemsResult | Promise<MentionItemsResult>)
  pagination?: { pageSize: number }  // 启用后端分页(仅对函数源生效)
  debounce?: number               // 异步防抖毫秒数,默认 0
  toData?: (item: MentionItem) => unknown  // 持久化为 mention 的 `data`
  onSelect?: (item: MentionItem) => void   // 命令模式回调
}

// 函数源可返回裸数组,或带显式 hasMore 的对象:
type MentionItemsResult = MentionItem[] | { items: MentionItem[]; hasMore?: boolean }

多触发器

const triggers: MentionTrigger[] = [
  { char: '@', items: users, toData: (item) => ({ kind: 'user', userId: item.id }) },
  { char: '#', items: topics, toData: (item) => ({ kind: 'topic', topicId: item.id }) },
  { char: '/', mode: 'command', items: commands, onSelect: (item) => handleCommand(item) },
]

异步数据源

{
  char: '@',
  items: async (query) => {
    const res = await fetch(`/api/search?q=${query}`)
    return res.json()
  },
  debounce: 200,
}

后端分页(加载更多)

{
  char: '@',
  pagination: { pageSize: 20 },
  items: async (query, page) => {
    const res = await fetch(`/api/users?q=${query}&offset=${page.offset}&limit=${page.limit}`)
    const users = await res.json()

    // 方式一 —— 返回裸数组;hasMore 按 `length >= limit` 推断
    return users

    // 方式二 —— 显式告知是否还有下一页
    // return { items: users.list, hasMore: users.hasNext }
  },
}
  • 仅对函数源生效 —— 静态数组维持一次性全量渲染。
  • 向后兼容 —— 不配置 pagination 时,旧的 (query) => items 约定完全不变(不会传入第二个参数)。
  • loading 对应首屏(替换列表),loadingMore 对应后续页(保留列表、底部显示指示器)。 两者都由所有适配层暴露,也由 Vue 的 #list 插槽暴露。

MentionInput(@mentionly/vue)

Props

| Prop | 类型 | 默认值 | 说明 | |------|------|--------|------| | triggers | MentionTrigger[] | 必填 | 触发器配置 | | placeholder | string | '' | 占位文本 | | disabled | boolean | false | 是否禁用 | | maxHeight | string | '200px' | 编辑器最大高度 | | submitOnEnter | boolean | true | 是否 Enter 提交 | | onEnter | (e: KeyboardEvent) => void | - | Enter 提交前触发;e.preventDefault() 可阻止提交/换行 | | popupMode | 'fixed' \| 'cursor' | 'fixed' | 弹窗定位模式 | | popupScrollBehavior | 'reposition' \| 'close' \| 'ignore' | 'reposition' | 滚动时弹窗行为 | | teleport | boolean | true | 是否把下拉列表 teleport 到 <body> |

事件

| 事件 | 载荷 | 说明 | |------|------|------| | submit | Part[] | 按 Enter 时触发 | | change | Part[] | 内容变化时触发 |

插槽

| 插槽 | Props | 说明 | |------|-------|------| | #list | { items, activeIndex, select, loading, hasMore, loadingMore, loadMore, ids } | 自定义下拉列表 | | #item | { item, active, select } | 自定义候选项渲染 | | #empty | { query } | 无匹配结果 | | #error | { error } | 数据源错误(替换默认的提示框) | | #loading | {} | 加载中(首屏) | | #loading-more | {} | 加载下一页时的指示器 | | #actions | { submit, clear, isEmpty } | 自定义操作栏 | | #inner-actions | { submit, clear, isEmpty } | 编辑器内部、输入框下方(如发送按钮) | | #default | { submit, clear, isEmpty, focus, getParts } | 最外层底部,自由放置内容 |

暴露方法

通过 template ref 访问:

const inputRef = ref()

inputRef.value.getParts()        // Part[]
inputRef.value.getDataParts()    // DataPart[](已废弃,兼容 1.x)
inputRef.value.getPlainText()    // string
inputRef.value.clear()
inputRef.value.setContent(parts) // Part[](也兼容旧的 ContentPart[])
inputRef.value.insertMention({
  id: 'ctx-1',
  label: '选中内容',
  data: { kind: 'context', sourceId: 'ctx-1' },
})
inputRef.value.focus()
inputRef.value.ids               // { listbox, option(index) }

insertMention() 也可以插入未注册到任何 trigger 上的自定义原子节点。

无障碍(a11y)

core 会在编辑器元素上设置并维护 combobox 相关属性:

| 属性 | 值 | |------|-----| | role | combobox(除非你自己设置了 role) | | aria-autocomplete | list | | aria-expanded | 下拉列表打开时为 true | | aria-controls | ids.listbox | | aria-activedescendant | 打开且有结果时为 ids.option(activeIndex) |

下拉列表用 role="listbox" + id={ids.listbox} 渲染,候选项用 role="option" + id={ids.option(index)} + aria-selected。Vue 的 MentionList 已经做好;React / Svelte 需要你自己接。↑ / ↓ / Enter / Tab / Escape(含 IME 组词)都由 core 处理,不要重复实现。

错误状态

异步数据源 reject 时,core 不会抛出,而是暴露出来:

  • React / Svelte 的 state.error、Vue 的 error ref 持有该错误;下次加载成功或 close() 后回到 null。
  • MentionInput 渲染 #error(默认是一个 alert 提示框,文案 "Failed to load suggestions.")。
  • 首屏失败会清空列表;追加失败会保留已加载项与 hasMore,便于重试。

从 1.x 迁移

mentionly 仍然可用(转发 @mentionly/vue),但输出格式与部分 API 有变化。 每一条变更及前后代码对比见 MIGRATION.md。

许可

MIT