Smart HTML-tags
Hello everyone! Do you remember the recent series of announcements from IT giants about various AI functionalities being integrated into everything possible? Among these announcements were tools for drafting emails and messages for email and other services. These assistants can check your text for errors, translate it into another language, change the tone and mood of the message, make it more concise, or, conversely, elaborate on it.
In my humble opinion, we find ourselves in a situation where creating our own general-purpose AI services is quite risky, as large companies can easily replicate your functionality, given their access to significant computational and data resources. However, we, as independent developers and enthusiasts, have our advantages: we can create solutions at the level of libraries and components that can blur and adjust the competitive edges between giants and small startups.
So, in this material, I propose that we together create a smart HTML-tag—textarea, that can help users in adjusting their input text. This tag can be used on any website, in any web application built with any modern framework, or even in a simple static HTML file.
Important: we will create a simplified conceptual example to demonstrate the basic principles, which can be configured to meet your needs later on: improving UX, working on design, implementing API access according to specific user interaction policies.
First Steps
Let's clarify the technologies. To prepare the dish according to our recipe, we need two key ingredients: a library for convenient work with custom HTML tags and an API for access to the AI model.
For working with tags, I choose Symbiote.js, a library perfectly suited for creating agnostic components and universal widgets for the web.
So, let's start by installing Symbiote in our project:
npm i @symbiotejs/symbiote
Next, we'll create a JavaScript file for our web component with a framework for our future code (smart-textarea.js):
import Symbiote, { html, css } from '@symbiotejs/symbiote';
export class SmartTextarea extends Symbiote {
// Object that initializes the state and core entities of the component:
init$ = {}
}
// Here will be the component styles:
SmartTextarea.rootStyles = css``;
// Here will be the template:
SmartTextarea.template = html``;
// Register the custom tag in the browser's registry:
SmartTextarea.reg('smart-textarea');
If you are familiar with any modern frontend framework or library, I think you will find everything you've seen here quite familiar and understandable. It is worth explaining only about the rootStyles
interface, which allows styling independently of whether your component is in any top-level Shadow DOM context or not. The styles will be added precisely to the root
of the DOM tree segment in which our component is used, allowing flexible management of the display with minimal dependence on what surrounds our component, with the ability to isolate everything you consider necessary to isolate.
Now, let's create an HTML file that uses our smart tag:
<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>
In this example, you see only what is important for displaying and testing the behavior of our text area. This is sufficient to continue, as we can leave aside all the other elements of a typical web document, such as head
, body
, and so on.
An important point here is the block with importmap
. In our example, we are connecting the Symbiote.js library via CDN, which will allow us to efficiently and repeatedly share a common dependency between different independent application components, without the need for separate bulky solutions (like Module Federation). Since we initially installed the dependency via npm, we will have everything necessary for the development environment tools: type declarations for TypeScript support, access to definitions of entities, and so forth.
Template
Let's move on to developing the functionality.
We create a working template:
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>
`;
For better syntax highlighting of template literals in JavaScript, you can install one of the many extensions for your IDE. Now, I'll explain all the basic points so you can understand how the template works.
The first construction we encounter is a binding of a handler to an element:
html`<input ${{oninput: 'saveSourceText'}} >`;
In it, we see the normal template literal syntax with an object describing the binding of component logic to the template's DOM elements. The keys in such an object are the element's own properties, while the values are textual keys to the state entities of the symbiote-component.
The second construction is:
html`<label>Text style: {{+currentTextStyle}}</label>`;
In this way, (double curly braces without the $
symbol) in Symbiote.js, data binding to text nodes is carried out. The plus +
at the beginning of the name indicates that the property is computed, meaning it is automatically derived when the state properties change or manually via a special method called notify
.
Lastly:
html`<div>${textStyles.length}</div>`;
is the most straightforward string interpolation, where the value is directly placed into the template without any additional magic.
So far, we have a component template that contains:
- the main text area
- a field for entering the preferred language in free format (for example, you can write "Argentinian Spanish")
- the display of the selected text style
- a button for generating text
- a button to revert to the user's original text
State Entities and Handlers
Now, let's describe the properties and methods we bind to the template.
export class SmartTextarea extends Symbiote {
// Storing the user's original text in a private class property:
#sourceText = '';
init$ = {
// Attribute-to-property binding, default LLM name:
'@model': 'gpt-4o',
// Computed property,
// contains a description of the style to which we need to bring our text:
'+currentTextStyle': () => {
return textStyles[this.ref.textStyleRange.value - 1];
},
// Saving the user's input text for the undo functionality:
saveSourceText: () => {
this.#sourceText = this.ref.text.value;
},
// Reverting the text area to the original text:
revertChanges: () => {
this.ref.text.value = this.#sourceText;
},
// Responding to text style selection:
onTextStyleChange: (e) => {
// Manually triggering the calculation of the computed property:
this.notify('+currentTextStyle');
},
// ...
}
}
The contents of the eighth line of the above code have the following explanation: state properties of the component, whose names start with the @
symbol, are automatically bound to the values of the HTML attributes of our custom tag, if they are explicitly specified. If the attribute is not specified, the property will have the default value set during initialization, which in our case is gpt-4o
.
On the twelfth line, we see a computed property with the prefix +
, whose value will be obtained as a result of executing a function.
The saveSourceText
and revertChanges
methods probably do not need further explanation; they are simply handlers for button clicks in the template.
The onTextStyleChange
method is a handler for the slider position changes that forcibly calls the calculation of the value of the +currentTextStyle
property. For this method to work and calculate the current value, we need an array describing the text styles, which we'll create in a separate module (textStyles.js), containing the following:
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',
];
We asked ChatGPT to write descriptions for the text styles, ranking from the most informal to the strictest.
Additionally, in the code provided above, we see examples of accessing elements described in the template through the ref
interface, for instance:
this.ref.text.value
This is somewhat similar to how it works in React and is needed to avoid searching for elements manually through the DOM API. Essentially, this.ref
is a collection of references to DOM elements for which the corresponding attributes are set in the HTML template, for example: ref="text"
.
Request to the LLM
Now we need to do the most important thing: ask the AI to rewrite our text according to the obtained settings. In this example, I will do this in the simplest way possible, without using additional libraries or any layers for access control, by sending a direct request to the API:
// ...
export class SmartTextarea extends Symbiote {
// ...
init$ = {
// ...
askAi: async () => {
// If the text area is empty, cancel everything and show an alert:
if (!this.ref.text.value.trim()) {
alert('Your text input is empty');
return;
}
// Sending a request to the API endpoint taken from the configuration:
let aiResponse = await (await window.fetch(CFG.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Fetching the API key for access from a JS module hidden from git:
Authorization: `Bearer ${CFG.apiKey}`,
},
body: JSON.stringify({
// Reading the required model name from the HTML attribute (gpt-4o-mini),
// or using the default model (gpt-4o):
model: this.$['@model'],
messages: [
{
role: 'system',
// Passing the model language and tone settings:
content: JSON.stringify({
useLanguage: this.ref.lang.value || 'Same as the initial text language',
textStyle: this.$['+currentTextStyle'],
}),
},
{
role: 'assistant',
// Describing the role of the AI assistant:
content: 'You are the text writing assistant. Rewrite the input text according to parameters provided.',
},
{
role: 'user',
// Passing the text we want to modify:
content: this.ref.text.value,
},
],
temperature: 0.7,
}),
})).json();
// Waiting for the response and updating the text in the input field:
this.ref.text.value = aiResponse?.choices?.[0]?.message.content || this.ref.text.value;
},
}
}
In this block of code, what's interesting is how we access the values of the state properties of our Symbiote.js component. It looks like this:
this.$['@model']
// Or:
this.$['+currentTextStyle']
// Or simply:
this.$.myProperty // for regular properties without prefixes
Next, we need to create a configuration module (secret.js) that we will hide from prying eyes through .gitignore
:
export const CFG = {
apiUrl: 'https://api.openai.com/v1/chat/completions',
apiKey: '<YOUR_API_KEY>',
};
Important! This example should not, of course, be used in a production environment or in any other uncontrolled public form. In real conditions, you would need to additionally protect your keys or allow the client to use their own access settings.
In this example, I've used the API from OpenAI, but you can use any other suitable AI service, self-hosted model, or your own middleware.
Styles
We just need to add styles to our web component. I won't spend too much time on this, as it's not very important in our case:
// ...
SmartTextarea.rootStyles = css`
smart-textarea {
display: inline-flex;
flex-flow: column;
gap: 10px;
width: 500px;
textarea {
width: 100%;
height: 200px;
}
}
`;
// ...
Result
Here is the complete code of the resulting component:
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');
After that, we can see our example working in the server-side rendering mode of the template. Here you can use your apiKey
to make real requests to ChatGPT and edit code in real-time:
That's all. Now we can use the <smart-textarea></smart-textarea>
tag in the templates of other components written using any other modern frameworks; in markup generated on the server using any template engine or static site generator, in simple HTML files with forms, and so forth.
In this example, I did not delve into the issues of building and deploying such agnostic components. I also did not go into style questions. If there is interest from the audience on this topic, I will write a separate article. But briefly, you can use any modern bundler for building. For instance, I most often use esbuild.
Symbiote.js supports a template syntax fully based on HTML tags and their attributes, allowing you to extract and describe templates as separate HTML files and use the corresponding loader for your bundler. You can also embed such templates as part of a larger HTML file and control them afterward using a wrapper tag, which itself initializes your logic. This is exactly how true server components should work, which favorably differentiates Symbiote.js from many similar solutions targeted at more popular libraries. All you need to do is to enable the ssrMode
flag.
This material is a logical continuation of my previous article, where I discuss web technologies as an important stabilizing factor and a control tool in the practical work with AI.