Всем привет! Помните череду недавних анонсов от IT-гигантов, о различном ИИ-функционале, внедренном во все, что только можно? Среди этих анонсов, например, были помощники в написании текстов писем и сообщений для почтовых и других сервисов. Эти помощники могут проверить вас на ошибки, перевести текст на другой язык, поменять тональность и настроение текста, сделать его более кратким, либо, напротив, дополнить.

На мой скромный взгляд, сейчас складывается такая ситуация, что создавать собственные ИИ-сервисы общего назначения очень рискованно, ибо крупным компаниям не составит труда воспроизвести ваш функционал, имея доступ к более значительным ресурсам, как вычислительным, так и в плане данных. Но и у нас, независимых разработчиков и энтузиастов, есть свои преимущества: мы можем создавать решения на уровне библиотек и компонентов, которые будут способны размыть и скорректировать разницу в конкурентных преимуществах, между гигантами и мелкими стартапами.

Итак, в этом материале, я предлагаю вам вместе со мной создать умный HTML-тег - текстовое поле, которое сможет помогать пользователю в настройке введенного текста. Этот тег можно будет использовать на любом сайте, в любом веб-приложении, созданном с помощью любого современного фреймворка, или даже, в простом статическом HTML-файле.

Важно: мы создадим упрощенный концептуальный пример для демонстрации основных принципов, который можно будет настроить под ваши нужды впоследствии: учлучшить UX, проработать дизайн, реализовать доступ к API, согласно конкретным политикам взаимодействия с пользователями.

Подготовительные действия

Для начала, определимся с технологиями. Для приготовления блюда по нашему рецепту, нам необходимы 2 ключевых ингредиента: библиотека для удобной работы с кастомными HTML-тегами и API для доступа к ИИ-модели.

Для работы с тегами я выбираю Symbiote.js, библиотеку, идеально подходящую для создания компонентов-агностиков и универсальных виджетов для веб.

Итак, сначала, установим Симбиот в наш проект:

npm i @symbiotejs/symbiote

Затем, создаем JavaScript файл для нашего веб-компонента c каркасом для нашего будущего кода (smart-textarea.js):

import Symbiote, { html, css } from '@symbiotejs/symbiote';

export class SmartTextarea extends Symbiote {

  // Объект, инициализирующий состояние и основные сущности компонента:
  init$ = {}

}

// Тут будут стили компонента:
SmartTextarea.rootStyles = css``;

// Тут будет шаблон:
SmartTextarea.template = html``;

// Регистрируем кастомный тег в реестре браузера:
SmartTextarea.reg('smart-textarea');

Если вы знакомы с любым современным фронтенд-фреймворком или библиотекой, думаю, вам будет, в целом, очень знакомо и понятно все, что вы тут видите. Дополнительно рассказать стоит только о интерфейсе rootStyles, который позволяет задавать стили независимо от того, находится ли ваш компонент в каком-либо контексте Shadow DOM верхнего уровня или нет. Стили будут добавлены именно в тот root , того сегмента DOM-дерева, в котором используется наш компонент, что позволяет гибко управлять отображением с минимальной зависимостью от того, что наш компонент окружает, с возможностью изоляции всего, что вы посчитаете необходимым изолировать.

Теперь, давайте создадим HTML-файл, использующий наш умный тег:

<script type="importmap">
  {
    "imports": {
      "@symbiotejs/symbiote": "https://esm.run/@symbiotejs/symbiote"
    }
  }
</script>
<script type="module" src="./smart-textarea.js"></script>

<smart-textarea model="gpt-4o-mini"></smart-textarea>

В данном примере вы видите только то, что представляет важность для отображения и тестирования поведения нашего текстового поля. Этого достаточно для продолжения работы, мы просто оставим за скобками все остальные элементы обычного веб-документа, типа head, body и т. д.

Важным тут является блок c importmap. В нашем примере, мы подключим библиотеку Symbiote.js через CDN, что позволит нам, впоследствии, эффективно и многократно использовать общую зависимость между разными независимыми компонентами приложения, без необходимости использовать какие-то отдельные громоздкие решения для этого (типа Module Federation). При этом, поскольку мы, изначально, установили зависимость через npm, нам будет доступно всё необходимое, для работы инструментов окружения разработки: декларации типов для поддержки TypeScript, переход к определениям сущностей и так далее.

Шаблон

Приступаем, непосредственно, к работе над функционалом.

Создаем рабочий шаблон:

SmartTextarea.template = html`
  <textarea 
    ${{oninput: 'saveSourceText'}}
    placeholder="AI assisted text input..." 
    ref="text"></textarea>

  <input 
    type="text" 
    placeholder="Preferred Language" 
    ref="lang">

  <label>Text style: {{+currentTextStyle}}</label>
  <input 
    ${{onchange: 'onTextStyleChange'}}
    type="range" 
    min="1"
    max="${textStyles.length}"
    step="1"
    ref="textStyleRange">

  <button ${{onclick: 'askAi'}}>Rewrite text</button>
  <button ${{onclick: 'revertChanges'}}>Revert AI changes</button>
`;

Для лучшей подсветки синтаксиса шаблонных литералов в JS, вы можете установить одно из множества расширений для своей IDE, но тут на Хабре - мы имеем что имеем. Сейчас я объясню все базовые моменты и станет понятнее, как работает шаблон.

Первая конструкция, которую мы встречаем - это привязка обработчика к элементу:

`${{oninput: 'saveSourceText'}}`

В ней мы видим обычный синтаксис шаблонного литерала с объектом, описывающим привязку логики компонента к DOM-элементам шаблона. Ключами, в таком объекте, являются собственные свойства элементов, а значения - текстовыми ключами к сущностям состояния компонента-симбиота.

Вторая конструкция это:

{{+currentTextStyle}}

Таким образом, (двойные фигурные скобки, без символа $) в Symbiote.js осуществляется привязка данных к текстовым нодам. Плюс + в начале имени, говорит о том, что свойство является вычислимым, то есть, оно получается автоматически при изменении свойств состояния или при принудительно, с помощью специального метода notify.

И, наконец:

`${textStyles.length}`

это самая банальная интерполяция строк, где в шаблон прямо подставляется значение без какой-либо дополнительной магии.

Итого, мы имеем шаблон компонента, который содержит:

  • основное текстовое поле

  • поле для ввода нужно языка в свободном формате (например, можно написать "аргентинский испанский", или "старославянский")

  • отображение выбранного стиля текста

  • кнопку генерации текста

  • кнопку возврата к исходному тексту пользователя

Сущности состояния и обработчики

Теперь приступим к описанию свойств и методов, которые мы привязываем к шаблону.

export class SmartTextarea extends Symbiote {

  // Храним исходный текст пользователя в приватном свойстве класса: 
  #sourceText = '';

  init$ = {
    // Имя LLM по умолчанию: 
    '@model': 'gpt-4o',

    // Вычислимое свойство (computed property), 
    // содержит описание стиля, к которому нужно привести наш текст:
    '+currentTextStyle': () => {
      return textStyles[this.ref.textStyleRange.value - 1];
    },

    // Сохраняем текст пользователя, для функции отмены изменений:
    saveSourceText: () => {
      this.#sourceText = this.ref.text.value;
    },
    // Возвращаем текстовое поле к исходному тексту:
    revertChanges: () => {
      this.ref.text.value = this.#sourceText;
    },
    // Реагируем на выбор стиля текста:
    onTextStyleChange: (e) => {
      // Принудительно вызываем расчет вычислимого свойства:
      this.notify('+currentTextStyle');
    },
    
    // ...
  }

}

Содержимое 8-й строки вышеприведенного кода, имеет следующее объяснение: свойства состояния компонента, имена которых начинаются символом @ , автоматически привязываются к значению HTML-атрибутов нашего кастомного тега, если те будут явно заданы. Если атрибут не задан, свойство будет иметь значение по умолчанию, полученное при инициализации, в нашем случае - gpt-4o.

На 12-й строке мы видим вычисляемое свойство, с префиксом + , значение которого будет получено в результате выполнения функции.

Методы saveSourceText и revertChanges, думаю, не нуждаются в дополнительных объяснениях, это просто обработчики нажатий на кнопки в шаблоне.

Метод onTextStyleChange - это обработчик изменений положения слайдера, который принудительно вызывает расчет значение свойства +currentTextStyle. Для работы этого метода и вычисления текущего значения, нам нужен массив с описаниями стилей текста, который мы создадим в отдельном модуле textStyles.js, со следующим содержимым:

export const textStyles = [
  'Free informal speech, jokes, memes, emoji, possibly long',
  'Casual chat, friendly tone, occasional emoji, short and relaxed',
  'Medium formality, soft style, basic set of emoji possible, compact',
  'Neutral tone, clear and direct, minimal slang or emoji',
  'Professional tone, polite and respectful, no emoji, short sentences',
  'Strict business language. Polite and grammatically correct.',
  'Highly formal, authoritative, extensive use of complex vocabulary, long and structured',
];

Написать описания стилей текста, с ранжированием от самого неформального, до самого строгого, мы, конечно же, попросили сам ChatGPT.

Также, в приведенном выше коде, мы видим примеры обращений к элементам, описанным в шаблоне, через интерфейс ref, например:

this.ref.text.value

Это чем-то похоже на то, как это работает в React и нужно для того, чтобы не искать элементы вручную через DOM API. По сути, this.ref - это коллекция ссылок на DOM-элементы, для которых задан соответствующий атрибут в HTML-шаблоне, например: ref="text"

Запрос к LLM

Сейчас нам требуется сделать самое главное: попросить ИИ переписать наш текст в согласии с полученными настройками. В этом примере, я сделаю это максимально простым способом, без использования дополнительных библиотек и каких-либо слоев для контроля доступа, отправив прямой запрос к API:

// ...

export class SmartTextarea extends Symbiote {

  // ...

  init$ = {
    // ...
    
    askAi: async () => {

      // Если текстовое поле пустое, отменяем все и выводим алерт:
      if (!this.ref.text.value.trim()) {
        alert('Your text input is empty');
        return;
      }

      // Отправляем запрос к API эндпоинту, взятому из конфигурации:
      let aiResponse = await (await window.fetch(CFG.apiUrl, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          
          // Берем ключ для работы с API из скрытого от git js-модуля:
          Authorization: `Bearer ${CFG.apiKey}`,
        },
        body: JSON.stringify({
          
          // Читаем название нужной модели из HTML-атрибута (gpt-4o-mini), 
          // либо используем модель по умолчанию (gpt-4o):
          model: this.$['@model'],
          messages: [
            {
              role: 'system',
              
              // Передаем в модель настройки языка и тона:
              content: JSON.stringify({
                useLanguage: this.ref.lang.value || 'Same as the initial text language',
                textStyle: this.$['+currentTextStyle'],
              }),
            },
            {
              role: 'assistant',

              // Описываем роль ИИ-ассистента:
              content: 'You are the text writing assistant. Rewrite the input text according to parameters provided.',
            },
            {
              role: 'user',

              // Передаем сам текст, который ходим модифицировать:
              content: this.ref.text.value,
            },
          ],
          temperature: 0.7,
        }),
      })).json();

      // Дожидаемся ответа и обновляем текст в поле ввода:
      this.ref.text.value = aiResponse?.choices?.[0]?.message.content || this.ref.text.value;
    },
  }

}

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

this.$['@model']

// Или:

this.$['+currentTextStyle']

// Или просто:

this.$.myProperty // для обычных свойств без префиксов

Теперь, нам нужно создать модуль конфигураций (secret.js), который мы спрячем от чужих глаз через .gitignore :

export const CFG = {
  apiUrl: 'https://api.openai.com/v1/chat/completions',
  apiKey: '<YOUR_API_KEY>',
};

Внимание! Конечно же, этот пример нельзя использовать в проде, или каком-либо ином неконтролируемом публичном виде. В реальных условиях, вам нужно будет дополнительно защитить ваши ключи, либо дать клиенту возможность использовать свои настройки доступа.

Для данного примера, я использовал API от OpenAI, но вы можете использовать любой другой подходящий ИИ-сервис, self-hosted модель или свой middleware.

Стили

Нам осталось добавить стили нашему веб-компоненту. Я не стану посвящать этому много внимания, так как это не очень важно в нашем случае:

// ... 

SmartTextarea.rootStyles = css`
  smart-textarea {
    display: inline-flex;
    flex-flow: column;
    gap: 10px;
    width: 500px;

    textarea {
      width: 100%;
      height: 200px;
    }

  }
`;

// ...

Результат

Приведу полный код получившегося компонента:

import Symbiote, { html, css } from '@symbiotejs/symbiote';
import { CFG } from './secret.js';
import { textStyles } from './textStyles.js';

export class SmartTextarea extends Symbiote {

  #sourceText = '';

  init$ = {
    '@model': 'gpt-4o',

    '+currentTextStyle': () => {
      return textStyles[this.ref.textStyleRange.value - 1];
    },

    saveSourceText: () => {
      this.#sourceText = this.ref.text.value;
    },
    revertChanges: () => {
      this.ref.text.value = this.#sourceText;
    },
    onTextStyleChange: (e) => {
      this.notify('+currentTextStyle');
    },
    askAi: async () => {
      if (!this.ref.text.value.trim()) {
        alert('Your text input is empty');
        return;
      }
      let aiResponse = await (await window.fetch(CFG.apiUrl, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${CFG.apiKey}`,
        },
        body: JSON.stringify({
          model: this.$['@model'],
          messages: [
            {
              role: 'system',
              content: JSON.stringify({
                useLanguage: this.ref.lang.value || 'Same as the initial text language',
                textStyle: this.$['+currentTextStyle'],
              }),
            },
            {
              role: 'assistant',
              content: 'You are the text writing assistant. Rewrite the input text according to parameters provided.',
            },
            {
              role: 'user',
              content: this.ref.text.value,
            },
          ],
          temperature: 0.7,
        }),
      })).json();
  
      this.ref.text.value = aiResponse?.choices?.[0]?.message.content || this.ref.text.value;
    },
  }

}

SmartTextarea.rootStyles = css`
  smart-textarea {
    display: inline-flex;
    flex-flow: column;
    gap: 10px;
    width: 500px;

    textarea {
      width: 100%;
      height: 200px;
    }

  }
`;

SmartTextarea.template = html`
  <textarea 
    ${{oninput: 'saveSourceText'}}
    placeholder="AI assisted text input..." 
    ref="text"></textarea>

  <input 
    type="text" 
    placeholder="Preferred Language" 
    ref="lang">

  <label>Text style: {{+currentTextStyle}}</label>
  <input 
    ${{onchange: 'onTextStyleChange'}}
    type="range" 
    min="1"
    max="${textStyles.length}"
    step="1"
    ref="textStyleRange">

  <button ${{onclick: 'askAi'}}>Rewrite text</button>
  <button ${{onclick: 'revertChanges'}}>Revert AI changes</button>
`;

SmartTextarea.reg('smart-textarea');

Открыв наш HTML-файл в браузере, мы увидим следующее:

Готово. Теперь мы можем использовать тег <smart-textarea></smart-textarea> в шаблонах других компонентов, написанных с использованием любых других современных фреймворков; в разметке, которая генерируется на сервере с помощью любого шаблонизатора или генератора статики, в простых HTML-файлах с формами, и так далее.

В этом примере, я не коснулся вопроса сборки и настройки деплоя для подобных компонентов-агностиков. Также, я не углублялся в вопрос стилизации. Если аудитория проявит интерес к данной теме - напишу отдельную статью. Но если кратко, то для сборки вы можете использовать любой современный сборщик. Я, к примеру, чаще всего использую esbiuld.

Symbiote.js поддерживает синтаксис шаблонов, полностью основанный на HTML-тегах и их атрибутах, поэтому, вы можете выносить и описывать шаблоны как отдельные HTML-файлы и использовать соответствующий лоадер для своего сборщика. Также, вы можете внедрять такие шаблоны как часть общего HTML-файла и брать их под контроль уже постфактум, с помощью тега-враппера, который сам инициализирует вашу логику. Это именно то, как должны работать настоящие серверные компоненты, что выгодно отличает Symbiote.js от множества, сходных по назначению, решений для более популярных библиотек. Все, что вам нужно для этого сделать - это использовать флаг ssrMode

Этот материал является логическим продолжением моей предыдущей статьи, где я рассматриваю веб-технологии в роли важного стабилизирующего фактора, в практической работе с ИИ.

Комментарии (10)


  1. 19Zb84
    17.09.2024 14:14

    А можно в symbiot js темплей вынести в html ?

    Я раньше темплейты в строке описывал, но в итоге пришел к тому, что удобнее темплейт оставлять в html е

    <custom-element>
    <template>
    <div>text</div>
    </template>
    </custom-element>


    1. i360u Автор
      17.09.2024 14:14
      +1

      Да, конечно, я об этом как раз и пишу. Есть следующие варианты:

      • рендерить шаблон компонента в общем HTML-документе на этапе работы сервера, используя любой механизм генерации документа, используя флаг ssrMode. В этом случае нужный HTML может быть просто внутри вашего тега

      • использовать отдельный HTML-файл и грузить шаблон по необходимости, только если браузер встретил ваш кастомный тег и инициализировал его

      • использовать внешний файл подгружаемый через лоадер вашего сборщика на этапе сборки

      • использовать внешний JS-файл с шаблоном в виде экспорта шаблонного литерала

      • Использовать внешнюю декларацию шаблона с уникальным идентификатором, и далее использовать его в компоненте с помощью флага allowCustomTemplate и атрибута use-template . В этом случае (как и в первом примере из списка), вы можете определять свой шаблон для каждого конкретного применения вашего тега индивидуально, что дает огромную гибкость

      Конкретный вариант будет зависеть от ваших задач, но, как видите, Симбиот позволяет находить решение для множества кейсов без лишней сложности.


      1. 19Zb84
        17.09.2024 14:14
        +1

        Я смотрел как то Симбиот. Мне очень понравился он. Даже думал на него перейти, но решил сначала свой базовый компонент написать с теми функциями, которые мне нужны.

        После нового года попробую его использовать.

        А вот ещё вопрос.

        У меня есть в проекте 20 компонентов. Порядок загрузки любой. Они между собой пересылают большое колличество разных данных.

        Что бы у себя решить эту проблему, я сделал метод task, который если компонент загружен выполняет задание, если не загружен, задание попадает в очередь и ждет загрузки компонента.

        Есть ли подобные механизмы в симбиоте ?


        1. i360u Автор
          17.09.2024 14:14
          +1

          В общем случае, можно создавать абстрактный стейт-контекст, доступный для всех компонентов, как, например, тут: https://symbiotejs.org/2x/playground/l10n/

          При инициализации, каждый компонент сможет автоматом получать актуальное значение, вносить изменения и использовать прямые привязки данных в шаблонах. Если нужно что-то более сложное, с очередями, версионированием либо графовыми структурами - можно написать небольшую обвязку поверх базового интерфейса PubSub Но это если нужно действительно что-то сложное, в основной массе случаев, достаточно базовых возможностей работы с контекстами: https://symbiotejs.org/2x/docs/Context/


          1. 19Zb84
            17.09.2024 14:14

            Посмотрю обязательно.
            Плюс этой библиотеки в том, что она уже написанна и есть документация и даже сайт.
            Останавливает меня только то, что Симбиот уже базовый класс и лишние стейты не хочется создавать.

            У себя я просто использую базовый класс который расширяю через defineProperties.
            У него нет поддержки всех вариантов, которые вы перечислили выше, но есть все самое необходимое, что бы снять вопрос о сложности структуры кода.
            компонент можно описать одной формулой HTMLElement[( new Function * )defineProperties]

            Что бы использовать симбиот, надо подумать как вписать его в эту формулу.


  1. impwx
    17.09.2024 14:14

    Для работы с тегами я выбираю Symbiote.js, библиотеку, идеально подходящую для создания компонентов-агностиков и универсальных виджетов для веб.

    Тут стоит оставить disclaimer, что вы являетесь автором этой библиотеки


    1. i360u Автор
      17.09.2024 14:14
      +1

      С одной стороны, я это не скрываю. С другой, на смысл статьи, на мой взгляд, это никак не влияет. Она, скорее о том, что даже такие штуки как HTML-теги могут иметь свои встроенные ИИ-функции, и, за счет композиции таких тегов, можно решать разные интересные и, иногда, очень нетривиальные задачи.


  1. masterWeber
    17.09.2024 14:14

    Получается ключ от API Open AI будет виден на фронте...


    1. i360u Автор
      17.09.2024 14:14
      +1

      Об этом в статье есть соответствующий диксклеймер и предупреждения. Странно было не заметить.

      Вопрос о доступе к ИИ, потребляющем много ресурсов, является общим для любого, основанного на ИИ, сервиса и на рассматривается в рамках данного материала. Повторю, что для использования в продакшн, такие вещи должны работать через ваш собственный сервер, либо через собственные ключи пользователя. Но это уже другая история.


      1. masterWeber
        17.09.2024 14:14

        извиняюсь, не заметил