window.aiAssistantTools = window.aiAssistantTools || [];
window.aiAssistantTools = [
    {
      "type": "function",
      "function": {
        "name": "page",
        "description": "Run JavaScript in the live page iframe to read or change page content and structure. DOM nodes come back as [{html, selector}];",
        "parameters": {
          "type": "object",
          "required": [
            "code"
          ],
          "properties": {
            "code": {
              "type": "string",
              "description": "JavaScript code to evaluate in the page"
            }
          }
        }
      }
    },
    {
      "type": "function",
      "function": {
        "name": "ui",
        "description": "Run JavaScript in the builder application window (admin UI), NOT the page: here `document` is the builder UI and `Vvveb.*` is available. Use ONLY for application control - saving, selecting, loading pages and API discovery via inspect(Vvveb.*) - never for page content edits (use the page tool for those). Examples: Vvveb.Gui.save() to save the page, inspect(Vvveb.Builder) to list builder APIs. DOM nodes come back as [{html, selector}]",
        "parameters": {
          "type": "object",
          "required": [
            "code"
          ],
          "properties": {
            "code": {
              "type": "string",
              "description": "JavaScript code to evaluate in the builder window"
            }
          }
        }
      }
    },
];

(function(){
  // the editor page must load this file exactly once (the plugin injects the
  // versioned copy - no static tag in the template). Guard anyway: a second
  // run would bind every handler twice and toggles would cancel themselves out.
  if (window.__vvAiSidebarInit) return;
  window.__vvAiSidebarInit = true;
  const thread        = document.getElementById('vvThread');
  const emptyState    = document.getElementById('vvEmptyState');
  const textInput     = document.getElementById('vvTextInput');
  const sendBtn       = document.getElementById('vvSendBtn');
  const attachBtn     = document.getElementById('vvAttachBtn');
  const fileInput     = document.getElementById('vvFileInput');
  const stagedWrap    = document.getElementById('vvStaged');
  const micBtn        = document.getElementById('vvMicBtn');
  const recLive        = document.getElementById('vvRecLive');
  const hintText       = document.getElementById('vvHintText');
  const dropZone        = document.getElementById('vvDropZone');
  const contextMenu     = document.getElementById('vvContextMenu');
  const scopeIcon       = document.getElementById('vvScopeIcon');
  const scopeLabelEl    = document.getElementById('vvScopeLabel');
  const clearBtn        = document.getElementById('vvClearBtn');
  const cancelBtnEl     = document.getElementById('vvCancelBtn');
  const suggestionBtns  = document.querySelectorAll('.vv-suggestion-btn');
  const modelSelect     = document.getElementById('vvModelSelect');
  const thinkingSelect  = document.getElementById('vvThinkingSelect');

  // scroll tolerance (px): the thread only auto-follows the live tail while
  // the user is already near the bottom, so reading history above is never
  // yanked away by streaming updates. Pass force=true for explicit jumps
  // (e.g. session switch renders).
  const VV_SCROLL_TOLERANCE = 48;
  function vvNearBottom(){
    try {
      return (thread.scrollHeight - thread.scrollTop - thread.clientHeight) <= VV_SCROLL_TOLERANCE;
    } catch(e){ return true; }
  }
  function vvAutoscroll(force){
    try { if (force || vvNearBottom()) thread.scrollTop = thread.scrollHeight; } catch(e){}
  }
  // local HTML escaper (mirrors VvvebAi.escapeHtml) for paths that must work
  // even when ai-core failed to load
  function escHtml(s){
    return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  }

  function vvStoreGet(key){ try { return localStorage.getItem(key); } catch(e){ return null; } }
  function vvStoreSet(key, value){ try { localStorage.setItem(key, value); } catch(e){} }
  // restore saved preferences
  const savedModel = vvStoreGet('vv-ai-model');
  if (savedModel && modelSelect) modelSelect.value = savedModel;
  const savedThinking = vvStoreGet('vv-ai-thinking');
  if (savedThinking && thinkingSelect) thinkingSelect.value = savedThinking;
  if (modelSelect) modelSelect.addEventListener('change', () => vvStoreSet('vv-ai-model', modelSelect.value));
  if (thinkingSelect) thinkingSelect.addEventListener('change', () => vvStoreSet('vv-ai-thinking', thinkingSelect.value));
  const modelRefreshBtn = document.getElementById('vvModelRefresh');
  if (modelRefreshBtn && modelSelect){
    modelRefreshBtn.addEventListener('click', async () => {
      const orig = modelRefreshBtn.innerHTML;
      modelRefreshBtn.disabled = true;
      modelRefreshBtn.innerHTML = '<i class="la la-spinner la-spin" style="font-size:14px"></i>';
      try {
        const opts = window.VvvebAi ? VvvebAi.options() : {};
        const provider = (opts.provider || 'openai').toLowerCase();
        const csrf = opts.csrf || document.getElementById('csrf')?.value || '';
        const proxyUrl = opts.proxyUrl || '../../../index.php?module=plugins/ai-assistant/proxy';
        const url = proxyUrl + (proxyUrl.includes('?') ? '&' : '?') + 'action=models';
        const res = await fetch(url, {
          method: 'POST',
          headers: {'Content-Type':'application/json'},
          body: JSON.stringify({provider, csrf, key: (window.VvvebAi ? (VvvebAi.options().key || VvvebAi.options()[provider + '_key'] || '') : '')})
        });
        const data = await res.json();
        if (!res.ok) throw new Error(data.error && data.error.message ? data.error.message : (data.error || 'Failed'));
        // proxy answers HTTP 200 with {error, data: fallback} when upstream fails -
        // still populate the fallback list but warn instead of failing silently
        const modelsError = data.error && data.error.message ? data.error.message : (typeof data.error === 'string' ? data.error : '');
        let models = data.data || data.models || [];
        if (models.length && typeof models[0] === 'object') models = models.map(m => m.id || m.name || String(m));
        if (!models.length) throw new Error('No models');
        const cur = modelSelect.value;
        const autoOpt = modelSelect.querySelector('option[value=""]');
        modelSelect.innerHTML = '';
        if (autoOpt) modelSelect.appendChild(autoOpt); else { const o=document.createElement('option'); o.value=""; o.textContent="Auto (settings)"; modelSelect.appendChild(o); }
        models.forEach(m => { const o=document.createElement('option'); o.value=m; o.textContent=m; modelSelect.appendChild(o); });
        if (cur && models.includes(cur)) modelSelect.value = cur;
        else if (cur){ const o=document.createElement('option'); o.value=cur; o.textContent=cur+' (custom)'; modelSelect.appendChild(o); modelSelect.value=cur; }
        if (window.displayToast) displayToast(modelsError ? 'bg-warning' : 'bg-success', modelsError ? 'Warning' : 'Success', (modelsError ? (modelsError + ' - showing fallback list. ') : '') + 'Loaded '+models.length+' models for '+provider);
      } catch(e){
        console.error(e);
        if (window.displayToast) displayToast('bg-danger','Error','Failed to refresh models: '+(e.message||String(e)));
      } finally {
        modelRefreshBtn.disabled = false;
        modelRefreshBtn.innerHTML = orig;
      }
    });
  }

  let stagedFiles = [];
  let activeScope = 'section';
  let isRecording = false;
  let isThinking  = false;
  const queueWrap = document.getElementById('vvQueue');
  let workingWrap = document.getElementById('vvWorking');
  let workingQueue = document.getElementById('vvWorkingQueue');
  let messageQueue = [];
  let queueSeq = 0;


	// Circular-safe JSON for tool results. The set is per-call (a shared one
	// would misreport re-sent objects as circular) and arrays are preserved.
	function safeStringify(value){
		const seen = new WeakSet();
		return JSON.stringify(value, (key, val) => {
			if (typeof val === 'object' && val !== null){
				if (seen.has(val)) return '[Circular]';
				seen.add(val);
			}
			if (typeof val === 'function') return 'function ' + (val.name || '(anonymous)');
			return val;
		});
	}

  /* ---------- context scope dropdown (bootstrap dropdown-menu) ---------- */
  const scopeIcons = {
    element: '<rect x="4" y="4" width="16" height="16" rx="2"/>',
    section: '<path d="M3 6h18M3 12h18M3 18h18"/>',
    page:    '<path d="M4 4h16v16H4z"/>',
    site:    '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.7 4 6.1 4 9s-1.5 6.3-4 9c-2.5-2.7-4-6.1-4-9s1.5-6.3 4-9z"/>'
  };
  function updateScopeIcon(){
    scopeIcon.innerHTML = scopeIcons[activeScope] || scopeIcons.section;
    if (scopeLabelEl) scopeLabelEl.textContent = scopeLabel();
  }
  contextMenu.addEventListener('click', (e) => {
    const item = e.target.closest('.dropdown-item');
    if (!item) return;
    activeScope = item.dataset.scope;
    //scopeLabelEl.textContent = item.dataset.label;
    contextMenu.querySelectorAll('.dropdown-item').forEach(el => el.classList.remove('active'));
    item.classList.add('active');
    updateScopeIcon();
  });
  updateScopeIcon();

  /* ---------- textarea autosize + enable/disable send ---------- */
  function autosize(){
    textInput.style.height = 'auto';
    textInput.style.height = Math.min(Math.max(textInput.scrollHeight, 30), 120) + 'px';
    sendBtn.disabled = textInput.value.trim().length === 0 && stagedFiles.length === 0;
  }
  textInput.addEventListener('input', function(){
    autosize();
    // if user types while navigating history, reset navigation (but keep draft)
    if (historyIndex !== -1){
      const cur = getUserMessageAt(historyIndex);
      const curText = cur ? messageText(cur) : "";
      if (textInput.value !== curText && textInput.value !== draftText) {
        historyIndex = -1;
      }
    }
  });
  // history navigation via messages object (no duplicate storage) - iterate over VvvebAi.history directly
  let historyIndex = -1;
  let draftText = "";
  function countUserMessages(){
    const hist = (window.VvvebAi && VvvebAi.history) || [];
    let c = 0;
    for (let i = 0; i < hist.length; i++) if (hist[i] && hist[i].role === 'user') c++;
    return c;
  }
  function getUserMessageAt(reverseIdx){
    const hist = (window.VvvebAi && VvvebAi.history) || [];
    let seen = -1;
    for (let i = hist.length - 1; i >= 0; i--){
      const m = hist[i];
      if (m && m.role === 'user'){
        seen++;
        if (seen === reverseIdx) return m;
      }
    }
    return null;
  }
  function messageText(msg){
    if (!msg || !msg.content) return "";
    let t = "";
    for (let i = 0; i < msg.content.length; i++){
      const b = msg.content[i];
      if (b && b.type === 'text' && typeof b.text === 'string') t += (t ? "\n" : "") + b.text;
    }
    if (t.startsWith("[context]")){
      let lines = t.split("\n");
      for (let i = lines.length -1; i >=0; i--){
        if (!lines[i].startsWith("[context]") && !lines[i].startsWith("[attachment]")) { t = lines[i]; break; }
        if (i===0) t = "";
      }
    }
    return t;
  }
  textInput.addEventListener('keydown', (e) => {
    if (e.key === 'Enter' && !e.shiftKey && !e.isComposing){
      e.preventDefault();
      historyIndex = -1;
      draftText = "";
      sendMessage();
      return;
    }
    if (e.key === 'ArrowUp' || e.key === 'ArrowDown'){
      const atStart = textInput.selectionStart === 0 && textInput.selectionEnd === 0;
      const isEmpty = textInput.value.trim() === "";
      const total = countUserMessages();
      if (!total) return;
      if (!isEmpty && !atStart && e.key === 'ArrowUp' && textInput.value.includes("\n")) return;
      if (e.key === 'ArrowUp'){
        e.preventDefault();
        if (historyIndex === -1) draftText = textInput.value;
        historyIndex = Math.min(historyIndex + 1, total - 1);
        const msg = getUserMessageAt(historyIndex);
        if (msg){
          textInput.value = messageText(msg);
          autosize();
          // move caret to end
          textInput.selectionStart = textInput.selectionEnd = textInput.value.length;
        }
      } else if (e.key === 'ArrowDown'){
        e.preventDefault();
        if (historyIndex === -1) return;
        historyIndex = historyIndex - 1;
        if (historyIndex === -1){
          textInput.value = draftText;
        } else {
          const msg = getUserMessageAt(historyIndex);
          textInput.value = msg ? messageText(msg) : draftText;
        }
        autosize();
        textInput.selectionStart = textInput.selectionEnd = textInput.value.length;
      }
    }
  });

  /* ---------- attachments: button + drag/drop ---------- */
  attachBtn.addEventListener('click', () => fileInput.click());
  fileInput.addEventListener('change', () => {
    stageFiles(fileInput.files);
    fileInput.value = '';
  });

  ['dragenter','dragover'].forEach(evt =>
    dropZone.addEventListener(evt, (e) => { e.preventDefault(); dropZone.classList.add('vv-dragover'); })
  );
  ['dragleave','drop'].forEach(evt =>
    dropZone.addEventListener(evt, (e) => { e.preventDefault(); dropZone.classList.remove('vv-dragover'); })
  );
  dropZone.addEventListener('drop', (e) => {
    if (e.dataTransfer.files.length) stageFiles(e.dataTransfer.files);
  });

  function stageFiles(fileList){
    Array.from(fileList).forEach(file => {
      const id = 'f' + Math.random().toString(36).slice(2, 9);
      const entry = { id, file, isImage: file.type.startsWith('image/') };
      if (entry.isImage) entry.url = URL.createObjectURL(file);
      stagedFiles.push(entry);
    });
    renderStaged();
    autosize();
  }

  function removeStaged(id){
    const entry = stagedFiles.find(f => f.id === id);
    if (entry && entry.url){ try { URL.revokeObjectURL(entry.url); } catch(e){} }
    stagedFiles = stagedFiles.filter(f => f.id !== id);
    renderStaged();
    autosize();
  }

  function renderStaged(){
    stagedWrap.innerHTML = '';
    stagedFiles.forEach(entry => {
      const chip = document.createElement('div');
      chip.className = 'vv-staged-chip';
      chip.innerHTML = entry.isImage
        ? `<img src="${entry.url}" alt="">`
        : `<span class="vv-file-ico">
             <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>
           </span>`;
      const label = document.createElement('span');
      label.textContent = entry.file.name;
      chip.appendChild(label);
      const rm = document.createElement('button');
      rm.type = 'button';
      rm.setAttribute('aria-label', 'Remove attachment');
      rm.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg>`;
      rm.addEventListener('click', () => removeStaged(entry.id));
      chip.appendChild(rm);
      stagedWrap.appendChild(chip);
    });
  }

  /* ---------- voice input (Web Speech API, graceful fallback) ---------- */
  const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
  let recognizer = null;
  if (SpeechRecognition){
    recognizer = new SpeechRecognition();
    recognizer.continuous = true;
    recognizer.interimResults = true;
    recognizer.onresult = (e) => {
      let transcript = '';
      for (let i = 0; i < e.results.length; i++) transcript += e.results[i][0].transcript;
      textInput.value = transcript;
      autosize();
    };
    recognizer.onend = () => stopRecording();
    recognizer.onerror = () => stopRecording();
  }

  function startRecording(){
    if (!recognizer) {
      hintText.textContent = 'Voice input not supported in this browser';
      setTimeout(() => hintText.textContent = 'Enter to send · Shift+Enter for newline', 2500);
      return;
    }
    isRecording = true;
    micBtn.classList.add('vv-recording');
    recLive.classList.add('on');
    recognizer.start();
  }
  function stopRecording(){
    isRecording = false;
    micBtn.classList.remove('vv-recording');
    recLive.classList.remove('on');
    if (recognizer) { try { recognizer.stop(); } catch(_){} }
    autosize();
  }
  micBtn.addEventListener('click', () => isRecording ? stopRecording() : startRecording());

  /* ---------- clear conversation ---------- */
  if (clearBtn) clearBtn.addEventListener('click', () => {
    thread.querySelectorAll('.vv-msg, .vv-thinking').forEach(el => el.remove());
    stagedFiles = [];
    renderStaged();
    messageQueue = [];
    renderQueue();
    emptyState.style.display = '';
    historyIndex = -1;
    draftText = "";
    if (window.VvvebAi && VvvebAi.clearHistory) VvvebAi.clearHistory();
  });
  
  suggestionBtns.forEach(btn => {
    btn.addEventListener('click', () => {
      textInput.value = btn.dataset.fill;
      autosize();
      textInput.focus();
    });
  });

  /* ---------- sessions (localStorage) ---------- */
  const sessionsBtn = document.getElementById('vvSessionsBtn');
  const sessionsMenu = document.getElementById('vvSessionsMenu');
  const newSessionBtn = document.getElementById('vvNewSessionBtn');
  function formatSessionDate(ts){
    try { return new Date(ts).toLocaleString([], {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'}); } catch(e){ return ''; }
  }
  function renderSessions(){
    if (!sessionsMenu) return;
    // keep first two items (New session + divider), remove rest
    const keep = 2;
    while (sessionsMenu.children.length > keep) sessionsMenu.removeChild(sessionsMenu.lastChild);
    const sessions = (window.VvvebAi && VvvebAi.listSessions) ? VvvebAi.listSessions() : [];
    // iterate directly over sessions object to save memory (no copy)
    if (!sessions.length){
      const li = document.createElement('li');
      li.className = 'vv-sessions-empty text-muted small px-3 py-2';
      li.textContent = 'No previous sessions';
      sessionsMenu.appendChild(li);
      return;
    }
    // remove empty placeholder if exists
    const empty = sessionsMenu.querySelector('.vv-sessions-empty');
    if (empty) empty.remove();
    for (let i = 0; i < sessions.length; i++){
      const s = sessions[i];
      if (!s || !s.id) continue;
      const li = document.createElement('li');
      li.style.display = 'flex';
      li.style.alignItems = 'center';
      const btn = document.createElement('button');
      btn.type = 'button';
      btn.className = 'dropdown-item text-truncate';
      btn.style.flex = '1';
      btn.style.maxWidth = '280px';
      btn.title = s.title || 'Untitled';
      // show title and date, iterate over messages to get title without extra allocation is already done in Ai._titleForHistory
      btn.innerHTML = '<span style="display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + (window.VvvebAi ? VvvebAi.escapeHtml(s.title || 'Untitled') : escHtml(s.title || 'Untitled')) + '</span><small class="text-muted" style="font-size:.80em">' + formatSessionDate(s.updated) + ' · ' + (s.messages ? s.messages.length : (s.message_count || 0)) + ' msgs</small>';
      btn.addEventListener('click', () => {
        loadSession(s.id);
      });
      const del = document.createElement('button');
      del.type = 'button';
      del.className = 'btn btn-sm';
      del.style.padding = '.15rem .3rem';
      del.style.lineHeight = '0';
      del.style.color = 'var(--vv-text-dim)';
      del.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M18 6 6 18M6 6l12 12"/></svg>';
      del.title = 'Delete session';
      del.addEventListener('click', (e) => {
        e.stopPropagation();
        if (window.VvvebAi && VvvebAi.deleteSession) VvvebAi.deleteSession(s.id);
        renderSessions();
      });
      li.appendChild(btn);
      li.appendChild(del);
      sessionsMenu.appendChild(li);
    }
  }
  function renderHistoryToThread(){
    thread.querySelectorAll('.vv-msg, .vv-thinking').forEach(el => el.remove());
    const hist = (window.VvvebAi && VvvebAi.history) || [];
    if (!hist.length){
      emptyState.style.display = '';
      return;
    }
    emptyState.style.display = 'none';
    for (let i = 0; i < hist.length; i++){
      const m = hist[i];
      if (!m || !m.content) continue;
      let text = "";
      let thinking = "";
      let hasTool = false;
      for (let j = 0; j < m.content.length; j++){
        const b = m.content[j];
        if (b.type === 'text') text += (text ? "\n" : "") + b.text;
        else if (b.type === 'thinking') thinking += (thinking ? "\n" : "") + b.text;
        else if (b.type === 'tool_call') hasTool = true;
      }
      // strip context prefix for display
      if (text.startsWith("[context]")) text = text.split("\n").filter(l => !l.startsWith("[context]")).join("\n");
      if (!text.trim() && !thinking.trim() && !hasTool) continue;
      const role = m.role === 'user' ? 'user' : 'assistant';
      const wrap = appendMessage(role, text, []);
      // thinking blocks get their own node (hidden unless show-thinking is on)
      if (thinking.trim()){
        let cleanThink = thinking;
        if (window.VvvebAi && VvvebAi.stripToolCallTags) cleanThink = VvvebAi.stripToolCallTags(cleanThink);
        if (window.VvvebAi && VvvebAi.stripThinkingTags) cleanThink = VvvebAi.stripThinkingTags(cleanThink);
        if (cleanThink.trim()) wrap.prepend(aiCreateThinkingNode(cleanThink));
      }
      if (!text.trim()){
        // thinking/tool-only message: drop the empty bubble, mark the wrap
        // like the live thinking-only path does (vv-thinking-msg)
        const bub = wrap.querySelector('.vv-bubble');
        if (bub) bub.remove();
        if (thinking.trim()) wrap.classList.add('vv-thinking-msg');
      }
      // if assistant and markdown, re-render bubble with markdown
      else if (role === 'assistant' && window.VvvebAi && VvvebAi.markdownToHtml){
        const bub = wrap.querySelector('.vv-bubble');
        if (bub) bub.innerHTML = VvvebAi.markdownToHtml(text);
      }
      // if assistant had tool calls, render them
      if ((m.role === 'assistant' && hasTool)){
        for (let j = 0; j < m.content.length; j++){
          const b = m.content[j];
          if (b.type === 'tool_call') vvUpsertToolCallGlobal(wrap, b);
        }
      }
      
	  // attach following tool result(s) to their tool-call node, matched by id so
	  // multi-tool turns survive session render instead of piling into one node
	  let next = (i + 1 < hist.length) ? hist[i+1] : {};
      while (next && (next.role === 'tool')) {
        const txt = (next.content || []).filter(b => b && b.type === 'text').map(b => b.text).join("\n");
        let node = null;
        if (next.toolCallId) node = wrap.querySelector('[data-tool-id="' + vvEscape(next.toolCallId) + '"]');
        if (!node && next.name) node = wrap.querySelector('[data-tool-id="' + vvEscape(next.name) + '"]');
        const toolResult = (node && node.querySelector('.vv-tool-result')) || wrap.querySelector('.vv-tool-result');
        if (toolResult && txt.trim()){ toolResult.textContent = txt.slice(0, 4000); toolResult.style.display = ""; }
		i++;
        next = (i + 1 < hist.length) ? hist[i+1] : {};
      }
    }
    vvAutoscroll(true);
  }
  function loadSession(id){
    if (!(window.VvvebAi && VvvebAi.loadSession)) return;
    try {
      // stop any in-flight run so its late completion can't refill the loaded session
      if (window.VvvebAi.cancelCurrent) VvvebAi.cancelCurrent();
      const sess = VvvebAi.loadSession(id);
      if (sess){
        messageQueue = [];
        renderQueue();
        renderHistoryToThread();
        hideThinking();
        releaseRun();
        historyIndex = -1;
        draftText = "";
        // close dropdown
        if (sessionsBtn) {
          try { bootstrap.Dropdown.getInstance(sessionsBtn)?.hide(); } catch(e){}
        }
      }
    } catch(e){ console.error(e); toast("bg-danger", "Error", e.message || String(e)); }
  }
  function startNewSession(){
    try {
      // stop any in-flight run so its late completion can't refill the new session
      if (window.VvvebAi && VvvebAi.cancelCurrent) VvvebAi.cancelCurrent();
      messageQueue = [];
      renderQueue();
      if (window.VvvebAi && VvvebAi.history && VvvebAi.history.length) VvvebAi.saveCurrentSession();
      if (window.VvvebAi && VvvebAi.newSession) VvvebAi.newSession();
      thread.querySelectorAll('.vv-msg, .vv-thinking').forEach(el => el.remove());
      hideThinking();
      releaseRun();
      emptyState.style.display = '';
      historyIndex = -1;
      draftText = "";
      renderSessions();
      if (sessionsBtn) { try { bootstrap.Dropdown.getInstance(sessionsBtn)?.hide(); } catch(e){} }
    } catch(e){ console.error(e); toast("bg-danger", "Error", e.message || String(e)); }
  }
  if (newSessionBtn) newSessionBtn.addEventListener('click', startNewSession);
  if (sessionsBtn) sessionsBtn.addEventListener('click', renderSessions);
  if (sessionsBtn) sessionsBtn.addEventListener('show.bs.dropdown', renderSessions);
  // session select dropdown below input (also iterates over messages object)
  const sessionSelect = document.getElementById('vvSessionSelect');
  const newSessionBtn2 = document.getElementById('vvNewSessionBtn2');
  function renderSessionSelect(){
    if (!sessionSelect) return;
    const curId = (window.VvvebAi && VvvebAi.currentSessionId) || '';
    while (sessionSelect.children.length > 1) sessionSelect.removeChild(sessionSelect.lastChild);
    const sessions = (window.VvvebAi && VvvebAi.listSessions) ? VvvebAi.listSessions() : [];
    for (let i = 0; i < sessions.length; i++){
      const s = sessions[i];
      if (!s || !s.id) continue;
      const opt = document.createElement('option');
      opt.value = s.id;
      opt.textContent = (s.title || 'Untitled').slice(0,40) + ' · ' + formatSessionDate(s.updated);
      if (s.id === curId) opt.selected = true;
      sessionSelect.appendChild(opt);
    }
    sessionSelect.value = curId || '';
  }
  if (sessionSelect){
    sessionSelect.addEventListener('change', () => {
      const id = sessionSelect.value;
      if (!id) startNewSession();
      else loadSession(id);
      renderSessionSelect();
    });
    if (newSessionBtn2) newSessionBtn2.addEventListener('click', () => { startNewSession(); renderSessionSelect(); });
    renderSessionSelect();
    // wrap renderSessions to also update select
    const _origRenderSessions = renderSessions;
    renderSessions = function(){ _origRenderSessions(); renderSessionSelect(); };
  }
  
  // initial render from loaded session (if any)
  document.addEventListener('DOMContentLoaded', () => {
	  if (window.VvvebAi && VvvebAi.history && VvvebAi.history.length){
		renderHistoryToThread();
		renderSessions();
		if (sessionSelect) renderSessionSelect();
	  }
	});

  /* ---------- wired to real AI backend (plugins/ai-assistant/js/ai-core.js via VvvebAi) ---------- */
  function scopeLabel(){
    return { element: 'selected element', section: 'current section', page: 'whole page', site: 'entire site' }[activeScope];
  }
  function aiPageContext(){
	const params = new URLSearchParams(window.location.search);
	let url = window.location.pathname + window.location.search;
	let ctx = { name: "page", value: { url, title: document.title, scope: activeScope } };
	//page builder
	if (params.get['module'] == 'editor/editor') {
		let el = window.Vvveb?.Builder?.selectedEl;
		if (el && el.tagName) {
		  ctx.value.selectedElement = el.tagName.toLowerCase() + (el.className ? "." + String(el.className).trim().split(/\s+/).join(".") : "");
		  try { ctx.value.selectedHtml = (el.outerHTML || "").slice(0, 4000); } catch(_){}
		}
		// include nearby section html when scope is section
		if (activeScope === 'section' && el) {
		  let sec = el.closest ? el.closest('section, [data-v-component], .section, main, [class*="section"]') : null;
		  if (sec && sec !== el) { try { ctx.value.sectionHtml = (sec.outerHTML || "").slice(0, 6000); } catch(_){} }
		}
		if (activeScope === 'page') {
		  try { ctx.value.pageHtml = document.documentElement.outerHTML.slice(0, 8000); } catch(_){}
		}
	} else {
		
	}
	
    return ctx;
  }
  function toast(type, title, msg){
    if (window.displayToast) displayToast(type, title, msg);
    else console.warn(title + ": " + msg);
  }
  function vvToolId(call){ return (call && (call.id || call.name)) || 'tool'; }
  function vvEscape(s){ try{ return CSS.escape(String(s)); }catch(e){ return String(s).replace(/[^a-zA-Z0-9_-]/g,'\\$&'); } }
  // Strip markdown ```js exec blocks + tool-call tags from model text
  function stripExecBlocks(text){
    let out = String(text || "").replace(/```(?:js|javascript)?\n[\s\S]*?```/gi, '').trim();
    if (window.VvvebAi && VvvebAi.stripToolCallTags) out = VvvebAi.stripToolCallTags(out);
    return out;
  }
  // Release a stuck run state (busy flag + pill); idempotent
  function releaseRun(){
    isThinking = false;
    hideThinking();
    updateWorking();
  }
  // True when the user switched sessions (or started a new one) after this
  // run's payload was built - its late completion must not touch history/ui
  function sessionChanged(payload){
    return ((window.VvvebAi && VvvebAi.currentSessionId) || null) !== ((payload && payload._sessionId) || null);
  }

  function renderQueue(){
    if (!queueWrap) return;
    queueWrap.innerHTML = '';
    if (!messageQueue.length){ queueWrap.style.display = 'none'; return; }
    queueWrap.style.display = '';
    messageQueue.forEach((item, idx) => {
      const el = document.createElement('div');
      el.className = 'vv-queue-item';
      const num = document.createElement('span');
      num.className = 'vv-queue-num';
      num.textContent = String(idx + 1);
      const label = document.createElement('span');
      label.className = 'vv-queue-text';
      label.textContent = item.text || '(attachment)';
      label.title = item.text || '';
      el.appendChild(num);
      el.appendChild(label);
      if (item.files && item.files.length){
        const att = document.createElement('span');
        att.className = 'vv-queue-files';
        att.textContent = '\uD83D\uDCCE\u00D7' + item.files.length;
        el.appendChild(att);
      }
      const del = document.createElement('button');
      del.type = 'button';
      del.className = 'vv-queue-del';
      del.title = 'Remove from queue';
      del.setAttribute('aria-label', 'Remove queued message');
      del.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg>';
      del.addEventListener('click', () => {
        messageQueue = messageQueue.filter(q => q.id !== item.id);
        renderQueue();
      });
      el.appendChild(del);
      queueWrap.appendChild(el);
    });
    updateWorking();
  }

  function pumpQueue(){
    if (isThinking) return;
    const next = messageQueue.shift();
    if (!next) return;
    renderQueue();
    processMessage(next.text, next.files).catch((err) => {
      releaseRun();
      if (err) toast("bg-danger", "Error", err.message || String(err));
      pumpQueue();
    });
  }

  function cancelCurrentRun(){
    if (window.VvvebAi && VvvebAi.cancelCurrent) VvvebAi.cancelCurrent();
  }

  // Build the working pill (with its own Stop button) when the template does
  // not provide one (stale/cached markup) so the indicator can never go missing.
  // Safe to call repeatedly; existing nodes are reused.
  function ensureWorkingUi(){
    workingWrap = document.getElementById('vvWorking') || workingWrap;
    workingQueue = (workingWrap && workingWrap.querySelector('#vvWorkingQueue')) || document.getElementById('vvWorkingQueue') || workingQueue;
    if (!workingWrap){
      const bar = document.querySelector('.vv-inputbar');
      if (!bar) return;
      const el = document.createElement('div');
      el.className = 'vv-working';
      el.id = 'vvWorking';
      el.style.display = 'none';
      const dots = document.createElement('span');
      dots.className = 'vv-working-dots';
      for (let i = 0; i < 3; i++) dots.appendChild(document.createElement('span'));
      const txt = document.createElement('span');
      txt.className = 'vv-working-text';
      txt.textContent = 'Working\u2026';
      const badge = document.createElement('span');
      badge.className = 'vv-working-queue';
      badge.id = 'vvWorkingQueue';
      badge.style.display = 'none';
      const stop = document.createElement('button');
      stop.type = 'button';
      stop.className = 'vv-working-stop';
      stop.id = 'vvWorkingStop';
      stop.title = 'Stop response';
      stop.textContent = 'Stop';
      stop.dataset.wired = '1';
      stop.addEventListener('click', cancelCurrentRun);
      el.append(dots, txt, badge, stop);
      const q = bar.querySelector('#vvQueue');
      const dz = bar.querySelector('.vv-drop-zone');
      if (q) bar.insertBefore(el, q);
      else if (dz) bar.insertBefore(el, dz);
      else bar.appendChild(el);
      workingWrap = el;
      workingQueue = badge;
    } else {
      let stop = workingWrap.querySelector('.vv-working-stop');
      if (!stop){
        stop = document.createElement('button');
        stop.type = 'button';
        stop.className = 'vv-working-stop';
        stop.id = 'vvWorkingStop';
        stop.title = 'Stop response';
        stop.textContent = 'Stop';
        workingWrap.appendChild(stop);
      }
      if (!stop.dataset.wired){
        stop.dataset.wired = '1';
        stop.addEventListener('click', cancelCurrentRun);
      }
    }
  }

  // persistent working indicator: visible for the whole run (unlike the
  // thinking bubble, which is removed once content starts streaming)
  function updateWorking(){
    ensureWorkingUi();
    if (workingWrap) workingWrap.style.display = isThinking ? '' : 'none';
    if (workingQueue){
      if (isThinking && messageQueue.length){
        workingQueue.style.display = '';
        workingQueue.textContent = '+' + messageQueue.length + ' queued';
      } else workingQueue.style.display = 'none';
    }
    if (cancelBtnEl) cancelBtnEl.style.display = isThinking ? '' : 'none';
  }

  async function sendMessage(){
    const text = textInput.value.trim();
    if (!text && stagedFiles.length === 0) return;

    const hasVvvebAi = typeof window.VvvebAi !== "undefined" && typeof window.VvvebAi.sendQuery === "function";
    const hasLegacy = typeof window.aiAssistantSendQuery === "function";
    if (!hasVvvebAi && !hasLegacy) {
      toast("bg-danger", "Error", "AI assistant is unavailable. Enable the AI assistant plugin.");
      return;
    }

    const filesToSend = stagedFiles.slice();
    textInput.value = '';
    stagedFiles = [];
    renderStaged();
    autosize();
    historyIndex = -1;
    draftText = "";
    if (isRecording) stopRecording();
    if (isThinking) {
      // model still working - queue the message to send after finish
      messageQueue.push({id: 'q' + (++queueSeq) + Date.now().toString(36), text, files: filesToSend});
      renderQueue();
      return;
    }
    processMessage(text, filesToSend).catch((err) => {
      releaseRun();
      if (err) toast("bg-danger", "Error", err.message || String(err));
      pumpQueue();
    });
  }

  async function processMessage(text, filesToSend){
    const hasVvvebAi = typeof window.VvvebAi !== "undefined" && typeof window.VvvebAi.sendQuery === "function";
    const hasLegacy = typeof window.aiAssistantSendQuery === "function";
    if (!hasVvvebAi && !hasLegacy) {
      toast("bg-danger", "Error", "AI assistant is unavailable. Enable the AI assistant plugin.");
      return;
    }

    emptyState.style.display = 'none';
    appendMessage('user', text, filesToSend);

    showThinking();

    // convert staged File entries to provider blocks (VvvebAi.fileToBlock handles image/audio/file dataUrls)
    let blocks = [];
    if (hasVvvebAi && window.VvvebAi.fileToBlock) {
      for (let entry of filesToSend) {
        try { let b = await window.VvvebAi.fileToBlock(entry.file); b.filename = entry.file.name; blocks.push(b); } catch(e){}
      }
    } else {
      blocks = filesToSend.map(e => e.file);
    }

    if (hasVvvebAi) {
      const opts = window.VvvebAi.options ? window.VvvebAi.options() : {};
	  /*
      if (opts.apiMode !== "proxy" && !opts.key) {
        hideThinking();
        toast("bg-danger", "Error", 'No AI API key configured! Enter a valid key in Plugins → AI assistant → Settings.');
        appendMessage('assistant', "⚠️ No API key configured for provider '" + (opts.provider||"openai") + "'. Open Plugins → AI assistant → Settings and add a key or switch to Proxy mode.");
        return;
      }*/
	  
      // model & thinking selections below text input
      let selectedModel = (modelSelect && modelSelect.value) ? modelSelect.value : opts.model;
      let thinkingLevel = thinkingSelect ? thinkingSelect.value : "";
      let thinking = null;
      if (thinkingLevel === 'low') thinking = {budgetTokens: 0, effort: 'low'};
      else if (thinkingLevel === 'medium') thinking = {budgetTokens: 0, effort: 'medium'};
      else if (thinkingLevel === 'high') thinking = {budgetTokens: 0, effort: 'high'};
      const payload = window.VvvebAi.buildPayload({
        text: text || undefined,
        attachments: blocks,
        context: [aiPageContext()],
        system: opts.systemPrompt,
        provider: opts.provider,
        model: selectedModel,
        temperature: opts.temperature,
        maxTokens: opts.maxTokens,
        contextSize: opts.contextSize,
        thinking: thinking,
        tools: window.aiAssistantTools || [],
        options: {
          key: opts.key,
          url: opts.url,
          model: selectedModel,
          temperature: opts.temperature,
          max_tokens: opts.maxTokens,
          contextSize: opts.contextSize,
          apiMode: opts.apiMode,
          proxyUrl: opts.proxyUrl || window.location.pathname + "?module=plugins/ai-assistant/proxy",
          proxyCsrf: opts.csrf
        }
      });
      // session this run belongs to - a late completion after switching to
      // another session must not write history into it (see sessionChanged())
      payload._sessionId = (window.VvvebAi && VvvebAi.currentSessionId) || null;

      const streamWrap = appendMessage('assistant', '', []);
      const bubble = streamWrap.querySelector('.vv-bubble');

      // live path shares the global renderer, plus the name description
      function vvUpsertToolCall(wrap, call){ return vvUpsertToolCallGlobal(wrap, call, true); }
      function vvSetToolResult(wrap, toolCallId, text){
        let container = wrap.querySelector('.vv-tool-calls');
        if (!container) return;
        let el = container.querySelector('[data-tool-id="' + vvEscape(toolCallId) + '"]');
        if (!el) return;
        let res = el.querySelector('.vv-tool-result');
        if (res){ res.style.display=''; res.textContent = String(text).slice(0, 4000); }
      }

      // helper to run a sendQuery with tool-loop support
      async function runWithTools(initialPayload, initialWrap, initialBubble){
        let currentPayload = initialPayload;
        let currentWrap = initialWrap;
        let currentBubble = initialBubble;
        // reuse onEvent that closes over currentWrap/currentBubble? create per-iteration handler
        let finalResult = null;
        let toolsExecuted = false; // any iteration ran tool calls to completion
        let autoContinues = 0; // bounded rescue nudges, see break branch below
        for (let iter=0; iter<12; iter++){
          let wrap = currentWrap;
          let bubbleEl = currentBubble;
          let gotTextIter = false;
          let gotToolCallIter = false;
          // if not first iteration, create a new assistant bubble for continuation
          if (iter > 0){
            wrap = appendMessage('assistant', '', []);
            bubbleEl = wrap.querySelector('.vv-bubble');
            showThinking();
          }
          // per-iteration event handler
          const ev = (event) => {
            switch(event.type){
              case "text-delta":
                if (!gotTextIter) { hideThinking(); gotTextIter = true; }
                bubbleEl.__raw = (bubbleEl.__raw || "") + event.text;
                bubbleEl.innerHTML = (window.VvvebAi && VvvebAi.markdownToHtml) ? VvvebAi.markdownToHtml(bubbleEl.__raw) : bubbleEl.__raw;
                vvAutoscroll();
                break;
              case "thinking-delta":
                { let txt = event.text; if (window.VvvebAi && VvvebAi.stripToolCallTags) txt = VvvebAi.stripToolCallTags(txt); if (window.VvvebAi && VvvebAi.stripThinkingTags) txt = VvvebAi.stripThinkingTags(txt); if (!txt || !txt.trim()) break; let th = document.getElementById('vvThinkingIndicator'); if (th) { let c = th.querySelector('.thinking-content'); if (c) { c.textContent = (c.textContent || "") + txt; th.open = true; } else th.textContent = (th.textContent || "Thinking…") + txt; } break; }
              case "tool-call":
                { if (!gotToolCallIter) { hideThinking(); gotToolCallIter = true; } vvUpsertToolCall(wrap, event.call); vvAutoscroll(); break; }
              case "tool-call-delta":
                { if (!gotToolCallIter) { hideThinking(); gotToolCallIter = true; } vvUpsertToolCall(wrap, event.call); vvAutoscroll(); break; }
              case "error":
                hideThinking();
                toast("bg-danger", "Error", event.error && event.error.message || String(event.error));
                if (!gotTextIter && !gotToolCallIter) bubbleEl.textContent = "⚠️ " + (event.error && event.error.message || String(event.error));
                break;
            }
          };
          let result;
          try {
            result = await window.VvvebAi.sendQuery(currentPayload, ev);
          } catch (err){
            hideThinking();
            const aborted = err && (err.name === 'AbortError' || /abort/i.test(err.message || ''));
            if (aborted && !gotTextIter && !gotToolCallIter) bubbleEl.textContent = "Stopped.";
            if (!aborted){
              if (!gotTextIter && !gotToolCallIter) bubbleEl.textContent = "⚠️ " + (err.message || String(err));
              toast("bg-danger", "Error", err.message || String(err));
            }
            // persist partial progress so the next message keeps context: the
            // user turn (already in currentPayload.messages, exactly once) plus
            // any streamed text so far. Partial thinking is deliberately
            // omitted (truncated reasoning blocks break provider continuity)
            // and in-flight tool fragments are dropped (a tool_call without its
            // result would orphan the next request) - completed iterations are
            // already folded into currentPayload.messages via their follow-ups.
            try {
              if (!sessionChanged(currentPayload) && (!currentPayload.options || currentPayload.options.preserveHistory !== false)){
                VvvebAi.history = (currentPayload.messages || []).slice();
                VvvebAi.history.push(VvvebAi.createAssistantMessage({
                  text: (bubbleEl && bubbleEl.__raw) || "",
                  thinking: "", toolCalls: [], usage: null,
                  stopReason: aborted ? "cancelled" : "error"
                }));
                VvvebAi.saveCurrentSession();
                document.dispatchEvent(new CustomEvent('vvveb-ai-history-updated'));
                updateSidebarContextUsage();
              }
            } catch(_persistErr){}
            finalResult = null;
            break;
          }
          hideThinking();
          // handle tool calls that spilled into text as markdown code blocks (model stopped before emitting structured tool_calls)
          let allToolCalls = (result.toolCalls || []).slice();
          if (window.VvvebAi && window.VvvebAi.extractToolCallsFromText && result.text){
            let extra = window.VvvebAi.extractToolCallsFromText(result.text).filter(c => !allToolCalls.some(f => f.arguments && f.arguments.code === c.arguments.code));
            if (extra.length){
              let cleaned = stripExecBlocks(result.text);
              if (!gotTextIter){
                if (cleaned && cleaned.trim()){
                  bubbleEl.__raw = cleaned;
                  bubbleEl.innerHTML = (window.VvvebAi && VvvebAi.markdownToHtml) ? VvvebAi.markdownToHtml(cleaned) : cleaned;
                  gotTextIter = true;
                } else {
                  bubbleEl.__raw = "";
                  bubbleEl.innerHTML = "";
                }
              }
              for (let c of extra){ vvUpsertToolCall(wrap, c); allToolCalls.push(c); }
              result.toolCalls = allToolCalls;
            }
          }
          if (!gotTextIter && result && result.text && !allToolCalls.length){
            let cleanText = (window.VvvebAi && VvvebAi.stripToolCallTags) ? VvvebAi.stripToolCallTags(result.text) : result.text;
            if (cleanText !== result.text && window.VvvebAi && VvvebAi.extractToolCallsFromText){
              let extra = VvvebAi.extractToolCallsFromText(result.text);
              for (let c of extra) vvUpsertToolCall(wrap, c);
            }
            if (cleanText && cleanText.trim()){
              bubbleEl.__raw = cleanText;
              bubbleEl.innerHTML = (window.VvvebAi && VvvebAi.markdownToHtml) ? VvvebAi.markdownToHtml(cleanText) : cleanText;
            }
          } else if (!gotTextIter && result && result.thinking){
            let cleanThink = result.thinking; if (window.VvvebAi && VvvebAi.stripToolCallTags) cleanThink = VvvebAi.stripToolCallTags(cleanThink); if (window.VvvebAi && VvvebAi.stripThinkingTags) cleanThink = VvvebAi.stripThinkingTags(cleanThink);
            if (cleanThink && cleanThink.trim()){ wrap.classList.add('vv-thinking-msg'); bubbleEl.textContent = cleanThink; }
          } else if (!gotTextIter && !gotToolCallIter && !(result.text) && !(result.thinking) && !((result.toolCalls||[]).length)){
            bubbleEl.textContent = "No content returned.";
          }
          // ensure all tool calls are rendered (non-streaming fallback) - include markdown-extracted exec calls
          let finalToolCalls = allToolCalls && allToolCalls.length ? allToolCalls : (result.toolCalls || []);
          // also check for any remaining markdown exec in text that wasn't in allToolCalls yet (in case text was not processed above)
          if (window.VvvebAi && window.VvvebAi.extractToolCallsFromText && result.text && !finalToolCalls.length){
            let extra2 = window.VvvebAi.extractToolCallsFromText(result.text).filter(c => !finalToolCalls.some(f => f.arguments && f.arguments.code === c.arguments.code));
            if (extra2.length){ for (let c of extra2) vvUpsertToolCall(wrap, c); finalToolCalls = finalToolCalls.concat(extra2); result.toolCalls = finalToolCalls; }
          }
          for (let c of finalToolCalls) vvUpsertToolCall(wrap, c);
          currentPayload._lastToolCalls = finalToolCalls;
          // store cleaned text (without exec code blocks) for history so model doesn't see duplicate code as text
          let historyText = result.text;
          if (finalToolCalls.length && window.VvvebAi && window.VvvebAi.extractToolCallsFromText){
            historyText = stripExecBlocks(result.text);
          }
          currentPayload._lastText = historyText || result.text || "";
          currentPayload._lastThinking = result.thinking || "";
          currentPayload._lastUsage = result.usage || null;
          currentPayload._lastStopReason = result.stopReason || null;
          currentPayload._lastRaw = result.raw || null;
          window.aiAssistantLastPayload = currentPayload;
          if (finalToolCalls.length){
            document.dispatchEvent(new CustomEvent("vvveb-ai-tool-calls", {detail:{calls:finalToolCalls, payload:currentPayload, result}}));
            // auto-execute via registered handlers
            let handlers = window.aiAssistantToolHandlers || {};
            let results = [];
            for (let call of finalToolCalls){
              let handler = handlers[call.name] || handlers[call.id];
              let content;
              try {
                if (handler) {
					if (typeof call.arguments === 'string') {
						 content = await handler(call.arguments || '', call);
					} else if (typeof call.arguments === 'object') {
						if (call.arguments.code) {
							content = await handler(call.arguments.code || '', call);
						} else {
							content = await handler.apply(Object.values(call.arguments || {}));
						}
					}
                } else {
                  content = {error: 'No handler registered for "' + call.name + '"'};
                }
              } catch(e){ content = {error: e.message || String(e)}; }
              // Normalize content to string for display + model
              let textContent = typeof content === 'string' ? content : safeStringify(content);
              vvSetToolResult(wrap, vvToolId(call), textContent);
              results.push({toolCallId: call.id, name: call.name, content: textContent});
            }
            // build follow-up payload with tool results and continue
            let followUp = window.VvvebAi.continueWithToolResults(currentPayload, results);
            // continueWithToolResults returns plain object with messages; re-wrap as payload for sendQuery
            currentPayload = Object.assign({}, followUp, {
              provider: currentPayload.provider,
              model: currentPayload.model,
              system: currentPayload.system,
              temperature: currentPayload.temperature,
              maxTokens: currentPayload.maxTokens,
              contextSize: currentPayload.contextSize,
              thinking: currentPayload.thinking,
              tools: currentPayload.tools,
              input: currentPayload.input,
              options: currentPayload.options,
              stream: true
            });
            toolsExecuted = true;
            finalResult = result;
            continue; // next iteration streams the model's answer after tool results
          } else {
            // rescue: tools ran but the model answered empty - nudge it to go on
            // instead of stranding the task until the user types "continue".
            // Bounded (2 per run, inside the 12-iteration cap) and user-visible
            // in history, exactly as if typed manually.
            const quietAfterTools = toolsExecuted && !((result && result.text) || "").trim()
              && autoContinues < 2 && !sessionChanged(currentPayload)
              && (!currentPayload.options || currentPayload.options.preserveHistory !== false);
            if (quietAfterTools) {
              autoContinues++;
              if (bubbleEl && bubbleEl.textContent === "No content returned.") bubbleEl.textContent = "";
              currentPayload.messages = (currentPayload.messages || []).concat([{
                role: 'user',
                content: [{ type: 'text', text: 'Continue with the task using the tool results above. Do not reply with an empty message.' }]
              }]);
              continue;
            }
            finalResult = result;
            // no more tool calls -> break loop; persist full turn with all server context to history
            if (!sessionChanged(currentPayload) && (!currentPayload.options || currentPayload.options.preserveHistory !== false)) {
              VvvebAi.history = (currentPayload.messages || []).slice();
              VvvebAi.history.push(VvvebAi.createAssistantMessage(result));
              try { VvvebAi.saveCurrentSession(); document.dispatchEvent(new CustomEvent('vvveb-ai-history-updated')); } catch(e){}
              try { updateSidebarContextUsage(); } catch(e){}
            }
            break;
          }
        }
        // tool loop finished without final text (e.g., all tool calls) still persist history
        if (finalResult && (finalResult.toolCalls||[]).length && !sessionChanged(currentPayload) && (!currentPayload.options || currentPayload.options.preserveHistory !== false)) {
          // edge: loop ended on tool calls without follow-up - persist what we have
          VvvebAi.history = (currentPayload.messages || []).slice();
          try { VvvebAi.saveCurrentSession(); document.dispatchEvent(new CustomEvent('vvveb-ai-history-updated')); updateSidebarContextUsage(); } catch(e){}
        }
        return finalResult;
      }

      try {
        const result = await runWithTools(payload, streamWrap, bubble);
        vvAutoscroll();
        if (result && window.VvvebAi && VvvebAi.isTruncated && VvvebAi.isTruncated(result.stopReason)) {
          const out = result.usage && result.usage.outputTokens;
          toast("bg-warning", "Response cut off", "Output token limit reached" + (out ? " (" + Number(out).toLocaleString() + " tokens)" : "") + " \u2013 ask the model to continue or raise max tokens in settings.");
        }
      } catch (err) {
        if (!err || (err.name !== 'AbortError' && !/abort/i.test(err.message || ''))) {
          console.error(err);
          toast("bg-danger", "Error", err.message || String(err));
        }
      } finally {
        releaseRun();
      }
      pumpQueue();
      return;
    }
    // legacy backend (or missing VvvebAi): nothing was sent, release the run state
    releaseRun();
    pumpQueue();
    return;
  }
  sendBtn.addEventListener('click', sendMessage);
  if (cancelBtnEl) cancelBtnEl.addEventListener('click', cancelCurrentRun);
  document.addEventListener('vvveb-ai-busy', updateWorking);
  document.addEventListener('vvveb-ai-idle', updateWorking);
  updateWorking();

  function appendMessage(role, text, files){
    const wrap = document.createElement('div');
    wrap.className = 'vv-msg ' + role;

    if (files && files.length){
      files.forEach(entry => {
        const fileObj = entry.file || entry;
        const isImg = entry.isImage != null ? entry.isImage : (fileObj.type||'').startsWith('image/');
        const url = entry.url || fileObj._url || '';
        const name = fileObj.name || entry.filename || 'attachment';
        const att = document.createElement('div');
        att.className = 'vv-attachment-preview-msg';
        att.innerHTML = isImg && url
          ? `<img src="${url}" alt="">`
          : `<span class="vv-file-ico"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/></svg></span>`;
        const span = document.createElement('span');
        span.textContent = name;
        att.appendChild(span);
        wrap.appendChild(att);
      });
    }

    // always create a bubble for assistant so streaming has a target (empty string yields empty bubble)
    if (text || role === 'assistant'){
      const bubble = document.createElement('div');
      bubble.className = 'vv-bubble';
      if (text) bubble.textContent = text;
      wrap.appendChild(bubble);
    }

    const meta = document.createElement('div');
    meta.className = 'vv-meta';
    meta.textContent = (role === 'user' ? 'You' : 'Assistant') + ' · ' +
      new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
    wrap.appendChild(meta);

    thread.appendChild(wrap);
    vvAutoscroll();
    return wrap;
  }

  // renders (or updates) one tool-call node; withDesc appends the live
  // first-line description to the name (used by the streaming path)
  function vvUpsertToolCallGlobal(wrap, call, withDesc){
    let container = wrap.querySelector('.vv-tool-calls');
    if (!container){
      container = document.createElement('div');
      container.className = 'vv-tool-calls';
      container.style.cssText = 'margin-top:.4rem;flex-direction:column;gap:.35rem;width:100%';
      wrap.appendChild(container);
    }
    let id = vvToolId(call);
    let el = container.querySelector('[data-tool-id="' + vvEscape(id) + '"]');
    let argsText = call.rawArguments != null ? call.rawArguments : (typeof call.arguments === 'string' ? call.arguments : JSON.stringify(call.arguments || {}, null, 2));
    if (el){
      if (call.name) { let n = el.querySelector('.vv-tool-name'); if (n) n.textContent = call.name; }
      let pre = el.querySelector('.vv-tool-args'); if (pre) pre.textContent = argsText;
      return el;
    }
    el = document.createElement('details');
    el.setAttribute('data-tool-id', id);
    el.className = 'vv-tool-call';
    el.style.cssText = 'border:1px solid var(--vv-border);background:var(--vv-panel-2);border-radius:8px;padding:.4rem .5rem;font-size:.75rem';
    el.innerHTML = '<summary style="font-weight:500"><span>🔧</span><span class="vv-tool-name"></span></summary><pre class="vv-tool-args" style="margin:.25rem 0 0;white-space:pre-wrap;word-break:break-word;font-size:.72rem;max-height:120px;overflow:auto"></pre><div class="vv-tool-result" style="display:none;margin-top:.3rem;padding-top:.3rem;border-top:1px solid var(--vv-border);color:#198754;white-space:pre-wrap;word-break:break-word;max-height:120px;overflow:auto"></div>';
    let name = call.name || 'tool';
    if (withDesc){
      let nl = argsText.indexOf("\n");
      let desc = (nl < 0 ? argsText : argsText.slice(0, nl)).replaceAll("//", "\n").trim().substring(0, 100);
      if (desc) name += desc;
    }
    el.querySelector('.vv-tool-name').textContent = name;
    el.querySelector('.vv-tool-args').textContent = argsText;
    container.appendChild(el);
    return el;
  }
  // Thinking blocks always render as <details class="ai-thinking"> so the
  // show-thinking toggle can target them (live indicator, history, everywhere)
  function aiCreateThinkingNode(text){
    const el = document.createElement('details');
    el.className = 'ai-thinking';
    el.open = true;
    el.innerHTML = `<summary class="text-muted small user-select-none"><i class="la la-lightbulb"></i> Thinking</summary><div class="thinking-content text-muted small ps-3"></div>`;
    if (text) el.querySelector('.thinking-content').textContent = text;
    return el;
  }
  function showThinking(){
    isThinking = true;
    updateWorking();
    const el = aiCreateThinkingNode('');
    el.id = 'vvThinkingIndicator';
    thread.appendChild(el);
    vvAutoscroll();
  }
  function hideThinking(){
    const el = document.getElementById('vvThinkingIndicator');
    if (el) el.remove();
  }

  function updateSidebarContextUsage(){
    if (!window.VvvebAi || !window.VvvebAi.getContextUsage) return;
    let draft = textInput ? textInput.value : "";
    let opts = window.VvvebAi.options ? window.VvvebAi.options() : {};
    let selectedModel = (typeof modelSelect !== 'undefined' && modelSelect && modelSelect.value) ? modelSelect.value : opts.model;
    let payload;
    try {
      payload = window.VvvebAi.buildPayload({
        text: draft || undefined,
        attachments: stagedFiles.map(e=>e.file),
        context: [aiPageContext()],
        provider: opts.provider,
        model: selectedModel,
        contextSize: opts.contextSize,
        options: {provider: opts.provider, model: selectedModel, maxTokens: opts.maxTokens, contextSize: opts.contextSize}
      });
    } catch(e){ return; }
    let usage = window.VvvebAi.getContextUsage(payload);
    let pct = Math.round(usage.pct * 100);
    let bar = document.querySelector(".vv-context-progressbar");
    let pctEl = document.querySelector(".vv-context-pct");
    let tokEl = document.querySelector(".vv-context-tokens");
    let wrap = document.querySelector(".vv-context-usage");
    if (bar) { bar.style.width = Math.min(100, pct) + "%"; bar.classList.remove("bg-danger","bg-warning","bg-success"); if (pct >= 90) bar.classList.add("bg-danger"); else if (pct >= 70) bar.classList.add("bg-warning"); else bar.classList.add("bg-success"); }
    if (pctEl) pctEl.textContent = pct + "%";
    if (tokEl) tokEl.textContent = "(" + usage.tokens.toLocaleString() + " / " + usage.limit.toLocaleString() + ")";
    if (wrap) wrap.style.display = "";
    let totalEl = document.querySelector(".vv-total-tokens");
    let cacheEl = document.querySelector(".vv-cache-hit");
    let cachePctEl = document.querySelector(".vv-cache-pct");
    let totalWrap = document.querySelector(".vv-total-usage");
    if (totalEl) {
      let hist = (window.VvvebAi && VvvebAi.history) || [];
      let totalIn = 0, totalOut = 0, totalCache = 0;
      for (let m of hist) if (m && m.usage) {
        totalIn += Number(m.usage.inputTokens||0);
        totalOut += Number(m.usage.outputTokens||0);
        let c = m.usage.cachedTokens ?? m.usage.cache_read_input_tokens ?? m.usage.cacheReadInputTokens ?? m.usage.cached_input_tokens ?? m.usage.cache_creation_input_tokens ?? 0;
        totalCache += Number(c||0);
        if (m.usage.raw && typeof m.usage.raw === 'object') { let rc = m.usage.raw.cached_tokens ?? m.usage.raw.cache_read_input_tokens ?? (m.usage.raw.usage && m.usage.raw.usage.cached_tokens) ?? 0; if (rc) totalCache += Number(rc); }
      }
      let total = totalIn + totalOut;
      // fallback to estimated tokens if provider didn't return usage (e.g., streaming without usage)
      if (!total && hist.length && window.VvvebAi && window.VvvebAi.estimateMessagesTokens) {
        try { total = window.VvvebAi.estimateMessagesTokens(hist); totalIn = total; totalOut = 0; } catch(e){}
      }
      totalEl.textContent = total.toLocaleString() + (totalIn||totalOut ? ` (${totalIn.toLocaleString()}→${totalOut.toLocaleString()})` : "");
      let hitPct = totalIn ? Math.round((totalCache / totalIn) * 100) : 0;
      if (cacheEl) cacheEl.textContent = totalCache.toLocaleString();
      if (cachePctEl) cachePctEl.textContent = hitPct + "%";
      if (totalWrap) totalWrap.style.display = ""; // always show, even if 0, so user sees it
    }
    let btn = document.querySelector(".vv-compact-btn");
    if (btn) btn.disabled = window.VvvebAi.history.length <= (window.VvvebAi.COMPACT_KEEP_RECENT + window.VvvebAi.COMPACT_KEEP_EARLY) || !window.VvvebAi.isIdle();
  }
  document.querySelector(".vv-compact-btn")?.addEventListener("click", async function(){
    let btn=this; let orig=btn.innerHTML; btn.disabled=true; btn.innerHTML='<span class="spinner-border spinner-border-sm" style="width:12px;height:12px"></span>';
    try {
      let r = window.VvvebAi.compact();
      if (!r) toast("bg-info","Info","Nothing to compact \u2013 conversation too short.");
      else if (r.deferred) toast("bg-warning","Queued","Model is busy \u2013 compaction will run when idle.");
      else { toast("bg-success","Compacted","Compacted "+r.before+"\u2192"+r.after+" messages, saved "+r.savedTokens+" tokens."); renderHistoryToThread(); }
    } catch(e){ toast("bg-danger","Error",e.message); }
    btn.innerHTML=orig; updateSidebarContextUsage();
  });
  // usage details (context/cache/compact) are collapsed by default behind a small arrow
  const usageToggle = document.getElementById('vvUsageToggle');
  const usageDetails = document.getElementById('vvUsageDetails');
  function setUsageOpen(open){
    if (usageDetails) usageDetails.style.display = open ? '' : 'none';
    if (usageToggle){
      usageToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
      usageToggle.title = open ? 'Hide usage details' : 'Show usage details';
      usageToggle.classList.toggle('open', !!open);
    }
    vvStoreSet('vv-ai-usage-open', open ? '1' : '0');
  }
  let usageOpen = false;
  usageOpen = vvStoreGet('vv-ai-usage-open') === '1';
  if (usageToggle) usageToggle.addEventListener('click', () => {
    usageOpen = !usageDetails || usageDetails.style.display === 'none';
    setUsageOpen(usageOpen);
  });
  setUsageOpen(usageOpen);
  // show/hide tool calls: toggle 'show-tools' on the thread (.vv-tool-calls
  // is hidden by default, see ai-assistant.scss). Preference persists.
  // NOTE: button id keeps its historical 'vvToogleToolsBtn' spelling.
  const toolsToggleBtn = document.getElementById('vvToogleToolsBtn');
  function setToolsVisible(show){
    if (thread) thread.classList.toggle('show-tools', !!show);
    if (toolsToggleBtn){
      toolsToggleBtn.classList.toggle('active', !!show);
      toolsToggleBtn.setAttribute('aria-pressed', show ? 'true' : 'false');
    }
    vvStoreSet('vv-ai-show-tools', show ? '1' : '0');
  }
  let toolsVisible = false;
  toolsVisible = vvStoreGet('vv-ai-show-tools') === '1';
  if (toolsToggleBtn) toolsToggleBtn.addEventListener('click', () => {
    toolsVisible = thread ? !thread.classList.contains('show-tools') : !toolsVisible;
    setToolsVisible(toolsVisible);
  });
  setToolsVisible(toolsVisible);
  // resize separator: drag the panel edge to resize, width persists.
  // (replaces the native CSS resize handle for a discoverable grip)
  (function initResizeHandle(){
    const handle = document.getElementById('vvResizeHandle');
    const sidebar = handle ? handle.closest('.vv-sidebar') : (thread ? thread.closest('.vv-sidebar') : null);
    if (!handle || !sidebar) return;
    const MIN = 280, MAX = 640;
    let saved = null;
    try { saved = parseInt(localStorage.getItem('vv-ai-sidebar-width'), 10); } catch(e){}
    if (saved >= MIN && saved <= window.innerWidth) sidebar.style.width = saved + 'px';
    let startX = 0, startW = 0, dragging = false;
    handle.addEventListener('pointerdown', (e) => {
      if (window.innerWidth <= 760) return; // mobile: panel is full-width fixed
      dragging = true;
      startX = e.clientX;
      startW = sidebar.getBoundingClientRect().width;
      handle.classList.add('vv-dragging');
      sidebar.classList.add('vv-dragging');
      try { handle.setPointerCapture(e.pointerId); } catch(err){}
      e.preventDefault();
    });
    handle.addEventListener('pointermove', (e) => {
      if (!dragging) return;
      const w = Math.min(MAX, Math.max(MIN, Math.round(startW + (e.clientX - startX))));
      sidebar.style.width = w + 'px';
    });
    const stop = () => {
      if (!dragging) return;
      dragging = false;
      handle.classList.remove('vv-dragging');
      sidebar.classList.remove('vv-dragging');
      try { localStorage.setItem('vv-ai-sidebar-width', String(Math.round(sidebar.getBoundingClientRect().width))); } catch(e){}
    };
    handle.addEventListener('pointerup', stop);
    handle.addEventListener('pointercancel', stop);
  })();
  // show/hide thinking: toggle 'show-thinking' on the thread (.vv-thinking,
  // .ai-thinking and thinking-only messages are hidden by default, see
  // ai-assistant.scss). Preference persists.
  const thinkingToggleBtn = document.getElementById('vvToggleThinkingBtn');
  function setThinkingVisible(show){
    if (thread) thread.classList.toggle('show-thinking', !!show);
    if (thinkingToggleBtn){
      thinkingToggleBtn.classList.toggle('active', !!show);
      thinkingToggleBtn.setAttribute('aria-pressed', show ? 'true' : 'false');
    }
    vvStoreSet('vv-ai-show-thinking', show ? '1' : '0');
  }
  let thinkingVisible = false;
  thinkingVisible = vvStoreGet('vv-ai-show-thinking') !== '0';
  if (thinkingToggleBtn) thinkingToggleBtn.addEventListener('click', () => {
    thinkingVisible = thread ? !thread.classList.contains('show-thinking') : !thinkingVisible;
    setThinkingVisible(thinkingVisible);
  });
  setThinkingVisible(thinkingVisible);
  // allow/disallow screenshots: gate for captureScreenshot (frame + top window).
  // On by default; persists like the other header toggles.
  const captureToggleBtn = document.getElementById('vvToggleCaptureBtn');
  function setCaptureAllowed(allowed){
    if (captureToggleBtn){
      captureToggleBtn.classList.toggle('active', !!allowed);
      captureToggleBtn.setAttribute('aria-pressed', allowed ? 'true' : 'false');
    }
    vvStoreSet('vv-ai-capture-allowed', allowed ? '1' : '0');
    try {
      const fw = window.FrameWindow;
      if (fw) fw.__vvCaptureAllowed = !!allowed;
    } catch(e){}
  }
  let captureAllowed = vvStoreGet('vv-ai-capture-allowed') !== '0';
  if (captureToggleBtn) captureToggleBtn.addEventListener('click', () => {
    captureAllowed = vvStoreGet('vv-ai-capture-allowed') === '0';
    setCaptureAllowed(captureAllowed);
  });
  setCaptureAllowed(captureAllowed);
  let usageDebounce = null;
  textInput?.addEventListener("input", () => {
    if (usageDebounce) clearTimeout(usageDebounce);
    usageDebounce = setTimeout(updateSidebarContextUsage, 250);
  });
  document.addEventListener('vvveb-ai-history-updated', updateSidebarContextUsage);
  document.addEventListener('vvveb-ai-busy', updateSidebarContextUsage);
  document.addEventListener('vvveb-ai-idle', updateSidebarContextUsage);
  document.addEventListener('vvveb-ai-history-updated', renderHistoryToThread);
  document.addEventListener('vvveb-ai-sessions-updated', () => {
    try { renderSessions(); } catch(e){}
    try { if (typeof renderSessionSelect === 'function') renderSessionSelect(); } catch(e){}
    try { updateSidebarContextUsage(); } catch(e){}
  });
  if (document.readyState !== 'loading') setTimeout(updateSidebarContextUsage, 80);
  else document.addEventListener('DOMContentLoaded', updateSidebarContextUsage);
  setInterval(updateSidebarContextUsage, 5000); // keep total/context live

  autosize();
})();




/**
 * Evaluates JavaScript code in the given window context (page iframe or builder)
 * and converts the result into a JSON-safe value for AI tool responses.
 *
 * - captures `console.log()` output and returns it instead of the raw value
 * - unwraps a single top-level `return expr` (as produced by AI assistants)
 * - DOM nodes become `{html, selector}` objects (cross-window safe)
 * - NodeList/HTMLCollection/XPathResult snapshots become arrays of the above
 * - plain values are bounded (depth/keys/string length) and circular-safe
 * - errors are returned as `"Name: message"` strings
 *
 * @param {string} jsCode - Code to evaluate.
 * @param {Window} [context=window] - Window whose global scope runs the code.
 * @returns {*} JSON-safe result, DOM descriptor(s), or an error string.
 */
function exec(jsCode, context = window) {
	if (!jsCode || typeof jsCode !== "string") return jsCode;

	if (!context || typeof context.eval !== "function") {
		return "TypeError: eval context has no eval()";
	}

	// capture console.log output: match only real console.log calls (not
	// `xconsole.log(` or `obj.console.log(`) and declare the collector with
	// `var` so strict-mode code does not throw on implicit globals
	if (/(^|[^\w$.])console\.log\s*\(/.test(jsCode)) {
		jsCode = "var _console = [];" + jsCode.replace(/(^|[^\w$.])console\.log\s*\(/g, "$1_console.push(") + "\n;_console;";
	}

	// allow a single top-level `return expr` (eval has no return context).
	// only the leading keyword is unwrapped - `return` statements inside
	// functions are left untouched.
	jsCode = jsCode.replace(/^\s*return(?=\s|;|$)/, "");

	let result;
	try {
		if (exec.debug) console.log('exec in:', jsCode);
		// direct eval call executes in the context window's global scope
		result = context.eval(jsCode);
	} catch (error) {
		return (error && error.name ? error.name : "Error") + ": " + (error && error.message ? error.message : String(error));
	}

	if (exec.debug) console.log('exec out:', result);
	if (result === null || result === undefined) return result;


	const toNode = (node) => {
		let selector = "";
		try { selector = Vvveb.StyleManager.getSelectorForElement(node); } catch (e) { /* detached/odd node */ }
		let html = "";
		try { html = node.outerHTML; } catch (e) { /* e.g. document node */ }
		return {html, selector};
	};
	// duck-type checks: instanceof fails across window boundaries (iframe nodes)
	const isNodeLike = (v) => !!(v && (v.nodeType === 1 || v.nodeType === 11) && typeof v.outerHTML === "string");
	const isListLike = (v) => !!(v && typeof v.length === "number" && typeof v.item === "function");
	const isXPathResult = (v) => !!(v && typeof v.resultType === "number" &&
		(typeof v.iterateNext === "function" || typeof v.snapshotItem === "function" ||
		 "singleNodeValue" in v || "booleanValue" in v || "numberValue" in v || "stringValue" in v));

	const convert = (value, depth, seen) => {
		if (value === null || value === undefined) return value;
		// image payloads (screenshots, attachments) travel verbatim: truncating
		// the base64 dataUrl at exec.maxString would destroy them. The
		// {type:'image',...} block is the attachment shape providers already map
		// to vision parts, and toolCall() passes {content,structuredContent} through.
		if (typeof value === 'object' && value.type === 'image' &&
			(typeof value.dataUrl === 'string' || typeof value.data === 'string')) return value;
		const type = typeof value;
		if (type === "string") return value.length > exec.maxString ? value.slice(0, exec.maxString) + "\u2026" : value;
		if (type === "number" || type === "boolean") return value;
		if (type === "bigint") return value.toString() + "n";
		if (type === "function") {
			try {
				const src = Function.prototype.toString.call(value);
				return src.length > 500 ? src.slice(0, 500) + "\u2026" : src;
			} catch (e) { return "[function]"; }
		}
		if (type !== "object") return String(value);
		if (seen.has(value)) return "[Circular]";
		if (isNodeLike(value)) return toNode(value);
		if (isListLike(value)) {
			seen.add(value);
			const len = Math.min(value.length, exec.maxKeys);
			const out = [];
			for (let i = 0; i < len; i++) {
				try { out.push(convert(value.item(i), depth + 1, seen)); } catch (e) { out.push("[unavailable]"); }
			}
			if (value.length > len) out.push(`\u2026(${value.length - len} more)`);
			return out;
		}
		if (isXPathResult(value)) {
			seen.add(value);
			try {
				// snapshot result types 6/7, single-node types 8/9, boolean 3, number 1, string 2
				if ((value.resultType === 6 || value.resultType === 7) && typeof value.snapshotLength === "number") {
					const len = Math.min(value.snapshotLength, exec.maxKeys);
					const out = [];
					for (let i = 0; i < len; i++) {
						try { out.push(convert(value.snapshotItem(i), depth + 1, seen)); } catch (e) { out.push("[unavailable]"); }
					}
					if (value.snapshotLength > len) out.push(`\u2026(${value.snapshotLength - len} more)`);
					return out;
				}
				if ((value.resultType === 8 || value.resultType === 9) && "singleNodeValue" in value) {
					return convert(value.singleNodeValue, depth + 1, seen);
				}
				if (value.resultType === 3 && "booleanValue" in value) return value.booleanValue;
				if (value.resultType === 1 && "numberValue" in value) return value.numberValue;
				if (value.resultType === 2 && "stringValue" in value) return String(value.stringValue);
			} catch (e) { return "[unavailable]"; }
			return "[XPathResult]";
		}
		if (Array.isArray(value)) {
			seen.add(value);
			if (depth >= exec.maxDepth) return `[Array(${value.length})]`;
			const len = Math.min(value.length, exec.maxKeys);
			const out = [];
			for (let i = 0; i < len; i++) {
				try { out.push(convert(value[i], depth + 1, seen)); } catch (e) { out.push("[unavailable]"); }
			}
			if (value.length > len) out.push(`\u2026(${value.length - len} more)`);
			return out;
		}
		if (depth >= exec.maxDepth) {
			let ctor = type;
			try { ctor = (value.constructor && value.constructor.name) || type; } catch (e) { /* null-prototype object */ }
			return `[${ctor}]`;
		}
		seen.add(value);
		let keys;
		try { keys = Object.keys(value).slice(0, exec.maxKeys); } catch (e) { return "[unavailable]"; }
		const out = {};
		for (const key of keys) {
			try { out[key] = convert(value[key], depth + 1, seen); } catch (e) { out[key] = "[unavailable]"; }
		}
		return out;
	};

	// async code returns a Promise: stay async and convert the settled value
	// so callers (e.g. eval_page tracking) can await the real result
	let isThenable = false;
	try { isThenable = (typeof result === "object" || typeof result === "function") && typeof result.then === "function"; } catch (e) { isThenable = false; }
	if (isThenable) {
		return result.then(
			(v) => { try { return convert(v, 0, new Set()); } catch (e) { return "Error: could not serialize eval result"; } },
			(e) => (e && e.name ? e.name : "Error") + ": " + (e && e.message ? e.message : String(e))
		);
	}
	try {
		return convert(result, 0, new Set());
	} catch (error) {
		return "Error: could not serialize eval result (" + (error && error.message ? error.message : String(error)) + ")";
	}
}
// bounds for AI tool payloads; console noise is opt-in
exec.maxDepth = 3;
exec.maxKeys = 50;
exec.maxString = 2000;
exec.debug = false;



/**
 * Evaluates JavaScript in the page iframe (see `exec`) and reveals the
 * element the code changed last: a MutationObserver records added /
 * attribute- / text-changed elements while the code runs, then the page is
 * scrolled to the last one and it is selected with
 * `Vvveb.Builder._positionSelectBox`. All tracking is best-effort and never
 * affects the returned tool result. Async code (a Promise result) is tracked
 * until it settles.
 *
 * @param {string} jsCode - Code to evaluate in the page context.
 * @returns {*} The `exec` result (or Promise thereof).
 */
// html2canvas for page screenshots (eval_page captureScreenshot). Vendored at
// /js/libs/html2canvas/html2canvas.min.js so pages work without CDN access.
const HTML2CANVAS_URL = '/js/libs/html2canvas/html2canvas.min.js';

// loads html2canvas into targetWin (page frame or top builder window, once per
// concurrent call). Resolves when targetWin.html2canvas is callable.
function ensureCaptureLib(targetWin) {
	if (!targetWin) return Promise.reject(new Error('captureScreenshot: window not available'));
	try {
		if (typeof targetWin.html2canvas === 'function') return Promise.resolve();
	} catch (e) { return Promise.reject(new Error('captureScreenshot: cannot access window')); }
	try { if (targetWin.__vvCapturePromise) return targetWin.__vvCapturePromise; } catch (e) {}
	let done;
	const pending = new Promise((resolve, reject) => { done = {resolve, reject}; });
	try { targetWin.__vvCapturePromise = pending; } catch (e) {}
	const fail = (msg) => { try { targetWin.__vvCapturePromise = null; } catch (e) {} done.reject(new Error(msg)); };
	let doc = null;
	try { doc = targetWin.document; } catch (e) {}
	if (!doc || typeof doc.createElement !== 'function') { fail('captureScreenshot: document not available'); return pending; }
	const ready = () => {
		try {
			if (typeof targetWin.html2canvas !== 'function') { fail('captureScreenshot: html2canvas failed to load'); return; }
			try { targetWin.__vvCapturePromise = null; } catch (e) {}
			done.resolve();
		} catch (e) { fail('captureScreenshot: ' + (e && e.message || String(e))); }
	};
	try {
		const s = doc.createElement('script');
		s.src = HTML2CANVAS_URL;
		s.onload = ready;
		s.onerror = () => fail('captureScreenshot: could not load ' + HTML2CANVAS_URL);
		(doc.head || doc.documentElement).appendChild(s);
	} catch (e) { fail('captureScreenshot: ' + (e && e.message || String(e))); }
	return pending;
}

// ensure the page frame exposes captureScreenshot(): installs a thin wrapper
// that renders with the TOP window's html2canvas copy, so nothing is ever
// injected into the page (CSP-safe, no per-navigation reinstall). The wrapper
// is idempotent; resolves immediately after installing.
function ensurePageCapture(frameWin) {
	if (!frameWin) return Promise.reject(new Error('captureScreenshot: page frame not available'));
	try { void frameWin.document; } catch (e) { return Promise.reject(new Error('captureScreenshot: cannot access page frame')); }
	try { installPageCapture(frameWin); } catch (e) { return Promise.reject(e); }
	return Promise.resolve();
}

// installs captureScreenshot(selector?, opts?) on the given window (page frame
// or top builder window) for use by eval_page / eval_ui code, e.g.
// `return captureScreenshot('#main')` or `return captureScreenshot()` for the
// whole window. Each installed copy captures ITS OWN window: frame calls shoot
// the iframe, top-window calls shoot the builder. Downscales long pages
// (default 1568px longest side, JPEG) so the payload stays model-sized.
// Returns the attachment image-block shape providers map to vision parts:
// {content:[{type:'image', mediaType, dataUrl}], structuredContent:{width,height,bytes,selector,format}}
// installs captureScreenshot(selector?, opts?) on the given window. The copy
// only forwards to captureWindow() below - rendering always uses the top
// window's html2canvas, never an in-frame copy.
function installPageCapture(win) {
	try {
		// rendering always uses the top window's html2canvas copy: ensure it
		// first (usually already loaded), then capture against win.
		win.captureScreenshot = function (selector, opts) {
			return ensureCaptureLib(window).then(() => captureWindow(win, selector, opts));
		};
	} catch (e) {}
}
	// scroll the target into view (and step through long documents) so
	// lazy-loaded images actually fetch before html2canvas renders. Scroll
	// only - no DOM writes - so eval_page change tracking stays quiet.
	// Restores every moved scroller afterwards. Bounded by opts.lazyTimeout.
	function prepareLazy(win, el, isDoc, opts) {
		const doc = win.document;
		const wait = (ms) => new Promise((res) => { try { setTimeout(res, ms); } catch (e) { res(); } });
		// remember scroll positions up the ancestor chain (inner scrollers too)
		const saved = [];
		try {
			let n = el;
			while (n && n !== doc) {
				if (n.scrollTop || n.scrollLeft) saved.push([n, n.scrollLeft, n.scrollTop]);
				n = n.parentNode;
			}
			saved.push([win, win.scrollX || win.pageXOffset || 0, win.scrollY || win.pageYOffset || 0]);
		} catch (e) {}
		const restore = () => {
			try {
				for (const [n, x, y] of saved) {
					if (n === win) win.scrollTo(x, y);
					else { n.scrollLeft = x; n.scrollTop = y; }
				}
			} catch (e) {}
		};
		const settled = async () => {
			// wait until lazy images inside the target finish (or time out)
			const deadline = Date.now() + (opts.lazyTimeout != null ? opts.lazyTimeout : 2500);
			for (;;) {
				let pending = false;
				try {
					const imgs = el.querySelectorAll ? el.querySelectorAll('img') : [];
					for (const img of imgs) {
						if (!img.complete) { pending = true; break; }
					}
				} catch (e) {}
				if (!pending || Date.now() >= deadline) break;
				await wait(100);
			}
			try {
				if (doc.fonts && doc.fonts.ready) {
					await Promise.race([doc.fonts.ready, wait(800)]);
				}
			} catch (e) {}
		};
		const run = async () => {
			if (isDoc && opts.lazyScroll !== false) {
				// step through the page so each viewport's lazy images trigger
				let viewH = 800, docH = 0;
				try {
					viewH = win.innerHeight || doc.documentElement.clientHeight || 800;
					docH = Math.max(docH, doc.documentElement.scrollHeight || 0, (doc.body && doc.body.scrollHeight) || 0);
				} catch (e) {}
				const steps = Math.min(12, Math.max(1, Math.ceil(docH / viewH)));
				for (let i = 1; i <= steps; i++) {
					try { win.scrollTo(0, Math.round((docH * i) / steps)); } catch (e) {}
					await wait(40);
				}
			} else {
				try { el.scrollIntoView({ block: 'center', inline: 'nearest' }); } catch (e) {
					try { el.scrollIntoView(); } catch (_e) {}
				}
				await wait(150);
			}
			await settled();
		};
		return { run, restore };
	}
// core capture against any same-origin window, rendered with THIS window's
// html2canvas copy (window.html2canvas must be loaded - see ensureCaptureLib).
// Scrolling/reading uses targetWin APIs; nothing runs inside the target.
function captureWindow(targetWin, selector, opts) {
		try {
			if (targetWin.__vvCaptureAllowed === false) {
				return Promise.reject(new Error(captureScreenshotDenied()));
			}
		} catch (e) {}
		opts = opts || {};
		const maxDim = opts.maxDim || 1568;
		const quality = (opts.quality != null) ? opts.quality : 0.85;
		const format = (opts.format === 'png') ? 'png' : 'jpeg';
		const doc = targetWin.document;
		let el = null;
		try { el = selector ? doc.querySelector(selector) : doc.documentElement; } catch (e) {}
		if (!el) return Promise.reject(new Error('captureScreenshot: no element matches "' + selector + '"'));
		let w = Math.max(1, Math.ceil(el.scrollWidth || el.clientWidth || 0));
		let h = Math.max(1, Math.ceil(el.scrollHeight || el.clientHeight || 0));
		if (selector) {
			try { const r = el.getBoundingClientRect(); w = Math.max(w, Math.ceil(r.width)); h = Math.max(h, Math.ceil(r.height)); } catch (e) {}
		}
		// fail fast on giant DOMs: html2canvas parses the whole subtree
		// synchronously on the main thread, so a huge page freezes the tab for
		// seconds (canvas size is already bounded by maxDim - this is parse
		// cost, not pixels). Refuse with guidance instead of hanging.
		let nodeCount = 0;
		try { nodeCount = el.getElementsByTagName ? el.getElementsByTagName('*').length : 0; } catch (e) {}
		const maxNodes = opts.maxNodes != null ? opts.maxNodes : 6000;
		if (nodeCount > maxNodes) {
			return Promise.reject(new Error('captureScreenshot: target has ' + nodeCount + ' nodes (max ' + maxNodes + ') - capture a smaller section instead, or raise opts.maxNodes'));
		}
		// pixel budget: bounds canvas memory even when opts.scale/maxDim are
		// raised (a forced scale=2 on a long page is an instant OOM freeze)
		const maxPixels = opts.maxPixels != null ? opts.maxPixels : 4000000;
		let scale = Math.min(1, maxDim / Math.max(w, h));
		const pxScale = Math.sqrt(maxPixels / Math.max(1, w * h));
		if (pxScale < scale) scale = pxScale;
		if (opts.scale) scale = Math.min(scale, opts.scale);
		const prep = prepareLazy(targetWin, el, !selector, opts);
		return prep.run().then(function () {
			// render back at the original scroll position with the REAL viewport
			// size and zeroed scroll offsets: html2canvas adds scrollX/scrollY
			// to every element position (parked-at-bottom page paints shifted
			// down, top blank) and resolves vh/vw against windowWidth/Height
			// (a faked full-page height stretches every 100vh hero). Never fake.
			prep.restore();
			let viewW = 0, viewH = 0;
			try {
				viewW = targetWin.innerWidth || doc.documentElement.clientWidth || w;
				viewH = targetWin.innerHeight || doc.documentElement.clientHeight || h;
			} catch (e) { viewW = w; viewH = h; }
			// NOTE: html2canvas here is the TOP window's copy rendering the
			// target's element - clone/styles/scroll/URLs all follow the
			// element's ownerDocument, so no in-target copy is ever needed.
			return window.html2canvas(el, {
			scale: scale, backgroundColor: opts.background || '#ffffff',
			logging: false, useCORS: true,
			windowWidth: viewW, windowHeight: viewH,
			scrollX: 0, scrollY: 0
		}).then(function (canvas) {
			let out = canvas;
			// second-pass downscale if still oversized (scrollbars/borders)
			if ((out.width > maxDim || out.height > maxDim) && doc.createElement) {
				const k = Math.min(1, maxDim / Math.max(out.width, out.height));
				const small = doc.createElement('canvas');
				small.width = Math.max(1, Math.round(out.width * k));
				small.height = Math.max(1, Math.round(out.height * k));
				small.getContext('2d').drawImage(out, 0, 0, small.width, small.height);
				out = small;
			}
			const mediaType = 'image/' + format;
			const dataUrl = out.toDataURL(mediaType, quality);
			// single copy of the pixels: adapters read dataUrl (splitting base64
			// via Ai.dataUrlParts where needed), so no data/mimeType duplicates.
			return {
				content: [{ type: 'image', mediaType: mediaType, dataUrl: dataUrl }],
				structuredContent: { width: out.width, height: out.height,
					bytes: dataUrl.length, selector: selector || 'document', format: format,
					nodes: nodeCount, scale: scale }
			};
		});
		}, function (err) {
			try { prep.restore(); } catch (e) {}
			throw err;
		});
}

// Builder-side entry so captureScreenshot() also works outside the page frame:
// devtools console, eval_ui code, or any builder script. Captures the TOP
// (builder) window - note embedded iframes render blank under html2canvas, so
// shoot page pixels via eval_page and use this for builder UI/chrome.
// Inside eval_page code the frame-local installed copy is used directly.
// Runs once: afterwards window.captureScreenshot IS the installed copy.
function captureScreenshotDenied() {
	return 'captureScreenshot: screenshots are disabled - allow them with the camera button in the AI assistant header';
}
function isCaptureAllowed(){
	try {
		if (typeof vvStoreGet === 'function') return vvStoreGet('vv-ai-capture-allowed') !== '0';
		return localStorage.getItem('vv-ai-capture-allowed') !== '0';
	} catch(e){ return true; }
}
function captureScreenshot(selector, opts) {
	if (!isCaptureAllowed()) return Promise.reject(new Error(captureScreenshotDenied()));
	return ensureCaptureLib(window).then(() => {
		installPageCapture(window);
		return window.captureScreenshot(selector, opts);
	});
}

function eval_page(jsCode) {
	// toolCall() may deliver args as an object ({code|input|prompt}) - unwrap it
	if (jsCode && typeof jsCode === 'object') jsCode = jsCode.code ?? jsCode.input ?? jsCode.prompt ?? '';
	let frameWin = null, frameDoc = null;
	try {
		frameWin = window.FrameWindow;
		frameDoc = frameWin && frameWin.document;
	} catch (e) { /* cross-origin / unloaded iframe */ }

	let lastChanged = null;
	let observer = null;
	let finalized = false;
	let stylesChanged = false;

	// the live page stylesheet: text/attribute edits here mean the builder's
	// parsed css is stale until Vvveb.StyleManager.loadCss() re-reads it
	const isStylesNode = (node) => {
		try {
			while (node && node.nodeType !== 1) node = node.parentNode;
			if (!node) return false;
			if (node.id === 'vvvebjs-styles') return true;
			return (typeof node.closest === 'function') && !!node.closest('#vvvebjs-styles');
		} catch(e){ return false; }
	};
	const record = (node) => {
		// walk up from text/comment nodes to the containing element
		while (node && node.nodeType !== 1) node = node.parentNode;
		if (!node || (frameDoc && (node === frameDoc || node === frameDoc.documentElement))) return;
		if (isStylesNode(node)) { stylesChanged = true; return; } // css sync covers it; never select invisible nodes
		lastChanged = node;
	};
	const collect = (mutations) => {
		for (const m of mutations || []) {
			try {
				if (m.type === "childList") {
					for (const n of m.addedNodes || []) { if (isStylesNode(n)) stylesChanged = true; }
				} else if (m.type === "attributes" || m.type === "characterData") {
					if (isStylesNode(m.target)) stylesChanged = true;
				}
			} catch(e){}
			if (m.type === "childList") {
				m.addedNodes.forEach(record);
				// pure removal: fall back to the parent that lost the node(s)
				if (!m.addedNodes.length && m.removedNodes.length) record(m.target);
			} else if (m.type === "attributes" || m.type === "characterData") {
				record(m.target);
			}
		}
	};

	if (frameDoc && frameDoc.nodeType === 9) {
		try {
			const MO = (frameWin && frameWin.MutationObserver) || MutationObserver;
			observer = new MO(collect);
			observer.observe(frameDoc, {childList: true, subtree: true, attributes: true, characterData: true});
		} catch (e) { observer = null; }
	}

	const reveal = () => {
		if (finalized) return;
		finalized = true;
		try {
			if (observer) {
				try { collect(observer.takeRecords()); } catch (e) {}
				try { observer.disconnect(); } catch (e) {}
				observer = null;
			}
		} catch (e) { /* tracking must never break the tool result */ }
		if (stylesChanged) {
			stylesChanged = false;
			try {
				const SM = window.Vvveb && Vvveb.StyleManager;
				if (SM && typeof SM.loadCss === 'function') SM.loadCss();
			} catch(e){}
		}
		if (!lastChanged || !frameWin) return;
		try {
			// center the element in the page viewport (instant scroll so the
			// selection box below is positioned against the final scroll)
			const pos = offset(lastChanged);
			const doc = frameWin.document;
			const viewH = frameWin.innerHeight || (doc && doc.documentElement.clientHeight) || 600;
			const elH = lastChanged.offsetHeight || 0;
			frameWin.scrollTo(0, Math.max(0, pos.top - Math.max(0, (viewH - elH) / 2)));
		} catch (e) {}
		try {
			Vvveb.Builder._positionSelectBox(lastChanged);
		} catch (e) {}
	};

	let result;
	
	// screenshot support: code calling captureScreenshot() needs html2canvas +
	// the capture helper installed in the page frame first (lazy, async). The
	// promise path below tracks it exactly like async eval results.
	const needsCapture = typeof jsCode === 'string' && /\bcaptureScreenshot\s*\(/.test(jsCode);
	if (needsCapture && !isCaptureAllowed()) return { error: captureScreenshotDenied() };
	const runExec = () => exec(jsCode, frameWin || null);
	try {
	// setup failures (no lib, no frame) reject here so toolCall() flags them
	// isError; in-code failures surface as "Error: ..." text via exec instead.
	const runCaptureExec = () => ensureCaptureLib(window)
		.then(() => ensurePageCapture(frameWin)).then(runExec);
		result = (needsCapture && frameWin) ? runCaptureExec() : runExec();
	} catch (e) {
		reveal();
		throw e;
	}
	if (result && typeof result.then === "function") {
		// async code may mutate the page after await points - track until settle
		const done = () => { try { reveal(); } catch (e) {} };
		try { result.then(done, done); } catch (e) {}
		try { setTimeout(done, 30000); } catch (e) {}
		return result;
	}
	reveal();
	return result;
}

function eval_ui(jsCode) {
	return exec(jsCode, window) 
}

//inspect objects to see methods and properties to call
/**
 * Summarizes an object's API (methods and properties) for AI discovery.
 * Call it from evaluated code, e.g. `inspect(Vvveb)` or `inspect(Vvveb.Builder)`.
 *
 * - functions become signature strings (`foo(a, b)`, `async get(url)`, `class Foo`)
 *   instead of full sources, so large namespaces stay small enough for context
 * - objects become their key lists, arrays become `[Array(N)]`, DOM nodes `[TagName]`
 * - null-safe, null-prototype safe, throwing getters become `"[unavailable]"`
 * - includes prototype methods (class methods live on the prototype) up to
 *   3 levels, skipping `Object.prototype`; output is capped (see below)
 *
 * @param {*} obj - Object to inspect.
 * @returns {Object} Map of property name to summary.
 */
function inspect(obj) {
	if (obj === null || obj === undefined) return obj;
	if (typeof obj !== "object" && typeof obj !== "function") return obj;

	const ret = {};
	const hasOwn = (o, k) => { try { return Object.prototype.hasOwnProperty.call(o, k); } catch (e) { return false; } };
	const ctorName = (v) => {
		try { return (v && v.constructor && v.constructor.name) || typeof v; } catch (e) { return typeof v; }
	};
	const signatureOf = (fn) => {
		let src;
		try { src = Function.prototype.toString.call(fn); } catch (e) { return "[function]"; }
		if (typeof src !== "string") return "[function]";
		if (/^\s*class[\s{]/.test(src)) {
			const cm = src.match(/^\s*class\s+[A-Za-z_$][\w$]*/);
			return cm ? cm[0] : "class";
		}
		const m = src.match(/^\s*(?:async\s+)?function\s*\*?\s*[^\(]*\([^\)]*\)/)
			|| src.match(/^\s*(?:async\s+)?\([^\)]*\)\s*=>/)
			|| src.match(/^\s*(?:async\s+)?[A-Za-z_$][\w$]*\s*=>/)
			|| src.match(/^\s*(?:async\s+)?(?:get\s+|set\s+)?[A-Za-z_$][\w$]*\s*\([^\)]*\)/);
		if (m) return m[0];
		return src.length > 200 ? src.slice(0, 200) + "\u2026" : src;
	};
	const summarize = (value) => {
		if (value === null || value === undefined) return value;
		const type = typeof value;
		if (type === "function") return signatureOf(value);
		if (type === "string") return value.length > 500 ? value.slice(0, 500) + "\u2026" : value;
		if (type === "number" || type === "boolean" || type === "bigint" || type === "symbol") return value;
		if (type !== "object") return String(value);
		if (Array.isArray(value)) return `[Array(${value.length})]`;
		if (value.nodeType === 1 && typeof value.outerHTML === "string") return `[${ctorName(value)}]`;
		try {
			const keys = Object.keys(value);
			if (!keys.length) return `[${ctorName(value)}]`;
			return keys.length > inspect.maxKeys
				? keys.slice(0, inspect.maxKeys).concat(`\u2026(${keys.length - inspect.maxKeys} more)`)
				: keys;
		} catch (e) { return "[unavailable]"; }
	};
	const truncated = () => Object.keys(ret).length >= inspect.maxEntries;
	const addEntry = (name, value) => {
		if (truncated() || hasOwn(ret, name)) return false;
		try { ret[name] = summarize(value); } catch (e) { ret[name] = "[unavailable]"; }
		return true;
	};

	// own properties (including non-enumerable ones like class fields)
	let ownNames = [];
	try { ownNames = Object.getOwnPropertyNames(obj); } catch (e) { return {"[unavailable]": String(e && e.message || e)}; }
	for (const property of ownNames) {
		if (truncated()) break;
		let value;
		try { value = obj[property]; } catch (e) { ret[property] = "[unavailable]"; continue; }
		addEntry(property, value);
	}

	// prototype methods (class methods are non-enumerable on the prototype).
	// getters are reported as "[getter]" without invoking them - calling a
	// prototype getter with the prototype as receiver throws for host objects.
	let proto = null;
	try { proto = Object.getPrototypeOf(obj); } catch (e) { proto = null; }
	let depth = 0;
	while (proto && proto !== Object.prototype && depth < 3 && !truncated()) {
		let names = [];
		try { names = Object.getOwnPropertyNames(proto); } catch (e) { break; }
		for (const name of names) {
			if (truncated() || name === "constructor" || hasOwn(ret, name)) continue;
			let descriptor = null;
			try { descriptor = Object.getOwnPropertyDescriptor(proto, name); } catch (e) { continue; }
			if (!descriptor) continue;
			if (typeof descriptor.get === "function" || typeof descriptor.set === "function") {
				addEntry(name, "[getter]");
			} else if ("value" in descriptor) {
				addEntry(name, descriptor.value);
			}
		}
		try { proto = Object.getPrototypeOf(proto); } catch (e) { break; }
		depth++;
	}
	if (truncated()) ret["\u2026"] = "(truncated)";

	return ret;
}
// caps for AI tool payloads
inspect.maxKeys = 100;
inspect.maxEntries = 200;

window.aiAssistantToolHandlers = window.aiAssistantToolHandlers || {};
window.aiAssistantToolHandlers['page'] = eval_page;
window.aiAssistantToolHandlers['ui'] = eval_ui;
// 'builder' is the legacy name of the ui tool (old prompts/sessions) - keep as alias
if (!window.aiAssistantToolHandlers['builder']) window.aiAssistantToolHandlers['builder'] = eval_ui;

async function toolCall(name, args, id = 1) {
	// normalize args - supports string, object or undefined (e.g. code_exec "print('hello')")
	if (args == null) args = {};
	if (typeof args === 'string') {
		let s = args.trim();
		try { args = JSON.parse(s); } catch(e){ args = {input: s, code: s}; }
	}
	// 1) try local AI handlers first (page/ui and any plugin-registered)
	let handler = window.aiAssistantToolHandlers && window.aiAssistantToolHandlers[name];
	if (handler) {
		try {
			let r = await handler(args, {id, name, arguments: args});
			// ensure {content:[{type:"text",text}], structuredContent:object} per MCP spec
			if (r == null) return {content: [{type:"text", text:""}], structuredContent: {}};
			if (typeof r === 'string') return {content: [{type:"text", text: r}], structuredContent: {result: r}};
			if (typeof r === 'object') {
				if (Array.isArray(r.content) && r.structuredContent && typeof r.structuredContent === 'object') return r;
				let text = r.output ?? r.result ?? r.stdout ?? r.text ?? '';
				if (!text && !r.content) text = JSON.stringify(r).slice(0,4000);
				let structured = r.structuredContent ?? r;
				if (typeof structured !== 'object' || Array.isArray(structured)) structured = {result: structured};
				return {
					content: r.content ?? [{type:"text", text: String(text)}],
					structuredContent: structured,
					isError: !!r.error
				};
			}
			return {content: [{type:"text", text: String(r)}], structuredContent: {result: r}};
		} catch(e){
			return {content: [{type:"text", text: e.name + ': ' + e.message}], structuredContent: {error: e.name + ': ' + e.message}, isError: true};
		}
	}
	// 2) fallback to MCP JSON-RPC (local /mcp/ endpoint, proxy will forward to configured server)
	const payload = {
	  jsonrpc: "2.0",
	  method: "tools/call",
	  params: { name, arguments: args },
	  id
	};
	try {
		let res = await fetch('/mcp/', {
			method: 'POST',
			headers: {'Content-Type':'application/json', 'Accept':'application/json'},
			body: JSON.stringify(payload)
		});
		let text = await res.text();
		let data;
		
		try { data = JSON.parse(text); } catch(e){ return {content: [{type:"text", text}], structuredContent: {output: text, result: text}}; }
		
		if (data.error) return {content: [{type:"text", text: data.error.message || JSON.stringify(data.error)}], structuredContent: {error: data.error.message || JSON.stringify(data.error)}, isError: true};
		
		let result = data.result != null ? data.result : data;
		
		if (result && Array.isArray(result.content) && result.structuredContent && typeof result.structuredContent === 'object') return result.structuredContent;
		
		if (result && typeof result === 'object' && result.content) return result.content;
			
			//return {content: result.content ?? [{type:"text", text: JSON.stringify(result).slice(0,4000)}], structuredContent: result.structuredContent};
		
		//let txt = result?.output ?? result?.result ?? (typeof result === 'string' ? result : JSON.stringify(result).slice(0,4000));
		
		//return {content: [{type:"text", text: String(txt)}], structuredContent: typeof result === 'object' ? result : {result}};
		return typeof result === 'object' ? result : {result};
	} catch(e){
		return {content: [{type:"text", text: 'toolCall fetch failed: ' + e.message}], structuredContent: {error: 'toolCall fetch failed: ' + e.message}, isError: true};
	}
}
// expose for AI and console use
window.toolCall = toolCall;
Vvveb.Builder.toolCall = toolCall;
Vvveb.toolCall = toolCall;

/*
//change console log to also return values for eval
const originalLog = window.console.log;
window.console.log = function (...value) {
  originalLog.apply(window.console, value);
  return value;
};
*/
