Перейти к содержимому

Расширение AI ​

Расширение AI добавляет кнопку на панель инструментов, которая открывает диалог запроса, отправляет его выбранному вами провайдеру, показывает потоковый результат и вставляет его в редактор кнопкой действия, зависящей от режима (Вставить / Заменить / Добавить в конец).

vue
<Tiptapify
  :content="content"
  :items="['ai', '|', 'bold', 'italic']"
  :ai="{
    aiProvider,
    model: 'gpt-4.1-mini',
    stream: true,
  }"
/>

Prop ai принимает true (параметры по умолчанию) или объект параметров (TiptapifyAiOptions). Элемент ai на панели инструментов регистрируется всегда, но кнопка отключена, пока не задан aiProvider.

Запускаемое демо см. в примере AI, а полный справочник параметров — в разделе Props.

Архитектура ​

Tiptapify.vue            Toolbar item `ai`           Dialog.vue
  resolves the `ai`     ->  Button.vue               ->  builds the request
  prop into a resolved  ->  (disabled without        ->  calls aiProvider(request, context, stream)
  options object and    ->    aiProvider)           ->  streams chunks into result/reasoning fields
  provides it (inject   ->  opens the dialog        ->  the action button (Insert/Replace/Append) places the result into the editor
  'tiptapifyAi')
  • Tiptapify нормализует prop ai: ai: true превращается в пустой объект параметров, promptExamples по умолчанию берёт локализованные значения, а результат передаётся потомкам через inject('tiptapifyAi') (TiptapifyAiResolvedOptions или false).
  • Элемент панели инструментов (src/components/Toolbar/ai.ts) отображает Button.vue. Кнопка проверяет доступность aiProvider и открывает Dialog.vue.
  • Dialog.vue владеет всем жизненным циклом запроса: формированием запроса, вызовом провайдера, потоковой передачей и вставкой результата.

Контракт провайдера ​

ts
type TiptapifyAiProvider = (
  request: TiptapifyAiRequest,
  context: TiptapifyAiEditorContext,
  stream?: TiptapifyAiStream,
) => Promise<TiptapifyAiResponse | TiptapifyAiOpenAiResponse | string>

Провайдер — это обычная асинхронная функция, которую вы реализуете. tiptapify сам не обращается ни к какому API — подойдёт всё, что умеет обрабатывать OpenAI-совместимый объект request (fetch, SDK, адаптер бэкенда). Исключение — режим бэкенда aiEndpoint (см. Генерация через бэкенд), где tiptapify отправляет запрос на ваш endpoint.

Запрос (TiptapifyAiRequest) формируется из ваших chatCompletionOptions, а затем дополняется плагином:

ПолеИсточник
messagescreateMessages(context), если задан, иначе стандартная пара: системное сообщение (ваш systemPrompt или встроенное значение по умолчанию) и пользовательское сообщение, в которое входят запрос, выделенный текст редактора с окружающим текстом и правило отвечать только для выделения (или для всего текста, если ничего не выделено)
modelпараметр model, если задан
temperatureпараметр temperature, если задан
streamпараметр stream или chatCompletionOptions.stream
enable_thinking / reasoning_effortпереключатель размышления / выпадающий список уровня — см. ниже

Контекст (TiptapifyAiEditorContext) даёт провайдеру полное состояние редактора: prompt, selectedText, textBefore / textAfter (текст абзаца выделения вокруг него, до 500 символов с каждой стороны), text, html, json и mode (insert / replace / append).

Выделение, которое начинается или заканчивается внутри слова, захватывает слово целиком, а сообщения по умолчанию отправляют текст вокруг выделения вместе с правилом отвечать только для выделения: получив один фрагмент, модель принимает его за весь текст и дописывает его, и замена повторила бы то, что идёт после выделения.

Ответ: провайдер может вернуть содержимое как обычную string, как { content } (TiptapifyAiResponse) или как OpenAI-совместимый payload choices (TiptapifyAiOpenAiResponse). Диалог извлекает содержимое именно в таком порядке и обрезает пробелы в начале. При отклонении сообщение об ошибке показывается в диалоге (или ai.error, если ошибка не является экземпляром Error), и диалог остаётся открытым.

Генерация через бэкенд (aiEndpoint) ​

Вместо реализации aiProvider можно направить AI-функцию на endpoint LLM на вашем бэкенде:

ts
// example
const ai = {
  aiEndpoint: '/api/ai/generate',
  model: 'gpt-4.1-mini',
  stream: true,
}

Тогда tiptapify сам формирует запрос и отправляет его на endpoint. Явно заданный aiProvider всегда имеет приоритет, если заданы оба.

Запрос — минимальный JSON-payload (TiptapifyAiBackendRequest):

ПолеКогда отправляется
promptвсегда — запрос, введённый в диалоге
instructionвсегда — ваш systemPrompt, системное сообщение пользовательского createMessages или встроенное значение по умолчанию
streamтолько при stream: true
thinkingтолько если переключатель Размышление в диалоге включён
reasoning_effortтолько если пользователь выбрал уровень (не default)
modelтолько если задан параметр model

Ответ — принимается нестрого: обычная строка, объект { content } или OpenAI-совместимый payload choices.

Потоковая передача — если в запросе stream: true, тело ответа читается как поток SSE (строки data:, завершается data: [DONE]). Допустимые форматы чанков:

  • В стиле OpenAI — data: {"choices":[{"delta":{"content":"…", "reasoning_content":"…"}}]}
  • Простой — data: {"type":"content"|"reasoning","data":"…"}
  • Плоский — data: {"content":"…","reasoning_content":"…"}
  • Обычный текст — data: some text (строка data без JSON считается чанком содержимого)

Чанки content обновляют поле результата через onChunk, а чанки reasoning_content наполняют панель Размышление через onReasoning. Если бэкенд игнорирует stream и отвечает обычным телом, используется это тело. Нажатие Остановить или закрытие диалога прерывает запрос; частично полученное содержимое остаётся в поле результата.

Заголовки — aiHeaders (Record<string, string>) накладываются поверх Content-Type: application/json. Заголовок Authorization в aiHeaders сохраняется как есть; иначе токен из tokenProvider отправляется как Authorization: Bearer <token>.

Ошибки — ответ с ошибочным статусом выводит в диалоге сообщение об ошибке бэкенда (JSON { error } / { message } или сырое тело).

Потоковая передача ​

Если stream: true (или chatCompletionOptions.stream равно true), диалог передаёт объект TiptapifyAiStream третьим аргументом:

ts
type TiptapifyAiStream = {
  signal: AbortSignal,
  onChunk: (chunk: string) => void,
  onReasoning: (chunk: string) => void,
}
  • signal — передайте его в fetch (или в вызов вашего SDK). Он срабатывает, когда пользователь нажимает Остановить или закрывает диалог, поэтому запросы в процессе выполнения не продолжают работать.
  • onChunk — добавляйте каждый чанк содержимого в поле результата. Во время генерации поле результата доступно только для чтения, а частично полученное содержимое сохраняется (можно нажать «Остановить», затем вставить или отбросить его).
  • onReasoning — передавайте вывод рассуждений/размышлений модели (например, дельту reasoning_content у Qwen/DeepSeek/o-series) в сворачиваемую панель Размышление над результатом. Панель автоматически раскрывается и прокручивается во время потоковой передачи. Вывод рассуждений только отображается и никогда не вставляется в редактор.

Провайдеры, которые не поддерживают потоковую передачу, игнорируют аргумент stream — он необязателен.

Размышление и уровень рассуждения ​

Два пользовательских элемента управления в диалоге соответствуют полям запроса. Оба включаются через параметры и принадлежат плагину — вы не задаёте (и не должны задавать) эти поля сами в chatCompletionOptions.

  • thinking: true показывает переключатель Размышление (включён по умолчанию, сбрасывается при каждом открытии диалога). Плагин записывает значение переключателя в запрос как enable_thinking: true | false.
  • thinking отсутствует или thinking: false скрывает переключатель, а плагин отправляет enable_thinking: false, поэтому модели, которые по умолчанию размышляют, не размышляют.
  • reasoningEffort: { options, default? } показывает выпадающий список Уровень размышления. options перечисляет пользовательские уровни, которые вы хотите предложить ('low' | 'medium' | 'high' | 'xhigh' | 'max'); расширение всегда само добавляет пункт default (базовый уровень режима размышления), поэтому default в вашей конфигурации перечислять не нужно. Поле default предварительно выбирает один из ваших уровней при открытии диалога — если оно не задано, предварительно выбран default. Значение сбрасывается при каждом открытии. Выпадающий список отображается, только если options — непустой список, thinking включён и переключатель Размышление включён.
  • showReasoning (по умолчанию false) управляет сворачиваемой панелью Размышление, в которую потоком выводится процесс рассуждения модели (дельта reasoning_content). Задайте showReasoning: true, чтобы показывать эту панель, не выключая размышление: модель по-прежнему рассуждает, результат применяется так же, лишь дополнительно отображаются пошаговые рассуждения.

Поскольку провайдеры (в частности vLLM/LM Studio) отклоняют одновременную передачу enable_thinking и reasoning_effort в одном запросе, плагин отправляет ровно одно из этих двух полей:

СостояниеПоле запроса
Размышление включено, уровень = default (или уровень не настроен)enable_thinking (из переключателя)
Размышление включено, уровень = low / medium / high / xhigh / maxreasoning_effort (из выпадающего списка); переключатель отключён
Размышление выключено (переключатель выключен или thinking не включён в конфигурации)enable_thinking: false; reasoning_effort не отправляется

Ошибки ​

Вместо необработанной ошибки диалог объясняет, почему запрос не удался, и добавляет после этого собственное сообщение сервиса:

Что произошлоПояснение
Браузер не получил ответа (Failed to fetch, NetworkError…, Load failed)Сервис не работает, недоступен или CORS заблокировал ответ; в консоли браузера видно, что именно
401 / 403Проверьте API-ключ или права доступа
404Проверьте путь endpoint
408 / 504 / таймаутСервис не ответил вовремя
429Слишком много запросов
5xxСервис недоступен (загружает модель, занят)

С aiEndpoint статус берётся из ответа. Пользовательский aiProvider получает те же пояснения, если выбрасывает ошибку со status:

ts
if (!response.ok) {
  const body = await response.json().catch(() => null)
  throw Object.assign(new Error(body?.error?.message ?? ''), { status: response.status })
}

Любая другая ошибка показывается с её собственным сообщением.

Вставка результата ​

По кнопке действия — Вставить, Заменить или Добавить в конец, в зависимости от режима — диалог помещает result в редактор:

  • Режим — кнопка действия в диалоге является split-кнопкой. Основная часть применяет результат в предварительно выбранном режиме (Вставить / Заменить / Добавить в конец): его предварительно выбирает параметр mode, а иначе — Заменить, если при открытии диалога был выделен текст, и Вставить в остальных случаях. Небольшая секция «ещё» (⋮) справа от кнопки открывает меню с остальными режимами — щелчок по одному из них сразу применяет результат в этом режиме. Вставить помещает результат в позицию курсора, Заменить заменяет выделенный текст (запомненный при нажатии Сгенерировать), а Добавить в конец добавляет его в конец документа.
  • Лимит символов — если редактор настроен с limit (расширение CharacterCount отклоняет любую не вставляющую транзакцию, которая увеличила бы документ сверх лимита), под полем результата показывается счётчик символов generated / limit (обновляется во время потоковой передачи, мягко-красный при превышении лимита), а сразу после генерации диалог показывает предупреждение ai.limit_exceeded, если результат превысит лимит. Кнопка Вставить и меню её альтернативных режимов тогда отключены: результат применяется целиком или не применяется, поэтому его нужно сократить перед применением.

Выпущено под лицензией MIT.