aboutsummaryrefslogtreecommitdiff
path: root/.bkp-fix-20261028_165025
diff options
context:
space:
mode:
Diffstat (limited to '.bkp-fix-20261028_165025')
-rw-r--r--.bkp-fix-20261028_165025/main.html1484
-rw-r--r--.bkp-fix-20261028_165025/media.js508
-rw-r--r--.bkp-fix-20261028_165025/ollama-manager.js1355
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;
+ }
+}