Большинство ИИ-ассистентов работают в облаке. А я сделал локальный — прямо внутри мессенджера 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

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


  1. iamkisly
    16.11.2025 11:37

    - HalChat Local AI
    - Chat Heil !!!

    Прогулка по тонкому льду на хабре) А теперь серьезно.

    Ничего не понимаю в LLM, но скроллить длинные листинги было утомительно. Достаточно было прикрепить ссылку на git репозиторий или gist. Аналогично с картинками, стоило уменьшить размер окна до того как делать скриншот.


    1. halwarsing Автор
      16.11.2025 11:37

      Спасибо за отзыв! Для первой публикации на Хабре получилось немного "тяжеловесно", но я рад, что добрался до основной аудитории. В будущих статьях сделаю оформление чище, уже учёл этот момент.