Большинство ИИ-ассистентов работают в облаке. А я сделал локальный — прямо внутри мессенджера HalChat.
Большинство современных ИИ-ассистентов работают в облаке, требуют подключения к серверам и не дают контроля над данными. Я решил исследовать, возможно ли встроить искусственный интеллект прямо в мессенджер, чтобы он работал локально прямо в браузере, офлайн и под управлением самого пользователя.
Цель HalChatLocalAI - упростить взаимодействие человека с ИИ и встроить его в повседневную жизнь через общение в мессенджере. Пользователь может общаться с локальным ассистентом, подключать свои модели, а в будущем - приглашать ИИ-ботов в групповые чаты и голосовые комнаты.
Система реализована на JavaScript и моём собственном языке HalSM, через плагинную архитектуру.
Ключевые принципы:
Локальность — всё выполняется на устройстве, без отправки данных в облако.
Приватность — полное отсутствие внешней зависимости.
Децентрализация — любой разработчик может публиковать и подключать собственные модели под нужные функции.
Расширяемость — взаимодействие реализовано через систему плагинов HalSM.
Почему не просто «ещё один интерфейс к Ollama/WLLama»
WLLama используется как низкоуровневый исполнитель моделей, но вся архитектура взаимодействия построена с нуля:
Плагины HalSM управляют логикой запросов и контекстом.
JS-слой отвечает за интеграцию с HalChat и UI.
Сами модели не зависят от конкретной реализации — можно подключить любую, даже свою собственную.
Таким образом, HalChatLocalAI — это не «обёртка», а мост между плагином, пользователем и моделью.
Архитектура
Пользователь → HalChat → HalChatPlugin
→ HalSM → LocalAIHalSM → LocalAI
→ HalSM → HalChatPlugin → HalChat → Ответ
Это базовый пример как проходит от сообщения пользователя до конечного сгенерированного результат.
Практическое применение
Общение с локальным ассистентом в HalChat.
Создание личных ботов, которые работают без сервера.
Подключение ИИ в групповые чаты.
В будущем — интеграция в HalVoice (ИИ-участник голосового чата).
Преимущества локальных ИИ
Приватность — ваши данные не обрабатывает ИИ на сервере, и тем самым не создаёт возможные утечки, и использование их для маркетинга или иных целей.
Экономия и экологичность — сейчас новые ИИ всё больше и больше требуют огромных масштабов серверов, что приводит к подорожанию компонентов и к ухудшению экологии.
Модульность — возможность создавать целые сети из несколько локальных ИИ для взаимодействия между ними. Также есть возможность динамично её изменять исходя из потребностей и запроса пользователя.
Работа без интернета (*если заранее установлены модели и плагины).
Недостатки
Безопасность — злоумышленники могут менять ИИ модели или внедрять в плагины вредоносный код, позволяя тем самым доступ к вашим данным. (Это решается модерацией и разрешениями от пользователей на определённый доступ к данным и действиям — но это не 100% защита).
Скорость — очень низкая скорость по сравнению с вычислительными кластерами (*повышается за счёт многопоточности или ещё лучше — работа на видеокарте).
Ограничения в размере ИИ — браузеры ограничивают размеры памяти для страниц и кода.
Мало знаний — не могут быть использованы модели ИИ выше 7 миллиардов параметров для обычных ПК и смартфонов. Но есть преимущество в возможности узконаправленности моделей, на каждую группу задач своя ИИ модель, а найти подходящую можно будет на HalNetMarket.
Реализация
Я не стал переписывать весь движок WLLama, а лишь добавил модуль взаимодействия на JS и плагин на HalSM.
Код для взаимодействия с WLLama:
/*
* LocalLLM.js (ESM)
*
* Как подключить:
* <script type="module" src="https://halchat.halwarsing.net/resources/js/ai/LocalLLM.js"></script>
*
* Требования для многопоточного WASM:
* На HTML-страницу отдай заголовки: COOP: same-origin, COEP: require-corp.
* Для wasm отдай CORP: cross-origin и правильный MIME: application/wasm.
*/
export class HalChatLocalLLM {
/**
* @param {Object} opts
* @param {string} [opts.wllamaModuleUrl] URL до /esm/wllama.js (локально на твоём сервере)
* @param {{single:string,multi:string}} [opts.wasmPaths] Пути к wasm (single/multi)
* @param {any} [opts.wllama] Уже импортированный класс Wllama (если не хочешь динамический import)
* @param {number} [opts.parallelDownloads]
*/
constructor(opts = {}) {
this.opts = opts;
this.core = null; // экземпляр Wllama
this.loaded = false;
}
async #ensureCore() {
if (this.core) return;
let WllamaCtor = this?.opts?.wllama;
if (!WllamaCtor) {
const moduleUrl = this?.opts?.wllamaModuleUrl;
if (!moduleUrl) {
throw new Error('[HalchatLocalLLM] Укажи opts.wllamaModuleUrl ИЛИ передай opts.wllama (класс Wllama).');
}
const mod = await import(moduleUrl);
WllamaCtor = mod.Wllama;
if (!WllamaCtor) throw new Error('[HalchatLocalLLM] В модуле нет экспорта Wllama: ' + moduleUrl);
}
const single = this?.opts?.wasmPaths?.single;
const multi = this?.opts?.wasmPaths?.multi;
if (!single || !multi) {
throw new Error('[HalchatLocalLLM] Укажи wasmPaths.single и wasmPaths.multi (локальные пути к wasm).');
}
this.core = new WllamaCtor(
{
'single-thread/wllama.wasm': single,
'multi-thread/wllama.wasm': multi,
},
{ parallelDownloads: this.opts.parallelDownloads ?? 4 }
);
}
/**
* Загрузка модели из разных источников
* @param {{kind:'hf',repo:string,file:string}|{kind:'url',url:string}|{kind:'urls',urls:string[]}|{kind:'files',files:FileList|Blob[]}} src
* @param {{useCache?:boolean,n_threads?:number,n_ctx?:number,n_batch?:number,seed?:number,progress?:(p:{loaded:number,total?:number,pct:number})=>void}} [opt]
*/
async load(src, opt = {}) {
await this.#ensureCore();
const w = this.core;
const cfg = {
useCache: opt.useCache ?? true,
n_threads: opt.n_threads,
n_ctx: opt.n_ctx,
n_batch: opt.n_batch,
seed: opt.seed,
progressCallback: (st) => {
const pct = st && st.total ? Math.round((st.loaded / st.total) * 100) : 0;
opt.progress && opt.progress({ loaded: st.loaded, total: st.total, pct });
},
};
if (src.kind === 'hf') {
await w.loadModelFromHF(src.repo, src.file, cfg);
} else if (src.kind === 'url') {
if (typeof w.loadModelFromUrl === 'function') {
var url=new URL(src.url);
url.searchParams.set("isJson","1");
url=url.toString();
const json=await (await fetch(url,{method:'GET',mode:'cors',credentials:'include'})).json();
console.log(json);
if(json['errorCode']===0) {
await w.loadModelFromUrl(json['url'], cfg);
}
//await w.loadModelFromUrl(src.url, cfg);
} else {
const blob = await (await fetch(src.url,{method:'GET',mode:'no-cors',credentials:'include'})).blob();
await w.loadModel([blob], cfg);
}
} else if (src.kind === 'urls') {
if (typeof w.loadModelFromUrl === 'function') {
for (const u of src.urls) await w.loadModelFromUrl(u, cfg);
} else {
const blobs = [];
for (const u of src.urls) blobs.push(await (await fetch(u)).blob());
await w.loadModel(blobs, cfg);
}
} else if (src.kind === 'files') {
const list = Array.isArray(src.files) ? src.files : Array.from(src.files);
await w.loadModel(list, cfg); // Blob[]/File[]
} else {
throw new Error('[HalchatLocalLLM] Unknown load source');
}
this.loaded = true;
}
async unload() {
if (this.core && typeof this.core.unload === 'function') {
try { this.core.unload(); } catch {}
}
this.loaded = false;
}
/**
* Генерация чата — поток
* @param {{role:'system'|'user'|'assistant',content:string}[]} messages
* @param {{template?:'qwen-chat'|'raw',temperature?:number,top_k?:number,top_p?:number,maxNewTokens?:number,stop?:string[]}} [opt]
*/
async *generateChatStream(messages, opt = {}) {
this.#assertLoaded();
const w = this.core;
const prompt = this.#renderChatPrompt(messages, opt.template || 'qwen-chat');
const t0 = performance.now();
let emitted = 0;
if (typeof w.createChatCompletion === 'function') {
const it = await w.createChatCompletion(
[ { role: 'user', content: prompt } ],
{
stream: true,
nPredict: opt.maxNewTokens ?? 192,
sampling: { temp: opt.temperature ?? 0.25, top_k: opt.top_k ?? 40, top_p: opt.top_p ?? 0.9 },
stopPrompts: opt.stop,
}
);
for await (const chunk of it) {
const text = chunk.currentText ?? chunk.text ?? '';
const delta = chunk.delta ?? '';
emitted++;
const dt = (performance.now() - t0) / 1000;
yield { text, delta, tokensPerSec: emitted / Math.max(dt, 0.001) };
}
return;
}
// Фолбэк: без стрима — одним куском
const text = await w.createCompletion(prompt, {
nPredict: opt.maxNewTokens ?? 192,
sampling: { temp: opt.temperature ?? 0.25, top_k: opt.top_k ?? 40, top_p: opt.top_p ?? 0.9 },
stopPrompts: opt.stop,
});
yield { text };
}
/**
* Генерация по одному промпту — поток
*/
async *generatePromptStream(prompt, opt = {}) {
this.#assertLoaded();
const w = this.core;
const t0 = performance.now();
let emitted = 0;
if (typeof w.createCompletionStream === 'function') {
const it = await w.createCompletionStream(prompt, {
nPredict: opt.maxNewTokens ?? 192,
sampling: { temp: opt.temperature ?? 0.25, top_k: opt.top_k ?? 40, top_p: opt.top_p ?? 0.9 },
stopPrompts: opt.stop,
});
for await (const chunk of it) {
const text = chunk.currentText ?? chunk.text ?? '';
const delta = chunk.delta ?? '';
emitted++;
const dt = (performance.now() - t0) / 1000;
yield { text, delta, tokensPerSec: emitted / Math.max(dt, 0.001) };
}
return;
}
const text = await w.createCompletion(prompt, {
nPredict: opt.maxNewTokens ?? 192,
sampling: { temp: opt.temperature ?? 0.25, top_k: opt.top_k ?? 40, top_p: opt.top_p ?? 0.9 },
stopPrompts: opt.stop,
});
yield { text };
}
/** Синхронизаторы: вернуть целиком */
async generateChat(messages, opt = {}) {
let out = '';
for await (const c of this.generateChatStream(messages, opt)) out = c.text;
return out;
}
async generatePrompt(prompt, opt = {}) {
let out = '';
for await (const c of this.generatePromptStream(prompt, opt)) out = c.text;
return out;
}
// ——— helpers ———
#renderChatPrompt(msgs, template) {
if (template === 'raw') {
return msgs.map(m => `${m.role.toUpperCase()}
${m.content}\n`).join('\n');
}
const parts = [];
for (const m of msgs) parts.push(`<|im_start|>${m.role}
${m.content}<|im_end|>`);
parts.push('<|im_start|>assistant\n');
return parts.join('\n');
}
#assertLoaded() {
if (!this.loaded) throw new Error('Model is not loaded. Call load(...) first.');
}
}
Дальше код модуля HalSM с LocalLLM.js:
import { HalChatLocalLLM } from "/resources/js/ai/LocalLLM.js";
let llm=null;
export class LocalLLMHalSM {
static name = 'LocalLLM';
static version = '0.0.1';
static funcs = {
"load": LocalLLMHalSM.load,
"run": LocalLLMHalSM.run,
"addEvent": LocalLLMHalSM.addEvent
}
static clsses={};
static events={
"generate":[],
"generate_stream":[],
"load":[],
"progressload":[],
};
static localLLM=new HalChatLocalLLM({wllamaModuleUrl: '/ai/wllama/esm/index.js',
wasmPaths: {
single: '/ai/wllama/esm/single-thread/wllama.wasm',
multi: '/ai/wllama/esm/multi-thread/wllama.wasm'
},
parallelDownloads: 4});
static initializeVars() {
return {
"test":MainHalChatPlugins.jsValueToHalSMVar("1455")
};
}
static async load(hsmc, args, vrs) {
var lArgs=Module._getSizeHalSMArray(args);
if (lArgs!=2) {return Module.HalSM.null;}
const urlVar=Module._getVariableFromHalSMArray(args,1);
if(Module._getTypeVariable(Module._getVariableFromHalSMArray(args,0))===Module.HalSM.HalSMVariableType.HalSMCModule&&Module._getTypeVariable(urlVar)===Module.HalSM.HalSMVariableType.str) {
const url=CharArrayToString(Module._getStringFromValue(Module._getValueVariable(urlVar)));
llm=LocalLLMHalSM.localLLM.load({ kind: 'url', url: url },
{ n_threads: 6, n_ctx: 1024, n_batch: 64, useCache: true, progress: (p)=>LocalLLMHalSM.runEvent("progressload",[p.pct]) });
llm.then(()=>{
LocalLLMHalSM.runEvent("load", []);
});
}
return Module.HalSM.null;
}
static async run(hsmc, args, vrs) {
var lArgs=Module._getSizeHalSMArray(args);
if (lArgs!=7) {return Module.HalSM.null;}
const promptSystemVar=Module._getVariableFromHalSMArray(args,1);
const promptVar=Module._getVariableFromHalSMArray(args,2);
const temperatureVar=Module._getVariableFromHalSMArray(args,3);
const top_kVar=Module._getVariableFromHalSMArray(args,4);
const top_pVar=Module._getVariableFromHalSMArray(args,5);
const max_new_tokensVar=Module._getVariableFromHalSMArray(args,6);
if(Module._getTypeVariable(Module._getVariableFromHalSMArray(args,0))===Module.HalSM.HalSMVariableType.HalSMCModule&&Module._getTypeVariable(promptSystemVar)===Module.HalSM.HalSMVariableType.str
&&Module._getTypeVariable(promptVar)===Module.HalSM.HalSMVariableType.str&&Module._getTypeVariable(temperatureVar)===Module.HalSM.HalSMVariableType.double&&Module._getTypeVariable(top_kVar)===Module.HalSM.HalSMVariableType.int
&&Module._getTypeVariable(top_pVar)===Module.HalSM.HalSMVariableType.double&&Module._getTypeVariable(max_new_tokensVar)===Module.HalSM.HalSMVariableType.int) {
const prompt=CharArrayToString(Module._getStringFromValue(Module._getValueVariable(promptVar)));
const promptSystem=CharArrayToString(Module._getStringFromValue(Module._getValueVariable(promptSystemVar)));
const temperature=Module._getDoubleFromValue(Module._getValueVariable(temperatureVar));
const top_k=Module._getIntFromValue(Module._getValueVariable(top_kVar));
const top_p=Module._getDoubleFromValue(Module._getValueVariable(top_pVar));
const max_new_tokens=Module._getIntFromValue(Module._getValueVariable(max_new_tokensVar));
await llm;
const msgs=[
{ role: 'system', content: promptSystem },
{ role: 'user', content: prompt }
];
var lastCh="";
for await (const ch of LocalLLMHalSM.localLLM.generateChatStream(msgs, { maxNewTokens: max_new_tokens, stop: ["<|im_end|>", "</s>", "<|endoftext|>"], temperature: temperature, top_k: top_k, top_p: top_p })) {
LocalLLMHalSM.runEvent("generate_stream", [ch.text]);
lastCh=ch.text;
}
LocalLLMHalSM.runEvent("generate", [lastCh]);
return MainHalChatPlugins.jsValueToHalSMVar(lastCh);
}
return Module.HalSM.null;
}
static addEvent(hsmc, args, vrs) {
var lArgs=Module._getSizeHalSMArray(args);
if (lArgs!=3) {return Module.HalSM.null;}
const nameVar=Module._getVariableFromHalSMArray(args,1);
const funcVar=Module._getVariableFromHalSMArray(args,2);
if(Module._getTypeVariable(Module._getVariableFromHalSMArray(args,0))===Module.HalSM.HalSMVariableType.HalSMCModule&&Module._getTypeVariable(nameVar)===Module.HalSM.HalSMVariableType.str&&Module._getTypeVariable(funcVar)===Module.HalSM.HalSMVariableType.HalSMLocalFunction) {
const name=CharArrayToString(Module._getStringFromValue(Module._getValueVariable(nameVar)));
const funcVal=Module._getValueVariable(funcVar);
if(Object.keys(LocalLLMHalSM.events).indexOf(name)!==-1) {
LocalLLMHalSM.events[name].push(funcVal);
}
}
}
static runEvent(name, args) {
if(Object.keys(LocalLLMHalSM.events).indexOf(name)!==-1) {
const hsmargs=MainHalChatPlugins.getHalSMArguments(args);
for(const funcVal of LocalLLMHalSM.events[name]) {
console.log("runEvent: "+name);
Module._runLocalFunction(funcVal, hsmargs, Module.HalSM.nulldict);
}
}
}
}
И сам код плагина на HalSM:
import LocalLLM
import HalChat
models=["https://haldrive.halwarsing.net/file/n0RZLj1AQDKUUmcgZ8anqKXhSqcaN5z0VbvI2mJstdOjBPdRnosm0VvqPSOmeJDqb1v8lOGyt1BcbqZ6WfQArx7o6ayzLAvQLIpT.gguf","https://haldrive.halwarsing.net/file/xx5BkX8ZnJZlkj1olJal55JK36I4Hg5ic9PClt3oPW2UFpNyjE28yWFfsucLkbRD4ivPaQymxqCE3kTUWouhbRl66k9nSIcuYfHM.gguf","https://haldrive.halwarsing.net/file/rXwzvkC6oNNiosBm3LZEnH3znjhVEQtvFKGpFDLJmjfPPAtQarR1T4Z1dD7pRvIKwubeq8ocZusfgJGLIk0i9vleaYYVzHB6lHWO.gguf"]
select_model=-1
global_msg_id=-1
system_prompt="Роль: локальный ИИ-помощник. Отвечай точно, кратко и логично. Если вопрос очевиден математика, код, факты - просто дай результат без пояснений. Если информации нет - скажи: Я не располагаю достоверной информацией. Разделяй факты и предположения только если ответ неоднозначный. Не выдумывай, не фантазируй. Все вычисления и логика происходят локально. Не используй интернет и не храни данные."
#generation
def on_generate(text) {
HalChat.editMessage(global_msg_id, text)
}
def on_generate_stream(text) {
HalChat.editLocalMessage(global_msg_id, text)
}
#download model
def on_load() {
if(global_msg_id==-1) {
return false
}
HalChat.editMessage(global_msg_id, "Модель успешно загружена");
}
def on_progress_load(pr) {
if(global_msg_id==-1) {
return false
}
HalChat.editLocalMessage(global_msg_id, "Загрузка: "+pr+"%");
}
#get config model
#on send message user
def on_send_message(msgId,type,time,text,fromNickname,fromId,fromIcon,chatUid,attachments, pluginData) {
if(select_model==-1) {
if((text=="1")||(text=="2")||(text=="3")) {
select_model=int(text)-1
HalChat.sendMessage("Модель загружается, подождите...", [], "", "", -1, -1, '{"LocalBotMsg":{
"nickname":"SUPER AI",
"icon":"7CvasBij84cPuQbyj7pMycUfPHXp7SNLRa6MfrwWGpmrhP7hp1xstSjK39kDBeRSriGFarbxSrZFEPsEcgHrmXEHjlQpqtQINuMx"
},"LocalLLMTest":"ignore"}')
LocalLLM.load(models[select_model])
}
return false
}
HalChat.sendMessage("Генерация...", [], "", "", -1, -1, '{"LocalBotMsg":{
"nickname":"SUPER AI",
"icon":"7CvasBij84cPuQbyj7pMycUfPHXp7SNLRa6MfrwWGpmrhP7hp1xstSjK39kDBeRSriGFarbxSrZFEPsEcgHrmXEHjlQpqtQINuMx"
}}')
print("Gen")
LocalLLM.run(system_prompt, text, 0.5, 40, 0.9, 1000)
}
#get last msgId
def on_local_sended_message(msgId, pluginData) {
if(select_model==-1) {
return false
}
global_msg_id=msgId
}
HalChat.addEvent("onUserSendMessage", on_send_message)
HalChat.addEvent("onLocalBotSendedMessage",on_local_sended_message)
LocalLLM.addEvent("generate", on_generate)
LocalLLM.addEvent("generate_stream", on_generate_stream)
LocalLLM.addEvent("load", on_load)
LocalLLM.addEvent("progressload",on_progress_load)
HalChat.sendMessage("Выберите ИИ (напишите цифру):
1. QWEN-2.5-coder 0.5B
2. Llama3.2 1B
3. Gemma-3 1B", [], "", "", -1, -1, '{"LocalBotMsg":{
"nickname":"SUPER AI",
"icon":"7CvasBij84cPuQbyj7pMycUfPHXp7SNLRa6MfrwWGpmrhP7hp1xstSjK39kDBeRSriGFarbxSrZFEPsEcgHrmXEHjlQpqtQINuMx"
},"LocalLLMTest":"ignore"}')
Демо
Создаём чат и добавляем тестовый ИИ плагин. При загрузке чата, он автоматически предложит выбрать модель из списка. После выбора он загружает модель с HalDrive (оттуда загружается и плагин). После загрузки пишем ему запрос.
Генерация идёт в реальном времени и выводит результат динамично, но только локально, он сохранит итог (изменит сообщение в HalChat) только после завершения генерации. Так что к переписке можем иметь доступ в любое время.


Итог
Сейчас HalChatLocalAI — базовая версия системы локальных ИИ. Несмотря на ограничения, подход показывает, что децентрализованные ИИ-агенты могут работать прямо в мессенджере (в браузере) без серверов. У локальных ИИ сейчас достаточно минусов, на мой взгляд, их потенциал перевешивает текущие ограничения.
Жду ваших вопросов связанной с этой статьёй, так и про мою экосистему и язык программирования HalSM.
Соц. сети:
https://halch.at/c/tZgWWT
https://t.me/halwarsingchat
https://www.youtube.com/@halwarsing
https://vk.com/halwarsingnet
iamkisly
Прогулка по тонкому льду на хабре) А теперь серьезно.
Ничего не понимаю в LLM, но скроллить длинные листинги было утомительно. Достаточно было прикрепить ссылку на git репозиторий или gist. Аналогично с картинками, стоило уменьшить размер окна до того как делать скриншот.
halwarsing Автор
Спасибо за отзыв! Для первой публикации на Хабре получилось немного "тяжеловесно", но я рад, что добрался до основной аудитории. В будущих статьях сделаю оформление чище, уже учёл этот момент.