v3.1.3: Fix SPA redeclaration errors, update docker-compose for cookies
Some checks failed
Docker Build & Push / build (push) Has been cancelled

This commit is contained in:
KV-Tube Deployer 2026-01-20 07:25:27 +07:00
parent 663ef6ba44
commit 79f69772a0
3 changed files with 304 additions and 295 deletions

View file

@ -14,11 +14,14 @@ services:
volumes: volumes:
# Persist data (Easy setup: Just maps a folder) # Persist data (Easy setup: Just maps a folder)
- ./data:/app/data - ./data:/app/data
# Cookies file for YouTube (Required for NAS/Server to fix "Transcript not available")
- ./data/cookies.txt:/app/cookies.txt
# Local videos folder (Optional) # Local videos folder (Optional)
# - ./videos:/app/youtube_downloads # - ./videos:/app/youtube_downloads
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- FLASK_ENV=production - FLASK_ENV=production
- COOKIES_FILE=/app/cookies.txt
healthcheck: healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:5000/" ] test: [ "CMD", "curl", "-f", "http://localhost:5000/" ]
interval: 30s interval: 30s

587
static/js/webllm-service.js Normal file → Executable file
View file

@ -3,338 +3,343 @@
* Uses MLC's WebLLM for on-device AI inference via WebGPU * Uses MLC's WebLLM for on-device AI inference via WebGPU
*/ */
class WebLLMService { // Guard against redeclaration on SPA navigation
constructor() { if (typeof WebLLMService === 'undefined') {
this.engine = null;
this.isLoading = false;
this.loadProgress = 0;
this.currentModel = null;
// Model configurations - Qwen2 chosen for Vietnamese support class WebLLMService {
this.models = { constructor() {
'qwen2-0.5b': 'Qwen2-0.5B-Instruct-q4f16_1-MLC', this.engine = null;
'phi-3.5-mini': 'Phi-3.5-mini-instruct-q4f16_1-MLC', this.isLoading = false;
'smollm2': 'SmolLM2-360M-Instruct-q4f16_1-MLC' this.loadProgress = 0;
}; this.currentModel = null;
// Default to lightweight Qwen2 for Vietnamese support // Model configurations - Qwen2 chosen for Vietnamese support
this.selectedModel = 'qwen2-0.5b'; this.models = {
'qwen2-0.5b': 'Qwen2-0.5B-Instruct-q4f16_1-MLC',
// Callbacks 'phi-3.5-mini': 'Phi-3.5-mini-instruct-q4f16_1-MLC',
this.onProgressCallback = null; 'smollm2': 'SmolLM2-360M-Instruct-q4f16_1-MLC'
this.onReadyCallback = null;
this.onErrorCallback = null;
}
/**
* Check if WebGPU is supported
*/
static isSupported() {
return 'gpu' in navigator;
}
/**
* Initialize WebLLM with selected model
* @param {string} modelKey - Model key from this.models
* @param {function} onProgress - Progress callback (percent, status)
* @returns {Promise<boolean>}
*/
async init(modelKey = null, onProgress = null) {
if (!WebLLMService.isSupported()) {
console.warn('WebGPU not supported in this browser');
if (this.onErrorCallback) {
this.onErrorCallback('WebGPU not supported. Using server-side AI.');
}
return false;
}
if (this.engine && this.currentModel === (modelKey || this.selectedModel)) {
console.log('WebLLM already initialized with this model');
return true;
}
this.isLoading = true;
this.onProgressCallback = onProgress;
try {
// Dynamic import of WebLLM
const webllm = await import('https://esm.run/@mlc-ai/web-llm');
const modelId = this.models[modelKey || this.selectedModel];
console.log('Loading WebLLM model:', modelId);
// Progress callback wrapper
const initProgressCallback = (progress) => {
this.loadProgress = Math.round(progress.progress * 100);
const status = progress.text || 'Loading model...';
console.log(`WebLLM: ${this.loadProgress}% - ${status}`);
if (this.onProgressCallback) {
this.onProgressCallback(this.loadProgress, status);
}
}; };
// Create engine // Default to lightweight Qwen2 for Vietnamese support
this.engine = await webllm.CreateMLCEngine(modelId, { this.selectedModel = 'qwen2-0.5b';
initProgressCallback: initProgressCallback
});
this.currentModel = modelKey || this.selectedModel; // Callbacks
this.isLoading = false; this.onProgressCallback = null;
this.loadProgress = 100; this.onReadyCallback = null;
this.onErrorCallback = null;
}
console.log('WebLLM ready!'); /**
if (this.onReadyCallback) { * Check if WebGPU is supported
this.onReadyCallback(); */
static isSupported() {
return 'gpu' in navigator;
}
/**
* Initialize WebLLM with selected model
* @param {string} modelKey - Model key from this.models
* @param {function} onProgress - Progress callback (percent, status)
* @returns {Promise<boolean>}
*/
async init(modelKey = null, onProgress = null) {
if (!WebLLMService.isSupported()) {
console.warn('WebGPU not supported in this browser');
if (this.onErrorCallback) {
this.onErrorCallback('WebGPU not supported. Using server-side AI.');
}
return false;
} }
return true; if (this.engine && this.currentModel === (modelKey || this.selectedModel)) {
console.log('WebLLM already initialized with this model');
} catch (error) { return true;
console.error('WebLLM initialization failed:', error);
this.isLoading = false;
if (this.onErrorCallback) {
this.onErrorCallback(error.message);
} }
return false; this.isLoading = true;
} this.onProgressCallback = onProgress;
}
/** try {
* Check if engine is ready // Dynamic import of WebLLM
*/ const webllm = await import('https://esm.run/@mlc-ai/web-llm');
isReady() {
return this.engine !== null && !this.isLoading;
}
/** const modelId = this.models[modelKey || this.selectedModel];
* Summarize text using local AI console.log('Loading WebLLM model:', modelId);
* @param {string} text - Text to summarize
* @param {string} language - Output language ('en' or 'vi')
* @returns {Promise<string>}
*/
async summarize(text, language = 'en') {
if (!this.isReady()) {
throw new Error('WebLLM not ready. Call init() first.');
}
// Truncate text to avoid token limits // Progress callback wrapper
const maxChars = 4000; const initProgressCallback = (progress) => {
if (text.length > maxChars) { this.loadProgress = Math.round(progress.progress * 100);
text = text.substring(0, maxChars) + '...'; const status = progress.text || 'Loading model...';
} console.log(`WebLLM: ${this.loadProgress}% - ${status}`);
const langInstruction = language === 'vi' if (this.onProgressCallback) {
? 'Respond in Vietnamese (Tiếng Việt).' this.onProgressCallback(this.loadProgress, status);
: 'Respond in English.'; }
};
const messages = [ // Create engine
{ this.engine = await webllm.CreateMLCEngine(modelId, {
role: 'system', initProgressCallback: initProgressCallback
content: `You are a helpful AI assistant that creates detailed, insightful video summaries. ${langInstruction}` });
},
{ this.currentModel = modelKey || this.selectedModel;
role: 'user', this.isLoading = false;
content: `Provide a comprehensive summary of this video transcript in 4-6 sentences. Include the main topic, key points discussed, and any important insights or conclusions. Make the summary informative and meaningful:\n\n${text}` this.loadProgress = 100;
console.log('WebLLM ready!');
if (this.onReadyCallback) {
this.onReadyCallback();
}
return true;
} catch (error) {
console.error('WebLLM initialization failed:', error);
this.isLoading = false;
if (this.onErrorCallback) {
this.onErrorCallback(error.message);
}
return false;
} }
];
try {
const response = await this.engine.chat.completions.create({
messages: messages,
temperature: 0.7,
max_tokens: 350
});
return response.choices[0].message.content.trim();
} catch (error) {
console.error('Summarization error:', error);
throw error;
}
}
/**
* Translate text between English and Vietnamese
* @param {string} text - Text to translate
* @param {string} sourceLang - Source language ('en' or 'vi')
* @param {string} targetLang - Target language ('en' or 'vi')
* @returns {Promise<string>}
*/
async translate(text, sourceLang = 'en', targetLang = 'vi') {
if (!this.isReady()) {
throw new Error('WebLLM not ready. Call init() first.');
} }
const langNames = { /**
'en': 'English', * Check if engine is ready
'vi': 'Vietnamese (Tiếng Việt)' */
}; isReady() {
return this.engine !== null && !this.isLoading;
}
const messages = [ /**
{ * Summarize text using local AI
role: 'system', * @param {string} text - Text to summarize
content: `You are a professional translator. Translate the following text from ${langNames[sourceLang]} to ${langNames[targetLang]}. Provide only the translation, no explanations.` * @param {string} language - Output language ('en' or 'vi')
}, * @returns {Promise<string>}
{ */
role: 'user', async summarize(text, language = 'en') {
content: text if (!this.isReady()) {
throw new Error('WebLLM not ready. Call init() first.');
} }
];
try { // Truncate text to avoid token limits
const response = await this.engine.chat.completions.create({ const maxChars = 4000;
messages: messages, if (text.length > maxChars) {
temperature: 0.3, text = text.substring(0, maxChars) + '...';
max_tokens: 500 }
});
return response.choices[0].message.content.trim(); const langInstruction = language === 'vi'
? 'Respond in Vietnamese (Tiếng Việt).'
: 'Respond in English.';
} catch (error) { const messages = [
console.error('Translation error:', error); {
throw error; role: 'system',
} content: `You are a helpful AI assistant that creates detailed, insightful video summaries. ${langInstruction}`
} },
{
role: 'user',
content: `Provide a comprehensive summary of this video transcript in 4-6 sentences. Include the main topic, key points discussed, and any important insights or conclusions. Make the summary informative and meaningful:\n\n${text}`
}
];
/** try {
* Extract key points from text const response = await this.engine.chat.completions.create({
* @param {string} text - Text to analyze messages: messages,
* @param {string} language - Output language temperature: 0.7,
* @returns {Promise<string[]>} max_tokens: 350
*/ });
async extractKeyPoints(text, language = 'en') {
if (!this.isReady()) { return response.choices[0].message.content.trim();
throw new Error('WebLLM not ready. Call init() first.');
} catch (error) {
console.error('Summarization error:', error);
throw error;
}
} }
const maxChars = 3000; /**
if (text.length > maxChars) { * Translate text between English and Vietnamese
text = text.substring(0, maxChars) + '...'; * @param {string} text - Text to translate
* @param {string} sourceLang - Source language ('en' or 'vi')
* @param {string} targetLang - Target language ('en' or 'vi')
* @returns {Promise<string>}
*/
async translate(text, sourceLang = 'en', targetLang = 'vi') {
if (!this.isReady()) {
throw new Error('WebLLM not ready. Call init() first.');
}
const langNames = {
'en': 'English',
'vi': 'Vietnamese (Tiếng Việt)'
};
const messages = [
{
role: 'system',
content: `You are a professional translator. Translate the following text from ${langNames[sourceLang]} to ${langNames[targetLang]}. Provide only the translation, no explanations.`
},
{
role: 'user',
content: text
}
];
try {
const response = await this.engine.chat.completions.create({
messages: messages,
temperature: 0.3,
max_tokens: 500
});
return response.choices[0].message.content.trim();
} catch (error) {
console.error('Translation error:', error);
throw error;
}
} }
const langInstruction = language === 'vi' /**
? 'Respond in Vietnamese.' * Extract key points from text
: 'Respond in English.'; * @param {string} text - Text to analyze
* @param {string} language - Output language
* @returns {Promise<string[]>}
*/
async extractKeyPoints(text, language = 'en') {
if (!this.isReady()) {
throw new Error('WebLLM not ready. Call init() first.');
}
const messages = [ const maxChars = 3000;
{ if (text.length > maxChars) {
role: 'system', text = text.substring(0, maxChars) + '...';
content: `You extract the main IDEAS and CONCEPTS from video content. ${langInstruction} Focus on: }
const langInstruction = language === 'vi'
? 'Respond in Vietnamese.'
: 'Respond in English.';
const messages = [
{
role: 'system',
content: `You extract the main IDEAS and CONCEPTS from video content. ${langInstruction} Focus on:
- Main topics discussed - Main topics discussed
- Key insights or takeaways - Key insights or takeaways
- Important facts or claims - Important facts or claims
- Conclusions or recommendations - Conclusions or recommendations
Do NOT copy sentences from the transcript. Instead, synthesize the core ideas in your own words. List 3-5 key points, one per line, without bullet points or numbers.` Do NOT copy sentences from the transcript. Instead, synthesize the core ideas in your own words. List 3-5 key points, one per line, without bullet points or numbers.`
}, },
{ {
role: 'user', role: 'user',
content: `What are the main ideas and takeaways from this video transcript?\n\n${text}` content: `What are the main ideas and takeaways from this video transcript?\n\n${text}`
}
];
try {
const response = await this.engine.chat.completions.create({
messages: messages,
temperature: 0.6,
max_tokens: 400
});
const content = response.choices[0].message.content.trim();
const points = content.split('\n')
.map(line => line.replace(/^[\d\.\-\*\•]+\s*/, '').trim())
.filter(line => line.length > 10);
return points.slice(0, 5);
} catch (error) {
console.error('Key points extraction error:', error);
throw error;
}
}
/**
* Stream chat completion for real-time output
* @param {string} prompt - User prompt
* @param {function} onChunk - Callback for each chunk
* @returns {Promise<string>}
*/
async streamChat(prompt, onChunk) {
if (!this.isReady()) {
throw new Error('WebLLM not ready.');
}
const messages = [
{ role: 'user', content: prompt }
];
try {
const chunks = await this.engine.chat.completions.create({
messages: messages,
temperature: 0.7,
stream: true
});
let fullResponse = '';
for await (const chunk of chunks) {
const delta = chunk.choices[0]?.delta?.content || '';
fullResponse += delta;
if (onChunk) {
onChunk(delta, fullResponse);
} }
];
try {
const response = await this.engine.chat.completions.create({
messages: messages,
temperature: 0.6,
max_tokens: 400
});
const content = response.choices[0].message.content.trim();
const points = content.split('\n')
.map(line => line.replace(/^[\d\.\-\*\•]+\s*/, '').trim())
.filter(line => line.length > 10);
return points.slice(0, 5);
} catch (error) {
console.error('Key points extraction error:', error);
throw error;
}
}
/**
* Stream chat completion for real-time output
* @param {string} prompt - User prompt
* @param {function} onChunk - Callback for each chunk
* @returns {Promise<string>}
*/
async streamChat(prompt, onChunk) {
if (!this.isReady()) {
throw new Error('WebLLM not ready.');
} }
return fullResponse; const messages = [
{ role: 'user', content: prompt }
];
} catch (error) { try {
console.error('Stream chat error:', error); const chunks = await this.engine.chat.completions.create({
throw error; messages: messages,
temperature: 0.7,
stream: true
});
let fullResponse = '';
for await (const chunk of chunks) {
const delta = chunk.choices[0]?.delta?.content || '';
fullResponse += delta;
if (onChunk) {
onChunk(delta, fullResponse);
}
}
return fullResponse;
} catch (error) {
console.error('Stream chat error:', error);
throw error;
}
}
/**
* Get available models
*/
getModels() {
return Object.keys(this.models).map(key => ({
id: key,
name: this.models[key],
selected: key === this.selectedModel
}));
}
/**
* Set selected model (requires re-init)
*/
setModel(modelKey) {
if (this.models[modelKey]) {
this.selectedModel = modelKey;
// Reset engine to force reload with new model
this.engine = null;
this.currentModel = null;
}
}
/**
* Cleanup and release resources
*/
async destroy() {
if (this.engine) {
// WebLLM doesn't have explicit destroy, but we can nullify
this.engine = null;
this.currentModel = null;
this.loadProgress = 0;
}
} }
} }
/** // Global singleton instance
* Get available models window.webLLMService = new WebLLMService();
*/
getModels() { // Export for module usage
return Object.keys(this.models).map(key => ({ if (typeof module !== 'undefined' && module.exports) {
id: key, module.exports = WebLLMService;
name: this.models[key],
selected: key === this.selectedModel
}));
} }
/** } // End guard block for WebLLMService
* Set selected model (requires re-init)
*/
setModel(modelKey) {
if (this.models[modelKey]) {
this.selectedModel = modelKey;
// Reset engine to force reload with new model
this.engine = null;
this.currentModel = null;
}
}
/**
* Cleanup and release resources
*/
async destroy() {
if (this.engine) {
// WebLLM doesn't have explicit destroy, but we can nullify
this.engine = null;
this.currentModel = null;
this.loadProgress = 0;
}
}
}
// Global singleton instance
window.webLLMService = new WebLLMService();
// Export for module usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = WebLLMService;
}

View file

@ -1703,10 +1703,11 @@
<script> <script>
// Track current language state and WebLLM status // Track current language state and WebLLM status
let currentSummaryLang = 'en'; // Use var and guards to prevent redeclaration errors on SPA navigation
let webllmInitialized = false; if (typeof currentSummaryLang === 'undefined') var currentSummaryLang = 'en';
let currentTranscriptText = null; if (typeof webllmInitialized === 'undefined') var webllmInitialized = false;
let currentSummaryData = { en: null, vi: null }; if (typeof currentTranscriptText === 'undefined') var currentTranscriptText = null;
if (typeof currentSummaryData === 'undefined') var currentSummaryData = { en: null, vi: null };
// WebLLM Progress Update Handler - Silent loading (just log to console) // WebLLM Progress Update Handler - Silent loading (just log to console)
function updateWebLLMProgress(percent, status) { function updateWebLLMProgress(percent, status) {