HalChatLocalAI: как я встроил офлайн-ИИ прямо в мессенджер. HalChat.. HalChat. HalSM.. HalChat. HalSM. JavaScript.. HalChat. HalSM. JavaScript. llm.. HalChat. HalSM. JavaScript. llm. WLLama.. HalChat. HalSM. JavaScript. llm. WLLama. ИИ.. HalChat. HalSM. JavaScript. llm. WLLama. ИИ. искусственный интеллект.. HalChat. HalSM. JavaScript. llm. WLLama. ИИ. искусственный интеллект. локальный ИИ.. HalChat. HalSM. JavaScript. llm. WLLama. ИИ. искусственный интеллект. локальный ИИ. Мессенджеры.. HalChat. HalSM. JavaScript. llm. WLLama. ИИ. искусственный интеллект. локальный ИИ. Мессенджеры. приватность.. HalChat. HalSM. JavaScript. llm. WLLama. ИИ. искусственный интеллект. локальный ИИ. Мессенджеры. приватность. Программирование.. HalChat. HalSM. JavaScript. llm. WLLama. ИИ. искусственный интеллект. локальный ИИ. Мессенджеры. приватность. Программирование. Развитие стартапа.. HalChat. HalSM. JavaScript. llm. WLLama. ИИ. искусственный интеллект. локальный ИИ. Мессенджеры. приватность. Программирование. Развитие стартапа. экосистема.

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

Автор: halwarsing

Источник

Rambler's Top100