/**
 * Vvveb AI core - provider agnostic AI request/response pipeline.
 *
 * Exposes a common contract used by the per-provider adapters
 * (aiProviderOpenAI.js, aiProviderAnthropic.js, aiProviderGoogle.js):
 *
 *   adapter = {
 *     name: 'openai',
 *     defaultModel: '...',
 *     buildRequest(payload, options) -> {url, headers, body}
 *         Builds the provider-native request. options.omitAuth suppresses
 *         credentials (used when requests are relayed through the proxy).
 *     parseResponse(data) -> result          // non-streaming JSON response
 *     createStreamParser(emit) -> {feed(chunk), end(), getResult()}
 *     normalizeError(data, response) -> string
 *   }
 *
 * Normalized payload (built by VvvebAi.buildPayload, translated per provider):
 *
 *   {
 *     provider: 'openai'|'anthropic'|'google',
 *     model, temperature, maxTokens, system,
 *     messages: [{
 *       role: 'user'|'assistant'|'tool'|'system',
 *       content: [
 *         {type:'text', text},
 *         {type:'image', mediaType, dataUrl|url|fileId},
 *         {type:'file', mediaType, dataUrl|fileId, filename},
 *         {type:'audio', mediaType, dataUrl},
 *         {type:'tool_call', id, name, arguments},        // assistant turn
 *         {type:'tool_result', toolCallId, name, plain}   // tool/user turn
 *       ]
 *     }],
 *     tools: [{name, description, parameters}],  // JSON schema array
 *     context: [{name, value}],                  // arbitrary metadata objects
 *     stream: true,
 *     thinking: {budgetTokens},                  // optional reasoning budget
 *     options: {key, url, model, apiMode:'proxy'|'direct', proxyUrl, ...}
 *   }
 *
 * Normalized events (emitted to onEvent callback while the request runs):
 *
 *   {type:'start', provider}
 *   {type:'text-delta', text}
 *   {type:'thinking-delta', text}
 *   {type:'tool-call', call:{id, name, arguments}}
 *   {type:'error', error:Error}
 *   {type:'done', result:{text, thinking, toolCalls, usage, stopReason}}
 */
(function (window) {
	"use strict";

	const Ai = window.VvvebAi = window.VvvebAi || {};
//Use Vvveb.StyleManager.getStyle(element, styleProp) and Vvveb.StyleManager.setStyle(element, styleProp, value) to manage css for elements.
	Ai.defaultSystemPrompt =
`You are an expert web developer assistant embedded in the Vvveb CMS visual editor. 
Help create clean, modern HTML with Bootstrap 5 markup, CSS and JavaScript. 
Use page tool to get, insert or edit elements. use document.evaluate('//*[contains(text(),"some text")]/../..\',document);  or document.querySelectorAll("selector") to query elements, the tool evalutes js code in the page window. 
Page css is saved in 'head > style#vvvebjs-styles' use this for all css, move inline styles to css after finish if any added.
Use only page tool for content edit or creation do not use ui tool for content.
Capture a screenshot with 'captureScreenshot("#selector", {"maxDim": 1200})' for specific element or section or 'captureScreenshot()' for whole page , works in both page and ui tools, use only if necessary as last resort.
Use ui tool to control page builder, use inspect(Vvveb) function to check objects, methods and properties to call methods directly.

Available objects:
Vvveb.Gui = {
  undo: function (),
  redo: function (),
  save: function (event, saveUrl = null, saveBtn = null),
  download: function (),
  viewport: function (),
  newPage: function (),
  newSection: function (e, onlyBase = false),
  toggleFileManager: function (),
  toggleLeftColumn: function (),
  toggleRightColumn: function (e, rightColumnEnabled = null),
  toggleTreeList: function (),
  treeListRight: function (),
  treeListPanel: function (),
  zoomChange: function (),
  setState: function (),
  setTheme: function ()
}

Vvveb.FileManager = {
  pages: [
    "index",
  ],
  currentPage: "index",
  clear: function (),
  getPages: function (),
  deletePage: function (element, e),
  renamePage: function (element, e, duplicate = false),
  addPage: function (name, data, afterPage = false, append = true),
  addPages: function (pages),
  addComponent: function (name, url, title, page),
  loadComponents: function (allowedComponents = {}),
  getCurrentUrl: function (),
  getCurrentPage: function (),
  getPageData: function (key),
  loadPage: function (name, allowedComponents = false, disableCache = true, loadComponents = false),
}


Vvveb.StyleManager = {
  breakpoints: [
    sm,
    md,
    lg,
    xl,
    xxl
  ],
  inlineCSS: false,
  currentElement: element,
  currentSelector: "",
  currentBreakpoint: none,
  currentMedia: none,
  state: ,
  themeMode: attribute,
  init: function (doc),
  loadCss: function (),
  getSelectorForElement: function (element),
  setState: function (state),
  setTheme: function (theme = null),
  setMedia: function (media = null),
  addSelectorState: function (selector),
  setStyle: function (element, styleProp, value),
  setCss: function (css),
  getCss: function (css),
  generateCss: function (),
  getStyle: function (element, styleProp)
}


Use optional chaining (?.) to avoid type errors, retry with different code if fails.
After planning start implementing right away`;

	// persistent conversation history - stores all messages with full server context
	Ai.history = [];
	Ai.clearHistory = function(){ Ai.history = []; };
	Ai.pushHistory = function(msg){ if(msg) Ai.history.push(msg); return Ai.history; };
	Ai.getHistory = function(){ return Ai.history.slice(); };

	// ---- sessions persisted to localStorage ----
	Ai.SESSIONS_KEY = 'vv-ai-sessions';
	Ai.CURRENT_KEY = 'vv-ai-current-session';
	try {
		Ai.sessions = JSON.parse(localStorage.getItem(Ai.SESSIONS_KEY) || '[]');
		if (!Array.isArray(Ai.sessions)) Ai.sessions = [];
	} catch(e){ Ai.sessions = []; }
	Ai.currentSessionId = null;
	try { Ai.currentSessionId = localStorage.getItem(Ai.CURRENT_KEY) || null; } catch(e){}
	Ai._genId = function(){ return Date.now().toString(36) + Math.random().toString(36).slice(2,6); };
	// id for the active session, assigned eagerly so the very first request of
	// a session already carries it (saveCurrentSession only runs debounced).
	Ai.ensureSessionId = function(){
		if (!Ai.currentSessionId) {
			Ai.currentSessionId = Ai._genId();
			try { localStorage.setItem(Ai.CURRENT_KEY, Ai.currentSessionId); } catch(e){}
		}
		return Ai.currentSessionId;
	};
	Ai._saveSessions = function(){
		try {
			localStorage.setItem(Ai.SESSIONS_KEY, JSON.stringify(Ai.sessions.slice(0, 50)));
			if (Ai.currentSessionId) localStorage.setItem(Ai.CURRENT_KEY, Ai.currentSessionId);
		} catch(e){}
	};
	Ai._titleForHistory = function(hist){
		// title = start of the first user prompt, skipping [context]/[attachment] lines.
		// NOTE: message text lives in content blocks (Ai.textOf), never in msg.text.
		for (let i=0;i<hist.length;i++){
			if (!hist[i] || hist[i].role !== 'user') continue;
			let lines = String(Ai.textOf(hist[i]) || '').split('\n');
			for (let k=0;k<lines.length;k++){
				let line = lines[k].trim();
				if (!line || line.startsWith('[context]') || line.startsWith('[attachment]') || line.startsWith('[tool ')) continue;
				return line.slice(0,60) || 'Untitled';
			}
		}
		return 'New session ' + new Date().toLocaleString();
	};
	Ai.saveCurrentSession = function(){
		if (!Ai.history.length) return null;
		let id = Ai.currentSessionId;
		let sess = id ? Ai.sessions.find(s=>s.id===id) : null;
		if (!sess){
			id = Ai.ensureSessionId();
			sess = {id, uuid: id, title: Ai._titleForHistory(Ai.history), created: Date.now(), updated: Date.now(), messages: []};
			Ai.sessions.unshift(sess);
			Ai.currentSessionId = id;
		}
		if (!sess.uuid) sess.uuid = sess.id;
		sess.messages = Ai.history.slice(); // iterate over messages object, shallow copy to save memory (no deep clone of content strings)
		sess.title = Ai._titleForHistory(sess.messages);
		sess.updated = Date.now();
		// move to front (most recent)
		Ai.sessions = [sess].concat(Ai.sessions.filter(s=>s.id!==id));
		Ai._saveSessions();
		Ai.scheduleServerSave();
		return sess;
	};
	Ai.newSession = function(){
		if (Ai.history.length) Ai.saveCurrentSession();
		Ai.history = [];
		Ai.currentSessionId = null;
		try { localStorage.removeItem(Ai.CURRENT_KEY); } catch(e){}
		return null;
	};
	Ai.loadSession = function(id){
		let sess = Ai.sessions.find(s=>s.id===id);
		if (!sess) return null;
		Ai.history = (sess.messages || []).slice();
		Ai.currentSessionId = id;
		try { localStorage.setItem(Ai.CURRENT_KEY, id); } catch(e){}
		// metadata-only rows (from server list) lazy-load full messages
		if (!sess.messages && Ai.sessionsEndpoint()) {
			Ai._postSessions('get', {uuid: sess.uuid || sess.id}).then(full => {
				if (full && Array.isArray(full.messages)) {
					sess.messages = full.messages;
					if (Ai.currentSessionId === id) {
						Ai.history = sess.messages.slice();
						try { document.dispatchEvent(new CustomEvent('vvveb-ai-history-updated', {detail:{history: Ai.history}})); } catch(e){}
					}
					Ai._saveSessions();
				}
			}).catch(()=>{});
		}
		return sess;
	};
	Ai.deleteSession = function(id){
		let sess = Ai.sessions.find(s=>s.id===id);
		Ai.sessions = Ai.sessions.filter(s=>s.id!==id);
		if (Ai.currentSessionId===id){ Ai.currentSessionId=null; try{localStorage.removeItem(Ai.CURRENT_KEY);}catch(e){} }
		Ai._saveSessions();
		// mirror to server, fire-and-forget (local stays source of truth when offline)
		if (sess && (sess.uuid || sess.id)) {
			Ai._postSessions('delete', {uuid: sess.uuid || sess.id}).catch(()=>{});
		}
	};
	Ai.listSessions = function(){
		// return iterator over sessions object to save memory (no copy)
		return Ai.sessions;
	};
	/* ---- server side session store (database, source of truth when reachable) ----
	 * localStorage stays as offline cache. Rows sync by client uuid so imports
	 * and retries are idempotent. All server calls fail silent -> local wins. */
	Ai.MIGRATED_KEY = 'vv-ai-sessions-migrated';
	Ai.serverOk = null;
	Ai.sessionsEndpoint = function(){
		let proxy = '';
		try { proxy = (Ai.options() && Ai.options().proxyUrl) || ''; } catch(e){}
		if (!proxy || proxy.indexOf('plugins/ai-assistant/proxy') < 0) return null;
		return proxy.replace('plugins/ai-assistant/proxy', 'plugins/ai-assistant/sessions');
	};
	Ai._postSessions = async function(action, data){
		let base = Ai.sessionsEndpoint();
		if (!base) throw new Error('no sessions endpoint');
		let csrf = '';
		try { csrf = (Ai.options() && Ai.options().csrf) || ''; } catch(e){}
		if (!csrf) { let el = document.querySelector('input[name=csrf]'); csrf = el ? el.value : ''; }
		let url = base + (base.indexOf('?') >= 0 ? '&' : '?') + 'action=' + action;
		let res = await fetch(url, {method: 'POST', headers: {'Content-Type': 'application/json'},
			body: JSON.stringify(Object.assign({csrf}, data || {}))});
		let json = await res.json().catch(()=>null);
		if (!res.ok || !json || json.error) throw new Error((json && json.error && (json.error.message || json.error)) || ('HTTP ' + res.status));
		Ai.serverOk = true;
		return json.data;
	};
	Ai._serverRow = function(sess){
		return {uuid: sess.uuid || sess.id, title: sess.title || 'Untitled',
			messages: sess.messages || [], message_count: (sess.messages || []).length};
	};
	Ai._serverTimer = null;
	Ai.scheduleServerSave = function(){
		clearTimeout(Ai._serverTimer);
		Ai._serverTimer = setTimeout(function(){
			try {
				let id = Ai.currentSessionId;
				let sess = id ? Ai.sessions.find(s=>s.id===id) : null;
				if (!sess || !sess.messages) return;
				Ai._postSessions('save', Ai._serverRow(sess)).catch(()=>{ Ai.serverOk = false; });
			} catch(e){}
		}, 800);
	};
	Ai._fromServerRow = function(r){
		return {id: r.uuid, uuid: r.uuid, title: r.title || 'Untitled',
			messages: undefined, message_count: r.message_count || 0,
			created: r.created_at ? Date.parse(r.created_at) : Date.now(),
			updated: r.updated_at ? Date.parse(r.updated_at) : Date.now()};
	};
	Ai.refreshSessionsFromServer = async function(){
		let data = await Ai._postSessions('index', {});
		let rows = (data && data.data) || [];
		let byUuid = {};
		rows.forEach(r => { if (r && r.uuid) byUuid[r.uuid] = r; });
		// server wins by uuid; keep local-only sessions (created offline) too
		let merged = rows.map(r => Ai._fromServerRow(r));
		Ai.sessions.forEach(s => {
			let uuid = s.uuid || s.id;
			if (uuid && !byUuid[uuid]) merged.push(s);
		});
		merged.sort((a, b) => (b.updated || 0) - (a.updated || 0));
		Ai.sessions = merged.slice(0, 50);
		Ai._saveSessions();
		// adopt server messages for the current session when newer/different
		try {
			let id = Ai.currentSessionId;
			let cur = id ? Ai.sessions.find(s=>s.id===id) : null;
			if (cur && cur.messages === undefined && byUuid[cur.uuid || cur.id]) {
				let full = await Ai._postSessions('get', {uuid: cur.uuid || cur.id});
				if (full && Array.isArray(full.messages)) {
					cur.messages = full.messages;
					if (!Ai.history.length) Ai.history = cur.messages.slice();
					Ai._saveSessions();
					try { document.dispatchEvent(new CustomEvent('vvveb-ai-history-updated', {detail:{history: Ai.history}})); } catch(e){}
				}
			}
		} catch(e){}
		try { document.dispatchEvent(new CustomEvent('vvveb-ai-sessions-updated')); } catch(e){}
		return Ai.sessions;
	};
	Ai.maybeMigrateSessions = async function(){
		let flag = null;
		try { flag = localStorage.getItem(Ai.MIGRATED_KEY); } catch(e){}
		if (flag) return 0;
		let local = Ai.sessions.filter(s => s.messages && s.messages.length).map(s => Ai._serverRow(s));
		if (!local.length) {
			try { localStorage.setItem(Ai.MIGRATED_KEY, '1'); } catch(e){}
			return 0;
		}
		let data = await Ai._postSessions('import', {sessions: local.slice(0, 50)});
		try { localStorage.setItem(Ai.MIGRATED_KEY, '1'); } catch(e){}
		return (data && data.imported) || 0;
	};
	Ai.initServerSessions = function(){
		// fire-and-forget: local cache renders instantly, server refreshes after
		if (!Ai.sessionsEndpoint()) return;
		Ai.refreshSessionsFromServer()
			.then(()=> Ai.maybeMigrateSessions().catch(()=>0))
			.catch(()=>{ Ai.serverOk = false; });
	};
	// auto-save on history changes (debounced) and auto-load last session if history empty
	Ai._saveTimer = null;
	Ai.scheduleSave = function(){
		clearTimeout(Ai._saveTimer);
		Ai._saveTimer = setTimeout(()=> Ai.saveCurrentSession(), 400);
	};
	try {
		if (!Ai.history.length && Ai.currentSessionId){
			let s = Ai.sessions.find(x=>x.id===Ai.currentSessionId);
			if (s && s.messages) Ai.history = s.messages.slice();
		}
	} catch(e){}
	// server is source of truth when reachable; local cache renders first
	try { Ai.initServerSessions(); } catch(e){}
	// wrap pushHistory/clearHistory to auto-save and notify UI autofill
	const _origPush = Ai.pushHistory;
	Ai.pushHistory = function(msg){ const r=_origPush(msg); Ai.scheduleSave(); try{ document.dispatchEvent(new CustomEvent('vvveb-ai-history-updated', {detail:{history: Ai.history}})); }catch(e){} return r; };
	const _origClear = Ai.clearHistory;
	Ai.clearHistory = function(){ const r=_origClear(); Ai.scheduleSave(); try{ document.dispatchEvent(new CustomEvent('vvveb-ai-history-updated', {detail:{history: Ai.history}})); }catch(e){} return r; };

	/* ------------------------------------------------------------------ */
	/* context compact - manual + auto when >90%                        */
	/* ------------------------------------------------------------------ */

	Ai.COMPACT_THRESHOLD = 0.9;
	Ai.COMPACT_KEEP_RECENT = 3;
	Ai.COMPACT_KEEP_EARLY = 1;
	Ai.COMPACT_SUMMARY_TRUNCATE = 80;

	// known context windows (tokens). Fallback 200k per settings default.
	Ai.DEFAULT_CONTEXT_SIZE = 1000000;
	Ai.MODEL_LIMITS = {
		'gpt-4o-mini': 128000, 'gpt-4o': 128000, 'gpt-4-turbo': 128000, 'gpt-4': 8192, 'gpt-3.5-turbo': 16385,
		'o1-mini': 128000, 'o1-preview': 128000, 'o3-mini': 200000, 'o1': 200000,
		'claude-sonnet-4-20250514': 200000, 'claude-3-5-sonnet-20241022': 200000, 'claude-3-opus-20240229': 200000, 'claude-3-haiku-20240307': 200000,
		'gemini-2.0-flash': 1048576, 'gemini-1.5-pro': 2097152, 'gemini-1.5-flash': 1048576, 'gemini-2.0-flash-thinking': 1048576,
		'deepseek-chat': 128000, 'deepseek-coder': 128000, 'deepseek-reasoner': 128000,
		'llama-3.3-70b-versatile': 32768, 'mixtral-8x7b-32768': 32768, 'qwen3-coder': 32768,
		'grok-3-latest': 131072, 'grok-2-latest': 131072
	};

	Ai.estimateTokens = function(str){
		if (!str) return 0;
		str = String(str);
		// rough: ~4 chars per token + overhead for unicode. Matches OpenAI tiktoken average.
		return Math.ceil(str.length / 4);
	};

	Ai.estimateMessagesTokens = function(messages, system){
		let n = 0;
		if (system) n += Ai.estimateTokens(system) + 8;
		for (let m of (messages || [])){
			if (m.role === 'system') n += Ai.estimateTokens(Ai.textOf(m) || m.content) + 4;
			else {
				n += 4; // role overhead
				for (let b of (m.content || [])){
					if (b.type === 'text') n += Ai.estimateTokens(b.text);
					else if (b.type === 'thinking') n += Ai.estimateTokens(b.text);
					else if (b.type === 'tool_call') n += Ai.estimateTokens(b.name) + Ai.estimateTokens(JSON.stringify(b.arguments || '')) + 6;
					else if (b.type === 'tool_result') n += Ai.estimateTokens(b.plain || JSON.stringify(b.content || '')) + 4;
					else if (b.type === 'image' || b.type === 'file' || b.type === 'audio') {
						// don't count full base64 dataUrl (can be MBs) → count as ~600-1000 tokens per file (provider image token estimate)
						let fname = b.filename || '';
						n += 800 + Ai.estimateTokens(fname);
						if (b.mediaType) n += 4;
					}
					else n += Ai.estimateTokens(JSON.stringify(b)) + 2;
				}
				if (m.thinking) n += Ai.estimateTokens(m.thinking);
			}
		}
		return n;
	};

	Ai.getModelLimit = function(model, provider){
		model = String(model || '').toLowerCase();
		provider = String(provider || '').toLowerCase();
		if (Ai.MODEL_LIMITS[model]) return Ai.MODEL_LIMITS[model];
		for (let k in Ai.MODEL_LIMITS) if (model.indexOf(k) !== -1 || k.indexOf(model) !== -1) return Ai.MODEL_LIMITS[k];
		if (provider === 'anthropic') return 1000000;
		if (provider === 'google') return 1048576;
		if (provider === 'openai' || provider === 'deepseek' || provider === 'openrouter' || provider === 'opencode' || provider === 'groq' || provider === 'mistral' || provider === 'xai' || provider === 'nim') return Ai.DEFAULT_CONTEXT_SIZE;
		return Ai.DEFAULT_CONTEXT_SIZE;
	};

	Ai.getContextUsage = function(payload){
		payload = payload || {};
		let model = payload.model || (payload.options && payload.options.model) || '';
		let provider = payload.provider || (payload.options && payload.options.provider) || 'openai';
		let system = payload.system || Ai.defaultSystemPrompt;
		let messages = payload.messages || Ai.history || [];
		let tokens = Ai.estimateMessagesTokens(messages, system);
		if (payload.tools && payload.tools.length) tokens += payload.tools.length * 30;
		// context size is configurable from settings (default 200k), not max_tokens (generation limit)
		let limit = null;
		let cfgSize = payload.contextSize || (payload.options && (payload.options.contextSize || payload.options.context_size)) || (Ai.options() && Ai.options().contextSize) || null;
		if (cfgSize) {
			let n = parseInt(cfgSize, 10);
			if (!isNaN(n) && n > 0) limit = n;
		}
		if (!limit) limit = Ai.getModelLimit(model, provider);
		// if still no limit, fall back to default context size
		if (!limit) limit = Ai.DEFAULT_CONTEXT_SIZE;
		let pct = limit ? tokens / limit : 0;
		return { tokens, limit, pct, remaining: Math.max(0, limit - tokens) };
	};

	Ai.needsCompact = function(payload){
		let u = Ai.getContextUsage(payload);
		return u.pct >= Ai.COMPACT_THRESHOLD;
	};

	Ai.buildCompactSummary = function(middle){
		if (!middle || !middle.length) return '';
		let parts = [];
		let totalLen = 0;
		for (let m of middle){
			let role = (m.role || 'user').toUpperCase();
			let txt = Ai.textOf(m);
			if (!txt && m.content){
				let tcs = (m.content || []).filter(b => b.type === 'tool_call').map(b => b.name + '(' + JSON.stringify(b.arguments||{}).slice(0,60) + ')');
				if (tcs.length) txt = '[' + tcs.join(', ') + ']';
			}
			if (!txt) continue;
			// aggressive truncate per message and overall 800 chars max for summary
			if (txt.length > Ai.COMPACT_SUMMARY_TRUNCATE) txt = txt.slice(0, Ai.COMPACT_SUMMARY_TRUNCATE) + '…';
			let line = role + ': ' + txt.replace(/\n+/g, ' ');
			if (totalLen + line.length > 800) { parts.push('… +' + (middle.length - parts.length) + ' more'); break; }
			parts.push(line);
			totalLen += line.length + 1;
		}
		return parts.join('\n');
	};

	/**
	 * Compact history in place. Keeps early + recent, replaces middle with a summary.
	 * Returns {compacted, before, after, tokensBefore, tokensAfter, summaryTokens} or null if not needed.
	 */
	Ai.compact = function(opts){
		opts = opts || {};
		if (!Ai.isIdle() && !opts.force) return Ai._enqueueCompact('heuristic', opts);
		let hist = Ai.history;
		if (!hist || hist.length <= (opts.keepRecent || Ai.COMPACT_KEEP_RECENT) + (opts.keepEarly || Ai.COMPACT_KEEP_EARLY)) return null;
		let keepRecent = opts.keepRecent != null ? opts.keepRecent : Ai.COMPACT_KEEP_RECENT;
		let keepEarly = opts.keepEarly != null ? opts.keepEarly : Ai.COMPACT_KEEP_EARLY;
		let tokensBefore = Ai.estimateMessagesTokens(hist, opts.system || Ai.defaultSystemPrompt);
		let early = hist.slice(0, keepEarly);
		let middle = hist.slice(keepEarly, hist.length - keepRecent);
		let recent = hist.slice(hist.length - keepRecent);
		if (!middle.length) return null;
		let summaryText = Ai.buildCompactSummary(middle);
		if (!summaryText) return null;
		let summaryMsg = {
			role: 'assistant',
			content: [{ type: 'text', text: '[Compact summary of ' + middle.length + ' earlier messages — auto-compacted at ' + new Date().toISOString() + ']\n' + summaryText }],
			_compactSummary: true,
			_compactedCount: middle.length,
			_compactedAt: Date.now()
		};
		let before = hist.length;
		Ai.history = early.concat([summaryMsg]).concat(recent);
		let tokensAfter = Ai.estimateMessagesTokens(Ai.history, opts.system || Ai.defaultSystemPrompt);
		Ai.scheduleSave();
		try { Ai.saveCurrentSession(); } catch(e){}
		try { document.dispatchEvent(new CustomEvent('vvveb-ai-history-updated', {detail:{history: Ai.history, compact:true}})); } catch(e){}
		return { compacted: true, before, after: Ai.history.length, tokensBefore, tokensAfter, savedTokens: tokensBefore - tokensAfter, summary: summaryText };
	};

	/**
	 * LLM-based compact: asks the model to summarize middle history.
	 * Falls back to local heuristic on failure. Returns Promise<compactResult>.
	 */
	Ai.compactWithLLM = async function(opts){
		opts = opts || {};
		if (!Ai.isIdle() && !opts.force) return Ai._enqueueCompact('llm', opts);
		let hist = Ai.history;
		if (!hist || hist.length <= (opts.keepRecent || Ai.COMPACT_KEEP_RECENT) + (opts.keepEarly || Ai.COMPACT_KEEP_EARLY)) return null;
		let keepRecent = opts.keepRecent != null ? opts.keepRecent : Ai.COMPACT_KEEP_RECENT;
		let keepEarly = opts.keepEarly != null ? opts.keepEarly : Ai.COMPACT_KEEP_EARLY;
		let early = hist.slice(0, keepEarly);
		let middle = hist.slice(keepEarly, hist.length - keepRecent);
		let recent = hist.slice(hist.length - keepRecent);
		if (!middle.length) return null;
		let conversation = middle.map(m => (m.role||'user').toUpperCase() + ': ' + (Ai.textOf(m) || JSON.stringify(m.content||'').slice(0,500))).join('\n\n');
		let prompt = 'Summarize the following conversation history concisely, preserving key decisions, facts, and pending tasks. Keep under 300 words, no preamble:\n\n' + conversation;
		let payload = Ai.buildPayload({
			provider: opts.provider || (Ai.options() && Ai.options().provider) || 'openai',
			model: opts.model,
			text: prompt,
			preserveHistory: false,
			system: 'You are a conversation summarizer. Output only the summary.',
			options: opts.options || Ai.options()
		});
		// avoid recursion: mark as compact sub-request
		payload._isCompact = true;
		try {
			let result = await Ai.sendQuery(payload, function(){});
			let summaryText = (result && result.text) ? result.text.trim().slice(0, 2000) : '';
			if (!summaryText) throw new Error('empty summary');
			let summaryMsg = { role: 'assistant', content: [{ type: 'text', text: '[LLM summary of ' + middle.length + ' earlier messages]\n' + summaryText }], _compactSummary: true, _compactedCount: middle.length, _compactedAt: Date.now(), _llmSummary: true };
			let before = hist.length;
			let tokensBefore = Ai.estimateMessagesTokens(hist, opts.system || Ai.defaultSystemPrompt);
			Ai.history = early.concat([summaryMsg]).concat(recent);
			let tokensAfter = Ai.estimateMessagesTokens(Ai.history, opts.system || Ai.defaultSystemPrompt);
			Ai.scheduleSave();
			try { Ai.saveCurrentSession(); } catch(e){}
			try { document.dispatchEvent(new CustomEvent('vvveb-ai-history-updated', {detail:{history: Ai.history, compact:true}})); } catch(e){}
			return { compacted: true, before, after: Ai.history.length, tokensBefore, tokensAfter, savedTokens: tokensBefore - tokensAfter, summary: summaryText, llm: true };
		} catch(e){
			// fallback to heuristic
			return Ai.compact(opts);
		}
	};

	Ai.autoCompact = function(payload){
		if (!payload || payload._isCompact) return null;
		if (!Ai.isIdle() && !payload._forceCompact) {
			Ai._enqueueCompact('heuristic', {system: payload.system, provider: payload.provider, model: payload.model});
			return null;
		}
		let last = null;
		let attempts = 0;
		// loop while still over threshold and history can be compacted further (aggressive for large histories)
		while (Ai.needsCompact(payload) && attempts < 3) {
			let r = Ai.compact({ system: payload.system, provider: payload.provider, model: payload.model });
			if (!r) break;
			last = r;
			// rebuild payload messages from compacted history for next needsCompact check
			if (payload.messages && payload._lastHistoryLength != null) {
				let lastMsg = payload.messages[payload.messages.length-1];
				if (lastMsg && lastMsg.role === 'user') payload.messages = Ai.history.slice().concat([lastMsg]);
				payload._lastHistoryLength = Ai.history.length;
			}
			attempts++;
			if (attempts >= 3) break;
		}
		return last;
	};

	// stop reasons meaning "cut by the output token limit": openai chat 'length',
	// responses api 'incomplete', anthropic 'max_tokens', google 'MAX_TOKENS'
	Ai.TRUNCATED_REASONS = ['length', 'incomplete', 'max_tokens', 'max-tokens', 'MAX_TOKENS'];
	Ai.isTruncated = function(reason){
		return !!reason && Ai.TRUNCATED_REASONS.indexOf(String(reason)) >= 0;
	};
	// helper to create a full assistant message from server result (text+thinking+tools+usage)
	// usage/stopReason are stored as metadata (not visible content) so they don't leak to the UI
	Ai.createAssistantMessage = function(result){
		result = result || {};
		const content = [];
		let cleanThinking = result.thinking ? Ai.stripThinkingTags(String(result.thinking)) : "";
		let cleanText = result.text ? Ai.stripThinkingTags(Ai.stripToolCallTags(String(result.text))) : "";
		if (cleanThinking && cleanThinking.trim()) content.push({ type: 'thinking', text: cleanThinking });
		if (cleanText && cleanText.trim()) content.push({ type: 'text', text: cleanText });
		else if (result.text && String(result.text).trim()) content.push({ type: 'text', text: String(result.text).trim() });
		for (const c of [].concat(result.toolCalls || [])) {
			content.push({ type: 'tool_call', id: c.id, name: c.name, arguments: c.arguments });
		}
		if (!content.length) content.push({ type: 'text', text: '' });
		return { role: 'assistant', content, thinking: result.thinking, usage: result.usage, stopReason: result.stopReason, raw: result.raw };
	};

	const providers = {};

	Ai.registerProvider = function (name, adapter) {
		providers[String(name).toLowerCase()] = adapter;
	};

	Ai.getProvider = function (name) {
		return providers[String(name || 'openai').toLowerCase()] || null;
	};

	Ai.providerNames = function () {
		return Object.keys(providers);
	};

	/* ------------------------------------------------------------------ */
	/* shared helpers                                                      */
	/* ------------------------------------------------------------------ */

	Ai.safeJsonParse = function (text, fallback) {
		if (text == null) return fallback;
		if (typeof text === 'object') return text;
		try { return JSON.parse(text); } catch (e) { return fallback; }
	};

	// split a data-url into [meta, base64-payload]
	Ai.dataUrlParts = function (dataUrl) {
		const i = String(dataUrl || '').indexOf(',');
		if (i < 0) return ['', ''];
		return [String(dataUrl).slice(0, i), String(dataUrl).slice(i + 1)];
	};

	Ai.numOr = function (value, fallback, min, max) {
		let n = parseFloat(value);
		if (isNaN(n)) n = parseFloat(fallback);
		if (isNaN(n)) n = 0;
		if (min != null && n < min) n = min;
		if (max != null && n > max) n = max;
		return n;
	};

	// plain text of a message (concatenating text blocks)
	Ai.textOf = function (message) {
		return (message.content || [])
			.filter(b => b.type === 'text')
			.map(b => b.text)
			.join('\n');
	};

	Ai.escapeHtml = function(s){
		return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
	};

	// strip any <tool_calls> / <invoke> / <tool_call> XML that may spill into text/thinking (stream-safe)
	Ai.stripToolCallTags = function(s){
		if (!s) return s;
		let out = String(s);
		// remove complete blocks
		out = out.replace(/<tool_calls>[\s\S]*?<\/tool_calls>/gi, '');
		out = out.replace(/<tool_call>[\s\S]*?<\/tool_call>/gi, '');
		out = out.replace(/<invoke[^>]*>[\s\S]*?<\/invoke>/gi, '');
		// remove any remaining opening tags without closing (partial streaming) - keep trailing text before tag
		// if string ends with an incomplete tag, truncate before it
		let idx = out.search(/<(tool_calls|tool_call|invoke)[^>]*$/i);
		if (idx !== -1) out = out.slice(0, idx);
		// remove any stray tags
		out = out.replace(/<\/?tool_calls>/gi, '');
		out = out.replace(/<\/?tool_call[^>]*>/gi, '');
		out = out.replace(/<\/?invoke[^>]*>/gi, '');
		// also strip <parameter> wrappers that may be inside
		out = out.replace(/<parameter[^>]*>/gi, '');
		out = out.replace(/<\/parameter>/gi, '');
		return out;
	};

	// strip [thinking] / <think> wrappers that should be internal only (e.g. [thinking] ... </think:6124c78e>)
	// keeps inner content, removes only the wrapper tags
	Ai.stripThinkingTags = function(s){
		if (!s) return s;
		let out = String(s);
		// keep inner content for block wrappers - handle mixed [thinking] ... </think:...> as well
		out = out.replace(/\[thinking[^\]]*\]([\s\S]*?)\[\/thinking[^\]]*\]/gi, '$1');
		out = out.replace(/\[think[^\]]*\]([\s\S]*?)\[\/think[^\]]*\]/gi, '$1');
		out = out.replace(/<think[^>]*>([\s\S]*?)<\/think[^>]*>/gi, '$1');
		out = out.replace(/\[thinking[^\]]*\]([\s\S]*?)<\/think[^>]*>/gi, '$1');
		out = out.replace(/<think[^>]*>([\s\S]*?)\[\/think[^\]]*\]/gi, '$1');
		// remove any remaining opening/closing tags (including </think:6124c78e> and [thinking] )
		out = out.replace(/\[\/?thinking[^\]]*\]/gi, '');
		out = out.replace(/\[\/?think[^\]]*\]/gi, '');
		out = out.replace(/<\/?think[^>]*>/gi, '');
		out = out.replace(/<\/think:[^>]+>/gi, '');
		// handle truncated tags at end from streaming
		let idx2 = out.search(/<(think)[^>]*$/i);
		if (idx2 !== -1) out = out.slice(0, idx2);
		let idx3 = out.search(/\[\/?think[^\]]*$/i);
		if (idx3 !== -1) out = out.slice(0, idx3);
		return out;
	};

	Ai.sanitizeText = function(s){
		if (!s) return s;
		let out = Ai.stripToolCallTags(s);
		out = Ai.stripThinkingTags(out);
		return out;
	};

	Ai.extractToolCallsFromText = function(text){
		let calls = [];
		if (!text) return calls;
		let s = String(text);
		// try to find <invoke name="page"> with code
		let re = /<invoke[^>]*name=["']?page["']?[^>]*>([\s\S]*?)<\/invoke>/gi;
		let m;
		while ((m = re.exec(s)) !== null){
			let inner = m[1];
			// try to extract <code> or <parameter name="code">
			let codeMatch = inner.match(/<code[^>]*>([\s\S]*?)<\/code>/i) || inner.match(/<parameter[^>]*name=["']?code["']?[^>]*>([\s\S]*?)<\/parameter>/i);
			let code = codeMatch ? codeMatch[1] : inner;
			// strip any remaining tags
			code = code.replace(/<[^>]+>/g, '').trim();
			if (code) calls.push({ id: 'call_' + Math.random().toString(36).slice(2), name: 'page', arguments: { code } });
		}
		// also handle <tool_call> with json
		let re2 = /<tool_call[^>]*>([\s\S]*?)<\/tool_call>/gi;
		while ((m = re2.exec(s)) !== null){
			try { let obj = JSON.parse(m[1]); if (obj.name) calls.push(obj); } catch(e){}
		}
		
		return calls;
		
		// also handle markdown fenced code blocks that look like exec JS (streaming may contain ```js ... ```)
		// only treat as exec if code contains Vvveb/document/window/exec patterns and is not pure html
		let re3 = /```(?:js|javascript)?\n([\s\S]*?)```/gi;
		while ((m = re3.exec(s)) !== null){
			let code = m[1].trim();
			if (!code) continue;
			// avoid treating pure html snippets as exec - exec is for JS, html should be inserted via page tool
			let isJs = /\b(document|window|Vvveb|page|eval|querySelector|getElementById|\$\()\b/.test(code) || /\b(function|const|let|var|if|for|return)\b/.test(code);
			let isHtml = /^\s*<\w+[^>]*>/.test(code) && /<\/\w+>/.test(code) && !isJs;
			if (isJs && !isHtml){
				// avoid duplicate if already captured via <invoke>
				if (!calls.some(c => c.arguments && c.arguments.code === code)){
					calls.push({ id: 'call_' + Math.random().toString(36).slice(2), name: 'page', arguments: { code } });
				}
			}
		}
		return calls;
	};

	// helper to check if text contains exec-like code that should be treated as tool call even without explicit tool_calls
	Ai.shouldAutoExec = function(text){
		if (!text) return false;
		let calls = Ai.extractToolCallsFromText(text);
		return calls.length > 0;
	};

	// lightweight markdown -> html for assistant chat bubbles (stream-safe, preserves code fences)
	Ai.markdownToHtml = function(md){
		if (!md) return "";
		let html = String(md);
		// strip any tool-call XML and thinking wrappers that should stay internal
		html = Ai.stripToolCallTags(html);
		html = Ai.stripThinkingTags(html);
		// protect fenced code blocks ```lang \n code \n```
		const codeBlocks = [];
		html = html.replace(/```(\w*)\n([\s\S]*?)```/g, function(m, lang, code){
			let ph = "⟦CB" + codeBlocks.length + "⟧";
			codeBlocks.push('<pre><code' + (lang ? ' class="language-' + Ai.escapeHtml(lang) + '"' : '') + '>' + Ai.escapeHtml(code.trim()) + '</code></pre>');
			return ph;
		});
		// protect inline code `code`
		const inlineCodes = [];
		html = html.replace(/`([^`\n]+?)`/g, function(m, code){
			let ph = "⟦IC" + inlineCodes.length + "⟧";
			inlineCodes.push('<code>' + Ai.escapeHtml(code) + '</code>');
			return ph;
		});
		// escape remaining html to avoid XSS - keep placeholders intact
		html = Ai.escapeHtml(html);
		// headings # ## ###
		html = html.replace(/^######\s+(.*)$/gm, '<h6>$1</h6>');
		html = html.replace(/^#####\s+(.*)$/gm, '<h5>$1</h5>');
		html = html.replace(/^####\s+(.*)$/gm, '<h4>$1</h4>');
		html = html.replace(/^###\s+(.*)$/gm, '<h3>$1</h3>');
		html = html.replace(/^##\s+(.*)$/gm, '<h2>$1</h2>');
		html = html.replace(/^#\s+(.*)$/gm, '<h1>$1</h1>');
		// hr --- *** ___
		html = html.replace(/^\s*([-*_]\s*){3,}$/gm, '<hr>');
		// blockquote > text
		html = html.replace(/^&gt;\s?(.*)$/gm, '<blockquote>$1</blockquote>');
		// tables | header | header |\n| --- | --- |\n| cell | cell |
		html = (function(text){
			let lines = text.split('\n');
			let out = [];
			let i = 0;
			let sepPattern = /^\|?[\s]*:?-+:?[\s]*(\|[\s]*:?-+:?[\s]*)*\|?[\s]*$/;
			while (i < lines.length){
				let line = lines[i];
				let next = lines[i+1] || '';
				let isHeader = line.includes('|');
				let isSep = sepPattern.test(next.trim());
				if (isHeader && isSep){
					let headerRow = line;
					let sepRow = next;
					let bodyRows = [];
					let k = i+2;
					while (k < lines.length && lines[k].trim() !== '' && lines[k].includes('|')){
						// stop if line is separator-like but not data? already handled, body rows are data
						bodyRows.push(lines[k]);
						k++;
					}
					function splitRow(row){
						let r = row.trim();
						if (r.startsWith('|')) r = r.slice(1);
						if (r.endsWith('|')) r = r.slice(0, -1);
						return r.split('|').map(c => c.trim());
					}
					let headers = splitRow(headerRow);
					let seps = splitRow(sepRow);
					let aligns = seps.map(s=>{
						s=s.trim();
						if (s.startsWith(':') && s.endsWith(':')) return 'center';
						if (s.startsWith(':')) return 'left';
						if (s.endsWith(':')) return 'right';
						return 'left';
					});
					let htmlTable = '<table>';
					htmlTable += '<thead><tr>';
					for (let idx=0; idx<headers.length; idx++){
						let h = headers[idx] || '';
						let a = aligns[idx] || 'left';
						htmlTable += '<th style="text-align:'+a+'">' + h + '</th>';
					}
					htmlTable += '</tr></thead>';
					htmlTable += '<tbody>';
					for (let row of bodyRows){
						let cells = splitRow(row);
						htmlTable += '<tr>';
						for (let idx=0; idx<headers.length; idx++){
							let c = cells[idx] || '';
							let a = aligns[idx] || 'left';
							htmlTable += '<td style="text-align:'+a+'">' + c + '</td>';
						}
						htmlTable += '</tr>';
					}
					htmlTable += '</tbody></table>';
					out.push(htmlTable);
					i = k;
					continue;
				}
				out.push(line);
				i++;
			}
			return out.join('\n');
		})(html);
		// images ![alt](url)
		html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img alt="$1" src="$2" style="max-width:100%">');
		// links [text](url)
		html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
		// bold **text** __text__
		html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
		html = html.replace(/__([^_]+?)__/g, '<strong>$1</strong>');
		// strikethrough ~~text~~
		html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');
		// italic *text* _text_ (avoid matching bold)
		html = html.replace(/(^|[^*])\*([^\*\n]+?)\*(?=[^*]|$)/g, '$1<em>$2</em>');
		html = html.replace(/(^|[^_])_([^_\n]+?)_(?=[^_]|$)/g, '$1<em>$2</em>');
		// unordered lists - and *
		html = html.replace(/^\s*[-*+]\s+(.*)$/gm, '<li>$1</li>');
		html = html.replace(/(<li>.*<\/li>\n?)+/g, function(m){ return '<ul>' + m + '</ul>'; });
		// ordered lists 1. 2.
		html = html.replace(/^\s*\d+\.\s+(.*)$/gm, '<li class="ol">$1</li>');
		html = html.replace(/(<li class="ol">.*<\/li>\n?)+/g, function(m){ return '<ol>' + m.replace(/ class="ol"/g,'') + '</ol>'; });
		// paragraphs - split by double newline, wrap loose text
		html = html.split(/\n{2,}/).map(function(block){
			let b = block.trim();
			if (!b) return '';
			if (/^<(h[1-6]|ul|ol|blockquote|pre|hr|table)/.test(b)) return b;
			// convert single newlines to <br> inside paragraphs
			b = b.replace(/\n/g, '<br>');
			return '<p>' + b + '</p>';
		}).join('\n');
		// restore inline code and code blocks
		html = html.replace(/⟦IC(\d+)⟧/g, function(m,i){ return inlineCodes[Number(i)] || ''; });
		html = html.replace(/⟦CB(\d+)⟧/g, function(m,i){ return codeBlocks[Number(i)] || ''; });
		// merge adjacent blockquotes
		html = html.replace(/<\/blockquote>\n<blockquote>/g, '<br>');
		return html;
	};

	Ai.extractHtmlFromMarkdown = function(md){
		if (!md) return "";
		let s = String(md);
		// prefer fenced html code block
		let m = s.match(/```(?:html|HTML)?\n([\s\S]*?)```/);
		if (m) return m[1].trim();
		// generic fenced code block - if single block, assume it is html to insert
		let blocks = [...s.matchAll(/```\w*\n([\s\S]*?)```/g)];
		if (blocks.length === 1) return blocks[0][1].trim();
		// if contains html tags and no markdown markers, treat as raw html
		if (/<\w[^>]*>/.test(s) && !/^(#{1,6}\s|\*\*|__|[\*\-+]\s|\d+\.\s|\[.*\]\(.*\)|!\[)/m.test(s)) return s.trim();
		// otherwise markdown -> html (for insertion we want rendered html without code fences)
		return Ai.markdownToHtml(s);
	};

	/**
	 * Generic provider error extraction.
	 * Handles OpenAI ({error:{message}}), Anthropic ({error:{type,message}})
	 * Google ({error:{code,message,status}}).
	 */
	Ai.extractError = function (data) {
		if (!data) return 'Unknown error';
		if (typeof data === 'string') return data;
		if (typeof data.error === 'string') return data.error;
		if (data.error && typeof data.error === 'object') {
			const e = data.error;
			if (typeof e.message === 'string') {
				return e.message + (e.code ? ' (' + e.code + ')' : '') + (e.type ? ' [' + e.type + ']' : '') + (e.model ? ' model:' + e.model : '');
			}
			return Object.keys(e).map(k => k + ':' + e[k]).join('\n');
		}
		// handle {object:"error", model:"glm-4.7", error:..., message} shapes
		if (data.object === 'error'){
			let msg = data.error || data.message || '';
			if (typeof msg === 'object' && msg.message) msg = msg.message;
			if (typeof msg === 'string' && msg) return msg + (data.model ? ' (model:' + data.model + ')' : '');
			if (data.model) return 'Model ' + data.model + ' error: ' + (msg ? JSON.stringify(msg).slice(0,200) : JSON.stringify(data).slice(0,300));
			return JSON.stringify(data).slice(0, 300);
		}
		if (data.model && data.object === 'error') return 'Model ' + data.model + ' error: ' + JSON.stringify(data).slice(0,300);
		if (typeof data.message === 'string') return data.message + (data.model ? ' (model:' + data.model + ')' : '');
		try { return JSON.stringify(data).slice(0, 300); } catch (e) { return String(data); }
	};

	/* ------------------------------------------------------------------ */
	/* SSE stream parsing                                                  */
	/* ------------------------------------------------------------------ */

	/**
	 * Creates an incremental server-sent-events parser.
	 * feed(chunk) with raw text chunks, end() when the stream closes.
	 * Every complete `data:` payload is passed to onData as a string.
	 */
	Ai.createSseParser = function (onData) {
		let buffer = '';
		let dataLines = [];

		function flushData() {
			if (dataLines.length) {
				const data = dataLines.join('\n');
				dataLines = [];
				onData(data);
			}
		}

		function processLine(line) {
			line = line.replace(/\r$/, '');
			if (line === '') { flushData(); return; }
			if (line.charAt(0) === ':') return; // comment / keep-alive
			if (line.startsWith('data:')) {
				dataLines.push(line.slice(5).replace(/^ /, ''));
			}
			// `event:`/`id:`/`retry:` fields are ignored, providers encode
			// the event type inside the json payload itself
		}

		return {
			feed(chunk) {
				buffer += chunk;
				let nl;
				while ((nl = buffer.indexOf('\n')) !== -1) {
					processLine(buffer.slice(0, nl));
					buffer = buffer.slice(nl + 1);
				}
			},
			end() {
				if (buffer !== '') processLine(buffer);
				buffer = '';
				flushData();
			}
		};
	};

	/* ------------------------------------------------------------------ */
	/* plugin options                                                      */
	/* ------------------------------------------------------------------ */

	/**
	 * Reads the aiAssistantOptions global injected by the plugin and returns a
	 * normalized option object. Per-provider keys (openai_key, anthropic_key,
	 * google_key) fall back to the generic `key`.
	 */
	Ai.options = function () {
		const o = (typeof window.aiAssistantOptions !== 'undefined' && window.aiAssistantOptions) ? window.aiAssistantOptions :  {};
		const provider = String(o.provider ?? 'openai').toLowerCase();
		return {
			provider,
			model: o.model ?? '',
			key: o[provider + '_key'] || o.key || '',
			url: o.url ?? '',
			temperature: parseFloat(o.temperature ?? 0),
			maxTokens: parseInt(o.max_tokens ?? 120000, 10),
			contextSize: parseInt(o.context_size ?? o.contextSize ?? 1000000, 10) || 1000000,
			imageModel: o.image_model ?? o.imageModel ?? 'dall-e-3',
			imageSize: o.image_size ?? o.imageSize ?? '1024x1024',
			systemPrompt: o.system_prompt || Ai.defaultSystemPrompt,
			apiMode: String(o.api_mode ?? 'proxy').toLowerCase(),
			proxyUrl: o.proxy_url ?? '',
			csrf: (() => { const el = document.querySelector("input[name=csrf]"); return el ? el.value : ''; })()
		};
	};

	/* ------------------------------------------------------------------ */
	/* payload building                                                    */
	/* ------------------------------------------------------------------ */

	/**
	 * Reads a File/Blob into a normalized attachment block:
	 *   {type:'image'|'audio'|'file', mediaType, dataUrl, filename}
	 */
	Ai.fileToBlock = function (file) {
		return new Promise(function (resolve, reject) {
			const reader = new FileReader();
			reader.onload = function () {
				const dataUrl = String(reader.result || '');
				const meta = Ai.dataUrlParts(dataUrl)[0]; // data:<mime>;base64
				const m = meta.match(/^data:([^;,]*)/);
				const mediaType = (m && m[1]) || file.type || 'application/octet-stream';
				const block = { type: 'file', mediaType, dataUrl, filename: file.name || 'attachment' };
				if (/^image\//.test(mediaType)) block.type = 'image';
				else if (/^audio\//.test(mediaType)) block.type = 'audio';
				resolve(block);
			};
			reader.onerror = function () { reject(reader.error || new Error('Could not read file')); };
			reader.readAsDataURL(file);
		});
	};

	/**
	 * Builds the internal provider-agnostic payload from UI-level options.
	 */
	Ai.buildPayload = function (opts) {
		opts = opts || {};
		// recover history from active session if in-memory history was lost (e.g., after network error or hard reload without session restore)
		if ((!Ai.history || !Ai.history.length) && Ai.currentSessionId) {
			try {
				let sess = Ai.sessions.find(s=>s.id===Ai.currentSessionId);
				if (sess && sess.messages && sess.messages.length) Ai.history = sess.messages.slice();
			} catch(e){}
		}
		// snapshot for network-error recovery: remember last known good history length
		if (Ai._lastGoodHistory == null) Ai._lastGoodHistory = Ai.history.length;
		if (Ai.history.length) Ai._lastGoodHistory = Ai.history.length;
		const blocks = [];

		for (const c of [].concat(opts.context || [])) {
			if (!c) continue;
			blocks.push({
				type: 'text',
				text: '[context] ' + (c.name ? c.name + ': ' : '') +
					(typeof c.value === 'string' ? c.value : JSON.stringify(c.value))
			});
		}

		for (const a of [].concat(opts.attachments || [])) {
			if (a) blocks.push(a);
		}

		if (opts.text) blocks.push({ type: 'text', text: String(opts.text) });
		if (opts.input && !opts.text && blocks.length === 0) blocks.push({ type: 'text', text: String(opts.input) });
		if (!blocks.length) blocks.push({ type: 'text', text: '' });

		const options = opts.options || {};
		// build messages with full history: if opts.messages provided use it, else prepend Ai.history
		// also support opts.input for Responses API style (custom tools use input)
		// fix: after network error, opts.messages may be [] (empty) – treat as not provided so history is not lost
		let messages;
		let input = opts.input || null;
		if (opts.messages && Array.isArray(opts.messages) && opts.messages.length) {
			messages = opts.messages.slice();
		} else if (opts.messages && Array.isArray(opts.messages) && !opts.messages.length && opts.preserveHistory !== false && Ai.history.length) {
			// empty messages array passed (e.g., retry after network error) – fall back to history + new turn instead of losing all
			messages = Ai.history.slice().concat([{ role: 'user', content: blocks }]);
			if (input) { let last = messages[messages.length-1]; if (last && last.role === 'user') last.content = [{ type: 'text', text: String(input) }]; }
		} else if (input && !blocks.length) {
			messages = [{ role: 'user', content: [{ type: 'text', text: String(input) }] }];
		} else if (opts.preserveHistory !== false && Ai.history.length) {
			messages = Ai.history.slice().concat([{ role: 'user', content: blocks }]);
			if (input) {
				// if input provided alongside history, replace last user block text with input
				let last = messages[messages.length-1];
				if (last && last.role === 'user') last.content = [{ type: 'text', text: String(input) }];
			}
		} else {
			messages = [{ role: 'user', content: blocks }];
			if (input && messages[0]) messages[0].content = [{ type: 'text', text: String(input) }];
		}
		
		return {
			provider: (opts.provider || options.provider || 'openai').toLowerCase(),
			model: opts.model || options.model,
			system: opts.system != null ? opts.system : Ai.defaultSystemPrompt,
			input: input || (opts.text ? String(opts.text) : null),
			messages,
			tools: opts.tools || [],
			temperature: opts.temperature,
			maxTokens: opts.maxTokens,
			contextSize: opts.contextSize || opts.context_size || options.contextSize || options.context_size || (Ai.options() && Ai.options().contextSize) || Ai.DEFAULT_CONTEXT_SIZE,
			thinking: opts.thinking,
			context: opts.context || [],
			stream: opts.stream !== false && typeof window.ReadableStream !== 'undefined',
			options,
			// snapshot of full server context for history replay
			_lastHistoryLength: Ai.history.length
		};
	};

	/**
	 * Builds a follow-up payload that appends tool results to the previous
	 * conversation so the model can continue after tool execution.
	 *
	 * @param previousPayload payload returned by buildPayload()
	 * @param results [{toolCallId, name, content}] content: string|object
	 */
	Ai.continueWithToolResults = function (previousPayload, results) {
		const prev = previousPayload || {};
		const calls = prev._lastToolCalls || [];
		const messages = (prev.messages || []).slice();

		if (calls.length) {
			const content = [];
			// preserve all server context: thinking, text, and tool calls (usage/stopReason kept as metadata, not visible)
			let cleanThinking = prev._lastThinking ? Ai.stripThinkingTags(Ai.stripToolCallTags(String(prev._lastThinking))) : "";
			let cleanText = prev._lastText ? Ai.stripThinkingTags(Ai.stripToolCallTags(String(prev._lastText))) : "";
			if (cleanThinking && cleanThinking.trim()) content.push({ type: 'thinking', text: cleanThinking });
			else if (prev._lastThinking) content.push({ type: 'thinking', text: String(prev._lastThinking) });
			if (cleanText && cleanText.trim()) content.push({ type: 'text', text: cleanText });
			else if (prev._lastText) content.push({ type: 'text', text: String(prev._lastText) });
			for (const c of calls) content.push({ type: 'tool_call', id: c.id, name: c.name, arguments: c.arguments });
			messages.push({
				role: 'assistant',
				content,
				thinking: prev._lastThinking || undefined,
				usage: prev._lastUsage || undefined,
				stopReason: prev._lastStopReason || undefined
			});
		}
		for (const r of [].concat(results || [])) {
			messages.push({
				role: 'tool',
				toolCallId: r.toolCallId,
				name: r.name,
				content: [{ type: 'text', text: typeof r.content === 'string' ? r.content : JSON.stringify(r.content) }]
			});
		}

		// clear transient server context after forking
		return Object.assign({}, prev, { messages, _lastToolCalls: [], _lastText: "", _lastThinking: "", _lastUsage: null, _lastStopReason: null });
	};

	/* ------------------------------------------------------------------ */
	/* transport                                                           */
	/* ------------------------------------------------------------------ */

	// current abort controller for cancel button
	Ai.currentAbort = null;
	Ai._busy = false;
	// max silence between stream chunks before the run is failed as stalled
	Ai.STREAM_STALL_MS = 180000;
	Ai._setBusy = function(busy){
		Ai._busy = !!busy;
		try { document.dispatchEvent(new CustomEvent(busy ? 'vvveb-ai-busy' : 'vvveb-ai-idle', {detail: busy ? {busy:true} : {idle:true}})); } catch(e){}
	};
	// idle-gated compaction queue: requests made while busy wait here and run
	// on the next vvveb-ai-idle instead of being dropped. Latest opts win per kind.
	Ai._compactQueue = [];
	Ai._enqueueCompact = function(kind, opts){
		Ai._compactQueue = Ai._compactQueue.filter(q => q.kind !== kind).concat([{kind, opts: opts || {}}]).slice(-4);
		try { console.warn('compact queued (' + kind + '): model busy, will run when idle'); } catch(e){}
		return {deferred: true};
	};
	Ai._flushCompactQueue = function(){
		if (!Ai.isIdle() || !Ai._compactQueue.length) return;
		let q = Ai._compactQueue.shift();
		try {
			// no force: each entry re-checks idle itself and re-queues if a new
			// run started between the idle event and this flush (self-healing)
			if (q.kind === 'llm') Ai.compactWithLLM(q.opts);
			else Ai.compact(q.opts);
		} catch(e){ try { console.error('queued compact failed', e); } catch(_){} }
	};
	try { document.addEventListener('vvveb-ai-idle', function(){ Ai._flushCompactQueue(); }); } catch(e){}
	Ai.isIdle = function(){ return !Ai._busy && (!Ai.currentAbort || !Ai.currentAbort.signal || Ai.currentAbort.signal.aborted); };
	Ai.cancelCurrent = function(){
		if (Ai.currentAbort){
			try { Ai.currentAbort.abort(); } catch(e){}
			Ai.currentAbort = null;
			Ai._setBusy(false);
			return true;
		}
		return false;
	};

	// ask for confirmation before leaving/reloading the page while a model
	// request is still running - set Ai.confirmOnLeave = false to opt out
	Ai.confirmOnLeave = true;
	try {
		window.addEventListener('beforeunload', function(e) {
			if (Ai.confirmOnLeave && !Ai.isIdle()) {
				e.preventDefault();
				e.returnValue = '';
				return '';
			}
		});
	} catch(e){ /* non-browser context */ }

	async function directFetch(adapter, payload, options) {
		const req = adapter.buildRequest(payload, options);
		req.headers['Content-Type'] = 'application/json';
		const fetchOpts = {
			method: 'POST',
			headers: req.headers,
			body: JSON.stringify(req.body)
		};
		if (payload.signal) fetchOpts.signal = payload.signal;
		else if (Ai.currentAbort) fetchOpts.signal = Ai.currentAbort.signal;
		return fetch(req.url, fetchOpts);
	}

	/**
	 * Proxy mode: the request is relayed through the site's own backend
	 * (plugins/ai-assistant/proxy) which injects the API key server side so it is
	 * never exposed to the browser. The adapter still builds the native body,
	 * the proxy only re-addresses it and streams the raw answer back.
	 */
	// envelope for the backend proxy (kept separate for testability). The chat
	// session id travels outside the provider body so the proxy can forward it
	// as a correlation header without polluting the upstream payload.
	Ai.proxyEnvelope = function(adapter, body, options){
		options = options || {};
		return { provider: adapter.name, body: body, csrf: options.proxyCsrf || '',
			session_id: Ai.ensureSessionId() || '' };
	};
	async function proxyFetch(adapter, payload, options) {
		const req = adapter.buildRequest(payload, Object.assign({}, options, { omitAuth: true }));
		const fetchOpts = {
			method: 'POST',
			headers: { 'Content-Type': 'application/json' },
			body: JSON.stringify(Ai.proxyEnvelope(adapter, req.body, options))
		};
		if (payload.signal) fetchOpts.signal = payload.signal;
		else if (Ai.currentAbort) fetchOpts.signal = Ai.currentAbort.signal;
		return fetch(options.proxyUrl, fetchOpts);
	}

	/**
	 * Sends a normalized payload through the selected provider adapter.
	 *
	 * @param payload see buildPayload()
	 * @param onEvent callback receiving normalized event objects
	 * @returns Promise<result> where result = {text, thinking, toolCalls, usage, stopReason, raw}
	 */
	Ai.sendQuery = async function (payload, onEvent) {
		onEvent = typeof onEvent === 'function' ? onEvent : function () {};

		function emit(event) {
			try { onEvent(event); } catch (e) { console.error('VvvebAi onEvent handler error', e); }
		}
		// setup abort controller for this query if not already provided
		let ownAbort = false;
		if (!payload.signal){
			if (!Ai.currentAbort || Ai.currentAbort.signal.aborted){
				Ai.currentAbort = new AbortController();
				ownAbort = true;
			}
			payload.signal = Ai.currentAbort.signal;
		}

		const options = payload.options || {};
		const providerName = (payload.provider || 'openai').toLowerCase();
		const adapter = Ai.getProvider(providerName);
		if (!adapter) {
			const err = new Error('Unknown AI provider: "' + providerName + '". Registered: ' + Ai.providerNames().join(', '));
			emit({ type: 'error', error: err });
			throw err;
		}

		payload.stream = payload.stream !== false && typeof window.ReadableStream !== 'undefined';

		// snapshot history for network-error recovery (autoCompact may mutate it)
		let _historyBackup = Ai.history ? Ai.history.slice() : [];
		Ai._historyBackup = _historyBackup;
		if (!payload._historySnapshot) payload._historySnapshot = _historyBackup.slice();

		// auto-compact when context >90% (unless this is a compact sub-request) - only when idle
		if (!payload._isCompact && Ai.isIdle()) {
			let usage = Ai.getContextUsage(payload);
			if (usage.pct >= Ai.COMPACT_THRESHOLD) {
				let cr = Ai.autoCompact(payload);
				if (cr) {
					emit({ type: 'compact', compact: cr, usage: Ai.getContextUsage(Object.assign({}, payload, { messages: Ai.history.slice().concat(payload.messages.slice(payload._lastHistoryLength || 0)) })) });
					// rebuild payload.messages from compacted history + last user turn
					if (payload.messages && payload.messages.length && payload._lastHistoryLength != null) {
						let last = payload.messages[payload.messages.length - 1];
						if (last && last.role === 'user') {
							payload.messages = Ai.history.slice().concat([last]);
						}
					}
				}
			}
		}

		Ai._setBusy(true);
		emit({ type: 'start', provider: providerName });

		let response;
		try {
			response = (options.apiMode === 'proxy')
				? await proxyFetch(adapter, payload, options)
				: await directFetch(adapter, payload, options);
		} catch (error) {
			if (error && (error.name === 'AbortError' || error.message && error.message.includes('aborted'))){
				emit({ type: 'cancel', reason: 'aborted' });
				if (ownAbort) Ai.currentAbort = null;
				Ai._setBusy(false);
				throw error;
			}
			// network failure: restore history from backup/session so next payload doesn't lose all previous messages
			if ((!Ai.history || !Ai.history.length) && _historyBackup && _historyBackup.length) {
				Ai.history = _historyBackup.slice();
			} else if ((!Ai.history || !Ai.history.length) && Ai.currentSessionId) {
				try { let sess = Ai.sessions.find(s=>s.id===Ai.currentSessionId); if (sess && sess.messages && sess.messages.length) Ai.history = sess.messages.slice(); } catch(e){}
			}
			try { Ai.saveCurrentSession(); } catch(e){}
			try { document.dispatchEvent(new CustomEvent('vvveb-ai-history-updated', {detail:{history: Ai.history}})); } catch(e){}
			const err = new Error('Network error while contacting ' + providerName + ': ' + (error && error.message));
			emit({ type: 'error', error: err });
			if (ownAbort) Ai.currentAbort = null;
			Ai._setBusy(false);
			throw err;
		}

		if (!response.ok) {
			let data = null;
			try { data = await response.json(); } catch (e) { /* not json */ }
			const err = new Error(adapter.normalizeError
				? adapter.normalizeError(data, response)
				: Ai.extractError(data));
			emit({ type: 'error', error: err });
			if (ownAbort) Ai.currentAbort = null;
			Ai._setBusy(false);
			throw err;
		}

		if (!payload.stream || !response.body) {
			const data = await response.json();
			if (data && data.error) {
				const err = new Error(adapter.normalizeError ? adapter.normalizeError(data, response) : Ai.extractError(data));
				emit({ type: 'error', error: err });
				if (ownAbort) Ai.currentAbort = null;
				throw err;
			}
			const result = adapter.parseResponse(data);
			result._payload = payload;
			emit({ type: 'done', result });
			if (ownAbort) Ai.currentAbort = null;
			Ai._setBusy(false);
			return result;
		}

		// streaming - parse incrementally; when relaying through the proxy the
		// upstream format is echoed back in X-Vvveb-Ai-Format
		const format = (response.headers.get('x-vvveb-ai-format') || providerName).toLowerCase();
		const fmtAdapter = Ai.getProvider(format) || adapter;
		const state = fmtAdapter.createStreamParser(emit);
		const reader = response.body.getReader();
		const decoder = new TextDecoder();

		// stall watchdog: the proxy no longer low-speed-aborts streams (they go
		// legitimately silent), so a dead connection must be caught here. Any
		// bytes reset the timer; on expiry the reader is cancelled, the loop
		// ends, and a clear error is thrown (partial text persists via the
		// caller's failure path, so context survives and sending again continues).
		const stallMs = Ai.STREAM_STALL_MS || 180000;
		let stallTimer = null, watchdogFired = false;
		const armWatchdog = () => {
			try { clearTimeout(stallTimer); } catch(e){}
			stallTimer = setTimeout(() => {
				watchdogFired = true;
				try { reader.cancel('stalled'); } catch(e){}
			}, stallMs);
		};
		try {
			armWatchdog();
			while (true) {
				const chunk = await reader.read();
				if (chunk.done) break;
				armWatchdog();
				state.feed(decoder.decode(chunk.value, { stream: true }));
			}
			try { clearTimeout(stallTimer); } catch(e){}
			if (watchdogFired) {
				throw new Error('Stream stalled: no data for ' + Math.round(stallMs / 1000) + 's - the provider may still be reasoning; send again to continue from the partial answer');
			}
			state.feed(decoder.decode());
			state.end();
		} catch (error) {
			if (error && (error.name === 'AbortError' || error.message && error.message.includes('aborted'))){
				emit({ type: 'cancel', reason: 'aborted' });
				if (ownAbort) Ai.currentAbort = null;
				Ai._setBusy(false);
				throw error;
			}
			const err = new Error('Stream error: ' + (error && error.message));
			emit({ type: 'error', error: err });
			if (ownAbort) Ai.currentAbort = null;
			Ai._setBusy(false);
			throw err;
		} finally {
			try { clearTimeout(stallTimer); } catch(e){}
			if (reader.releaseLock) try{ reader.releaseLock(); }catch(e){}
			if (ownAbort) Ai.currentAbort = null;
			Ai._setBusy(false);
		}

		const result = state.getResult();
		result._payload = payload;
		emit({ type: 'done', result });
		if (ownAbort) Ai.currentAbort = null;
		Ai._setBusy(false);
		return result;
	};
})(window);
