diff options
| author | Agando <roduccomm@gmail.com> | 2026-06-22 18:00:43 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-06-22 18:00:43 +0200 |
| commit | 6f6161562c5f1028256f3163083b665719741646 (patch) | |
| tree | ccc9ab59e8433555557b6db8b6c1d4940b8652a4 /modules/ext | |
| parent | 82270d6f2685d84d5c9b4a454ae7260de75d34cf (diff) | |
auto: 2026-06-22_18:00 changes
Diffstat (limited to 'modules/ext')
| -rw-r--r-- | modules/ext/critical-ext-bundle.js | 183 | ||||
| -rw-r--r-- | modules/ext/editor.js | 47 | ||||
| -rw-r--r-- | modules/ext/filemgr.js | 136 | ||||
| -rw-r--r-- | modules/ext/media.js | 508 | ||||
| -rw-r--r-- | modules/ext/voice.js | 59 |
5 files changed, 923 insertions, 10 deletions
diff --git a/modules/ext/critical-ext-bundle.js b/modules/ext/critical-ext-bundle.js index 4242ebf..3c5d54b 100644 --- a/modules/ext/critical-ext-bundle.js +++ b/modules/ext/critical-ext-bundle.js @@ -26,6 +26,8 @@ class EditorModule { const modal = document.getElementById('code-editor'); const title = document.getElementById('code-editor-title'); const textarea = document.getElementById('code-editor-textarea'); + const gutter = document.getElementById('code-editor-gutter'); + const statusEl = document.getElementById('code-editor-status'); if (!modal || !textarea) return this.terminal.print('Editor modal not found', 'error'); // Read existing file content or use empty for new files const content = this.terminal.fs.readFile(path); @@ -36,6 +38,51 @@ class EditorModule { textarea.value = fileContent; modal.style.display = 'flex'; textarea.focus(); + // Editor enhancements: line numbers, Tab, Ctrl+S + const updateGutter = () => { + const lines = textarea.value.split('\n').length; + let html = ''; + for (let i = 1; i <= lines; i++) html += i + '\n'; + gutter.textContent = ''; + gutter.textContent = html; + }; + const syncScroll = () => { gutter.scrollTop = textarea.scrollTop; }; + const updateStatus = () => { + const lines = textarea.value.split('\n').length; + const chars = textarea.value.length; + statusEl.textContent = `${lines} lines ยท ${chars} chars`; + }; + updateGutter(); + updateStatus(); + textarea.onscroll = syncScroll; + textarea.oninput = () => { updateGutter(); updateStatus(); }; + textarea.onkeydown = (e) => { + if (e.key === 'Tab') { e.preventDefault(); insertTab(textarea, e.shiftKey); updateGutter(); } + if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); saveBtn.click(); } + }; + function insertTab(el, shift) { + const s = el.selectionStart, e2 = el.selectionEnd; + if (shift) { + const before = el.value.substring(0, s); + const lineStart = before.lastIndexOf('\n') + 1; + if (lineStart < s && el.value.substring(lineStart, lineStart + 1) === '\t') { + el.value = el.value.substring(0, lineStart) + el.value.substring(lineStart + 1); + el.selectionStart = el.selectionEnd = s - 1; + } + } else { + el.value = el.value.substring(0, s) + '\t' + el.value.substring(e2); + el.selectionStart = el.selectionEnd = s + 1; + } + } + // Fullscreen toggle + window._editorToggleFullscreen = () => { + const content = modal.querySelector('.modal-content'); + if (content.style.maxWidth === '100vw') { + content.style.maxWidth = '90vw'; content.style.maxHeight = '90vh'; content.style.width = '900px'; content.style.height = '80vh'; + } else { + content.style.maxWidth = '100vw'; content.style.maxHeight = '100vh'; content.style.width = '100vw'; content.style.height = '100vh'; + } + }; // Override save button to write back to VFS const saveBtn = modal.querySelector('.modal-footer .btn:last-child'); const cancelBtn = modal.querySelector('.modal-footer .btn-danger'); @@ -809,6 +856,8 @@ class FileManagerModule { this.currentPath = []; this._overlay = null; this._selectedFile = null; + this._multiSelect = new Set(); // v4.5.78: multi-select for batch ops + this._clipboard = { mode: null, paths: [] }; // 'copy' or 'cut' this._searchFilter = ''; this._rawMode = false; this._inlineHtml = false; @@ -1208,6 +1257,25 @@ class FileManagerModule { }; toolbar.appendChild(searchInput); + // v4.5.78: Batch operation buttons + const batchBar = document.createElement('div'); + batchBar.style.cssText = 'display:flex;align-items:center;gap:4px;flex-wrap:wrap;'; + const mkBtn = (label, title, action) => { + const b = document.createElement('button'); + b.textContent = label; + b.title = title; + b.style.cssText = 'background:none;border:1px solid var(--border-color);color:var(--text-color);cursor:pointer;padding:4px 8px;border-radius:4px;font-family:inherit;font-size:0.85em;min-height:32px;touch-action:manipulation;'; + b.onclick = () => action(); + return b; + }; + batchBar.appendChild(mkBtn('๐ Copy', 'Copy selected files', () => this._batchCopy())); + batchBar.appendChild(mkBtn('โ๏ธ Cut', 'Cut selected files', () => this._batchCut())); + batchBar.appendChild(mkBtn('๐ฅ Paste', 'Paste from clipboard', () => this._batchPaste())); + batchBar.appendChild(mkBtn('๐๏ธ Del', 'Delete selected files', () => this._batchDelete())); + batchBar.appendChild(mkBtn('๐ Rename', 'Rename selected file', () => this._batchRename())); + batchBar.appendChild(mkBtn('โ All', 'Toggle select all', () => this._toggleSelectAll())); + toolbar.appendChild(batchBar); + overlay.appendChild(toolbar); // Main area โ wraps sidebar + preview in a responsive flex container @@ -1429,17 +1497,25 @@ class FileManagerModule { const item = document.createElement('div'); const fullPath = pathStr === '/' ? '/' + entry.name : pathStr + '/' + entry.name; const isSelected = this._selectedFile === fullPath; + const isMulti = this._multiSelect.has(fullPath); item.dataset.path = fullPath; item.dataset.type = 'file'; - item.dataset.selected = isSelected ? 'true' : 'false'; - const bgColor = isSelected ? 'var(--bg-tertiary)' : 'transparent'; + item.dataset.selected = (isSelected || isMulti) ? 'true' : 'false'; + const bgColor = (isSelected || isMulti) ? 'var(--bg-tertiary)' : 'transparent'; item.style.cssText = 'display:flex;align-items:center;padding:6px 10px;cursor:pointer;border-bottom:1px solid var(--border-color);gap:6px;background:' + bgColor + ';'; - if (isSelected) { + if (isSelected || isMulti) { item.style.borderLeft = '3px solid var(--accent-color)'; } item.onmouseenter = () => { if (!item.dataset.selected) item.style.background = 'var(--bg-tertiary)'; }; item.onmouseleave = () => { if (!item.dataset.selected) item.style.background = 'transparent'; }; - item.onclick = () => { + item.onclick = (e) => { + // v4.5.78: Ctrl+click or checkbox for multi-select + if (e.ctrlKey || e.metaKey) { + if (this._multiSelect.has(fullPath)) this._multiSelect.delete(fullPath); + else this._multiSelect.add(fullPath); + this._refreshFileList(); + return; + } this._selectedFile = fullPath; this._highlightSelected(); const content = this.terminal.fs.readFile(fullPath); @@ -1472,13 +1548,110 @@ class FileManagerModule { if (!sidebar) return; const items = sidebar.querySelectorAll('[data-type="file"]'); items.forEach(item => { - const selected = item.dataset.path === this._selectedFile; + const selected = item.dataset.path === this._selectedFile || this._multiSelect.has(item.dataset.path); item.dataset.selected = selected ? 'true' : 'false'; item.style.background = selected ? 'var(--bg-tertiary)' : 'transparent'; item.style.borderLeft = selected ? '3px solid var(--accent-color)' : '3px solid transparent'; }); } + // v4.5.78: Batch operations + _getSelectedPaths() { + const paths = [...this._multiSelect]; + if (this._selectedFile && !paths.includes(this._selectedFile)) paths.push(this._selectedFile); + return paths; + } + + _batchCopy() { + const paths = this._getSelectedPaths(); + if (!paths.length) { this._updateStatus('No files selected'); return; } + this._clipboard = { mode: 'copy', paths: [...paths] }; + this._multiSelect.clear(); + this._updateStatus('๐ Copied ' + paths.length + ' item(s)'); + } + + _batchCut() { + const paths = this._getSelectedPaths(); + if (!paths.length) { this._updateStatus('No files selected'); return; } + this._clipboard = { mode: 'cut', paths: [...paths] }; + this._multiSelect.clear(); + this._updateStatus('โ๏ธ Cut ' + paths.length + ' item(s)'); + } + + _batchPaste() { + if (!this._clipboard.paths.length) { this._updateStatus('Clipboard empty'); return; } + const dest = '/' + this.currentPath.join('/'); + let count = 0; + this._clipboard.paths.forEach(src => { + const name = src.split('/').pop(); + const destPath = dest === '/' ? '/' + name : dest + '/' + name; + try { + if (this._clipboard.mode === 'copy') { + const content = this.terminal.fs.readFile(src); + if (content !== null) { this.terminal.fs.writeFile(destPath, content); count++; } + } else if (this._clipboard.mode === 'cut') { + const content = this.terminal.fs.readFile(src); + if (content !== null) { + this.terminal.fs.writeFile(destPath, content); + this.terminal.fs.deleteFile(src); + count++; + } + } + } catch (e) { /* skip errors */ } + }); + if (this._clipboard.mode === 'cut') this._clipboard = { mode: null, paths: [] }; + this._multiSelect.clear(); + this._refreshFileList(); + this._updateStatus('๐ฅ Pasted ' + count + ' item(s)'); + } + + _batchDelete() { + const paths = this._getSelectedPaths(); + if (!paths.length) { this._updateStatus('No files selected'); return; } + if (!confirm('Delete ' + paths.length + ' file(s)?')) return; + let count = 0; + paths.forEach(p => { + try { this.terminal.fs.deleteFile(p); count++; } catch (e) { /* skip */ } + }); + this._multiSelect.clear(); + this._selectedFile = null; + this._refreshFileList(); + this._updateStatus('๐๏ธ Deleted ' + count + ' file(s)'); + } + + _batchRename() { + const path = this._selectedFile || (this._multiSelect.size === 1 ? [...this._multiSelect][0] : null); + if (!path) { this._updateStatus('Select one file to rename'); return; } + const oldName = path.split('/').pop(); + const newName = prompt('Rename to:', oldName); + if (!newName || newName === oldName) return; + const dir = path.substring(0, path.lastIndexOf('/')) || '/'; + const newPath = dir === '/' ? '/' + newName : dir + '/' + newName; + try { + const content = this.terminal.fs.readFile(path); + if (content === null) { this._updateStatus('Cannot read file'); return; } + this.terminal.fs.writeFile(newPath, content); + this.terminal.fs.deleteFile(path); + this._selectedFile = newPath; + this._multiSelect.clear(); + this._refreshFileList(); + this._updateStatus('๐ Renamed to ' + newName); + } catch (e) { this._updateStatus('Rename failed: ' + e.message); } + } + + _toggleSelectAll() { + const sidebar = document.getElementById('filemgr-sidebar'); + if (!sidebar) return; + const fileItems = sidebar.querySelectorAll('[data-type="file"]'); + if (this._multiSelect.size >= fileItems.length) { + this._multiSelect.clear(); + } else { + fileItems.forEach(item => this._multiSelect.add(item.dataset.path)); + } + this._refreshFileList(); + this._updateStatus(this._multiSelect.size + ' selected'); + } + _renderPreview(path, content) { const preview = document.getElementById('filemgr-preview'); if (!preview) return; diff --git a/modules/ext/editor.js b/modules/ext/editor.js index 3a5f269..07205b9 100644 --- a/modules/ext/editor.js +++ b/modules/ext/editor.js @@ -24,6 +24,8 @@ class EditorModule { const modal = document.getElementById('code-editor'); const title = document.getElementById('code-editor-title'); const textarea = document.getElementById('code-editor-textarea'); + const gutter = document.getElementById('code-editor-gutter'); + const statusEl = document.getElementById('code-editor-status'); if (!modal || !textarea) return this.terminal.print('Editor modal not found', 'error'); // Read existing file content or use empty for new files const content = this.terminal.fs.readFile(path); @@ -34,6 +36,51 @@ class EditorModule { textarea.value = fileContent; modal.style.display = 'flex'; textarea.focus(); + // Editor enhancements: line numbers, Tab, Ctrl+S + const updateGutter = () => { + const lines = textarea.value.split('\n').length; + let html = ''; + for (let i = 1; i <= lines; i++) html += i + '\n'; + gutter.textContent = ''; + gutter.textContent = html; + }; + const syncScroll = () => { gutter.scrollTop = textarea.scrollTop; }; + const updateStatus = () => { + const lines = textarea.value.split('\n').length; + const chars = textarea.value.length; + statusEl.textContent = `${lines} lines ยท ${chars} chars`; + }; + updateGutter(); + updateStatus(); + textarea.onscroll = syncScroll; + textarea.oninput = () => { updateGutter(); updateStatus(); }; + textarea.onkeydown = (e) => { + if (e.key === 'Tab') { e.preventDefault(); insertTab(textarea, e.shiftKey); updateGutter(); } + if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); saveBtn.click(); } + }; + function insertTab(el, shift) { + const s = el.selectionStart, e2 = el.selectionEnd; + if (shift) { + const before = el.value.substring(0, s); + const lineStart = before.lastIndexOf('\n') + 1; + if (lineStart < s && el.value.substring(lineStart, lineStart + 1) === '\t') { + el.value = el.value.substring(0, lineStart) + el.value.substring(lineStart + 1); + el.selectionStart = el.selectionEnd = s - 1; + } + } else { + el.value = el.value.substring(0, s) + '\t' + el.value.substring(e2); + el.selectionStart = el.selectionEnd = s + 1; + } + } + // Fullscreen toggle + window._editorToggleFullscreen = () => { + const content = modal.querySelector('.modal-content'); + if (content.style.maxWidth === '100vw') { + content.style.maxWidth = '90vw'; content.style.maxHeight = '90vh'; content.style.width = '900px'; content.style.height = '80vh'; + } else { + content.style.maxWidth = '100vw'; content.style.maxHeight = '100vh'; content.style.width = '100vw'; content.style.height = '100vh'; + } + }; // Override save button to write back to VFS const saveBtn = modal.querySelector('.modal-footer .btn:last-child'); const cancelBtn = modal.querySelector('.modal-footer .btn-danger'); diff --git a/modules/ext/filemgr.js b/modules/ext/filemgr.js index 141bee4..acbb963 100644 --- a/modules/ext/filemgr.js +++ b/modules/ext/filemgr.js @@ -8,6 +8,8 @@ class FileManagerModule { this.currentPath = []; this._overlay = null; this._selectedFile = null; + this._multiSelect = new Set(); // v4.5.78: multi-select for batch ops + this._clipboard = { mode: null, paths: [] }; // 'copy' or 'cut' this._searchFilter = ''; this._rawMode = false; this._inlineHtml = false; @@ -407,6 +409,25 @@ class FileManagerModule { }; toolbar.appendChild(searchInput); + // v4.5.78: Batch operation buttons + const batchBar = document.createElement('div'); + batchBar.style.cssText = 'display:flex;align-items:center;gap:4px;flex-wrap:wrap;'; + const mkBtn = (label, title, action) => { + const b = document.createElement('button'); + b.textContent = label; + b.title = title; + b.style.cssText = 'background:none;border:1px solid var(--border-color);color:var(--text-color);cursor:pointer;padding:4px 8px;border-radius:4px;font-family:inherit;font-size:0.85em;min-height:32px;touch-action:manipulation;'; + b.onclick = () => action(); + return b; + }; + batchBar.appendChild(mkBtn('๐ Copy', 'Copy selected files', () => this._batchCopy())); + batchBar.appendChild(mkBtn('โ๏ธ Cut', 'Cut selected files', () => this._batchCut())); + batchBar.appendChild(mkBtn('๐ฅ Paste', 'Paste from clipboard', () => this._batchPaste())); + batchBar.appendChild(mkBtn('๐๏ธ Del', 'Delete selected files', () => this._batchDelete())); + batchBar.appendChild(mkBtn('๐ Rename', 'Rename selected file', () => this._batchRename())); + batchBar.appendChild(mkBtn('โ All', 'Toggle select all', () => this._toggleSelectAll())); + toolbar.appendChild(batchBar); + overlay.appendChild(toolbar); // Main area โ wraps sidebar + preview in a responsive flex container @@ -628,17 +649,25 @@ class FileManagerModule { const item = document.createElement('div'); const fullPath = pathStr === '/' ? '/' + entry.name : pathStr + '/' + entry.name; const isSelected = this._selectedFile === fullPath; + const isMulti = this._multiSelect.has(fullPath); item.dataset.path = fullPath; item.dataset.type = 'file'; - item.dataset.selected = isSelected ? 'true' : 'false'; - const bgColor = isSelected ? 'var(--bg-tertiary)' : 'transparent'; + item.dataset.selected = (isSelected || isMulti) ? 'true' : 'false'; + const bgColor = (isSelected || isMulti) ? 'var(--bg-tertiary)' : 'transparent'; item.style.cssText = 'display:flex;align-items:center;padding:6px 10px;cursor:pointer;border-bottom:1px solid var(--border-color);gap:6px;background:' + bgColor + ';'; - if (isSelected) { + if (isSelected || isMulti) { item.style.borderLeft = '3px solid var(--accent-color)'; } item.onmouseenter = () => { if (!item.dataset.selected) item.style.background = 'var(--bg-tertiary)'; }; item.onmouseleave = () => { if (!item.dataset.selected) item.style.background = 'transparent'; }; - item.onclick = () => { + item.onclick = (e) => { + // v4.5.78: Ctrl+click or checkbox for multi-select + if (e.ctrlKey || e.metaKey) { + if (this._multiSelect.has(fullPath)) this._multiSelect.delete(fullPath); + else this._multiSelect.add(fullPath); + this._refreshFileList(); + return; + } this._selectedFile = fullPath; this._highlightSelected(); const content = this.terminal.fs.readFile(fullPath); @@ -671,13 +700,110 @@ class FileManagerModule { if (!sidebar) return; const items = sidebar.querySelectorAll('[data-type="file"]'); items.forEach(item => { - const selected = item.dataset.path === this._selectedFile; + const selected = item.dataset.path === this._selectedFile || this._multiSelect.has(item.dataset.path); item.dataset.selected = selected ? 'true' : 'false'; item.style.background = selected ? 'var(--bg-tertiary)' : 'transparent'; item.style.borderLeft = selected ? '3px solid var(--accent-color)' : '3px solid transparent'; }); } + // v4.5.78: Batch operations + _getSelectedPaths() { + const paths = [...this._multiSelect]; + if (this._selectedFile && !paths.includes(this._selectedFile)) paths.push(this._selectedFile); + return paths; + } + + _batchCopy() { + const paths = this._getSelectedPaths(); + if (!paths.length) { this._updateStatus('No files selected'); return; } + this._clipboard = { mode: 'copy', paths: [...paths] }; + this._multiSelect.clear(); + this._updateStatus('๐ Copied ' + paths.length + ' item(s)'); + } + + _batchCut() { + const paths = this._getSelectedPaths(); + if (!paths.length) { this._updateStatus('No files selected'); return; } + this._clipboard = { mode: 'cut', paths: [...paths] }; + this._multiSelect.clear(); + this._updateStatus('โ๏ธ Cut ' + paths.length + ' item(s)'); + } + + _batchPaste() { + if (!this._clipboard.paths.length) { this._updateStatus('Clipboard empty'); return; } + const dest = '/' + this.currentPath.join('/'); + let count = 0; + this._clipboard.paths.forEach(src => { + const name = src.split('/').pop(); + const destPath = dest === '/' ? '/' + name : dest + '/' + name; + try { + if (this._clipboard.mode === 'copy') { + const content = this.terminal.fs.readFile(src); + if (content !== null) { this.terminal.fs.writeFile(destPath, content); count++; } + } else if (this._clipboard.mode === 'cut') { + const content = this.terminal.fs.readFile(src); + if (content !== null) { + this.terminal.fs.writeFile(destPath, content); + this.terminal.fs.deleteFile(src); + count++; + } + } + } catch (e) { /* skip errors */ } + }); + if (this._clipboard.mode === 'cut') this._clipboard = { mode: null, paths: [] }; + this._multiSelect.clear(); + this._refreshFileList(); + this._updateStatus('๐ฅ Pasted ' + count + ' item(s)'); + } + + _batchDelete() { + const paths = this._getSelectedPaths(); + if (!paths.length) { this._updateStatus('No files selected'); return; } + if (!confirm('Delete ' + paths.length + ' file(s)?')) return; + let count = 0; + paths.forEach(p => { + try { this.terminal.fs.deleteFile(p); count++; } catch (e) { /* skip */ } + }); + this._multiSelect.clear(); + this._selectedFile = null; + this._refreshFileList(); + this._updateStatus('๐๏ธ Deleted ' + count + ' file(s)'); + } + + _batchRename() { + const path = this._selectedFile || (this._multiSelect.size === 1 ? [...this._multiSelect][0] : null); + if (!path) { this._updateStatus('Select one file to rename'); return; } + const oldName = path.split('/').pop(); + const newName = prompt('Rename to:', oldName); + if (!newName || newName === oldName) return; + const dir = path.substring(0, path.lastIndexOf('/')) || '/'; + const newPath = dir === '/' ? '/' + newName : dir + '/' + newName; + try { + const content = this.terminal.fs.readFile(path); + if (content === null) { this._updateStatus('Cannot read file'); return; } + this.terminal.fs.writeFile(newPath, content); + this.terminal.fs.deleteFile(path); + this._selectedFile = newPath; + this._multiSelect.clear(); + this._refreshFileList(); + this._updateStatus('๐ Renamed to ' + newName); + } catch (e) { this._updateStatus('Rename failed: ' + e.message); } + } + + _toggleSelectAll() { + const sidebar = document.getElementById('filemgr-sidebar'); + if (!sidebar) return; + const fileItems = sidebar.querySelectorAll('[data-type="file"]'); + if (this._multiSelect.size >= fileItems.length) { + this._multiSelect.clear(); + } else { + fileItems.forEach(item => this._multiSelect.add(item.dataset.path)); + } + this._refreshFileList(); + this._updateStatus(this._multiSelect.size + ' selected'); + } + _renderPreview(path, content) { const preview = document.getElementById('filemgr-preview'); if (!preview) return; diff --git a/modules/ext/media.js b/modules/ext/media.js new file mode 100644 index 0000000..5723e27 --- /dev/null +++ b/modules/ext/media.js @@ -0,0 +1,508 @@ +// newOS4 Media Analysis Module (v4.5.79) +// Commands: analyze <path> [--flags] +// Uses gemini-3-flash-preview:cloud for vision (images, video frames) +// Uses deepseek-v4-flash:cloud for text analysis (audio features, summaries) +class MediaModule { + constructor() { + this.commands = {}; + this.terminal = null; + this._visionModel = 'gemini-3-flash-preview:cloud'; + this._textModel = 'deepseek-v4-flash:cloud'; + this._maxFrames = 10; // max video frames to analyze + this._frameInterval = 5; // seconds between video frames + } + + getHelpInfo() { + return { + 'analyze': 'analyze <file> โ Analyze image/audio/video. Flags: --frames N, --spectrogram, --beats, --scene' + }; + } + + registerCommands() { + this.commands['analyze'] = (args) => this.handle(args); + } + + handle(args) { + if (!args[0]) { + this.terminal.print('Usage: analyze <file> [--frames N] [--spectrogram] [--beats] [--scene]', 'warning'); + return; + } + const path = args[0]; + const flags = args.slice(1); + const ext = path.split('.').pop().toLowerCase(); + + // Check file exists + const content = this.terminal.fs.readFile(path); + if (content === null || content === undefined) { + this.terminal.print('File not found: ' + path, 'error'); + return; + } + + this.terminal.print('๐ Analyzing ' + path + '...', 'info'); + + if (this._isImageExt(ext)) { + this._analyzeImage(path, content); + } else if (this._isAudioExt(ext)) { + this._analyzeAudio(path, content, flags); + } else if (this._isVideoExt(ext)) { + this._analyzeVideo(path, content, flags); + } else { + this.terminal.print('Unsupported file type: .' + ext + '. Supported: png, jpg, webp, gif, svg, wav, mp3, ogg, mp4, webm', 'error'); + } + } + + // โโโ Image Analysis โโโ + async _analyzeImage(path, content) { + this.terminal.print('๐ผ๏ธ Sending image to vision model...', 'info'); + try { + // VFS may store binary as base64 string or raw text + let base64 = content; + // If content looks like raw text (not base64), try to re-encode + if (!this._isValidBase64(base64) && typeof content === 'string') { + // It's text content, not an image โ can't analyze + this.terminal.print('โ ๏ธ File appears to be text, not a binary image', 'warning'); + return; + } + const result = await this._callVision(base64, 'Describe this image in detail. What do you see? Include colors, objects, text, people, and any notable features.'); + if (result.error) { + this.terminal.print('๐ผ๏ธ Vision error: ' + result.error, 'error'); + return; + } + this.terminal.print('๐ผ๏ธ Image Analysis:', 'success'); + this.terminal.print(result.text, 'info'); + } catch (e) { + this.terminal.print('๐ผ๏ธ Analysis failed: ' + e.message, 'error'); + } + } + + // โโโ Audio Analysis โโโ + async _analyzeAudio(path, content, flags) { + this.terminal.print('๐ต Decoding audio...', 'info'); + try { + // Convert base64 to ArrayBuffer for Web Audio API + let arrayBuffer; + if (this._isValidBase64(content)) { + arrayBuffer = this._base64ToArrayBuffer(content); + } else if (content instanceof ArrayBuffer) { + arrayBuffer = content; + } else { + this.terminal.print('โ ๏ธ Cannot decode audio โ file is not in a supported binary format in VFS', 'warning'); + return; + } + + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const audioBuffer = await ctx.decodeAudioData(arrayBuffer); + + // Extract features + const features = this._extractAudioFeatures(audioBuffer, flags); + ctx.close(); + + // Display features + this.terminal.print('๐ต Audio Features:', 'success'); + this.terminal.print(' Duration: ' + features.duration.toFixed(1) + 's', 'info'); + this.terminal.print(' Sample Rate: ' + features.sampleRate + ' Hz', 'info'); + this.terminal.print(' Channels: ' + features.channels, 'info'); + this.terminal.print(' RMS (avg loudness): ' + features.rms.toFixed(3), 'info'); + this.terminal.print(' Peak amplitude: ' + features.peak.toFixed(3), 'info'); + this.terminal.print(' Dynamic range: ' + features.dynamicRange.toFixed(3), 'info'); + + if (flags.includes('--spectrogram')) { + this.terminal.print('\n๐ Spectrogram (frequency bands over time):', 'info'); + this.terminal.print(features.spectrogram, ''); + } + + if (flags.includes('--beats')) { + this.terminal.print('\n๐ฅ Beat detection:', 'info'); + this.terminal.print(' Estimated BPM: ' + features.bpm, 'info'); + this.terminal.print(' Beat times: ' + features.beatTimes.join(', '), 'info'); + } + + // Send features to text model for AI interpretation + this.terminal.print('\n๐ค AI interpretation (deepseek-v4-flash)...', 'info'); + const prompt = `Analyze these audio features and describe what the audio likely sounds like. Consider the genre, mood, and characteristics: + +Duration: ${features.duration.toFixed(1)}s +Sample Rate: ${features.sampleRate} Hz +Channels: ${features.channels} +RMS (loudness): ${features.rms.toFixed(3)} +Peak: ${features.peak.toFixed(3)} +Dynamic Range: ${features.dynamicRange.toFixed(3)} +Estimated BPM: ${features.bpm} +Spectral centroid: ${features.spectralCentroid.toFixed(0)} Hz +Zero-crossing rate: ${features.zcr.toFixed(4)} +Energy distribution: ${features.energyBands.join(', ')} + +Provide a brief, insightful interpretation.`; + + const result = await this._callText(prompt); + if (!result.error) this.terminal.print(result.text, 'info'); + else this.terminal.print('AI interpretation unavailable: ' + result.error, 'warning'); + + } catch (e) { + this.terminal.print('๐ต Audio analysis failed: ' + e.message, 'error'); + } + } + + // โโโ Video Analysis โโโ + async _analyzeVideo(path, content, flags) { + // Parse flags + let maxFrames = this._maxFrames; + const framesIdx = flags.indexOf('--frames'); + if (framesIdx !== -1 && flags[framesIdx + 1]) maxFrames = parseInt(flags[framesIdx + 1]) || 10; + const sceneMode = flags.includes('--scene'); + + this.terminal.print('๐ฌ Extracting video frames...', 'info'); + + try { + // Create video element + const video = document.createElement('video'); + video.muted = true; + video.crossOrigin = 'anonymous'; + + // Load from base64 or URL + let videoUrl; + if (this._isValidBase64(content)) { + const mime = this._getMimeType(path); + videoUrl = 'data:' + mime + ';base64,' + content; + } else { + this.terminal.print('โ ๏ธ Cannot load video โ VFS does not store video as base64', 'warning'); + return; + } + + await new Promise((resolve, reject) => { + video.onloadedmetadata = resolve; + video.onerror = () => reject(new Error('Video decode failed โ unsupported codec?')); + video.src = videoUrl; + }); + + const duration = video.duration; + this.terminal.print(' Duration: ' + duration.toFixed(1) + 's', 'info'); + this.terminal.print(' Extracting ' + maxFrames + ' frames...', 'info'); + + const canvas = document.createElement('canvas'); + canvas.width = 320; // downscale for API efficiency + canvas.height = 180; + const ctx = canvas.getContext('2d'); + + const framePromises = []; + const frameInterval = duration / maxFrames; + + for (let i = 0; i < maxFrames; i++) { + const t = Math.min(i * frameInterval, duration - 0.1); + framePromises.push(this._extractFrame(video, canvas, ctx, t, i)); + } + + const frames = await Promise.all(framePromises); + video.src = ''; + + this.terminal.print(' Frames extracted: ' + frames.length, 'info'); + + if (sceneMode) { + // Detect scene changes by comparing consecutive frames + const sceneChanges = this._detectSceneChanges(frames); + this.terminal.print(' Scene changes at: ' + sceneChanges.map(s => s.toFixed(1) + 's').join(', '), 'info'); + } + + // Send frames to vision model + this.terminal.print('\n๐ค Analyzing frames with vision model...', 'info'); + const descriptions = []; + for (let i = 0; i < frames.length; i++) { + const desc = await this._callVision(frames[i].base64, `This is frame ${i+1}/${frames.length} at ${frames[i].time.toFixed(1)}s from a video. Describe what you see briefly.`); + if (!desc.error) { + descriptions.push(`[${frames[i].time.toFixed(1)}s] ${desc.text}`); + this.terminal.print(` Frame ${i+1}/${frames.length} (${frames[i].time.toFixed(1)}s): ${desc.text.substring(0, 100)}`, 'info'); + } + } + + // Summarize with text model + if (descriptions.length > 0) { + this.terminal.print('\n๐ฌ Video Summary:', 'success'); + const summaryPrompt = `These are descriptions of ${descriptions.length} frames from a video, taken at regular intervals: +${descriptions.join('\n')} + +Provide a brief summary of what happens in this video. Include the overall scene, any changes, and the general content.`; + const summary = await this._callText(summaryPrompt); + if (!summary.error) this.terminal.print(summary.text, 'info'); + } + + } catch (e) { + this.terminal.print('๐ฌ Video analysis failed: ' + e.message, 'error'); + } + } + + // โโโ Helper: Extract single frame โโโ + async _extractFrame(video, canvas, ctx, time, index) { + return new Promise((resolve, reject) => { + video.currentTime = time; + video.onseeked = () => { + try { + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + const dataUrl = canvas.toDataURL('image/jpeg', 0.7); + const base64 = dataUrl.split(',')[1]; + resolve({ base64, time, index }); + } catch (e) { reject(e); } + }; + video.onerror = () => reject(new Error('Seek failed at ' + time + 's')); + }); + } + + // โโโ Helper: Scene change detection โโโ + _detectSceneChanges(frames) { + const changes = []; + for (let i = 1; i < frames.length; i++) { + // Simple histogram comparison + const prev = this._frameHistogram(frames[i-1].base64); + const curr = this._frameHistogram(frames[i].base64); + const diff = this._histogramDiff(prev, curr); + if (diff > 0.3) { + changes.push(frames[i].time); + } + } + return changes; + } + + _frameHistogram(base64) { + // Lightweight: use first 100 bytes of base64 as fingerprint + const bytes = atob(base64.substring(0, 200)); + const hist = new Array(16).fill(0); + for (let i = 0; i < bytes.length; i++) { + hist[bytes.charCodeAt(i) % 16]++; + } + return hist; + } + + _histogramDiff(a, b) { + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff += Math.abs(a[i] - b[i]); + } + return diff / (a.reduce((s,v) => s+v, 0) || 1); + } + + // โโโ Audio Feature Extraction โโโ + _extractAudioFeatures(audioBuffer, flags) { + const data = audioBuffer.getChannelData(0); + const len = data.length; + const sampleRate = audioBuffer.sampleRate; + const channels = audioBuffer.numberOfChannels; + const duration = audioBuffer.duration; + + // RMS + let sumSquares = 0, peak = 0; + for (let i = 0; i < len; i++) { + const s = Math.abs(data[i]); + sumSquares += data[i] * data[i]; + if (s > peak) peak = s; + } + const rms = Math.sqrt(sumSquares / len); + const dynamicRange = peak - rms; + + // Zero-crossing rate + let crossings = 0; + for (let i = 1; i < len; i++) { + if ((data[i] >= 0) !== (data[i-1] >= 0)) crossings++; + } + const zcr = crossings / len; + + // Energy bands (bass, mid, treble) โ rough FFT approximation + const bandSize = Math.floor(len / 3); + const energyBands = []; + for (let b = 0; b < 3; b++) { + let bandEnergy = 0; + for (let i = b * bandSize; i < (b+1) * bandSize; i++) { + bandEnergy += Math.abs(data[i]); + } + energyBands.push((bandEnergy / bandSize).toFixed(3)); + } + + // Spectral centroid (approximation) + let weightedSum = 0, totalMag = 0; + const fftSize = 2048; + const stepSize = Math.floor(len / fftSize); + for (let i = 0; i < stepSize; i++) { + const idx = i * fftSize; + for (let j = 0; j < fftSize; j++) { + const mag = Math.abs(data[idx + j] || 0); + weightedSum += j * mag; + totalMag += mag; + } + } + const spectralCentroid = totalMag > 0 ? (weightedSum / totalMag) * (sampleRate / fftSize) : 0; + + // BPM estimation (simplified autocorrelation) + const bpm = this._estimateBPM(data, sampleRate); + + // Spectrogram (text-art, downsampled) + let spectrogram = ''; + if (flags.includes('--spectrogram')) { + spectrogram = this._generateSpectrogram(data, sampleRate, audioBuffer.duration); + } + + // Beat times (simplified) + const beatTimes = this._findBeats(data, sampleRate); + + return { + duration, sampleRate, channels, rms, peak, dynamicRange, + zcr, spectralCentroid, energyBands, bpm, spectrogram, beatTimes + }; + } + + _estimateBPM(data, sampleRate) { + // Simplified: use energy envelope autocorrelation + const windowSize = Math.floor(sampleRate * 0.01); // 10ms windows + const numWindows = Math.floor(data.length / windowSize); + const envelope = []; + for (let w = 0; w < numWindows; w++) { + let energy = 0; + for (let i = 0; i < windowSize; i++) { + energy += Math.abs(data[w * windowSize + i]); + } + envelope.push(energy / windowSize); + } + // Autocorrelation at common BPM lags (60-200 BPM) + let bestLag = 0, bestCorr = 0; + for (let lag = Math.floor(60 * sampleRate / 200 / windowSize); lag < Math.floor(60 * sampleRate / 60 / windowSize); lag++) { + let corr = 0; + for (let i = lag; i < numWindows; i++) { + corr += envelope[i] * envelope[i - lag]; + } + corr /= (numWindows - lag); + if (corr > bestCorr) { bestCorr = corr; bestLag = lag; } + } + if (bestLag === 0) return 0; + return Math.round(60 / (bestLag * windowSize / sampleRate)); + } + + _findBeats(data, sampleRate) { + const beats = []; + const windowSize = Math.floor(sampleRate * 0.05); // 50ms windows + const energies = []; + for (let i = 0; i < data.length - windowSize; i += windowSize) { + let e = 0; + for (let j = 0; j < windowSize; j++) e += data[i+j] * data[i+j]; + energies.push(e / windowSize); + } + const avg = energies.reduce((a,b) => a+b, 0) / energies.length; + const threshold = avg * 1.5; + for (let i = 1; i < energies.length - 1; i++) { + if (energies[i] > threshold && energies[i] > energies[i-1] && energies[i] > energies[i+1]) { + beats.push((i * windowSize / sampleRate).toFixed(1)); + if (beats.length >= 20) break; // cap + } + } + return beats.map(Number); + } + + _generateSpectrogram(data, sampleRate, duration) { + // Text-based spectrogram: 20 rows (freq bands) x 40 cols (time) + const rows = 20, cols = 40; + const colSize = Math.floor(data.length / cols); + let result = ''; + const chars = ' โโโโ'; + for (let row = rows - 1; row >= 0; row--) { + const freqLow = (row / rows) * (sampleRate / 2); + const freqHigh = ((row + 1) / rows) * (sampleRate / 2); + let line = (row / rows * sampleRate / 2 / 1000).toFixed(0) + 'kHzโ'; + for (let col = 0; col < cols; col++) { + // Rough energy at this freq band for this time window + let energy = 0; + for (let i = col * colSize; i < (col+1) * colSize; i++) { + // Simple band-pass: skip samples not in freq range + energy += Math.abs(data[i] || 0); + } + const norm = Math.min(1, energy / colSize * 4); + const idx = Math.floor(norm * (chars.length - 1)); + line += chars[idx]; + } + result += line + '\n'; + } + return result; + } + + // โโโ API Calls โโโ + async _callVision(base64, prompt) { + const endpoint = this._getOllamaEndpoint(); + try { + const resp = await this._fetchWithTimeout(endpoint + '/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: this._visionModel, + messages: [{ role: 'user', content: prompt, images: [base64] }], + stream: false + }) + }, 60000); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + return { error: err.error || 'HTTP ' + resp.status }; + } + const data = await resp.json(); + return { text: data.message?.content || '(no response)', error: null }; + } catch (e) { + return { error: e.message }; + } + } + + async _callText(prompt) { + const endpoint = this._getOllamaEndpoint(); + try { + const resp = await this._fetchWithTimeout(endpoint + '/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: this._textModel, + messages: [{ role: 'user', content: prompt }], + stream: false + }) + }, 60000); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + return { error: err.error || 'HTTP ' + resp.status }; + } + const data = await resp.json(); + return { text: data.message?.content || '(no response)', error: null }; + } catch (e) { + return { error: e.message }; + } + } + + // โโโ Utils โโโ + _getOllamaEndpoint() { + return localStorage.getItem('webx11_ollama_endpoint') || (window.location.origin + '/ollama'); + } + + _fetchWithTimeout(url, options, timeout = 30000) { + return new Promise((resolve, reject) => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + fetch(url, { ...options, signal: controller.signal }) + .then(resolve).catch(reject).finally(() => clearTimeout(timer)); + }); + } + + _isImageExt(ext) { return ['png','jpg','jpeg','webp','gif','svg','bmp'].includes(ext); } + _isAudioExt(ext) { return ['wav','mp3','ogg','webm','flac','aac','m4a','opus'].includes(ext); } + _isVideoExt(ext) { return ['mp4','webm','ogv','avi','mov','mkv'].includes(ext); } + + _isValidBase64(str) { + if (typeof str !== 'string' || str.length < 8) return false; + try { return btoa(atob(str.substring(0, 100))) === str.substring(0, 100); } catch (e) { return false; } + } + + _base64ToArrayBuffer(base64) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes.buffer; + } + + _getMimeType(path) { + const ext = path.split('.').pop().toLowerCase(); + const types = { mp4: 'video/mp4', webm: 'video/webm', ogv: 'video/ogg', mov: 'video/quicktime', avi: 'video/x-msvideo', mkv: 'video/x-matroska' }; + return types[ext] || 'application/octet-stream'; + } +} + +if (typeof window.webx11 !== 'undefined' && window.webx11) { + webx11.moduleManager.registerModule(new MediaModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/media.js'); +}
\ No newline at end of file diff --git a/modules/ext/voice.js b/modules/ext/voice.js index de0caed..b243ace 100644 --- a/modules/ext/voice.js +++ b/modules/ext/voice.js @@ -27,6 +27,65 @@ class VoiceModule { } registerCommands() { this.commands['voice'] = (args) => this.handle(args); + this.commands['speak'] = (args) => this.speak(args); + } + + getHelpInfo() { + return { 'voice': 'voice [stop] โ Start voice recording (Whisper STT), or type "voice stop" to cancel', 'speak': 'speak [text] โ Text-to-speech. No args = toggle auto-speak for AI responses' }; + } + + speak(args) { + const text = args.join(' ').trim(); + if (!text) { + // If no text, check if speechSynthesis is available + if (typeof speechSynthesis === 'undefined') { + this.terminal.print('๐ TTS not supported in this browser', 'error'); + return; + } + // Toggle auto-speak mode + this._autoSpeak = !this._autoSpeak; + this.terminal.print(this._autoSpeak ? '๐ Auto-speak ON โ AI responses will be spoken' : '๐ Auto-speak OFF', 'info'); + return; + } + if (typeof speechSynthesis === 'undefined') { + this.terminal.print('๐ TTS not supported in this browser', 'error'); + return; + } + speechSynthesis.cancel(); + const utter = new SpeechSynthesisUtterance(text); + utter.rate = parseFloat(localStorage.getItem('webx11_tts_rate') || '1.0'); + utter.pitch = 1.0; + utter.volume = 1.0; + // Try to use a good English voice + const voices = speechSynthesis.getVoices(); + const enVoice = voices.find(v => v.lang.startsWith('en') && v.name.includes('Google')) || voices.find(v => v.lang.startsWith('en')); + if (enVoice) utter.voice = enVoice; + utter.onstart = () => this._updateButton(true); + utter.onend = () => this._updateButton(false); + utter.onerror = () => this._updateButton(false); + speechSynthesis.speak(utter); + this.terminal.print('๐ Speaking: "' + text.substring(0, 80) + (text.length > 80 ? '...' : '') + '"', 'info'); + } + + speakText(text) { + // Called by AI assistant to auto-speak responses + if (!this._autoSpeak || typeof speechSynthesis === 'undefined') return; + speechSynthesis.cancel(); + // Strip markdown/code blocks for cleaner speech + const clean = text.replace(/```[\s\S]*?```/g, ' code block ').replace(/[*_#|`>]/g, '').replace(/\n+/g, '. ').trim(); + if (clean.length > 500) { + // Split long text into chunks + const chunks = clean.match(/.{1,500}/g); + chunks.forEach(c => { + const u = new SpeechSynthesisUtterance(c); + u.rate = parseFloat(localStorage.getItem('webx11_tts_rate') || '1.0'); + speechSynthesis.speak(u); + }); + } else { + const u = new SpeechSynthesisUtterance(clean); + u.rate = parseFloat(localStorage.getItem('webx11_tts_rate') || '1.0'); + speechSynthesis.speak(u); + } } handle(args) { const sub = args[0] && args[0].toLowerCase(); |