chatbot-js / adapters /llm-adapter.js
mr4's picture
Upload 26 files
91111e4 verified
Raw
History Blame Contribute Delete
15.5 kB
// ============================================================
// LLM Adapter — Chạy LLM trực tiếp trên trình duyệt qua WebGPU
// Tham khảo: webml-community/Qwen3.5-WebGPU (HuggingFace Spaces)
// Sử dụng @huggingface/transformers để load model và generate text
// Phụ thuộc: currentLang, _adapterPath
// ============================================================
/**
* Trạng thái LLM — singleton, load model 1 lần duy nhất.
*/
var _llmProcessor = null;
var _llmModel = null;
var _llmLoading = false;
var _llmReady = false;
var _llmLoadError = null;
var _llmTransformers = null; // Lưu ref module transformers để dùng RawImage
var _llmLastError = null; // Lỗi cuối cùng từ LLM (để hiển thị cho user debug)
var _llmStoppingCriteria = null; // InterruptableStoppingCriteria để cancel generate
var _llmGenerating = false; // Đang generate hay không
var _llmThinkingEnabled = false; // Mặc định tắt thinking
/**
* Callback để thông báo trạng thái loading lên UI.
* Được set từ app.js qua setLLMStatusCallback().
* @type {function(string, string)|null}
* - action: 'loading_start' | 'loading_progress' | 'loading_done' | 'loading_error'
* - message: Mô tả trạng thái
*/
var _llmStatusCallback = null;
/**
* Đăng ký callback nhận thông báo trạng thái loading.
* @param {function(string, string)} callback - fn(action, message)
*/
function setLLMStatusCallback(callback) {
_llmStatusCallback = typeof callback === 'function' ? callback : null;
}
function _notifyStatus(action, message) {
if (typeof _llmStatusCallback === 'function') {
try { _llmStatusCallback(action, message); } catch (e) { /* ignore */ }
}
}
/**
* Model ID mặc định — Qwen3.5 0.6B (nhỏ nhất, phù hợp chạy trên browser).
* Có thể thay đổi bằng cách gọi setLLMModelId() trước khi load.
*/
var LLM_MODEL_ID = 'onnx-community/Qwen3.5-0.8B-ONNX-OPT';
/**
* Max tokens cho mỗi lần generate.
*/
var LLM_MAX_NEW_TOKENS = 256;
/**
* Thay đổi model ID (phải gọi trước loadLLMModel).
* @param {string} modelId - HuggingFace model ID
*/
function setLLMModelId(modelId) {
if (typeof modelId === 'string' && modelId.trim()) {
LLM_MODEL_ID = modelId.trim();
}
}
/**
* Bật/tắt thinking mode.
* @param {boolean} enabled
*/
function setLLMThinkingEnabled(enabled) {
_llmThinkingEnabled = !!enabled;
try { localStorage.setItem('hikari_llm_thinking', _llmThinkingEnabled ? '1' : '0'); } catch (e) {}
}
/**
* Kiểm tra thinking mode có đang bật không.
* @returns {boolean}
*/
function isLLMThinkingEnabled() {
return _llmThinkingEnabled;
}
/**
* Build prompt string từ history + message hiện tại.
* History được truyền từ app.js, đã trim sẵn.
* @param {string} userMessage
* @param {boolean} hasImage
* @param {Array<{role: string, content: string}>} [history] - Lịch sử hội thoại đã trim
* @returns {string}
*/
function _buildPromptWithHistory(userMessage, hasImage, history) {
var prompt = '';
// Thêm history nếu có
if (history && history.length > 0) {
for (var i = 0; i < history.length; i++) {
var msg = history[i];
prompt += '<|im_start|>' + msg.role + '\n' + msg.content + '<|im_end|>\n';
}
}
// Thêm message hiện tại
prompt += '<|im_start|>user\n';
if (hasImage) {
prompt += '<|vision_start|><|image_pad|><|vision_end|>';
}
prompt += userMessage + '<|im_end|>\n';
// Assistant prefix
prompt += '<|im_start|>assistant\n';
if (_llmThinkingEnabled) {
prompt += '<think>\n';
}
return prompt;
}
/**
* Kiểm tra trình duyệt có hỗ trợ WebGPU không.
* @returns {boolean}
*/
function isWebGPUSupported() {
return typeof navigator !== 'undefined' && !!navigator.gpu;
}
/**
* Load model và processor (lazy — chỉ load khi cần lần đầu).
* Tham khảo cách Qwen3.5-WebGPU load model với dtype q4 và device webgpu.
* @returns {Promise<boolean>} true nếu load thành công
*/
async function loadLLMModel() {
if (_llmReady) return true;
if (_llmLoadError) return false;
if (_llmLoading) {
// Đợi nếu đang load
return new Promise(function (resolve) {
var check = setInterval(function () {
if (!_llmLoading) {
clearInterval(check);
resolve(_llmReady);
}
}, 200);
});
}
_llmLoading = true;
try {
// Dynamic import — @huggingface/transformers từ CDN
var transformers;
if (typeof module !== 'undefined' && module.exports) {
// Node/test: skip — WebGPU không khả dụng
_llmLoadError = 'WebGPU not available in Node.js';
_llmLoading = false;
return false;
}
// Browser: import từ CDN (giống Qwen3.5-WebGPU)
transformers = await import(
'https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.2.0'
);
_llmTransformers = transformers;
// Khởi tạo stopping criteria để hỗ trợ cancel
_llmStoppingCriteria = new transformers.InterruptableStoppingCriteria();
if (!isWebGPUSupported()) {
_llmLoadError = 'WebGPU not supported';
_llmLoading = false;
return false;
}
console.log('[LLM Adapter] Loading processor:', LLM_MODEL_ID);
_notifyStatus('loading_start', 'Loading processor...');
_llmProcessor = await transformers.AutoProcessor.from_pretrained(LLM_MODEL_ID);
console.log('[LLM Adapter] Loading model:', LLM_MODEL_ID);
_notifyStatus('loading_progress', 'Loading model weights...');
_llmModel = await transformers.Qwen3_5ForConditionalGeneration.from_pretrained(
LLM_MODEL_ID,
{
dtype: {
embed_tokens: 'q4',
vision_encoder: 'fp16',
decoder_model_merged: 'q4'
},
device: 'webgpu'
}
);
_llmReady = true;
_llmLoading = false;
console.log('[LLM Adapter] Model loaded successfully');
_notifyStatus('loading_done', 'Model loaded successfully');
return true;
} catch (err) {
console.error('[LLM Adapter] Failed to load model:', err);
_llmLoadError = err.message || 'Unknown error';
_llmLoading = false;
_notifyStatus('loading_error', 'Failed to load model: ' + _llmLoadError);
return false;
}
}
/**
* Helper: dispose past_key_values để giải phóng bộ nhớ GPU.
*/
function _disposePastKeyValues(pastKeyValues) {
if (!pastKeyValues) return;
try {
for (var key of Object.keys(pastKeyValues)) {
if (pastKeyValues[key] && typeof pastKeyValues[key].dispose === 'function') {
pastKeyValues[key].dispose();
}
}
} catch (e) { /* ignore */ }
}
/**
* Generate text từ LLM cho một prompt.
* Sử dụng TextStreamer để thu thập response token-by-token.
* @param {string} userMessage - Tin nhắn người dùng
* @param {function} [onToken] - Callback nhận text tích lũy mỗi khi có token mới: fn(accumulatedText)
* @param {Array} [history] - Lịch sử hội thoại đã trim
* @returns {Promise<string|null>} Phản hồi từ LLM hoặc null nếu lỗi
*/
async function llmGenerate(userMessage, onToken, history) {
if (!_llmReady || !_llmModel || !_llmProcessor || !_llmTransformers) return null;
_llmGenerating = true;
try {
var prompt = _buildPromptWithHistory(userMessage, false, history);
var inputs = await _llmProcessor(prompt);
var fullText = '';
var streamer = new _llmTransformers.TextStreamer(_llmProcessor.tokenizer, {
skip_prompt: true,
skip_special_tokens: true,
callback_function: function (token) {
fullText += token;
if (typeof onToken === 'function') {
onToken(fullText);
}
}
});
if (_llmStoppingCriteria) _llmStoppingCriteria.reset();
var generateOpts = {
...inputs,
max_new_tokens: LLM_MAX_NEW_TOKENS,
do_sample: true,
streamer: streamer,
return_dict_in_generate: true
};
if (_llmStoppingCriteria) {
generateOpts.stopping_criteria = _llmStoppingCriteria;
}
var result = await _llmModel.generate(generateOpts);
_disposePastKeyValues(result.past_key_values);
var trimmed = fullText.replace(/^\n+/, '').trim();
_llmGenerating = false;
return trimmed.length > 0 ? trimmed : null;
} catch (err) {
console.error('[LLM Adapter] Generate error:', err);
_llmLastError = 'Generate error: ' + (err.message || err);
_llmGenerating = false;
return null;
}
}
/**
* Generate text từ LLM cho một prompt kèm image.
* Sử dụng TextStreamer để thu thập response token-by-token.
* @param {string} userMessage - Tin nhắn người dùng
* @param {string} imageDataURL - Data URL hoặc blob URL của ảnh
* @param {function} [onToken] - Callback nhận text tích lũy mỗi khi có token mới: fn(accumulatedText)
* @param {Array} [history] - Lịch sử hội thoại đã trim
* @returns {Promise<string|null>} Phản hồi từ LLM hoặc null nếu lỗi
*/
async function llmGenerateWithImage(userMessage, imageDataURL, onToken, history) {
if (!_llmReady || !_llmModel || !_llmProcessor || !_llmTransformers) return null;
_llmGenerating = true;
try {
var rawImage = await _llmTransformers.RawImage.read(imageDataURL);
var resizedImage = await rawImage.resize(448, 448);
var prompt = _buildPromptWithHistory(userMessage || '', true, history);
var inputs = await _llmProcessor(prompt, resizedImage);
var fullText = '';
var streamer = new _llmTransformers.TextStreamer(_llmProcessor.tokenizer, {
skip_prompt: true,
skip_special_tokens: true,
callback_function: function (token) {
fullText += token;
if (typeof onToken === 'function') {
onToken(fullText);
}
}
});
if (_llmStoppingCriteria) _llmStoppingCriteria.reset();
var generateOpts = {
...inputs,
max_new_tokens: LLM_MAX_NEW_TOKENS,
do_sample: true,
streamer: streamer,
return_dict_in_generate: true
};
if (_llmStoppingCriteria) {
generateOpts.stopping_criteria = _llmStoppingCriteria;
}
var result = await _llmModel.generate(generateOpts);
_disposePastKeyValues(result.past_key_values);
var trimmed = fullText.replace(/^\n+/, '').trim();
_llmGenerating = false;
return trimmed.length > 0 ? trimmed : null;
} catch (err) {
console.error('[LLM Adapter] Generate with image error:', err);
_llmLastError = 'Generate with image error: ' + (err.message || err);
_llmGenerating = false;
return null;
}
}
/**
* LLM Adapter — Interface chính, tương thích với hệ thống adapter.
* Có thể dùng như subroutine hoặc gọi trực tiếp.
* @param {object} rs - RiveScript instance (có thể null)
* @param {string[]} args - Mảng từ (input đã split)
* @param {string} [imageDataURL] - Data URL của ảnh đính kèm (optional)
* @param {function} [onToken] - Callback stream token realtime: fn(accumulatedText)
* @param {Array} [history] - Lịch sử hội thoại đã trim từ app.js
* @returns {Promise<string|null>} Phản hồi từ LLM hoặc null
*/
async function llmAdapter(rs, args, imageDataURL, onToken, history) {
_adapterPath.push('llm_adapter');
_llmLastError = null;
var query = (args || []).join(' ').trim();
var lang = currentLang || 'vi';
// Cho phép gửi ảnh mà không cần text (mô tả ảnh)
if (query.length === 0 && !imageDataURL) {
if (lang === 'en') return 'Please provide a message.';
if (lang === 'ja') return 'メッセージを入力してください。';
return 'Vui lòng nhập tin nhắn.';
}
// Nếu chỉ có ảnh mà không có text, thêm prompt mặc định
if (query.length === 0 && imageDataURL) {
if (lang === 'en') query = 'Describe this image in detail.';
else if (lang === 'ja') query = 'この画像を詳しく説明してください。';
else query = 'Hãy mô tả chi tiết hình ảnh này.';
}
// Kiểm tra WebGPU
if (!isWebGPUSupported()) {
_llmLastError = 'WebGPU not supported in this browser';
return null;
}
// Load model nếu chưa load
var loaded = await loadLLMModel();
if (!loaded) {
_llmLastError = 'Model load failed: ' + (_llmLoadError || 'unknown');
return null;
}
// Generate — không timeout, chờ LLM chạy hết
var generateFn = imageDataURL
? llmGenerateWithImage(query, imageDataURL, onToken, history)
: llmGenerate(query, onToken, history);
var result = await generateFn;
if (result) return result;
if (!_llmLastError) {
_llmLastError = 'Generate returned empty response';
}
return null;
}
/**
* Hủy bỏ quá trình generate đang chạy.
*/
function cancelLLMGeneration() {
if (_llmStoppingCriteria && _llmGenerating) {
_llmStoppingCriteria.interrupt();
console.log('[LLM Adapter] Generation cancelled by user');
}
}
/**
* Kiểm tra LLM đang generate hay không.
* @returns {boolean}
*/
function isLLMGenerating() {
return _llmGenerating;
}
/**
* Lấy lỗi cuối cùng từ LLM adapter (để hiển thị debug info).
* @returns {string|null}
*/
function getLLMLastError() {
return _llmLastError;
}
/**
* Kiểm tra LLM đã sẵn sàng chưa.
* @returns {boolean}
*/
function isLLMReady() {
return _llmReady;
}
/**
* Lấy trạng thái LLM.
* @returns {{ready: boolean, loading: boolean, error: string|null, modelId: string}}
*/
function getLLMStatus() {
return {
ready: _llmReady,
loading: _llmLoading,
error: _llmLoadError,
modelId: LLM_MODEL_ID
};
}
// Node/test: export to globalThis
if (typeof module !== 'undefined' && module.exports) {
globalThis.llmAdapter = llmAdapter;
globalThis.loadLLMModel = loadLLMModel;
globalThis.llmGenerate = llmGenerate;
globalThis.llmGenerateWithImage = llmGenerateWithImage;
globalThis.isWebGPUSupported = isWebGPUSupported;
globalThis.isLLMReady = isLLMReady;
globalThis.getLLMStatus = getLLMStatus;
globalThis.getLLMLastError = getLLMLastError;
globalThis.cancelLLMGeneration = cancelLLMGeneration;
globalThis.isLLMGenerating = isLLMGenerating;
globalThis.setLLMModelId = setLLMModelId;
globalThis.setLLMThinkingEnabled = setLLMThinkingEnabled;
globalThis.isLLMThinkingEnabled = isLLMThinkingEnabled;
globalThis.setLLMStatusCallback = setLLMStatusCallback;
globalThis.LLM_MODEL_ID = LLM_MODEL_ID;
globalThis.LLM_MAX_NEW_TOKENS = LLM_MAX_NEW_TOKENS;
}