diff options
Diffstat (limited to '.bkp-fix-20261028_165025')
| -rw-r--r-- | .bkp-fix-20261028_165025/main.html | 1484 | ||||
| -rw-r--r-- | .bkp-fix-20261028_165025/media.js | 508 | ||||
| -rw-r--r-- | .bkp-fix-20261028_165025/ollama-manager.js | 1355 |
3 files changed, 3347 insertions, 0 deletions
diff --git a/.bkp-fix-20261028_165025/main.html b/.bkp-fix-20261028_165025/main.html new file mode 100644 index 0000000..c386cc2 --- /dev/null +++ b/.bkp-fix-20261028_165025/main.html @@ -0,0 +1,1484 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> + <meta name="theme-color" content="#000000"> + <title>WebX11 Module System</title> + <style> + :root { + --bg-color: #000000; + --bg-secondary: #0a0a0a; + --bg-tertiary: #111111; + --text-color: #00ff00; + --text-muted: #888888; + --accent-color: #00aa00; + --border-color: #005500; + --error-color: #ff5555; + --warning-color: #ffaa00; + --info-color: #00aaff; + --success-color: #00ff00; + --ai-text-color: #aaddff; + --ai-bg-color: #001a26; + --font-primary: 'Courier New', Courier, monospace; + } + * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; } + body { + background-color: var(--bg-color); + color: var(--text-color); + font-family: var(--font-primary); + font-size: var(--term-font-size, 16px); + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; + } + @media (max-width: 600px) { body { font-size: var(--term-font-size, 12px); } } + header { + border-bottom: 1px solid var(--border-color); + padding: 10px; + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-secondary); + flex-shrink: 0; + } + .header-title { font-weight: bold; font-size: 1.1em; } + .header-version { font-size: 0.7em; color: var(--text-muted); } + .status-pill { + font-size: 0.8em; + padding: 2px 6px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-tertiary); + } + #terminal-container { + flex-grow: 1; + overflow-y: auto; + padding: 10px; + scroll-behavior: smooth; + -webkit-overflow-scrolling: touch; + transition: padding 0.2s; + } + #terminal-container.terminal-expanded { + position: fixed; + left: 0; + top: 0; + width: 100vw; + height: 100vh; + z-index: 1000; + background: var(--bg-color); + padding: 16px; + } + #terminal-container.terminal-expanded + #input-area, + #terminal-container.terminal-expanded ~ #input-area { + display: none; + } + .line-copy-btn { + float: right; + font-size: 0.7em; + padding: 2px 6px; + margin-left: 6px; + border: 1px solid var(--border-color); + background: var(--bg-tertiary); + color: var(--text-muted); + border-radius: 4px; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s; + } + .output-line:hover .line-copy-btn { opacity: 1; } + .line-copy-btn:active { background: var(--accent-color); color: #000; } + #ai-confirm-area { + display: none; + flex-shrink: 0; + padding: 10px; + margin: 0 8px 4px; + border: 1px solid var(--warning-color); + border-radius: 6px; + background: var(--bg-secondary); + z-index: 200; + font-size: 0.95em; + min-height: 48px; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: center; + pointer-events: auto; + } + #ai-confirm-area.visible { display: flex; } + #ai-confirm-area.hidden { display: none !important; } + #ai-confirm-area .confirm-desc { + flex: 1; + min-width: 120px; + color: var(--warning-color); + word-break: break-word; + } + #ai-confirm-area .confirm-btn { + padding: 12px 24px; + border: 1px solid var(--success-color); + color: var(--success-color); + background: transparent; + border-radius: 6px; + cursor: pointer; + font-family: inherit; + font-size: 1.1em; + font-weight: bold; + touch-action: manipulation; + min-height: 48px; + min-width: 90px; + user-select: none; + -webkit-user-select: none; + transition: background 0.15s; + } + #ai-confirm-area .confirm-btn:active { + background: rgba(0, 255, 0, 0.15); + } + #ai-confirm-area .confirm-btn.deny { + border-color: var(--error-color); + color: var(--error-color); + } + #ai-confirm-area .confirm-timer { + font-size: 0.8em; + color: var(--warning-color); + flex-shrink: 0; + } + #ai-confirm-area .confirm-timer.urgent { + color: var(--error-color); + font-weight: bold; + } + #ai-confirm-area .confirm-timer.critical { + color: #ff0000; + font-weight: bold; + animation: pulse 1s infinite; + } + /* ── Startup Modules Modal ── */ + .output-line { white-space: pre-wrap; word-break: break-word; line-height: 1.4; } + .output-line.command-prompt { color: var(--text-color); font-weight: bold; } + .output-line.error { color: var(--error-color); } + .output-line.warning { color: var(--warning-color); } + .output-line.info { color: var(--info-color); } + .output-line.success { color: var(--success-color); } + .output-line.text-muted { color: var(--text-muted); } + .output-line.ollama { + color: var(--ai-text-color); + background: var(--ai-bg-color); + padding: 8px; + border-radius: 4px; + border: 1px solid #003344; + margin-bottom: 10px; + position: relative; + } + .output-line.user-prompt-line { + display: flex; + align-items: flex-start; + gap: 6px; + flex-wrap: wrap; + padding: 4px 0; + } + .output-line.user-prompt-line .prompt-prefix { + color: var(--accent-color); + white-space: nowrap; + flex-shrink: 0; + } + .output-line.user-prompt-line .prompt-text { + color: var(--text-color); + word-break: break-word; + flex: 1; + min-width: 0; + } + .copy-btn, .edit-btn { + font-size: 0.7em; + padding: 2px 8px; + cursor: pointer; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-muted); + touch-action: manipulation; + flex-shrink: 0; + transition: background 0.15s; + } + .copy-btn:hover, .edit-btn:hover, + .copy-btn:active, .edit-btn:active { background: var(--accent-color); color: var(--bg-color); } + #input-area { + border-top: 1px solid var(--border-color); + padding: 8px 10px; + background: var(--bg-secondary); + display: flex; + align-items: flex-start; + flex-shrink: 0; + position: relative; + } + .prompt-label { margin-right: 8px; color: var(--accent-color); white-space: nowrap; margin-top: 6px; } + #command-input { + flex-grow: 1; + background: transparent; + border: none; + color: var(--text-color); + font-family: var(--font-primary); + font-size: inherit; + outline: none; + resize: none; + overflow-y: auto; + max-height: 120px; + line-height: 1.4; + padding: 4px 0; + } + #send-btn { + flex-shrink: 0; + min-width: 48px; + min-height: 48px; + padding: 8px 16px; + font-size: 1em; + touch-action: manipulation; + margin-top: 2px; + } + #quick-actions { scrollbar-width: none; -ms-overflow-style: none; } + #quick-actions::-webkit-scrollbar { display: none; } + @media (max-width: 480px) { + #input-area { padding: 6px; } + #command-input { font-size: 0.9em; } + .prompt-label { font-size: 0.8em; max-width: 80px; overflow: hidden; text-overflow: ellipsis; } + } + #autocomplete-dropdown { + position: absolute; + bottom: 100%; + left: 0; + right: 0; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-top: none; + max-height: 200px; + overflow-y: auto; + display: none; + z-index: 1001; + } + @media (min-height: 700px) { + #autocomplete-dropdown { + bottom: auto; + top: 100%; + border-top: 1px solid var(--border-color); + border-bottom: none; + } + } + .autocomplete-item { + padding: 8px 12px; + cursor: pointer; + border-bottom: 1px solid #111; + } + .autocomplete-item:hover, .autocomplete-item.selected { + background: var(--bg-tertiary); + color: var(--success-color); + } + .autocomplete-item .cmd-name { color: var(--text-color); font-weight: bold; } + .autocomplete-item .cmd-desc { color: var(--text-muted); font-size: 0.85em; } + #quick-actions { + display: flex; + gap: 8px; + padding: 8px 10px; + overflow-x: auto; + border-top: 1px solid #111; + background: var(--bg-tertiary); + flex-shrink: 0; + } + .qa-btn { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + color: var(--text-color); + padding: 8px 12px; + border-radius: 4px; + font-family: inherit; + font-size: 0.9em; + cursor: pointer; + white-space: nowrap; + touch-action: manipulation; + } + .qa-btn:active { background: var(--border-color); } + @keyframes confetti-fall { + 0% { transform: translateY(-10vh) rotate(0deg); opacity: 1; } + 100% { transform: translateY(110vh) rotate(720deg); opacity: 0; } + } + .confetti-piece { + position: fixed; top: -5vh; z-index: 9999; + font-size: 24px; pointer-events: none; user-select: none; + animation: confetti-fall 3s ease-in forwards; + } + #status-bar { + border-top: 1px solid var(--border-color); + padding: 4px 10px; + font-size: 0.8em; + display: flex; + justify-content: space-between; + color: var(--text-muted); + flex-shrink: 0; + } + .modal-overlay { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.85); + display: none; + justify-content: center; + align-items: center; + z-index: 1000; + } + .modal-content { + background: var(--bg-color); + border: 1px solid var(--border-color); + width: 90%; + max-width: 500px; + max-height: 80vh; + display: flex; + flex-direction: column; + border-radius: 4px; + } + .modal-header { + padding: 10px; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + font-weight: bold; + } + .modal-body { padding: 10px; overflow-y: auto; flex-grow: 1; } + .modal-footer { + padding: 10px; + border-top: 1px solid var(--border-color); + display: flex; + justify-content: flex-end; + gap: 8px; + } + .btn { + padding: 6px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + color: var(--text-color); + cursor: pointer; + border-radius: 3px; + } + .btn:hover { background: var(--bg-tertiary); } + .btn-danger { border-color: var(--error-color); color: var(--error-color); } + .file-item, .module-item, .model-item { + padding: 8px; + border-bottom: 1px solid #111; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + } + .file-item:hover, .module-item:hover, .model-item:hover { background: var(--bg-tertiary); } + .file-item.dir { color: var(--info-color); } + .module-item.active { border-left: 3px solid var(--success-color); } + .module-item.disabled { opacity: 0.5; } + .model-item.connected { color: var(--success-color); } + .hidden { display: none !important; } + .text-center { text-align: center; } + .clickable-file, .clickable-dir { + cursor: pointer; + display: inline-block; + padding: 1px 3px; + border: 1px solid transparent; + border-radius: 2px; + margin: 1px 0; + } + .clickable-file:hover { background: rgba(0,255,0,0.1); border-color: var(--border-color); } + .module-url-input { + width: 100%; + padding: 8px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-color); + font-family: var(--font-primary); + margin-bottom: 10px; + } + .ollama-status { + padding: 10px; + margin-bottom: 10px; + border-radius: 4px; + } + .ollama-status.connected { background: rgba(0, 255, 0, 0.1); border: 1px solid var(--success-color); } + .ollama-status.disconnected { background: rgba(255, 85, 85, 0.1); border: 1px solid var(--error-color); } + /* Toggle slider styles */ + #ollama-optimization-slider::before, #ollama-validator-slider::before, #ollama-stream-slider::before { + content: ""; + position: absolute; + height: 20px; + width: 20px; + left: 2px; + bottom: 2px; + background: var(--text-color); + border-radius: 50%; + transition: 0.3s; + } + #ollama-optimization-toggle:checked + #ollama-optimization-slider::before, + #ollama-validator-toggle:checked + #ollama-validator-slider::before, + #ollama-stream-toggle:checked + #ollama-stream-slider::before { + transform: translateX(22px); + } + #ollama-optimization-toggle:checked + #ollama-optimization-slider, + #ollama-validator-toggle:checked + #ollama-validator-slider, + #ollama-stream-toggle:checked + #ollama-stream-slider { + border-color: var(--success-color); + } + /* ── Card Layout Styles ── */ + .msg-card { + margin-bottom: 8px; + border: 1px solid var(--border-color); + border-radius: 6px; + overflow: hidden; + background: var(--bg-secondary); + } + .msg-card.user { border-left: 3px solid var(--success-color); } + .msg-card.ai { border-left: 3px solid var(--ai-text-color); background: var(--ai-bg-color); } + .msg-card.system { border-left: 3px solid var(--text-muted); } + .msg-card-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 10px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-tertiary); + } + .msg-role { font-weight: bold; font-size: 0.85em; white-space: nowrap; } + .msg-buttons { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; } + .msg-card-body { + padding: 10px; + white-space: pre-wrap; + word-break: break-word; + line-height: 1.4; + font-size: 0.9em; + } + .card-btn { + display: inline-flex; + padding: 4px 12px; + font-size: 0.75em; + font-family: inherit; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-muted); + border-radius: 4px; + cursor: pointer; + touch-action: manipulation; + transition: background 0.15s; + white-space: nowrap; + line-height: 1.4; + } + .card-btn:hover, .card-btn:active { background: var(--accent-color); color: var(--bg-color); } + .msg-card.ai .ai-render-container { + margin: 0; + border: 1px solid var(--border-color); + } + .msg-card.ai .ai-content { + display: inline; + } + .msg-card.system .msg-card-body { + color: inherit; + } + /* ── End Card Layout ── */ + .ai-render-container { + margin: 10px 0; + border-radius: 6px; + overflow: hidden; + border: 1px solid var(--border-color); + background: #000; + } + .ai-render-iframe { + width: 100%; + border: none; + display: block; + background: #000; + min-height: 200px; + } + .module-badge { + display: inline-block; + padding: 2px 6px; + border-radius: 3px; + font-size: 0.75em; + margin-left: 5px; + } + .module-badge.builtin { background: var(--bg-tertiary); border: 1px solid var(--border-color); } + .fetch-content { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + padding: 10px; + margin: 5px 0; + border-radius: 4px; + max-height: 300px; + overflow-y: auto; + font-size: 0.85em; + } + .search-result { + margin-bottom: 10px; + padding: 8px; + background: var(--bg-tertiary); + border-radius: 4px; + border-left: 3px solid var(--info-color); + } + .search-result a { + color: var(--info-color); + text-decoration: none; + } + .search-result a:hover { + text-decoration: underline; + } + .search-result-image, .search-result-video { + margin-bottom: 10px; + padding: 8px; + background: var(--bg-tertiary); + border-radius: 4px; + } + .search-result-image img, .search-result-video img { + max-width: 200px; + max-height: 150px; + border-radius: 4px; + object-fit: cover; + } + + /* ── Splash Screen ── */ + #splash-overlay { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: var(--bg-color); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + z-index: 2000; + transition: opacity 0.3s ease; + } + #splash-overlay.hidden { + opacity: 0; + pointer-events: none; + } + #splash-title { + font-size: 1.8em; + font-weight: bold; + margin-bottom: 4px; + } + #splash-version { + font-size: 0.8em; + color: var(--text-muted); + margin-bottom: 24px; + } + #splash-progress-track { + width: 260px; + max-width: 70vw; + height: 8px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 4px; + overflow: hidden; + margin-bottom: 12px; + } + #splash-progress-fill { + height: 100%; + width: 0%; + background: var(--accent-color); + transition: width 0.15s ease; + border-radius: 3px; + } + #splash-phase { + font-size: 0.75em; + color: var(--text-muted); + margin-bottom: 4px; + } + #splash-filename { + font-size: 0.85em; + color: var(--info-color); + margin-bottom: 16px; + } + #splash-cursor { + display: inline-block; + width: 8px; + height: 14px; + background: var(--text-color); + animation: blink 1s step-end infinite; + } + @keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } + } + #splash-gear { + position: absolute; + top: 12px; + right: 12px; + font-size: 1.4em; + cursor: pointer; + opacity: 0.5; + transition: opacity 0.2s; + touch-action: manipulation; + padding: 8px; + } + #splash-gear:hover { opacity: 1; } + #splash-error { + color: var(--error-color); + font-size: 0.85em; + margin-top: 12px; + text-align: center; + display: none; + } + #splash-error.show { display: block; } + + /* ── Startup Modules Modal ── */ + .mod-card { + display: flex; + align-items: center; + padding: 10px 8px; + border-bottom: 1px solid #111; + min-height: 44px; + touch-action: manipulation; + } + .mod-card:last-child { border-bottom: none; } + .mod-card.disabled { opacity: 0.4; pointer-events: none; } + .mod-card label { + display: flex; + align-items: center; + gap: 10px; + flex-grow: 1; + cursor: pointer; + } + .mod-card input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--accent-color); + flex-shrink: 0; + } + .mod-card .mod-name { + font-size: 0.9em; + flex-shrink: 0; + } + .mod-card .mod-badge { + font-size: 0.7em; + padding: 2px 6px; + border-radius: 3px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-muted); + margin-left: auto; + } + .mod-card .mod-badge.foundation { + background: #001a00; + border-color: var(--accent-color); + color: var(--accent-color); + } + .mod-card .mod-badge.ext { + background: #1a001a; + border-color: #550055; + color: #aa55ff; + } + .mod-section-header { + padding: 8px 8px 4px; + font-size: 0.75em; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 1px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-secondary); + } + @media (max-width: 400px) { + .mod-card { padding: 10px 6px; } + .mod-card .mod-name { font-size: 0.82em; } + } + /* ── Command Cards Panel ── */ + #cmd-cards { + position: fixed; right: 0; top: 48px; bottom: 0; width: 280px; + background: var(--bg-secondary); border-left: 1px solid var(--border-color); + z-index: 999; display: none; flex-direction: column; + backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); + overflow-y: auto; + } + #cmd-cards.visible { display: flex; } + #cmd-cards-header { + display: flex; justify-content: space-between; align-items: center; + padding: 12px 14px; border-bottom: 1px solid var(--border-color); + font-weight: bold; font-size: 0.9em; + position: sticky; top: 0; background: var(--bg-secondary); + } + .cmd-card-section { padding: 10px 14px; } + .cmd-card-section + .cmd-card-section { border-top: 1px solid var(--border-color); } + .cmd-card-section-title { font-size: 0.75em; color: var(--text-muted); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 1px; } + .cmd-card { + display: flex; align-items: center; gap: 10px; + padding: 10px 12px; margin-bottom: 4px; + background: var(--bg-tertiary); border: 1px solid var(--border-color); + border-radius: 6px; cursor: pointer; transition: border-color .15s; + min-height: 44px; touch-action: manipulation; + } + .cmd-card:hover { border-color: var(--accent-color); } + .cmd-card .cmd { font-family: var(--font-primary); font-weight: bold; font-size: 0.85em; color: var(--accent-color); flex-shrink: 0; min-width: 60px; } + .cmd-card .desc { font-size: 0.78em; color: var(--text-muted); } + #cmd-cards-toggle { cursor: pointer; font-size: 1.1em; line-height: 1; } + + /* ── Floating UI Module ── */ + #floating-container { + position: fixed; + bottom: calc(56px + 36px + 12px); + right: 12px; + z-index: 500; + display: flex; + flex-direction: column-reverse; + align-items: flex-end; + gap: 6px; + pointer-events: none; + max-width: 280px; + } + .floating-btn { + pointer-events: auto; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-color); + padding: 8px 14px; + border-radius: 8px; + font-family: inherit; + font-size: 0.9em; + cursor: grab; + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; + touch-action: manipulation; + min-height: 44px; + box-shadow: 0 2px 8px rgba(0,0,0,0.4); + transition: border-color 0.15s, background 0.15s; + user-select: none; + } + .floating-btn:hover, + .floating-btn:active { + border-color: var(--accent-color); + background: #0a1a0a; + } + .floating-btn:active { + cursor: grabbing; + } + .floating-menu { + pointer-events: auto; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + min-width: 140px; + box-shadow: 0 2px 12px rgba(0,0,0,0.5); + } + .floating-menu-header { + padding: 8px 14px; + cursor: grab; + display: flex; + align-items: center; + gap: 8px; + font-size: 0.9em; + min-height: 44px; + touch-action: none; + user-select: none; + border-bottom: 1px solid var(--border-color); + } + .floating-menu-header:active { + cursor: grabbing; + } + .floating-menu-header:hover { + background: #0a1a0a; + } + .floating-menu-header .arrow { + transition: transform 0.2s; + font-size: 0.7em; + } + .floating-menu-header .arrow.collapsed { + transform: rotate(-90deg); + } + .floating-menu-body { + overflow: hidden; + transition: max-height 0.2s ease-out; + max-height: 0; + } + .floating-menu-body.expanded { + max-height: 300px; + } + .floating-menu-btn { + display: block; + width: 100%; + text-align: left; + padding: 8px 14px 8px 24px; + background: transparent; + border: none; + color: var(--text-muted); + font-family: inherit; + font-size: 0.85em; + cursor: pointer; + touch-action: manipulation; + min-height: 40px; + } + .floating-menu-btn:hover, + .floating-menu-btn:active { + background: #0a1a0a; + color: var(--text-color); + } + .floating-panel { + pointer-events: auto; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + font-size: 0.8em; + min-width: 120px; + box-shadow: 0 2px 8px rgba(0,0,0,0.4); + overflow: hidden; + } + .floating-panel-title { + padding: 6px 10px; + font-weight: bold; + color: var(--text-muted); + font-size: 0.9em; + cursor: grab; + touch-action: none; + user-select: none; + border-bottom: 1px solid var(--border-color); + background: rgba(0,0,0,0.15); + } + .floating-panel-title:active { + cursor: grabbing; + } + .floating-panel-content { + padding: 6px 10px; + color: var(--text-color); + line-height: 1.4; + } + @keyframes float-in { + from { opacity: 0; transform: translateY(8px) scale(0.95); } + to { opacity: 1; transform: translateY(0) scale(1); } + } + .floating-btn, + .floating-menu, + .floating-panel { + animation: float-in 0.15s ease-out; + } + </style> + <!-- Early persistence restore — apply saved theme + font-size BEFORE bundle loads to prevent FOUC --> + <script> + (function() { + try { + // Restore font size + var fs = localStorage.getItem('webx11_font_size'); + if (fs) { document.documentElement.style.fontSize = fs + 'px'; } + // Restore theme + var theme = localStorage.getItem('webx11_active_theme'); + if (theme) { + var themesRaw = localStorage.getItem('webx11_themes'); + var defaults = { + 'dark': { '--bg-color': '#000000', '--bg-secondary': '#0a0a0a', '--bg-tertiary': '#111111', '--text-color': '#00ff00', '--text-muted': '#888888', '--accent-color': '#00aa00', '--border-color': '#005500' }, + 'light': { '--bg-color': '#ffffff', '--bg-secondary': '#f5f5f5', '--bg-tertiary': '#eeeeee', '--text-color': '#222222', '--text-muted': '#888888', '--accent-color': '#0066cc', '--border-color': '#cccccc' }, + 'amber': { '--bg-color': '#1a0f00', '--bg-secondary': '#2a1a00', '--bg-tertiary': '#332200', '--text-color': '#ffaa00', '--text-muted': '#886600', '--accent-color': '#ff8800', '--border-color': '#663300' }, + 'cyber': { '--bg-color': '#000a14', '--bg-secondary': '#001a2e', '--bg-tertiary': '#002240', '--text-color': '#00ffff', '--text-muted': '#0088aa', '--accent-color': '#ff00ff', '--border-color': '#004466' }, + 'ocean': { '--bg-color': '#0a1628', '--bg-secondary': '#0f2240', '--bg-tertiary': '#142e54', '--text-color': '#88ddff', '--text-muted': '#5599bb', '--accent-color': '#00aaff', '--border-color': '#1a4470' }, + 'forest': { '--bg-color': '#0a1a0e', '--bg-secondary': '#0f2814', '--bg-tertiary': '#14361c', '--text-color': '#88dd88', '--text-muted': '#559955', '--accent-color': '#33cc33', '--border-color': '#1a4a22' }, + 'monochrome': { '--bg-color': '#0a0a0a', '--bg-secondary': '#181818', '--bg-tertiary': '#222222', '--text-color': '#cccccc', '--text-muted': '#666666', '--accent-color': '#999999', '--border-color': '#333333' }, + 'sunset': { '--bg-color': '#1a0a14', '--bg-secondary': '#2a1220', '--bg-tertiary': '#3a1a2c', '--text-color': '#ffbb99', '--text-muted': '#cc7755', '--accent-color': '#ff6644', '--border-color': '#552233' }, + 'midnight': { '--bg-color': '#050510', '--bg-secondary': '#0a0a20', '--bg-tertiary': '#101030', '--text-color': '#aaaaee', '--text-muted': '#555588', '--accent-color': '#6666ff', '--border-color': '#1a1a3a' } + }; + if (themesRaw) { try { Object.assign(defaults, JSON.parse(themesRaw)); } catch(e){} } + var t = defaults[theme]; + if (t) { for (var k in t) { document.documentElement.style.setProperty(k, t[k]); } } + } + } catch(e) {} + })(); + </script> +</head> +<body> + <header> + <div> + <span class="header-title">WebX11</span> + <span class="header-version"></span> + </div> + <div id="header-status" class="status-pill">Mem: --</div> + <span id="cmd-cards-toggle" onclick="if(webx11)webx11.toggleCmdCards()" title="Quick Commands (Ctrl+K)">📋</span> + </header> + + <!-- Command Cards Panel --> + <div id="cmd-cards"> + <div id="cmd-cards-header"> + <span>📋 Quick Commands</span> + <span onclick="if(webx11)webx11.toggleCmdCards()" style="cursor:pointer;font-size:1.2em;">✕</span> + </div> + <div class="cmd-card-section"> + <div class="cmd-card-section-title">🟢 Beginners</div> + <div class="cmd-card" onclick="webx11?.executeCommand('help')"><span class="cmd">help</span><span class="desc">Show all commands</span></div> + <div class="cmd-card" onclick="webx11?.executeCommand('ls')"><span class="cmd">ls</span><span class="desc">List files</span></div> + <div class="cmd-card" onclick="webx11?.executeCommand('clear')"><span class="cmd">clear</span><span class="desc">Clear terminal</span></div> + <div class="cmd-card" onclick="webx11?.executeCommand('status')"><span class="cmd">status</span><span class="desc">System health</span></div> + </div> + <div class="cmd-card-section"> + <div class="cmd-card-section-title">🤖 AI</div> + <div class="cmd-card" onclick="webx11?.executeCommand('ai \"what can you do?\"')"><span class="cmd">ai "..."</span><span class="desc">Ask AI assistant</span></div> + <div class="cmd-card" onclick="webx11?.executeCommand('ai-model')"><span class="cmd">ai-model</span><span class="desc">Set AI model</span></div> + </div> + <div class="cmd-card-section"> + <div class="cmd-card-section-title">🔍 Explore</div> + <div class="cmd-card" onclick="webx11?.executeCommand('modules')"><span class="cmd">modules</span><span class="desc">List loaded modules</span></div> + <div class="cmd-card" onclick="webx11?.executeCommand('tutorial')"><span class="cmd">tutorial</span><span class="desc">Interactive tour</span></div> + </div> + <div class="cmd-card-section"> + <div class="cmd-card-section-title">🚀 Advanced</div> + <div class="cmd-card" onclick="webx11?.executeCommand('cron-every 60 echo hello')"><span class="cmd">cron</span><span class="desc">Schedule tasks</span></div> + <div class="cmd-card" onclick="webx11?.executeCommand('webdav-status')"><span class="cmd">webdav</span><span class="desc">Cloud sync</span></div> + <div class="cmd-card" onclick="webx11?.executeCommand('help.html')"><span class="cmd">help</span><span class="desc">Full reference</span></div> + </div> + </div> + + <!-- Terminal Container --> + <div id="terminal-container"> + <div class="output-line info" id="terminal-welcome">WebX11 Module System - Unix-like Terminal</div> + <div class="output-line info">Commands: ls, cd, pwd, cat, echo, rm, mkdir, mv, cp, touch, tree, find, grep</div> + <div class="output-line info">Type 'help' for all commands</div> + <div class="output-line"></div> + <div class="output-line info loading" id="loading-indicator">Loading modules...</div> + </div> + + <!-- Floating UI Container — populated by FloatingModule --> + <div id="floating-container"></div> + + <!-- AI Confirm Area — fixed outside scrollable output, always visible --> + <div id="ai-confirm-area" class="hidden"></div> + + <div id="quick-actions"> + <button class="qa-btn" onclick="webx11.executeCommand('ls')">📁 Files</button> + <button class="qa-btn" onclick="webx11.executeCommand('help')">📖 Help</button> + <button class="qa-btn" onclick="webx11.executeCommand('modules')">🧩 Modules</button> + <button class="qa-btn" onclick="webx11.openOllamaManager()">🤖 Ollama</button> + <button class="qa-btn" onclick="webx11.openSkillsManager()">📚 Skills</button> + <button class="qa-btn" onclick="webx11.openConfigManager()">⚙ Config</button> + <button class="qa-btn" onclick="webx11.executeCommand('clear')">🧹 Clear</button> + <button class="qa-btn" id="voice-toggle-btn" onclick="webx11.toggleMicVisibility()">🎤 Mic</button> + <button class="qa-btn" onclick="webx11.toggleDebugPanel()">🐞 Debug</button> + <button class="qa-btn" id="sound-toggle-btn">🔊 Sound</button> + </div> + + <div id="input-area"> + <span class="prompt-label">$</span> + <textarea id="command-input" rows="1" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" placeholder="Type a command..." style="resize:none; overflow-y:auto; max-height:120px;"></textarea> + <button id="send-btn" onclick="var i=document.getElementById('command-input');webx11.executeCommand(i.value);i.value='';i.style.height='auto'" style="background:var(--bg-tertiary); border:1px solid var(--border-color); color:var(--success-color); padding:6px 12px; border-radius:4px; cursor:pointer; font-family:inherit; font-size:0.9em; margin-left:6px; white-space:nowrap; flex-shrink:0;">⏎ Send</button> + <div id="autocomplete-dropdown"></div> + </div> + + <div id="status-bar"> + <span id="status-text">Ready</span> + <span id="network-status">📶 Online</span> + <span id="stream-status" style="font-size:0.75em; margin-left:4px; color:var(--success-color);">▶ Stream</span> + <button id="ai-cancel-btn" style="display:none; background:var(--error-color); color:#fff; border:none; border-radius:4px; padding:2px 10px; cursor:pointer; font-family:inherit; font-size:0.8em; touch-action:manipulation; min-height:28px;" onclick="webx11.executeCommand('ai-stop')">⏹ Stop</button> + </div> + + <!-- File Browser Modal --> + <div id="file-browser" class="modal-overlay"> + <div class="modal-content"> + <div class="modal-header"> + <span>File Browser</span> + <span style="cursor:pointer" onclick="webx11.closeFileBrowser()">✕</span> + </div> + <div class="modal-body" id="fb-content"></div> + <div class="modal-footer"> + <button class="btn" onclick="document.getElementById('file-upload-input').click()">⬆️ Upload</button> + <button class="btn" onclick="webx11.closeFileBrowser()">Close</button> + </div> + </div> + </div> + + <!-- Module Manager Modal --> + <div id="module-manager" class="modal-overlay"> + <div class="modal-content" style="max-width: 600px;"> + <div class="modal-header"> + <span>Module Manager</span> + <span style="cursor:pointer" onclick="webx11.closeModuleManager()">✕</span> + </div> + <div class="modal-body"> + <div style="margin-bottom: 15px;"> + <input type="text" id="module-url-input" class="module-url-input" placeholder="Enter module URL (https://example.com/module.js)"> + <button class="btn" onclick="webx11.loadModuleFromURL()" style="width: 100%;">Load Module from URL</button> + </div> + <h4 style="color: var(--info-color); margin-bottom: 10px;">Loaded Modules</h4> + <div id="module-list"></div> + </div> + <div class="modal-footer"> + <button class="btn btn-danger" onclick="webx11.clearAllModules()">Clear All</button> + <button class="btn" onclick="webx11.closeModuleManager()">Close</button> + </div> + </div> + </div> + + <!-- Ollama Manager Modal --> + <div id="ollama-manager" class="modal-overlay"> + <div class="modal-content" style="max-width: 600px;"> + <div class="modal-header"> + <span>Ollama AI Manager</span> + <span style="cursor:pointer" onclick="webx11.closeOllamaManager()">✕</span> + </div> + <div class="modal-body"> + <div id="ollama-status" class="ollama-status disconnected"> + <strong>Status:</strong> <span id="ollama-status-text">Disconnected</span> + </div> + <div style="margin-bottom: 15px;"> + <label style="display: block; margin-bottom: 5px; color: var(--text-muted);">Ollama Endpoint:</label> + <input type="text" id="ollama-endpoint" class="module-url-input" value="http://localhost:11434" placeholder="http://localhost:11434"> + <button class="btn" onclick="webx11.connectOllama()" style="width: 100%;">Connect</button> + </div> + <h4 style="color: var(--info-color); margin-bottom: 10px;">Available Models</h4> + <input type="text" id="ollama-search" class="module-url-input" placeholder="Search models..." oninput="webx11.renderOllamaModels()" style="margin-bottom: 8px;"> + <div id="ollama-model-count" style="font-size: 0.8em; color: var(--text-muted); margin-bottom: 6px;"></div> + <div id="ollama-models" style="max-height: 300px; overflow-y: auto;"></div> + <div style="margin-top: 15px;"> + <label style="display: block; margin-bottom: 5px; color: var(--text-muted);">Current Model:</label> + <select id="ollama-current-model" class="module-url-input"> + <option value="">Select a model</option> + </select> + <button class="btn" onclick="webx11.setOllamaModel()" style="width: 100%;">Set Model</button> + </div> + <hr style="border-color:var(--border-color); margin: 15px 0;"> + <h4 style="color: var(--accent-color); margin-bottom: 10px;">⚡ AI Agent Optimization</h4> + <div style="display:flex; align-items:center; gap:10px; margin-bottom: 12px;"> + <span style="color:var(--text-muted); font-size:0.85em;">Agent Loop Optimizations:</span> + <label style="position:relative; display:inline-block; width:48px; height:26px;"> + <input type="checkbox" id="ollama-optimization-toggle" onchange="webx11.toggleOptimization()" style="opacity:0; width:0; height:0;"> + <span id="ollama-optimization-slider" style="position:absolute; cursor:pointer; inset:0; background:var(--bg-tertiary); border:1px solid var(--border-color); border-radius:26px; transition:0.3s;"></span> + </label> + <span id="ollama-optimization-label" style="font-size:0.85em; color:var(--success-color);">Enabled</span> + </div> + <div style="font-size:0.8em; color:var(--text-muted); margin-bottom: 10px;"> + ⚡ Tool def caching, trivial query skip, parallel safe tools, debounced reflection<br> + <span style="color:var(--warning-color);">Disable for debugging or if model behaves unexpectedly. Reload after toggle.</span> + </div> + <hr style="border-color:var(--border-color); margin: 15px 0;"> + <h4 style="color: var(--accent-color); margin-bottom: 10px;">⚡ Response Streaming</h4> + <div style="display:flex; align-items:center; gap:10px; margin-bottom: 6px;"> + <span style="color:var(--text-muted); font-size:0.85em;">Stream Tokens:</span> + <label style="position:relative; display:inline-block; width:48px; height:26px;"> + <input type="checkbox" id="ollama-stream-toggle" onchange="webx11.toggleStream()" style="opacity:0; width:0; height:0;"> + <span id="ollama-stream-slider" style="position:absolute; cursor:pointer; inset:0; background:var(--bg-tertiary); border:1px solid var(--border-color); border-radius:26px; transition:0.3s;"></span> + </label> + <span id="ollama-stream-label" style="font-size:0.85em; color:var(--success-color);">Enabled</span> + </div> + <div style="font-size:0.8em; color:var(--text-muted); margin-bottom: 10px;"> + Show AI responses token-by-token. Disable for instant full responses and reduced crash risk.<br> + <span style="color:var(--warning-color);">Disable on slow connections or when browser becomes unresponsive during long outputs.</span> + </div> + <hr style="border-color:var(--border-color); margin: 15px 0;"> + <h4 style="color: var(--info-color); margin-bottom: 10px;">🔍 Validator Agent</h4> + <div style="font-size:0.85em; color:var(--text-muted); margin-bottom: 10px;"> + A separate lightweight AI that validates the main agent's tool results. Has its own + endpoint and model — independent from your active AI model.<br> + <span style="color:var(--success-color);">⚡ Best validator model: <strong>qwen2.5:0.5b</strong> (1.94s, 352MB)</span> + </div> + <div style="display:flex; align-items:center; gap:10px; margin-bottom: 10px;"> + <span style="color:var(--text-muted); font-size:0.85em;">Enable Validator:</span> + <label style="position:relative; display:inline-block; width:48px; height:26px;"> + <input type="checkbox" id="ollama-validator-toggle" onchange="webx11.toggleValidator()" style="opacity:0; width:0; height:0;"> + <span id="ollama-validator-slider" style="position:absolute; cursor:pointer; inset:0; background:var(--bg-tertiary); border:1px solid var(--border-color); border-radius:26px; transition:0.3s;"></span> + </label> + <span id="ollama-validator-label" style="font-size:0.85em; color:var(--text-muted);">Disabled</span> + </div> + <div style="margin-bottom: 8px;"> + <label style="display: block; margin-bottom: 3px; color: var(--text-muted); font-size:0.85em;">Validator Endpoint:</label> + <input type="text" id="validator-endpoint-input" class="module-url-input" value="http://localhost:11434" placeholder="http://localhost:11434" style="margin-bottom:6px;"> + <button class="btn" onclick="webx11.connectValidatorEndpoint()" style="width:100%; border-color:var(--info-color); color:var(--info-color);">🔌 Connect & Load Models</button> + <div id="validator-connection-status" style="font-size:0.8em; margin-top:4px;"></div> + </div> + <div style="margin-bottom: 10px;"> + <label style="display: block; margin-bottom: 3px; color: var(--text-muted); font-size:0.85em;">Validator Model:</label> + <select id="validator-model-select" class="module-url-input" style="margin-bottom:6px;" disabled> + <option value="">Connect to load models</option> + </select> + <button class="btn" onclick="webx11.saveValidatorConfig()" style="width:100%; border-color:var(--info-color); color:var(--info-color);">Save Validator Config</button> + </div> + <div style="font-size:0.8em; color:var(--warning-color); border:1px solid var(--border-color); border-radius:4px; padding:10px; margin-top:10px;"> + <strong>⚠️ Connection Notes</strong><br> + • Cloud models (deepseek-v4*, gemma4:*b-cloud) via Ollama proxy often <strong>ignore tool calls</strong> + and answer from training data instead. Use local models for tool calling.<br> + • To connect to local Ollama from a web page: set <code>OLLAMA_ORIGINS</code> on the server: + <code>export OLLAMA_ORIGINS="https://webx11.duckdns.org"</code> then restart Ollama.<br> + • Test local connection: open a terminal and run <code>curl http://localhost:11434/api/tags</code><br> + • If you see <strong>403</strong> errors in console: the <code>OLLAMA_ORIGINS</code> env var isn't matching your page URL. + </div> + </div> + <div class="modal-footer"> + <button class="btn" onclick="webx11.closeOllamaManager()">Close</button> + </div> + </div> + </div> + + <!-- Splash Screen --> + <div id="splash-overlay"> + <span id="splash-gear" onclick="openStartupModulesSplash(event)" title="Select startup modules">⚙</span> + <div id="splash-title">⚡ WebX11</div> + <div id="splash-version"></div> + <div id="splash-progress-track"><div id="splash-progress-fill"></div></div> + <div id="splash-phase">Initializing...</div> + <div id="splash-filename"></div> + <div><span id="splash-cursor"></span></div> + <div id="splash-error"></div> + </div> + + <!-- Skills Manager Modal --> + <div id="skills-manager" class="modal-overlay"> + <div class="modal-content" style="max-width: 750px; max-height: 90vh;"> + <div class="modal-header"> + <span>📚 Skills Manager</span> + <span style="cursor:pointer" onclick="webx11.closeSkillsManager()">✕</span> + </div> + <div class="modal-body" style="display:flex; flex-direction:column; gap:8px;"> + <!-- Stats header --> + <div id="skills-stats" style="display:flex; gap:12px; flex-wrap:wrap; font-size:0.85em; color:var(--text-muted); padding-bottom:6px; border-bottom:1px solid var(--border-color);"></div> + <!-- Search + action bar --> + <div style="display:flex; gap:6px; align-items:center; flex-shrink:0;"> + <input type="text" id="skills-search-input" class="module-url-input" placeholder="🔍 Search skills..." style="flex:1;" oninput="webx11.refreshSkillsList()"> + <button class="btn" onclick="webx11.exportAllSkillsUI()" style="padding:4px 10px; font-size:0.8em">📤 Export All</button> + <button class="btn" onclick="document.getElementById('skills-import-input').click()" style="padding:4px 10px; font-size:0.8em">📥 Import</button> + <input type="file" id="skills-import-input" accept=".json" style="display:none" onchange="webx11.importSkillsUI(event)"> + <button class="btn" onclick="webx11.syncSkillsToWebdavUI()" style="padding:4px 10px; font-size:0.8em">☁️ Sync</button> + </div> + <!-- Skills list with category groups --> + <div id="skills-list" style="max-height:300px;overflow-y:auto; flex-shrink:0;"></div> + <!-- Preview (collapsible) --> + <div id="skill-preview" style="display:none; flex-shrink:0; background:var(--bg-tertiary); border:1px solid var(--border-color); border-radius:4px; padding:10px; overflow-y:auto; white-space:pre-wrap; font-family:var(--font-primary); font-size:0.85em; color:var(--text-color);"> + <div style="display:flex; justify-content:space-between; margin-bottom:6px;"> + <span id="skill-preview-title" style="color:var(--success-color); font-weight:bold;"></span> + <div style="display:flex; gap:6px;"> + <button class="btn" id="skill-preview-expand-btn" onclick="webx11.toggleSkillPreviewSize()" style="padding:2px 6px; font-size:0.75em">⛶ Expand</button> + <span style="cursor:pointer; color:var(--text-muted); font-size:1.2em;" onclick="webx11.closeSkillPreview()">✕</span> + </div> + </div> + <div id="skill-preview-content"></div> + </div> + <!-- Create form --> + <div style="flex-shrink:0; border-top:1px solid var(--border-color); padding-top:8px;"> + <h4 style="color: var(--info-color); margin-bottom: 6px;">Create New Skill</h4> + <div style="display:flex; gap:6px; flex-wrap:wrap;"> + <input type="text" id="skill-name-input" class="module-url-input" placeholder="Name (kebab-case)" style="flex:2; min-width:140px;"> + <input type="text" id="skill-desc-input" class="module-url-input" placeholder="Description" style="flex:3; min-width:180px;"> + <select id="skill-category-input" class="module-url-input" style="flex:1; min-width:100px;"> + <option value="general">general</option> + <option value="system">system</option> + <option value="research">research</option> + <option value="dev">dev</option> + <option value="productivity">productivity</option> + <option value="ui">ui</option> + <option value="security">security</option> + </select> + </div> + <div style="display:flex; gap:6px; margin-top:6px;"> + <input type="text" id="skill-tags-input" class="module-url-input" placeholder="Tags (comma-separated)" style="flex:1;"> + <button class="btn" onclick="webx11.createSkillFromUI()" style="border-color:var(--success-color);color:var(--success-color); flex-shrink:0;">Create</button> + </div> + <textarea id="skill-content-input" class="module-url-input" style="margin-top:6px; height:80px; resize:vertical; font-family:var(--font-primary)" placeholder="Markdown content (optional — will use template)"></textarea> + </div> + </div> + <div class="modal-footer"> + <button class="btn" onclick="webx11.refreshSkillsList()">🔄 Refresh</button> + <button class="btn" onclick="webx11.closeSkillsManager()">Close</button> + </div> + </div> + </div> + + <!-- Preview Modal --> + <div id="preview-modal" class="modal-overlay"> + <div class="modal-content" style="max-width: 95%; max-height: 90vh; width: 95%;"> + <div class="modal-header"> + <span id="preview-title">📄 Preview</span> + <span style="display:flex;gap:10px;align-items:center"> + <button class="btn btn-sm" onclick="var f=document.getElementById('preview-iframe');if(f&&f.srcdoc){var w=window.open('','_blank');if(w){w.document.open();w.document.write(f.srcdoc);w.document.close();}}" title="Open in new tab">⛶</button> + <span style="cursor:pointer" onclick="webx11.closePreview()">✕</span> + </span> + </div> + <div class="modal-body" style="padding:0; overflow:hidden; height:75vh;"> + <iframe id="preview-iframe" style="width:100%; height:100%; border:none; background:var(--bg-color);" sandbox="allow-same-origin allow-scripts"></iframe> + </div> + <div class="modal-footer"> + <button class="btn" onclick="webx11.closePreview()">Close</button> + </div> + </div> + </div> + + <!-- Config Manager Modal --> + <div id="config-manager" class="modal-overlay"> + <div class="modal-content" style="max-width: 550px; max-height: 80vh;"> + <div class="modal-header"> + <span>⚙ Configuration</span> + <span style="cursor:pointer" onclick="webx11.closeConfigManager()">✕</span> + </div> + <div class="modal-body" id="config-manager-body"> + <div id="config-list" style="margin-bottom:16px;"></div> + <div style="border-top:1px solid var(--border-color); padding-top:12px;"> + <h4 style="color:var(--info-color); margin-bottom:8px;">Add / Update Config</h4> + <div style="display:flex; gap:6px; flex-wrap:wrap;"> + <input type="text" id="config-key-input" class="module-url-input" placeholder="Key" style="flex:1; min-width:100px;"> + <input type="text" id="config-value-input" class="module-url-input" placeholder="Value" style="flex:3; min-width:150px;"> + <button class="btn" onclick="webx11.addConfigFromUI()" style="border-color:var(--success-color);color:var(--success-color); padding:6px 14px;">Add</button> + </div> + </div> + </div> + <div class="modal-footer"> + <button class="btn" onclick="webx11.exportConfigFromUI()">📤 Export</button> + <button class="btn btn-danger" onclick="webx11.resetConfigFromUI()">🗑 Reset All</button> + <button class="btn" onclick="webx11.closeConfigManager()">Close</button> + </div> + </div> + </div> + + <!-- Welcome Tour Modal --> + <div id="welcome-modal" class="modal-overlay"> + <div class="modal-content" style="max-width:500px;text-align:center;"> + <div class="modal-header"> + <span>🚀 Welcome to newOS4</span> + </div> + <div class="modal-body" style="padding:24px;"> + <div style="font-size:3em;margin-bottom:12px;">🖥️</div> + <h2 style="margin:0 0 8px;color:var(--accent-color);">Let's Get Started</h2> + <p style="color:var(--text-muted);margin-bottom:16px;line-height:1.6;"> + newOS4 is a terminal-centric browser OS with AI, filesystem, and modules. + Take the <strong>2-minute guided tour</strong> to learn the basics. + </p> + <div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap;"> + <button class="btn" onclick="var m=document.getElementById('welcome-modal');if(m)m.style.display='none';if(webx11&&webx11.tutorialModule)webx11.tutorialModule.startTutorial()" + style="border-color:var(--success-color);color:var(--success-color);padding:12px 28px;font-size:1em;"> + 🎮 Start Tutorial + </button> + <button class="btn" onclick="var m=document.getElementById('welcome-modal');if(m)m.style.display='none';location.href='course.html'" + style="border-color:var(--info-color);color:var(--info-color);padding:12px 28px;font-size:1em;"> + 📚 Full Course + </button> + <button class="btn" onclick="if(webx11)webx11.dismissWelcome()" + style="border-color:var(--text-muted);color:var(--text-muted);padding:12px 28px;font-size:1em;"> + Skip — I'll explore + </button> + </div> + <label style="display:flex;align-items:center;justify-content:center;gap:6px;margin-top:14px;font-size:0.78em;color:var(--text-muted);cursor:pointer;"> + <input type="checkbox" id="welcome-dont-show" onchange="if(this.checked)localStorage.setItem('webx11_welcome_dismissed','1');else localStorage.removeItem('webx11_welcome_dismissed')"> + Don't show this again + </label> + <p style="font-size:0.78em;color:var(--text-muted);margin-top:16px;"> + Your progress is saved. Type <code>tutorial</code> anytime to resume. + </p> + </div> + </div> + </div> + + <!-- Startup Modules Modal --> + <div id="startup-modules" class="modal-overlay"> + <div class="modal-content" style="max-width: 500px;"> + <div class="modal-header"> + <span>⚙ Startup Modules</span> + <span style="cursor:pointer" onclick="var w=window.webx11;if(w&&typeof w.closeStartupModules==='function')w.closeStartupModules();else document.getElementById('startup-modules').style.display='none'">✕</span> + </div> + <div class="modal-body" id="startup-modules-body"></div> + <div class="modal-footer"> + <button class="btn btn-danger" onclick="webx11.closeStartupModules()">Cancel</button> + <button class="btn" onclick="webx11.saveStartupModules()" style="border-color:var(--info-color);color:var(--info-color)">Save & Reload</button> + </div> + </div> + </div> + + <!-- Code Editor Modal --> + <div id="code-editor" class="modal-overlay"> + <div class="modal-content" style="max-width: 90vw; max-height: 90vh; width: 900px; height: 80vh;"> + <div class="modal-header"> + <span id="code-editor-title">📝 Edit Module</span> + <span style="cursor:pointer" onclick="if(webx11) webx11.closeCodeEditor()">✕</span> + </div> + <div class="modal-body" style="padding:0; display:flex; flex-direction:column; height:calc(100% - 100px);"> + <div id="code-editor-info" style="padding:6px 10px; font-size:0.8em; color:var(--text-muted); border-bottom:1px solid var(--border-color); flex-shrink:0; display:flex; justify-content:space-between; align-items:center;"> + <span id="code-editor-status">Ready</span> + <span><button class="btn" style="font-size:0.7em;padding:2px 8px" onclick="window._editorToggleFullscreen()">⤢ Fullscreen</button></span> + </div> + <div id="code-editor-wrapper" style="flex:1; display:flex; overflow:hidden; position:relative;"> + <div id="code-editor-gutter" style="flex-shrink:0; width:40px; background:var(--bg-secondary); color:var(--text-muted); font-family:monospace; font-size:13px; line-height:20px; padding:10px 4px; text-align:right; overflow:hidden; user-select:none; border-right:1px solid var(--border-color);"></div> + <textarea id="code-editor-textarea" style="flex:1; border:none; background:var(--bg-tertiary); color:var(--text-color); font-family:monospace; font-size:13px; line-height:20px; padding:10px; resize:none; outline:none; tab-size:2; white-space:pre; overflow:auto; display:block;" spellcheck="false"></textarea> + </div> + </div> + <div class="modal-footer"> + <button class="btn btn-danger" onclick="if(webx11) webx11.closeCodeEditor()">Cancel</button> + <button class="btn" onclick="(async()=>{if(webx11)await webx11.saveCodeEditor()})()" style="border-color:var(--info-color);color:var(--info-color)">💾 Save & Test</button> + </div> + </div> + </div> + + <input type="file" id="file-upload-input" style="display:none"> + + + <!-- Gear splash function — works before webx11 initializes --> + <script> + function openStartupModulesSplash(event) { + event.stopPropagation(); + var w = window.webx11; + if (w && typeof w.openStartupModules === 'function') { + w.openStartupModules(); + } else { + var overlay = document.getElementById('startup-modules'); + if (!overlay) return; + // Bare-bones render for when webx11 isn't ready yet + var body = document.getElementById('startup-modules-body'); + if (body && !body.children.length) { + var mods = [ + ['Foundation (always loaded)', ['virtual-fs.js','module-manager.js','ollama-manager.js','terminal.js'], true], + ['Built-in modules', ['module-system.js','module-fs.js','module-ollama.js','module-my.js','module-searxng.js','module-servermon.js','module-ollamamodels.js','module-config.js','module-simplegui.js','module-io.js','module-dynamicprompts.js','module-aiassist.js','module-aiasm.js','module-smartctx.js','module-duckduckgo.js','module-fetcher.js','module-chatstudio.js'], false], + ['Extensions', ['calc.js','voice.js','cron.js','theme.js','wpm.js','mindmap.js','webdav.js','skill-pack-core.js','editor.js','diff.js','alias.js','finder.js','filemgr.js','cryptogzip.js','tutorial.js','busybox.js','floating.js','console.js','kanban.js','course.js'], false] + ]; + var isSel = function(name) { return true; }; // default: all selected + mods.forEach(function(section) { + var h = document.createElement('div'); + h.className = 'mod-section-header'; + h.textContent = section[0]; + body.appendChild(h); + section[1].forEach(function(name) { + var card = document.createElement('div'); + card.className = 'mod-card' + (section[2] ? ' disabled' : ''); + var label = document.createElement('label'); + var cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.checked = section[2] || isSel(name); + cb.disabled = section[2]; + cb.dataset.name = name; + var nameSpan = document.createElement('span'); + nameSpan.className = 'mod-name'; + nameSpan.textContent = name; + var badgeSpan = document.createElement('span'); + badgeSpan.className = 'mod-badge' + (section[2] ? ' foundation' : ''); + badgeSpan.textContent = section[2] ? 'foundation' : 'built-in'; + label.appendChild(cb); + label.appendChild(nameSpan); + card.appendChild(label); + card.appendChild(badgeSpan); + body.appendChild(card); + }); + }); + } + overlay.style.display = 'flex'; + } + } + </script> + + <!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix --> +<script src="modules/core/core-bundle.js?v=v4.6.119"></script> + + <!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules --> +<script src="modules/core/module-loader.js?v=v4.6.119"></script> + + <!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work --> +<script src="modules/ext/critical-ext-bundle.js?v=v4.6.119"></script> + +<script> +// ═══ ONE TRUE VERSION SOURCE ═══ +// Reads from module-loader.js src attribute (single source of truth) +(function(){ + var version = 'v4.0'; // fallback + var scripts = document.getElementsByTagName('script'); + for (var i = 0; i < scripts.length; i++) { + var m = scripts[i].src && scripts[i].src.match(/module-loader\.js\?v=([v\d\.]+)/); + if (m) { version = m[1]; break; } + } + window.NEWOS4_VERSION = version; + var el = document.getElementById('splash-version'); + if (el) el.textContent = version; + el = document.querySelector('.header-version'); + if (el) el.textContent = version; + el = document.getElementById('terminal-welcome'); + if (el) el.textContent = 'WebX11 Module System ' + version + ' - Unix-like Terminal'; + document.title = 'WebX11 Module System ' + version; +})(); +</script> + +<!-- Phase 6: Boot Watchdog — auto-revert if recent commit broke the system --> +<script> +(function() { + // Wait for modules to load, then check health + var watchdogTimer = null; + var watchdogStarted = false; + + function checkBoot() { + if (watchdogStarted) return; + watchdogStarted = true; + + // Check if core modules loaded + if (typeof webx11 === 'undefined' || !webx11.moduleManager) { + // Critical: webx11 object doesn't exist — system is broken + handleBootFailure('webx11 object not found'); + return; + } + + var mm = webx11.moduleManager; + var loadedModules = Object.keys(mm.modules).length; + + // If less than 50% of expected modules loaded, boot failed + // Expected: ~19 modules at full load + if (loadedModules < 10) { + handleBootFailure('Only ' + loadedModules + ' modules loaded (expected ≥10)'); + return; + } + + // Boot OK — save current state as last-good + try { + var lastGood = { + version: window.NEWOS4_VERSION || 'unknown', + moduleCount: loadedModules, + timestamp: Date.now() + }; + localStorage.setItem('newos4_last_good_boot', JSON.stringify(lastGood)); + } catch (e) {} + } + + function handleBootFailure(reason) { + console.error('[Boot Watchdog] Boot failure detected: ' + reason); + + // Check if there was a recent commit (within last 30 min) + var commitLog = null; + try { + // Check localStorage for recent commit info + var lastCommit = localStorage.getItem('newos4_last_commit'); + if (lastCommit) { + var commit = JSON.parse(lastCommit); + if (commit && commit.timestamp && (Date.now() - commit.timestamp < 1800000)) { + console.warn('[Boot Watchdog] Recent commit detected (version ' + commit.version + '), attempting auto-revert...'); + attemptRevert(commit); + return; + } + } + } catch (e) {} + + // No recent commit or no revert info — just alert + console.error('[Boot Watchdog] No recent commit to revert. System may need manual intervention.'); + var banner = document.createElement('div'); + banner.style.cssText = 'position:fixed;top:0;left:0;right:0;background:#ff4444;color:#fff;padding:10px;font-family:monospace;font-size:12px;z-index:99999;text-align:center;'; + banner.textContent = '⚠️ Boot Watchdog: System failed to load (' + reason + '). Try hard refresh or clear localStorage.'; + document.body.insertBefore(banner, document.body.firstChild); + } + + function attemptRevert(commit) { + // Try to restore the previous version by setting the version back + if (commit.previousVersion) { + console.warn('[Boot Watchdog] Reverting to version ' + commit.previousVersion); + // Replace all ?v= in script tags with previous version + var scripts = document.getElementsByTagName('script'); + var reverted = 0; + for (var i = 0; i < scripts.length; i++) { + var src = scripts[i].src || ''; + if (src.indexOf('?v=') !== -1) { + scripts[i].src = src.replace(/\?v=[^&"']*/, '?v=' + commit.previousVersion); + reverted++; + } + } + console.log('[Boot Watchdog] Reverted ' + reverted + ' script tags to ' + commit.previousVersion); + + // Show warning banner + var banner = document.createElement('div'); + banner.style.cssText = 'position:fixed;top:0;left:0;right:0;background:#ffaa00;color:#000;padding:10px;font-family:monospace;font-size:12px;z-index:99999;text-align:center;'; + banner.textContent = '⚠️ Boot Watchdog: Auto-reverted from ' + commit.version + ' to ' + commit.previousVersion + '. Refresh page to load reverted version.'; + document.body.insertBefore(banner, document.body.firstChild); + + // Force reload after 3 seconds + setTimeout(function() { + // Update URL with previous version to force cache bypass + window.location.href = window.location.pathname + '?v=' + commit.previousVersion + '&watchdog=1'; + }, 3000); + } else { + console.error('[Boot Watchdog] No previous version stored. Manual intervention required.'); + } + } + + // Start watchdog after a delay to allow modules to load + watchdogTimer = setTimeout(checkBoot, 8000); +})(); +</script> + +</body> +</html> diff --git a/.bkp-fix-20261028_165025/media.js b/.bkp-fix-20261028_165025/media.js new file mode 100644 index 0000000..bdc50e2 --- /dev/null +++ b/.bkp-fix-20261028_165025/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.1-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.1-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.1-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/.bkp-fix-20261028_165025/ollama-manager.js b/.bkp-fix-20261028_165025/ollama-manager.js new file mode 100644 index 0000000..7dcecc3 --- /dev/null +++ b/.bkp-fix-20261028_165025/ollama-manager.js @@ -0,0 +1,1355 @@ +// Extracted from newOS4 index.html — OllamaManager / LLMRouter +// Refactored to support multiple LLM providers with auto-fallback +// Class name kept as OllamaManager for backward compatibility +class OllamaManager { + constructor(terminal) { + this.terminal = terminal; + // --- Backward-compat properties (reflected from active provider) --- + this.endpoint = localStorage.getItem('webx11_ollama_endpoint') || 'http://localhost:11434'; + this.connected = false; + this.models = []; + this.currentModel = localStorage.getItem('webx11_ollama_model') || 'gemma4:31b-cloud'; + this.responseHooks = []; + this._abortController = null; + // --- Usage Tracking --- + this._usageLog = []; + this._usageBuffer = []; + this._usageFlushTimer = null; + this._loadUsageLog(); + // Flush on page unload + if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', () => this._flushUsageLog()); + } + // --- AI Agent Loop Optimization Toggle --- + this.optimizationEnabled = localStorage.getItem('webx11_optimization_enabled') === 'true'; // v4.5.66: default OFF + // --- Streaming Toggle --- + this.streamEnabled = localStorage.getItem('webx11_stream_enabled') === 'true'; + // --- Validator Agent Config --- + this.validatorEnabled = localStorage.getItem('webx11_validator_enabled') === 'true'; + this.validatorModel = localStorage.getItem('webx11_validator_model') || 'gemma4:31b-cloud'; + this.validatorEndpoint = localStorage.getItem('webx11_validator_endpoint') || ''; + // --- Model Parameters (temperature, top_p, max_tokens, thinking) --- + this.modelParams = this._loadModelParams(); + // Build fallback endpoints + this.fallbackEndpoints = []; + const origin = typeof window !== 'undefined' ? window.location.origin : ''; + if (origin) { + this.fallbackEndpoints.push(origin + '/ollama/'); + } + + // --- Multi-provider architecture --- + this.providers = []; // array of { name, type, endpoint, apiKey, model, priority, enabled } + this.currentProvider = null; // currently active provider object + this.fallbackCount = 0; + this._loadProviders(); + + // If no active provider set, pick the highest-priority enabled one + if (!this.currentProvider) { + const sorted = this._getSortedEnabledProviders(); + if (sorted.length > 0) { + this.currentProvider = sorted[0]; + this._syncCompatProperties(this.currentProvider); + } + } + } + + // ─── Provider management ───────────────────────────────────────────────── + + // Ollama's server-side default context is small (2k-4k depending on the build). The agent + // sends a system prompt, up to 20 messages and up to 24k chars of tool results, so without an + // explicit num_ctx the server silently truncates the prompt — the model "forgets" what it was + // just told. Send it on every Ollama request; the OpenAI-compatible branch has its own params. + _ctxTokens() { + const n = Number(this.numCtx || (typeof localStorage !== 'undefined' ? localStorage.getItem('webx11_ollama_numctx') : 0)); + return Number.isFinite(n) && n >= 512 ? Math.floor(n) : 16384; + } + + _ollamaOptions(extra) { + return Object.assign({ num_ctx: this._ctxTokens() }, extra || {}); + } + + _loadProviders() { + const stored = this._getSecret('webx11_llm_providers'); + if (stored) { + try { + this.providers = JSON.parse(stored); + // Ensure all entries have the 'enabled' field + this.providers.forEach(p => { if (p.enabled === undefined) p.enabled = true; }); + } catch (e) { + this.providers = []; + } + } + if (this.providers.length === 0) { + // Default single provider: Ollama + this.providers = [{ + name: 'Ollama', + type: 'ollama', + endpoint: localStorage.getItem('webx11_ollama_endpoint') || 'http://localhost:11434', + apiKey: '', + model: localStorage.getItem('webx11_ollama_model') || 'gemma4:31b-cloud', + priority: 0, + enabled: true + }]; + } + } + + _saveProviders() { + this._setSecret('webx11_llm_providers', JSON.stringify(this.providers)); + } + + _loadModelParams() { + try { + const saved = localStorage.getItem('webx11_model_params'); + if (saved) { + const parsed = JSON.parse(saved); + return { + temperature: parsed.temperature !== undefined ? parsed.temperature : 0.7, + top_p: parsed.top_p !== undefined ? parsed.top_p : 0.9, + max_tokens: parsed.max_tokens !== undefined ? parsed.max_tokens : 4096, + thinking: parsed.thinking || 'off' + }; + } + } catch (e) {} + return { temperature: 0.7, top_p: 0.9, max_tokens: 4096, thinking: 'off' }; + } + + _saveModelParams() { + try { + localStorage.setItem('webx11_model_params', JSON.stringify(this.modelParams)); + } catch (e) {} + } + + _getSecret(key) { + if (typeof window !== 'undefined' && window.webx11_secretVault && typeof window.webx11_secretVault.decrypt === 'function') { + return window.webx11_secretVault.decrypt(key); + } + return localStorage.getItem(key); + } + + _setSecret(key, value) { + if (typeof window !== 'undefined' && window.webx11_secretVault && typeof window.webx11_secretVault.encrypt === 'function') { + window.webx11_secretVault.encrypt(key, value); + } else { + localStorage.setItem(key, value); + } + } + + _getSortedEnabledProviders() { + return this.providers + .filter(p => p.enabled) + .sort((a, b) => (a.priority || 0) - (b.priority || 0)); + } + + _syncCompatProperties(provider) { + if (!provider) return; + this.endpoint = provider.endpoint; + this.currentModel = provider.model || this.currentModel; + // Don't override backward-compat localStorage unless explicitly set + } + + _getProviderByType(type) { + return this.providers.find(p => p.type === type && p.enabled); + } + + addProvider(name, type, endpoint, apiKey, model, priority) { + const existing = this.providers.findIndex(p => p.name === name); + const entry = { + name: name, + type: type || 'ollama', + endpoint: endpoint || 'http://localhost:11434', + apiKey: apiKey || '', + model: model || localStorage.getItem('webx11_ollama_model') || 'gemma4:31b-cloud', + priority: priority !== undefined ? priority : this.providers.length, + enabled: true + }; + if (existing >= 0) { + this.providers[existing] = entry; + } else { + this.providers.push(entry); + } + this._saveProviders(); + // If this is the new highest-priority enabled provider, activate it + const sorted = this._getSortedEnabledProviders(); + if (sorted.length > 0 && sorted[0].name === entry.name) { + this.currentProvider = entry; + this._syncCompatProperties(entry); + } + return true; + } + + removeProvider(name) { + const idx = this.providers.findIndex(p => p.name === name); + if (idx < 0) return false; + this.providers.splice(idx, 1); + if (this.currentProvider && this.currentProvider.name === name) { + this.currentProvider = null; + const sorted = this._getSortedEnabledProviders(); + if (sorted.length > 0) { + this.currentProvider = sorted[0]; + this._syncCompatProperties(this.currentProvider); + } + } + this._saveProviders(); + return true; + } + + listProviders() { + return this.providers; + } + + setActiveProvider(name) { + const provider = this.providers.find(p => p.name === name); + if (!provider) { + this.terminal.print(`Provider "${name}" not found`, 'error'); + return false; + } + this.currentProvider = provider; + this._syncCompatProperties(provider); + this.terminal.print(`Active provider set to: ${name} (${provider.type} at ${provider.endpoint})`, 'success'); + return true; + } + + getActiveProvider() { + return this.currentProvider; + } + + getProviderStats() { + const sorted = this._getSortedEnabledProviders(); + const connectedProvider = this.connected + ? (this.currentProvider ? this.currentProvider.name : 'unknown') + : null; + return { + currentProvider: this.currentProvider ? this.currentProvider.name : null, + totalProviders: this.providers.length, + connectedProvider: connectedProvider, + fallbackCount: this.fallbackCount + }; + } + + // ─── Connection ────────────────────────────────────────────────────────── + + async connect() { + const sortedProviders = this._getSortedEnabledProviders(); + if (sortedProviders.length === 0) { + this.terminal.print('No enabled providers configured', 'error'); + this.connected = false; + return false; + } + + // Also build endpoint-based fallback list for backward compat + const allEndpoints = []; + // First try the original endpoint + fallbackEndpoints for plain Ollama connect + if (this._getProviderByType('ollama')) { + allEndpoints.push(this.endpoint); + for (const fb of this.fallbackEndpoints) { + if (!allEndpoints.includes(fb)) allEndpoints.push(fb); + } + } + + // Try each provider in priority order + for (const provider of sortedProviders) { + let success = false; + if (provider.type === 'ollama') { + // Try provider endpoint first, then fallback endpoints + const eps = [provider.endpoint]; + for (const fb of this.fallbackEndpoints) { + if (!eps.includes(fb)) eps.push(fb); + } + for (const ep of eps) { + try { + this.terminal.print(`Connecting to ${provider.name} at ${ep}...`, 'info'); + const response = await fetch(`${ep}/api/tags`, { + method: 'GET', headers: { 'Content-Type': 'application/json' } + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + this.models = data.models || []; + this.connected = true; + this.currentProvider = provider; + provider.endpoint = ep; // may have been updated via fallback + this._syncCompatProperties(provider); + this.saveSettings(); + this.terminal.print(`Connected to ${provider.name}! Found ${this.models.length} model(s)`, 'success'); + this.terminal.log('INFO', 'Ollama', 'Connected to ' + provider.name + ' at ' + ep + ' with ' + this.models.length + ' model(s)'); + if (!this.currentModel || !this.models.some(m => m.name === this.currentModel)) { + if (this.models.length > 0) { + this.currentModel = this.models[0].name; + provider.model = this.currentModel; + localStorage.setItem('webx11_ollama_model', this.currentModel); + this.terminal.print(`Auto-selected model: ${this.currentModel}`, 'info'); + } + } + this._saveProviders(); + success = true; + break; + } catch (error) { + this.terminal.log('WARN', 'Ollama', provider.name + ' endpoint ' + ep + ' failed: ' + error.message); + if (!this._quietConnect) this.terminal.print(`${provider.name} endpoint ${ep} failed: ${error.message}`, 'warning'); + } + } + } else { + // OpenAI-compatible provider (OpenRouter, etc.) + try { + this.terminal.print(`Connecting to ${provider.name} at ${provider.endpoint}...`, 'info'); + const headers = { 'Content-Type': 'application/json' }; + if (provider.apiKey) { + headers['Authorization'] = 'Bearer ' + provider.apiKey; + } + const response = await fetch(`${provider.endpoint}/v1/models`, { + method: 'GET', headers + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + this.models = (data.data || data.models || []).map(m => ({ + name: m.id || m.name, + size: m.size || 0 + })); + this.connected = true; + this.currentProvider = provider; + this._syncCompatProperties(provider); + this.saveSettings(); + this.terminal.print(`Connected to ${provider.name}! Found ${this.models.length} model(s)`, 'success'); + this.terminal.log('INFO', 'Ollama', 'Connected to ' + provider.name + ' at ' + provider.endpoint + ' with ' + this.models.length + ' model(s)'); + if (!this.currentModel || !this.models.some(m => m.name === this.currentModel)) { + if (this.models.length > 0) { + this.currentModel = this.models[0].name; + provider.model = this.currentModel; + localStorage.setItem('webx11_ollama_model', this.currentModel); + this.terminal.print(`Auto-selected model: ${this.currentModel}`, 'info'); + } + } + this._saveProviders(); + success = true; + } catch (error) { + this.terminal.log('WARN', 'Ollama', provider.name + ' failed: ' + error.message); + if (!this._quietConnect) this.terminal.print(`${provider.name} failed: ${error.message}`, 'warning'); + } + } + + if (success) return true; + } + + // If provider-based connect failed, try bare endpoint fallback (legacy) + for (const ep of allEndpoints) { + try { + if (!this._quietConnect) this.terminal.print(`Connecting (legacy fallback) to ${ep}...`, 'info'); + const response = await fetch(`${ep}/api/tags`, { + method: 'GET', headers: { 'Content-Type': 'application/json' } + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + this.models = data.models || []; + this.connected = true; + if (ep !== this.endpoint) { + this.endpoint = ep; + this.saveSettings(); + this.terminal.print(`Endpoint updated to: ${ep}`, 'info'); + } + this.terminal.print(`Connected! Found ${this.models.length} model(s)`, 'success'); + this.terminal.log('INFO', 'Ollama', 'Connected to ' + ep + ' with ' + this.models.length + ' model(s)'); + if (!this.currentModel || !this.models.some(m => m.name === this.currentModel)) { + if (this.models.length > 0) { + this.currentModel = this.models[0].name; + localStorage.setItem('webx11_ollama_model', this.currentModel); + this.terminal.print(`Auto-selected model: ${this.currentModel}`, 'info'); + } + } + this.saveSettings(); + return true; + } catch (error) { + this.terminal.log('WARN', 'Ollama', 'Endpoint ' + ep + ' failed: ' + error.message); + if (!this._quietConnect) this.terminal.print(`Endpoint ${ep} failed: ${error.message}`, 'warning'); + } + } + + this.terminal.log('ERROR', 'Ollama', 'Connection failed on all providers'); + this.connected = false; + return false; + } + + // v4.5.68: Fetch with timeout — wraps fetch() with AbortController to prevent network hangs + async fetchWithTimeout(url, options = {}, timeoutMs = 30000) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const resp = await fetch(url, { ...options, signal: controller.signal }); + clearTimeout(timer); + return resp; + } catch (e) { + clearTimeout(timer); + if (e.name === 'AbortError') throw new Error(`Request timed out after ${timeoutMs / 1000}s`); + throw e; + } + } + + // ─── Lazy connect ──────────────────────────────────────────────────────── + /** + * Every request path calls this first. Nothing connects at boot, so the user's first message + * used to die with "Use ollamaconnect first" even when the endpoint was healthy + * (reported 2026-10-26/27). One attempt, then the caller prints the real reason. + * Concurrent callers share the same in-flight promise. + */ + async _ensureConnected() { + if (this.connected) return true; + if (this._connecting) { + try { await this._connecting; } catch (e) { /* fall through to the state check */ } + return this.connected; + } + // An automatic connect must not silently rewrite the model the user picked. + const savedModel = (typeof localStorage !== 'undefined') ? localStorage.getItem('webx11_ollama_model') : null; + this.terminal.print('Ollama: connecting on first use…', 'info'); + let timedOut = false; + try { + // One failure should read as one failure: connect() prints a line per endpoint it + // tries, which made an auto-connect failure seven lines of noise. Keep the detail in + // the log, print only the outcome for an automatic attempt. + this._quietConnect = true; + this._connecting = this.connect(); + // connect() uses a bare fetch(), so a blackholed endpoint would hang this promise + // forever and — because the promise is shared — every later message with it. + const deadline = this.connectTimeoutMs || 10000; + const outcome = await Promise.race([ + this._connecting.then(() => 'done', e => { throw e; }), + new Promise(resolve => setTimeout(() => resolve('timeout'), deadline)), + ]); + timedOut = outcome === 'timeout' && !this.connected; + } catch (e) { + // A throwing terminal.log() must not leave _connecting poisoned: log defensively. + try { this.terminal.log('WARN', 'Ollama', 'Auto-connect failed: ' + (e && e.message ? e.message : e)); } catch (_) {} + } finally { + this._quietConnect = false; + this._connecting = null; + } + if (this.connected && savedModel && !(this.models || []).some(m => m.name === savedModel)) { + this.currentModel = savedModel; + if (this.currentProvider) this.currentProvider.model = savedModel; + // connect() may already have overwritten the stored preference; put it back. + try { localStorage.setItem('webx11_ollama_model', savedModel); } catch (_) {} + this.terminal.print('Keeping your saved model "' + savedModel + '" — this endpoint does not list it.', 'warning'); + } + if (!this.connected) { + this.terminal.print(timedOut + ? 'Auto-connect timed out after ' + ((this.connectTimeoutMs || 10000) / 1000) + 's — the endpoint is not answering. Check the Ollama tab or run ollamaconnect.' + : 'Auto-connect failed — check the endpoint in the Ollama tab (or run ollamaconnect).', 'warning'); + } + return this.connected; + } + + // ─── Chat (non-streaming) ──────────────────────────────────────────────── + + async chat(prompt, model = null) { + // Per-call state: never let a reason captured by an earlier failure be replayed here. + this._lastChatError = null; + if (!this.connected && !(await this._ensureConnected())) { + this.terminal.print('Ollama not connected: the endpoint is unreachable. Check the Ollama tab or run ollamaconnect.', 'error'); + return null; + } + const useModel = model || this.currentModel; + if (!useModel) { + this.terminal.print('No model selected. Use ollamamodels to select.', 'error'); + return null; + } + + const providersToTry = this._getFallbackChain(); + let lastError = null; + + for (const provider of providersToTry) { + const result = await this._chatWithProvider(provider, prompt, useModel); + if (result !== null && result !== undefined) { + // Success — if we fell back, increment fallbackCount + if (provider !== this.currentProvider) { + this.fallbackCount++; + this.terminal.print(`Fell back to provider: ${provider.name}`, 'warning'); + this.currentProvider = provider; + this._syncCompatProperties(provider); + } + return result; + } + lastError = result; + } + + if (lastError === null) { + // Name the real cause when one was captured; the generic wording is only a fallback. + const reason = this._lastChatError; + this.terminal.print( + reason ? `Ollama error: ${reason}` : 'Ollama error: No provider responded', + 'error'); + } + return null; + } + + async _chatWithProvider(provider, prompt, model) { + const useModel = model || provider.model || this.currentModel; + let fullText = ''; + this._abortController = new AbortController(); + const signal = this._abortController.signal; + const t0 = Date.now(); + try { + if (provider.type === 'ollama') { + const response = await fetch(`${provider.endpoint}/api/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: useModel, prompt: prompt, stream: this.streamEnabled, options: this._ollamaOptions() }), + signal + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + if (this.streamEnabled) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let isFirstToken = true; + let lastObj = null; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split('\n').filter(l => l.trim()); + + for (const line of lines) { + try { + const parsed = JSON.parse(line); + if (parsed.response) { + fullText += parsed.response; + this.terminal.printStreamingResponse(parsed.response, !isFirstToken); + isFirstToken = false; + } + lastObj = parsed; // capture last for eval_count + } catch (e) { + // Skip malformed partial lines + } + } + } + const latency = Date.now() - t0; + this._logUsage(provider, useModel, latency, lastObj?.prompt_eval_count, lastObj?.eval_count); + } else { + const data = await response.json(); + fullText = data.response || ''; + if (fullText) { + this.terminal.printStreamingResponse(fullText, false); + } + const latency = Date.now() - t0; + this._logUsage(provider, useModel, latency, data.prompt_eval_count, data.eval_count); + } + return fullText || null; + } else { + // OpenAI-compatible provider + const headers = { 'Content-Type': 'application/json' }; + if (provider.apiKey) { + headers['Authorization'] = 'Bearer ' + provider.apiKey; + } + const response = await fetch(`${provider.endpoint}/v1/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify({ + model: useModel, + messages: [{ role: 'user', content: prompt }], + stream: this.streamEnabled + }), + signal + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + if (this.streamEnabled) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let isFirstToken = true; + let buffer = ''; + let lastUsage = null; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed === 'data: [DONE]') continue; + if (trimmed.startsWith('data: ')) { + try { + const parsed = JSON.parse(trimmed.substring(6)); + const content = parsed.choices?.[0]?.delta?.content || ''; + if (content) { + fullText += content; + this.terminal.printStreamingResponse(content, !isFirstToken); + isFirstToken = false; + } + if (parsed.usage) lastUsage = parsed.usage; + } catch (e) { + // Skip malformed SSE lines + } + } + } + } + const latency = Date.now() - t0; + this._logUsage(provider, useModel, latency, lastUsage?.prompt_tokens, lastUsage?.completion_tokens); + } else { + const data = await response.json(); + fullText = data.choices?.[0]?.message?.content || ''; + if (fullText) { + this.terminal.printStreamingResponse(fullText, false); + } + const latency = Date.now() - t0; + this._logUsage(provider, useModel, latency, data.usage?.prompt_tokens, data.usage?.completion_tokens); + } + return fullText || null; + } + } catch (error) { + // Remember why this provider failed so chat() can name the real cause + // instead of falling back to the generic "No provider responded". + this._lastChatError = error.message || String(error); + if (fullText && fullText.length > 0) { + this.terminal.printStreamingResponse(`\n\n[Stream error: ${error.message}]`, true); + return fullText; + } + return null; // signal failure so caller can try next provider + } finally { + this._abortController = null; + } + } + + // ─── Chat Stream ───────────────────────────────────────────────────────── + + async chatStream(prompt, onToken, onComplete, model = null) { + if (!this.connected && !(await this._ensureConnected())) { + this.terminal.print('Ollama not connected: the endpoint is unreachable.', 'error'); + onComplete && onComplete(null); return; + } + const useModel = model || this.currentModel; + if (!useModel) { this.terminal.print('No model selected.', 'error'); onComplete && onComplete(null); return; } + + const providersToTry = this._getFallbackChain(); + let triedAny = false; + + for (const provider of providersToTry) { + triedAny = true; + const result = await this._chatStreamWithProvider(provider, prompt, onToken, onComplete, useModel); + if (result !== null) { + if (provider !== this.currentProvider) { + this.fallbackCount++; + this.terminal.print(`Fell back to provider: ${provider.name}`, 'warning'); + this.currentProvider = provider; + this._syncCompatProperties(provider); + } + return result; + } + } + + if (!triedAny) { + this.terminal.print('No model selected.', 'error'); + onComplete && onComplete(null); + } else { + this.terminal.print(`Stream error: No provider responded`, 'error'); + onComplete && onComplete(null); + } + return null; + } + + async _chatStreamWithProvider(provider, prompt, onToken, onComplete, model) { + const useModel = model || provider.model || this.currentModel; + this._abortController = new AbortController(); + const signal = this._abortController.signal; + const t0 = Date.now(); + try { + if (provider.type === 'ollama') { + const response = await fetch(`${provider.endpoint}/api/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: useModel, prompt: prompt, stream: this.streamEnabled, options: this._ollamaOptions() }), + signal + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + let fullResponse = ''; + if (this.streamEnabled) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let lastObj = null; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + if (!line.trim()) continue; + try { + const obj = JSON.parse(line); + if (obj.response) { + fullResponse += obj.response; + onToken && onToken(obj.response, fullResponse); + } + lastObj = obj; + } catch (e) { continue; } + } + } + if (buffer.trim()) { + try { + const obj = JSON.parse(buffer); + if (obj.response) { fullResponse += obj.response; onToken && onToken(obj.response, fullResponse); } + lastObj = obj; + } catch (e) {} + } + const latency = Date.now() - t0; + this._logUsage(provider, useModel, latency, lastObj?.prompt_eval_count, lastObj?.eval_count); + } else { + const data = await response.json(); + fullResponse = data.response || ''; + if (fullResponse) { + onToken && onToken(fullResponse, fullResponse); + } + const latency = Date.now() - t0; + this._logUsage(provider, useModel, latency, data.prompt_eval_count, data.eval_count); + } + this.fireResponseHooks(fullResponse); + onComplete && onComplete(fullResponse); + return fullResponse; + } else { + // OpenAI-compatible provider + const headers = { 'Content-Type': 'application/json' }; + if (provider.apiKey) { + headers['Authorization'] = 'Bearer ' + provider.apiKey; + } + const response = await fetch(`${provider.endpoint}/v1/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify({ + model: useModel, + messages: [{ role: 'user', content: prompt }], + stream: this.streamEnabled + }), + signal + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + let fullResponse = ''; + if (this.streamEnabled) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let lastUsage = null; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed === 'data: [DONE]') continue; + if (trimmed.startsWith('data: ')) { + try { + const parsed = JSON.parse(trimmed.substring(6)); + const content = parsed.choices?.[0]?.delta?.content || ''; + if (content) { + fullResponse += content; + onToken && onToken(content, fullResponse); + } + if (parsed.usage) lastUsage = parsed.usage; + } catch (e) { continue; } + } + } + } + const latency = Date.now() - t0; + this._logUsage(provider, useModel, latency, lastUsage?.prompt_tokens, lastUsage?.completion_tokens); + } else { + const data = await response.json(); + fullResponse = data.choices?.[0]?.message?.content || ''; + if (fullResponse) { + onToken && onToken(fullResponse, fullResponse); + } + const latency = Date.now() - t0; + this._logUsage(provider, useModel, latency, data.usage?.prompt_tokens, data.usage?.completion_tokens); + } + this.fireResponseHooks(fullResponse); + onComplete && onComplete(fullResponse); + return fullResponse; + } + } catch (error) { + this.terminal.print(`Stream error with ${provider.name}: ${error.message}`, 'error'); + onComplete && onComplete(null); + return null; + } finally { + this._abortController = null; + } + } + + // ─── Chat With Tools ───────────────────────────────────────────────────── + + _isModelType(modelName, type) { + if (!modelName) return false; + const lower = modelName.toLowerCase(); + return lower.includes(type); + } + + async chatWithTools(messages, tools, model = null) { + // Per-call state: never let a reason captured by an earlier failure be replayed here. + this._lastChatError = null; + if (!this.connected && !(await this._ensureConnected())) { + this.terminal.print('Ollama not connected: the endpoint is unreachable. Check the Ollama tab or run ollamaconnect.', 'error'); + return { message: null, tool_calls: null, error: 'Not connected' }; + } + const useModel = model || this.currentModel; + if (!useModel) { + this.terminal.print('No model selected.', 'error'); + return { message: null, tool_calls: null, error: 'No model' }; + } + + const providersToTry = this._getFallbackChain(); + let lastError = null; + + for (const provider of providersToTry) { + const result = await this._chatWithToolsWithProvider(provider, messages, tools, useModel); + if (result && !result.error) { + if (provider !== this.currentProvider) { + this.fallbackCount++; + this.terminal.print(`Fell back to provider: ${provider.name}`, 'warning'); + this.currentProvider = provider; + this._syncCompatProperties(provider); + } + return result; + } + // Only keep non-cancellation errors for fallback + if (result && result.error && result.error !== 'Cancelled') { + lastError = result; + } + } + + // Name the real cause when one was captured; the generic wording is only a fallback. + const reason = this._lastChatError; + return lastError || { + message: null, tool_calls: null, + error: reason ? `All providers failed: ${reason}` : 'All providers failed' + }; + } + + async _chatWithToolsWithProvider(provider, messages, tools, model) { + const useModel = model || provider.model || this.currentModel; + this._abortController = new AbortController(); + const signal = this._abortController.signal; + + try { + if (provider.type === 'ollama') { + const body = { model: useModel, messages, stream: false, options: this._ollamaOptions() }; + if (tools && tools.length > 0) body.tools = tools; + const t0 = Date.now(); + const response = await fetch(`${provider.endpoint}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal + }); + const networkTime = Math.round((Date.now() - t0) / 1000); + if (!response.ok) { + if (response.status === 400) { + return await this.chatWithToolsFallback(messages, tools, useModel, signal); + } + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + this.terminal.log('INFO', 'Ollama', `chatWithTools round-trip: ${networkTime}s (model: ${useModel}, provider: ${provider.name})`); + this._logUsage(provider, useModel, networkTime * 1000, data.prompt_eval_count, data.eval_count); + return { + message: data.message || null, + tool_calls: data.message?.tool_calls || null, + error: null + }; + } else { + // OpenAI-compatible provider (OpenRouter, DeepSeek, etc.) + const isDeepSeek = this._isModelType(useModel, 'deepseek'); + const headers = { 'Content-Type': 'application/json' }; + if (provider.apiKey) { + headers['Authorization'] = 'Bearer ' + provider.apiKey; + } + const body = { + model: useModel, + messages: messages, + stream: false + }; + // Apply model parameters (user-configured temperature, top_p, max_tokens, thinking) + if (this.modelParams) { + body.temperature = this.modelParams.temperature; + body.top_p = this.modelParams.top_p; + body.max_tokens = this.modelParams.max_tokens; + } + if (isDeepSeek) { + // DeepSeek defaults: user params override hardcoded defaults + if (body.temperature === undefined) body.temperature = 0.3; + if (body.max_tokens === undefined) body.max_tokens = 4096; + body.stop = ['<|end|>']; + // DeepSeek thinking mode + if (this.modelParams && this.modelParams.thinking !== 'off') { + body.thinking = { type: this.modelParams.thinking }; + } + } + if (tools && tools.length > 0) { + body.tools = tools; + } + const t0 = Date.now(); + const response = await fetch(`${provider.endpoint}/v1/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal + }); + const networkTime = Math.round((Date.now() - t0) / 1000); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + this.terminal.log('INFO', 'Ollama', `chatWithTools round-trip: ${networkTime}s (model: ${useModel}, provider: ${provider.name})`); + this._logUsage(provider, useModel, networkTime * 1000, data.usage?.prompt_tokens, data.usage?.completion_tokens); + const choice = data.choices?.[0]; + const message = choice?.message || null; + const toolCalls = message?.tool_calls || null; + const content = message?.content || ''; + + // DeepSeek: handle null content with tool calls — send empty string + if (isDeepSeek && content === '' && toolCalls && toolCalls.length > 0) { + const normalised = toolCalls.map(tc => ({ + id: tc.id, + type: tc.type || 'function', + function: { + name: tc.function?.name || '', + arguments: tc.function?.arguments || '{}' + } + })); + return { + // tool_calls must live INSIDE the assistant message: the caller pushes + // result.message and then a role:'tool' reply, and a tool message whose + // tool_call_id has no matching assistant call is the HTTP 400 this + // branch exists to avoid. + message: { role: 'assistant', content: '', tool_calls: normalised }, + tool_calls: normalised, + error: null + }; + } + + // DeepSeek: empty response with no tools — retry once with stream forcing + if (isDeepSeek && content === '' && (!toolCalls || toolCalls.length === 0)) { + this.terminal.print('⚠️ DeepSeek returned empty — retrying with stream...', 'warning'); + body.stream = true; + delete body.stop; + this._abortController = new AbortController(); + const retrySignal = this._abortController.signal; + const retryResponse = await fetch(`${provider.endpoint}/v1/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: retrySignal + }); + if (retryResponse.ok) { + const reader = retryResponse.body.getReader(); + const decoder = new TextDecoder(); + let fullText = ''; + let retryBuffer = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + retryBuffer += decoder.decode(value, { stream: true }); + const lines = retryBuffer.split('\n'); + retryBuffer = lines.pop() || ''; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed === 'data: [DONE]') continue; + if (trimmed.startsWith('data: ')) { + try { + const parsed = JSON.parse(trimmed.substring(6)); + const chunk = parsed.choices?.[0]?.delta?.content || ''; + if (chunk) fullText += chunk; + } catch (e) {} + } + } + } + if (fullText) { + return { + message: { role: 'assistant', content: fullText }, + tool_calls: null, + error: null + }; + } + } + return { + message: { role: 'assistant', content: '' }, + tool_calls: null, + error: 'DeepSeek returned empty response after retry — model may be overloaded or context too long' + }; + } + + return { + message: message ? { + role: message.role || 'assistant', + content: content, + // OpenAI-compatible providers require tool_calls INSIDE the assistant message; + // a bare { role:'tool' } with no preceding tool_calls is rejected with HTTP 400. + ...(Array.isArray(toolCalls) && toolCalls.length ? { tool_calls: toolCalls } : {}), + } : null, + tool_calls: toolCalls ? toolCalls.map(tc => ({ + id: tc.id, + type: tc.type || 'function', + function: { + name: tc.function?.name || '', + arguments: tc.function?.arguments || '{}' + } + })) : null, + error: null + }; + } + } catch (error) { + if (error.name === 'AbortError') { + this.terminal.print('⏹ AI request cancelled by user', 'warning'); + return { message: null, tool_calls: null, error: 'Cancelled' }; + } + if (error.message === 'Failed to fetch' && provider.type === 'ollama') { + return await this.chatWithToolsFallback(messages, tools, useModel); + } + this.terminal.print(`${provider.name} /api/chat error: ${error.message}`, 'error'); + this._lastChatError = error.message || String(error); + return { message: null, tool_calls: null, error: error.message }; + } finally { + this._abortController = null; + } + } + + async chatWithToolsFallback(messages, tools, model, signal) { + const provider = this.currentProvider; + const useModel = model || (provider ? provider.model : this.currentModel); + const t0 = Date.now(); + if (provider && provider.type !== 'ollama') { + // For OpenAI-compatible providers, try without tools + this.terminal.print(`Model doesn't support tool calling. Retrying without tools...`, 'warning'); + try { + const headers = { 'Content-Type': 'application/json' }; + if (provider.apiKey) { + headers['Authorization'] = 'Bearer ' + provider.apiKey; + } + const body = { + model: useModel, + messages: messages, + stream: false + }; + const response = await fetch(`${provider.endpoint}/v1/chat/completions`, { + method: 'POST', headers, + body: JSON.stringify(body), + signal + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + const text = data.choices?.[0]?.message?.content || ''; + const latency = Date.now() - t0; + this._logUsage(provider, useModel, latency, data.usage?.prompt_tokens, data.usage?.completion_tokens); + return { message: { role: 'assistant', content: text }, tool_calls: null, error: null }; + } catch (e) { + if (e.name === 'AbortError') { + return { message: null, tool_calls: null, error: 'Cancelled' }; + } + return { message: null, tool_calls: null, error: e.message }; + } + } + + // MiniCPM5 XML tool calling mode + if (useModel && useModel.includes('minicpm5') && tools && tools.length > 0) { + this.terminal.print(`🧩 Using XML tool mode for ${useModel}...`, 'info'); + try { + const toolDesc = tools.map(t => JSON.stringify({type:'function',function:t.function})).join('\n'); + const toolContext = { role: 'system', content: +`You are a tool orchestrator. When you need to call a tool, output XML like: +<function name="function-name"><param name="param-name">param-value</param> + +Available tools: +<tools> +${toolDesc} +</tools> + +Only output XML function calls for tool tasks. If no tool is needed, answer normally.` }; + const msgsWithTools = [toolContext, ...messages]; + const body = { model: useModel, messages: msgsWithTools, stream: false, options: this._ollamaOptions({ temperature: 0.1 }) }; + + const response = await fetch(`${this.endpoint}/api/chat`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), signal + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + const text = (data.message?.content || '').trim(); + const latency = Date.now() - t0; + this._logUsage(provider, useModel, latency, data.prompt_eval_count, data.eval_count); + + // Parse XML function calls from the response + const toolCalls = []; + const funcRegex = /<function\s+name="([^"]+)"(.*?)<\/function>/gs; + let match; + while ((match = funcRegex.exec(text)) !== null) { + const funcName = match[1]; + const args = {}; + const paramRegex = /<param\s+name="([^"]+)"(.*?)>(.*?)<\/param>/gs; + let pmatch; + while ((pmatch = paramRegex.exec(match[0])) !== null) { + args[pmatch[1]] = pmatch[3] + .replace('<![CDATA[', '').replace(']]>', '') + .replace(/^\s+|\s+$/g, ''); + } + if (funcName && Object.keys(args).length > 0) { + toolCalls.push({ + id: 'call_' + Math.random().toString(36).slice(2, 10), + type: 'function', + function: { name: funcName, arguments: JSON.stringify(args) } + }); + } + } + if (toolCalls.length > 0) { + this.terminal.print(`✅ XML tool call(s) parsed from ${useModel}`, 'success'); + return { message: { role: 'assistant', content: '' }, tool_calls: toolCalls, error: null }; + } + + return { message: { role: 'assistant', content: text || '(no response)' }, tool_calls: null, error: null }; + } catch (e) { + if (e.name === 'AbortError') { + return { message: null, tool_calls: null, error: 'Cancelled' }; + } + return { message: null, tool_calls: null, error: e.message }; + } + } + + // Original Ollama fallback + this.terminal.print(`Model doesn't support tool calling. Retrying /api/chat without tools...`, 'warning'); + try { + const body = { model: useModel, messages, stream: false, options: this._ollamaOptions() }; + const response = await fetch(`${this.endpoint}/api/chat`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + const text = data.message?.content || data.response || ''; + const latency = Date.now() - t0; + this._logUsage(provider, useModel, latency, data.prompt_eval_count, data.eval_count); + return { message: { role: 'assistant', content: text }, tool_calls: null, error: null }; + } catch (e) { + if (e.name === 'AbortError') { + return { message: null, tool_calls: null, error: 'Cancelled' }; + } + return { message: null, tool_calls: null, error: e.message }; + } + } + + // ─── Usage Tracking ────────────────────────────────────────────────────── + + _logUsage(provider, model, latencyMs, promptTokens, completionTokens) { + const entry = { + ts: Date.now(), + provider: provider?.name || 'unknown', + model: model || 'unknown', + latency: Math.round(latencyMs || 0), + prompt_tokens: promptTokens || 0, + completion_tokens: completionTokens || 0, + total_tokens: (promptTokens || 0) + (completionTokens || 0) + }; + this._usageBuffer.push(entry); + this._debouncedFlush(); + } + + _debouncedFlush() { + if (this._usageFlushTimer) clearTimeout(this._usageFlushTimer); + if (this._usageBuffer.length >= 5) { + this._flushUsageLog(); + } else { + this._usageFlushTimer = setTimeout(() => this._flushUsageLog(), 2000); + } + } + + _flushUsageLog() { + if (this._usageBuffer.length === 0) return; + this._usageLog.push(...this._usageBuffer); + this._usageBuffer = []; + if (this._usageLog.length > 500) { + this._usageLog = this._usageLog.slice(-500); + } + this._saveUsageLog(); + } + + _loadUsageLog() { + try { + const raw = localStorage.getItem('webx11_ollama_usage_log'); + this._usageLog = raw ? JSON.parse(raw) : []; + } catch (e) { + this._usageLog = []; + } + } + + _saveUsageLog() { + try { + localStorage.setItem('webx11_ollama_usage_log', JSON.stringify(this._usageLog)); + } catch (e) { + // localStorage quota exceeded — trim to 250 + this._usageLog = this._usageLog.slice(-250); + try { + localStorage.setItem('webx11_ollama_usage_log', JSON.stringify(this._usageLog)); + } catch (e2) {} + } + } + + getUsageStats(filters = {}) { + let log = this._usageLog; + if (filters.model) { + log = log.filter(e => e.model === filters.model); + } + if (filters.provider) { + log = log.filter(e => e.provider === filters.provider); + } + if (filters.since_hours) { + const cutoff = Date.now() - (filters.since_hours * 3600 * 1000); + log = log.filter(e => e.ts >= cutoff); + } + const total = log.length; + const totalPrompt = log.reduce((s, e) => s + e.prompt_tokens, 0); + const totalCompletion = log.reduce((s, e) => s + e.completion_tokens, 0); + const totalLatency = log.reduce((s, e) => s + e.latency, 0); + const avgLatency = total > 0 ? Math.round(totalLatency / total) : 0; + // Per-model breakdown + const byModel = {}; + for (const e of log) { + if (!byModel[e.model]) byModel[e.model] = { requests: 0, tokens: 0 }; + byModel[e.model].requests++; + byModel[e.model].tokens += e.total_tokens; + } + // Per-provider breakdown + const byProvider = {}; + for (const e of log) { + if (!byProvider[e.provider]) byProvider[e.provider] = { requests: 0, tokens: 0 }; + byProvider[e.provider].requests++; + byProvider[e.provider].tokens += e.total_tokens; + } + // Time span + const timeSpan = total > 1 + ? Math.round((log[log.length - 1].ts - log[0].ts) / 1000) + : 0; + return { total, totalPrompt, totalCompletion, totalLatency, avgLatency, timeSpan, byModel, byProvider }; + } + + // ─── Internal helpers ──────────────────────────────────────────────────── + + _getFallbackChain() { + const sorted = this._getSortedEnabledProviders(); + if (sorted.length === 0) return []; + + // Start with the current provider (if any), then all others sorted by priority + const chain = []; + const seen = new Set(); + + if (this.currentProvider && this.currentProvider.enabled) { + chain.push(this.currentProvider); + seen.add(this.currentProvider.name); + } + + for (const p of sorted) { + if (!seen.has(p.name)) { + chain.push(p); + seen.add(p.name); + } + } + + return chain; + } + + // ─── Backward-compat methods ───────────────────────────────────────────── + + setEndpoint(url) { + this.endpoint = url; + // Also update the current provider's endpoint if it exists + if (this.currentProvider) { + this.currentProvider.endpoint = url; + this._saveProviders(); + } + this.saveSettings(); + } + + setModel(name) { + this.currentModel = name; + // Also update the current provider's model + if (this.currentProvider) { + this.currentProvider.model = name; + this._saveProviders(); + } + this.saveSettings(); + this.terminal.print(`Model set to: ${name}`, 'success'); + } + + saveSettings() { + localStorage.setItem('webx11_ollama_endpoint', this.endpoint); + localStorage.setItem('webx11_ollama_model', this.currentModel); + localStorage.setItem('webx11_optimization_enabled', this.optimizationEnabled ? 'true' : 'false'); + localStorage.setItem('webx11_stream_enabled', this.streamEnabled ? 'true' : 'false'); + localStorage.setItem('webx11_validator_enabled', this.validatorEnabled ? 'true' : 'false'); + localStorage.setItem('webx11_validator_model', this.validatorModel); + localStorage.setItem('webx11_validator_endpoint', this.validatorEndpoint || ''); + } + + setOptimizationEnabled(val) { + this.optimizationEnabled = !!val; + this.terminal?.print(`Agent optimization ${this.optimizationEnabled ? 'enabled' : 'disabled'}. Reload to apply.`, 'info'); + this.saveSettings(); + } + + setStreamEnabled(val) { + this.streamEnabled = !!val; + this.terminal?.print(`Response streaming ${this.streamEnabled ? 'enabled' : 'disabled'}.`, 'info'); + this.terminal?.updateStreamIndicator(); + this.saveSettings(); + } + + setValidatorConfig(enabled, model) { + this.validatorEnabled = !!enabled; + if (model !== undefined) { + this.validatorModel = model; + localStorage.setItem('webx11_validator_model', model); + } + this.saveSettings(); + } + + async validateToolResult(toolName, args, result, aiAnswer) { + if (!this.validatorEnabled || !this.validatorModel) return true; + const endpoint = this.validatorEndpoint || this.endpoint; + const prompt = 'Tool called: ' + toolName + '(' + JSON.stringify(args) + ')\n' + + 'Tool returned: ' + ((result || '').substring(0, 500)) + '\n' + + 'AI answered: ' + ((aiAnswer || '').substring(0, 500)) + '\n' + + 'QUESTION: Is the AI answer consistent with the tool result?\nAnswer only YES or NO.'; + try { + const res = await fetch(endpoint + '/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: this.validatorModel, messages: [{ role: 'user', content: prompt }], stream: false }) + }); + const data = await res.json(); + const answer = (data.message?.content || '').trim().toUpperCase(); + return answer.startsWith('YES'); + } catch (e) { + if (this.terminal) this.terminal.print('⚠️ Validator error: ' + e.message, 'warning'); + return true; + } + } + + getModels() { return this.models; } + + addResponseHook(fn) { this.responseHooks.push(fn); } + + removeResponseHook(fn) { this.responseHooks = this.responseHooks.filter(h => h !== fn); } + + fireResponseHooks(text) { if (text) this.responseHooks.forEach(fn => { try { fn(text); } catch (e) {} }); } + + abort() { + if (this._abortController) { + this._abortController.abort(); + this._abortController = null; + return true; + } + return false; + } +} |