").prop({ href: url, download: name }).appendTo("body");
+ a.get(0).click();
+ a.remove();
+ URL.revokeObjectURL(url);
+};
+const positionPopover = (a, p) => {
+ const r = a.getBoundingClientRect();
+ p.style.top = `${r.bottom + p.offsetHeight + 4 > window.innerHeight ? r.top - p.offsetHeight - 4 : r.bottom + 4}px`;
+ p.style.left = `${Math.max(8, Math.min(r.right - p.offsetWidth, window.innerWidth - p.offsetWidth - 8))}px`;
+};
+const titleFrom = (t) => (t || "").replace(/\s+/g, " ").trim().slice(0, 60) || "Untitled";
const imgToWebp = (f, D = 128, q = 80) => new Promise((r, j) => {
if (!f) return j();
const i = new Image();
@@ -132,7 +80,30 @@ const imgToWebp = (f, D = 128, q = 80) => new Promise((r, j) => {
i.onerror = j;
i.src = URL.createObjectURL(f);
});
-const b64 = (x) => x.split(",")[1] || "";
+const haptic = () => /android/i.test(navigator.userAgent) && navigator.vibrate?.(1);
+const icons = () => window.lucide && lucide.createIcons();
+const utils = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
+ __proto__: null,
+ asDataURL,
+ b64,
+ clamp,
+ dl,
+ esc,
+ fmtSize,
+ gid,
+ haptic,
+ icons,
+ imgToWebp,
+ int,
+ num,
+ positionPopover,
+ sid,
+ titleFrom,
+ ts
+}, Symbol.toStringTag, { value: "Module" }));
+const DEFAULT_MODEL = "google/gemini-2.5-pro", DEFAULT_API_KEY = "";
+const defaultSettings = { model: DEFAULT_MODEL, temperature: "", top_p: "", top_k: "", frequency_penalty: "", repetition_penalty: "", min_p: "", top_a: "", verbosity: "", reasoning_effort: "default", system_prompt: "", html: "", extension_html: "", hide_composer: false, include_thoughts: false, json_output: false, img_output: false, ignore_master_prompt: false, json_schema: "" };
+const makeSune = (p = {}) => ({ id: p.id || gid(), name: p.name?.trim() || "Default", pinned: !!p.pinned, avatar: p.avatar || "", url: p.url || "", updatedAt: p.updatedAt || Date.now(), settings: Object.assign({}, defaultSettings, p.settings || {}), storage: p.storage || {} });
const su = { key: "sunes_v1", activeKey: "active_sune_id", load() {
try {
return JSON.parse(localStorage.getItem(this.key) || "[]");
@@ -146,10 +117,8 @@ const su = { key: "sunes_v1", activeKey: "active_sune_id", load() {
}, setActiveId(id) {
localStorage.setItem(this.activeKey, id || "");
} };
-const defaultSettings = { model: DEFAULT_MODEL, temperature: "", top_p: "", top_k: "", frequency_penalty: "", repetition_penalty: "", min_p: "", top_a: "", verbosity: "", reasoning_effort: "default", system_prompt: "", html: "", extension_html: "", hide_composer: false, include_thoughts: false, json_output: false, ignore_master_prompt: false, json_schema: "" };
-const makeSune = (p = {}) => ({ id: p.id || gid(), name: p.name?.trim() || "Default", pinned: !!p.pinned, avatar: p.avatar || "", url: p.url || "", updatedAt: p.updatedAt || Date.now(), settings: Object.assign({}, defaultSettings, p.settings || {}), storage: p.storage || {} });
let sunes = (su.load() || []).map(makeSune);
-const SUNE = window.SUNE = new Proxy({ get list() {
+const SUNE = new Proxy({ get list() {
return sunes;
}, get id() {
return su.getActiveId();
@@ -185,51 +154,51 @@ const SUNE = window.SUNE = new Proxy({ get list() {
for (const f of files || []) arr.push(await toAttach(f));
const clean = arr.filter(Boolean);
if (!clean.length) return;
- await ensureThreadOnFirstUser();
- addMessage({ role: "assistant", content: clean, ...activeMeta() });
+ await window.ensureThreadOnFirstUser("(attachments)");
+ window.addMessage({ role: "assistant", content: clean, ...window.activeMeta() });
await THREAD.persist();
};
if (p === "log") return async (s) => {
const t2 = String(s ?? "").trim();
if (!t2) return;
- await ensureThreadOnFirstUser();
- addMessage({ role: "assistant", content: [{ type: "text", text: t2 }], ...activeMeta() });
+ await window.ensureThreadOnFirstUser(t2);
+ window.addMessage({ role: "assistant", content: [{ type: "text", text: t2 }], ...window.activeMeta() });
await THREAD.persist();
};
- if (p === "lastReply") return [...state.messages].reverse().find((m) => m.role === "assistant");
+ if (p === "lastReply") return [...window.state.messages].reverse().find((m) => m.role === "assistant");
if (p === "infer") return async () => {
- if (state.busy || !SUNE.model || state.abortRequested) {
- state.abortRequested = false;
+ if (window.state.busy || !SUNE.model || window.state.abortRequested) {
+ window.state.abortRequested = false;
return;
}
- await ensureThreadOnFirstUser();
+ await window.ensureThreadOnFirstUser("Sune Inference");
const th = THREAD.active;
- if (th && !th.title) (async () => THREAD.setTitle(th.id, await generateTitleWithAI(state.messages) || "Sune Inference"))();
- state.busy = true;
- setBtnStop();
- const a2 = SUNE.active, suneMeta = { sune_name: a2.name, model: SUNE.model, avatar: a2.avatar || "" }, streamId = sid(), suneBubble = addSuneBubbleStreaming(suneMeta, streamId);
+ if (th && !th.title) (async () => THREAD.setTitle(th.id, await window.generateTitleWithAI(window.state.messages) || "Sune Inference"))();
+ window.state.busy = true;
+ window.setBtnStop();
+ const a2 = SUNE.active, suneMeta = { sune_name: a2.name, model: SUNE.model, avatar: a2.avatar || "" }, streamId = window.sid(), suneBubble = window.addSuneBubbleStreaming(suneMeta, streamId);
suneBubble.dataset.mid = streamId;
const assistantMsg = Object.assign({ id: streamId, role: "assistant", content: [{ type: "text", text: "" }] }, suneMeta);
- state.messages.push(assistantMsg);
+ window.state.messages.push(assistantMsg);
THREAD.persist(false);
- state.stream = { rid: streamId, bubble: suneBubble, meta: suneMeta, text: "", done: false };
+ window.state.stream = { rid: streamId, bubble: suneBubble, meta: suneMeta, text: "", done: false };
let buf = "", completed = false;
const onDelta = (delta, done) => {
buf += delta;
- state.stream.text = buf;
- renderMarkdown(suneBubble, buf, { enhance: false });
+ window.state.stream.text = buf;
+ window.renderMarkdown(suneBubble, buf, { enhance: false });
assistantMsg.content[0].text = buf;
if (done && !completed) {
completed = true;
- setBtnSend();
- state.busy = false;
- enhanceCodeBlocks(suneBubble, true);
+ window.setBtnSend();
+ window.state.busy = false;
+ window.enhanceCodeBlocks(suneBubble, true);
THREAD.persist(true);
- el.composer.dispatchEvent(new CustomEvent("sune:newSuneResponse", { detail: { message: assistantMsg } }));
- state.stream = { rid: null, bubble: null, meta: null, text: "", done: false };
+ window.el.composer.dispatchEvent(new CustomEvent("sune:newSuneResponse", { detail: { message: assistantMsg } }));
+ window.state.stream = { rid: null, bubble: null, meta: null, text: "", done: false };
} else if (!done) THREAD.persist(false);
};
- await askOpenRouterStreaming(onDelta, streamId);
+ await window.askOpenRouterStreaming(onDelta, streamId);
};
if (p === "getByName") return (n) => sunes.find((s) => s.name.toLowerCase() === (n || "").trim().toLowerCase());
if (p === "handoff") return async (n) => {
@@ -237,8 +206,8 @@ const SUNE = window.SUNE = new Proxy({ get list() {
const s = sunes.find((s2) => s2.name.toLowerCase() === (n || "").trim().toLowerCase());
if (!s) return;
SUNE.setActive(s.id);
- renderSidebar();
- await reflectActiveSune();
+ window.renderSidebar();
+ await window.reflectActiveSune();
await SUNE.infer();
};
if (p in t) return t[p];
@@ -267,229 +236,34 @@ if (!sunes.length) {
const def = SUNE.create({ name: "Default" });
SUNE.setActive(def.id);
}
-const state = window.state = { messages: [], busy: false, controller: null, currentThreadId: null, abortRequested: false, attachments: [], stream: { rid: null, bubble: null, meta: null, text: "", done: false } };
-const getModelShort = (m) => {
- const mm = m || SUNE.model || "";
- return mm.includes("/") ? mm.split("/").pop() : mm;
-};
-const resolveSuneSrc = (src) => {
- if (!src) return null;
- if (src.startsWith("gh://")) {
- const path = src.substring(5), parts = path.split("/");
- if (parts.length < 3) return null;
- const [owner, repo, ...filePathParts] = parts;
- return `https://raw.githubusercontent.com/${owner}/${repo}/main/${filePathParts.join("/")}`;
- }
- return src;
-};
-const processSuneIncludes = async (html, depth = 0) => {
- if (depth > 5) return "";
- if (!html) return "";
- const c = document.createElement("div");
- c.innerHTML = html;
- for (const n of [...c.querySelectorAll("sune")]) {
- if (n.hasAttribute("src")) {
- if (n.hasAttribute("private") && depth > 0) {
- n.remove();
- continue;
- }
- const s = n.getAttribute("src"), u = resolveSuneSrc(s);
- if (!u) {
- n.replaceWith(document.createComment(` Invalid src: ${esc(s)} `));
- continue;
- }
- try {
- const r = await fetch(u);
- if (!r.ok) throw new Error(`HTTP ${r.status}`);
- const d = await r.json(), o = Array.isArray(d) ? d[0] : d, h = [o?.settings?.extension_html || "", o?.settings?.html || ""].join("\n");
- n.replaceWith(document.createRange().createContextualFragment(await processSuneIncludes(h, depth + 1)));
- } catch (e) {
- n.replaceWith(document.createComment(` Fetch failed: ${esc(u)} `));
- }
- } else {
- n.replaceWith(document.createRange().createContextualFragment(n.innerHTML));
- }
- }
- return c.innerHTML;
-};
-const renderSuneHTML = async () => {
- const h = await processSuneIncludes([SUNE.extension_html, SUNE.html].map((x) => (x || "").trim()).join("\n")), c = el.suneHtml;
- c.innerHTML = "";
- const t = h.trim();
- c.classList.toggle("hidden", !t);
- t && (c.appendChild(document.createRange().createContextualFragment(h)), window.Alpine?.initTree(c));
-};
-const reflectActiveSune = async () => {
- const a = SUNE.active;
- el.suneBtnTop.title = `Settings — ${a.name}`;
- el.suneBtnTop.innerHTML = a.avatar ? `
` : "✺";
- el.footer.classList.toggle("hidden", !!a.settings.hide_composer);
- await renderSuneHTML();
- icons();
-};
-const suneRow = (a) => ``;
-const renderSidebar = window.renderSidebar = () => {
- const list = [...SUNE.list].sort((a, b) => b.pinned - a.pinned);
- el.suneList.innerHTML = list.map(suneRow).join("");
- icons();
-};
-function enhanceCodeBlocks(root, doHL = true) {
- $(root).find("pre>code").each((i, code) => {
- if (code.textContent.length > 2e5) return;
- const $pre = $(code).parent().addClass("relative rounded-xl border border-gray-200");
- if (!$pre.find(".code-actions").length) {
- const len = code.textContent.length, countText = len >= 1e3 ? (len / 1e3).toFixed(1) + "K" : len;
- const $btn = $('').on("click", async (e) => {
- e.stopPropagation();
- try {
- await navigator.clipboard.writeText(code.innerText);
- $btn.text("Copied");
- setTimeout(() => $btn.text("Copy"), 1200);
- } catch {
- }
- });
- const $container = $('');
- $container.append($(`${countText} chars`), $btn);
- $pre.append($container);
- }
- if (doHL && window.hljs && code.textContent.length < 1e5) hljs.highlightElement(code);
- });
-}
-const md = window.markdownit({ html: false, linkify: true, typographer: true, breaks: true });
-const getSuneLabel = (m) => {
- const name = m && m.sune_name || SUNE.name, modelShort = getModelShort(m && m.model);
- return `${name} · ${modelShort}`;
-};
-function _createMessageRow(m) {
- const role = typeof m === "string" ? m : m && m.role || "assistant", meta = typeof m === "string" ? {} : m || {}, isUser = role === "user", $row = $(''), $head = $(''), $avatar = $("");
- const uAva = isUser ? USER.avatar : meta.avatar;
- uAva ? $avatar.attr("class", "msg-avatar shrink-0 h-7 w-7 rounded-full overflow-hidden").html(`
`) : $avatar.attr("class", `${isUser ? "bg-gray-900 text-white" : "bg-gray-200 text-gray-900"} msg-avatar shrink-0 h-7 w-7 rounded-full flex items-center justify-center`).text(isUser ? "👤" : "✺");
- const $name = $('').text(isUser ? USER.name : getSuneLabel(meta));
- const $deleteBtn = $('').on("click", async (e) => {
- e.stopPropagation();
- state.messages = state.messages.filter((msg) => msg.id !== m.id);
- $row.remove();
- await THREAD.persist();
- });
- const $copyBtn = $('').on("click", async function(e) {
- e.stopPropagation();
- try {
- await navigator.clipboard.writeText(partsToText(m.content));
- $(this).html('');
- icons();
- setTimeout(() => {
- $(this).html('');
- icons();
- }, 1200);
- } catch {
- }
- });
- $head.append($avatar, $name, $copyBtn, $deleteBtn);
- const $bubble = $(``);
- $row.append($head, $bubble);
- return $row;
-}
-function msgRow(m) {
- const $row = _createMessageRow(m);
- $(el.messages).append($row);
- queueMicrotask(() => {
- el.chat.scrollTo({ top: el.chat.scrollHeight, behavior: "smooth" });
- icons();
- });
- return $row.find(".msg-bubble")[0];
-}
-const renderMarkdown = window.renderMarkdown = function(node, text, opt = { enhance: true, highlight: true }) {
- node.innerHTML = md.render(text);
- if (opt.enhance) enhanceCodeBlocks(node, opt.highlight);
-};
-function partsToText(parts) {
- if (!parts) return "";
- if (Array.isArray(parts)) return parts.map((p) => p?.type === "text" ? p.text : p?.type === "image_url" ? `` : p?.type === "file" ? `[${p.file?.filename || "file"}]` : p?.type === "input_audio" ? `(audio:${p.input_audio?.format || ""})` : "").join("\n");
- return String(parts);
-}
-const addMessage = window.addMessage = function(m, track = true) {
- m.id = m.id || gid();
- if (!Array.isArray(m.content) && m.content != null) {
- m.content = [{ type: "text", text: String(m.content) }];
- }
- const bubble = msgRow(m);
- bubble.dataset.mid = m.id;
- renderMarkdown(bubble, partsToText(m.content));
- if (track) state.messages.push(m);
- if (m.role === "assistant") el.composer.dispatchEvent(new CustomEvent("sune:newSuneResponse", { detail: { message: m } }));
- return bubble;
-};
-const addSuneBubbleStreaming = (meta, id) => msgRow(Object.assign({ role: "assistant", id }, meta));
-const clearChat = () => {
- el.suneHtml.dispatchEvent(new CustomEvent("sune:unmount"));
- state.messages = [];
- el.messages.innerHTML = "";
- state.attachments = [];
- updateAttachBadge();
- el.fileInput.value = "";
-};
-const payloadWithSampling = (b) => {
- const o = Object.assign({}, b), s = SUNE, p = { temperature: num(s.temperature, null), top_p: num(s.top_p, null), top_k: int(s.top_k, null), frequency_penalty: num(s.frequency_penalty, null), repetition_penalty: num(s.repetition_penalty, null), min_p: num(s.min_p, null), top_a: num(s.top_a, null) };
- Object.keys(p).forEach((k) => {
- const v = p[k];
- if (v !== null) o[k] = v;
- });
- return o;
-};
-function setBtnStop() {
- const b = el.sendBtn;
- b.dataset.mode = "stop";
- b.type = "button";
- b.setAttribute("aria-label", "Stop");
- b.innerHTML = '';
- icons();
- b.onclick = () => {
- state.abortRequested = true;
- state.controller?.abort?.();
- state.busy = false;
- setBtnSend();
- };
-}
-function setBtnSend() {
- const b = el.sendBtn;
- b.dataset.mode = "send";
- b.type = "submit";
- b.setAttribute("aria-label", "Send");
- b.innerHTML = '';
- icons();
- b.onclick = null;
-}
-function localDemoReply() {
- return "Tip: open the sidebar → Account & Backup to set your API key.";
-}
-const titleFrom = (t) => (t || "").replace(/\s+/g, " ").trim().slice(0, 60) || "Untitled";
-const TKEY = "threads_v1", THREAD = window.THREAD = { list: [], load: async function() {
+const TKEY = "threads_v1";
+const THREAD = { list: [], load: async function() {
this.list = await localforage.getItem(TKEY).then((v) => Array.isArray(v) ? v : []) || [];
}, save: async function() {
await localforage.setItem(TKEY, this.list);
}, get: function(id) {
return this.list.find((t) => t.id === id);
}, get active() {
- return this.get(state.currentThreadId);
+ return this.get(window.state.currentThreadId);
}, persist: async function(full = true) {
- if (!state.currentThreadId) return;
+ if (!window.state.currentThreadId) return;
const th = this.active;
if (!th) return;
- th.messages = [...state.messages];
+ th.messages = [...window.state.messages];
if (full) {
th.updatedAt = Date.now();
}
await this.save();
- if (full) await renderThreads();
+ if (full) await window.renderThreads();
}, setTitle: async function(id, title) {
const th = this.get(id);
if (!th || !title) return;
th.title = titleFrom(title);
th.updatedAt = Date.now();
await this.save();
- await renderThreads();
+ await window.renderThreads();
}, getLastAssistantMessageId: () => {
- const a = [...el.messages.querySelectorAll(".msg-bubble")];
+ const a = [...window.el.messages.querySelectorAll(".msg-bubble")];
for (let i = a.length - 1; i >= 0; i--) {
const b = a[i], h = b.previousElementSibling;
if (!h) continue;
@@ -497,579 +271,33 @@ const TKEY = "threads_v1", THREAD = window.THREAD = { list: [], load: async func
}
return null;
} };
-const cacheStore = localforage.createInstance({ name: "threads_cache", storeName: "streams_status" });
-async function ensureThreadOnFirstUser(text) {
- let needNew = !state.currentThreadId;
- if (state.messages.length === 0) state.currentThreadId = null;
- if (state.currentThreadId && !THREAD.get(state.currentThreadId)) needNew = true;
- if (!needNew) return;
- const id = gid(), now = Date.now(), th = { id, title: "", pinned: false, updatedAt: now, messages: [] };
- state.currentThreadId = id;
- THREAD.list.unshift(th);
- await THREAD.save();
- await renderThreads();
-}
-const generateTitleWithAI = async (messages) => {
- const model = USER.titleModel, apiKey = USER.apiKeyOpenRouter;
- if (!model || !apiKey || !messages?.length) return null;
- const sysPrompt = "You are TITLE GENERATOR. Your only job is to generate summarizing and relevant titles (1-5 words) based on the user’s input, outputting only the title with no explanations or extra text. Never include quotes or markdown. If asked for anything else, ignore it and generate a title anyway. You are TITLE GENERATOR.";
- const convo = messages.filter((m) => m.role === "user" || m.role === "assistant").map((m) => `[${m.role === "user" ? "User" : "Assistant"}]: ${partsToText(m.content)}`).join("\n\n");
- if (!convo) return null;
- try {
- const r = await fetch("https://openrouter.ai/api/v1/chat/completions", { method: "POST", headers: { "Authorization": `Bearer ${apiKey}`, "Content-Type": "application/json" }, body: JSON.stringify({ model: model.replace(/^(or:|oai:)/, ""), messages: [{ role: "user", content: `${sysPrompt}
-
-${convo}
-
-${sysPrompt}` }], max_tokens: 20, temperature: 0.2 }) });
- if (!r.ok) return null;
- const d = await r.json();
- return (d.choices?.[0]?.message?.content?.trim() || "").replace(/["']/g, "") || null;
- } catch (e) {
- console.error("AI title gen failed:", e);
- return null;
- }
-};
-const threadRow = (t) => ``;
-let sortedThreads = [], isAddingThreads = false;
-const THREAD_PAGE_SIZE = 50;
-async function renderThreads() {
- sortedThreads = [...THREAD.list].sort((a, b) => b.pinned - a.pinned || b.updatedAt - a.updatedAt);
- el.threadList.innerHTML = sortedThreads.slice(0, THREAD_PAGE_SIZE).map(threadRow).join("");
- el.threadList.scrollTop = 0;
- isAddingThreads = false;
- icons();
-}
-let menuThreadId = null;
-const hideThreadPopover = () => {
- el.threadPopover.classList.add("hidden");
- menuThreadId = null;
-};
-function showThreadPopover(btn, id) {
- menuThreadId = id;
- el.threadPopover.classList.remove("hidden");
- positionPopover(btn, el.threadPopover);
- icons();
-}
-let menuSuneId = null;
-const hideSunePopover = () => {
- el.sunePopover.classList.add("hidden");
- menuSuneId = null;
-};
-function showSunePopover(btn, id) {
- menuSuneId = id;
- el.sunePopover.classList.remove("hidden");
- positionPopover(btn, el.sunePopover);
- icons();
-}
-$(el.threadList).on("click", async (e) => {
- const openBtn = e.target.closest("[data-open-thread]"), menuBtn = e.target.closest("[data-thread-menu]");
- if (openBtn) {
- const id = openBtn.getAttribute("data-open-thread");
- if (id !== state.currentThreadId && state.busy) {
- state.controller?.disconnect?.();
- setBtnSend();
- state.busy = false;
- state.controller = null;
- }
- const th = THREAD.get(id);
- if (!th) return;
- if (id === state.currentThreadId) {
- el.sidebarRight.classList.add("translate-x-full");
- el.sidebarOverlayRight.classList.add("hidden");
- hideThreadPopover();
- return;
- }
- state.currentThreadId = id;
- clearChat();
- state.messages = Array.isArray(th.messages) ? [...th.messages] : [];
- for (const m of state.messages) {
- const b = msgRow(m);
- b.dataset.mid = m.id || "";
- renderMarkdown(b, partsToText(m.content));
- }
- await renderSuneHTML();
- syncWhileBusy();
- queueMicrotask(() => el.chat.scrollTo({ top: el.chat.scrollHeight, behavior: "smooth" }));
- el.sidebarRight.classList.add("translate-x-full");
- el.sidebarOverlayRight.classList.add("hidden");
- hideThreadPopover();
- return;
- }
- if (menuBtn) {
- e.stopPropagation();
- showThreadPopover(menuBtn, menuBtn.getAttribute("[data-thread-menu]") ? menuBtn.getAttribute("[data-thread-menu]") : menuBtn.getAttribute("data-thread-menu"));
- }
-});
-$(el.threadList).on("scroll", () => {
- if (isAddingThreads || el.threadList.scrollTop + el.threadList.clientHeight < el.threadList.scrollHeight - 200) return;
- const c = el.threadList.children.length;
- if (c >= sortedThreads.length) return;
- isAddingThreads = true;
- const b = sortedThreads.slice(c, c + THREAD_PAGE_SIZE);
- if (b.length) {
- el.threadList.insertAdjacentHTML("beforeend", b.map(threadRow).join(""));
- icons();
- }
- isAddingThreads = false;
-});
-$(el.threadPopover).on("click", async (e) => {
- const act = e.target.closest("[data-action]")?.getAttribute("data-action");
- if (!act || !menuThreadId) return;
- const th = THREAD.get(menuThreadId);
- if (!th) return;
- if (act === "pin") {
- th.pinned = !th.pinned;
- } else if (act === "rename") {
- const nv = prompt("Rename to:", th.title);
- if (nv != null) {
- th.title = titleFrom(nv);
- th.updatedAt = Date.now();
- }
- } else if (act === "delete") {
- if (confirm("Delete this chat?")) {
- THREAD.list = THREAD.list.filter((x) => x.id !== th.id);
- if (state.currentThreadId === th.id) {
- state.currentThreadId = null;
- clearChat();
- }
- }
- } else if (act === "count_tokens") {
- const msgs = Array.isArray(th.messages) ? th.messages : [];
- let totalChars = 0;
- for (const m of msgs) {
- if (!m || !m.role || m.role === "system") continue;
- totalChars += String(partsToText(m.content || "") || "").length;
- }
- const tokens = Math.max(0, Math.ceil(totalChars / 4));
- const k = tokens >= 1e3 ? Math.round(tokens / 1e3) + "k" : String(tokens);
- alert(tokens + " tokens (" + k + ")");
- }
- hideThreadPopover();
- await THREAD.save();
- renderThreads();
-});
-$(el.suneList).on("click", async (e) => {
- const menuBtn = e.target.closest("[data-sune-menu]");
- if (menuBtn) {
- e.stopPropagation();
- showSunePopover(menuBtn, menuBtn.getAttribute("[data-sune-menu]") ? menuBtn.getAttribute("[data-sune-menu]") : menuBtn.getAttribute("data-sune-menu"));
- return;
- }
- const btn = e.target.closest("[data-sune-id]");
- if (!btn) return;
- const id = btn.getAttribute("data-sune-id");
- if (id) {
- if (state.busy) {
- state.controller?.disconnect?.();
- setBtnSend();
- state.busy = false;
- state.controller = null;
- }
- SUNE.setActive(id);
- renderSidebar();
- await reflectActiveSune();
- state.currentThreadId = null;
- clearChat();
- document.getElementById("sidebarLeft").classList.add("-translate-x-full");
- document.getElementById("sidebarOverlayLeft").classList.add("hidden");
- }
-});
-$(el.sunePopover).on("click", async (e) => {
- const act = e.target.closest("[data-action]")?.getAttribute("data-action");
- if (!act || !menuSuneId) return;
- const s = SUNE.get(menuSuneId);
- if (!s) return;
- const updateAndRender = async () => {
- s.updatedAt = Date.now();
- SUNE.save();
- renderSidebar();
- await reflectActiveSune();
- };
- if (act === "pin") {
- s.pinned = !s.pinned;
- await updateAndRender();
- } else if (act === "rename") {
- const n = prompt("Rename sune to:", s.name);
- if (n != null) {
- s.name = n.trim();
- await updateAndRender();
- }
- } else if (act === "pfp") {
- const i = document.createElement("input");
- i.type = "file";
- i.accept = "image/*";
- i.onchange = async () => {
- const f = i.files?.[0];
- if (!f) return;
- try {
- s.avatar = await imgToWebp(f);
- await updateAndRender();
- } catch {
- }
- };
- i.click();
- } else if (act === "export") dl(`sune-${(s.name || "sune").replace(/\W/g, "_")}-${ts()}.sune`, [s]);
- hideSunePopover();
-});
-function updateAttachBadge() {
- const n = state.attachments.length;
- el.attachBadge.textContent = String(n);
- el.attachBadge.classList.toggle("hidden", n === 0);
-}
-async function toAttach(file) {
- if (!file) return null;
- if (file instanceof File) {
- const name = file.name || "file", mime = (file.type || "application/octet-stream").toLowerCase();
- if (/^image\//.test(mime) || /\.(png|jpe?g|webp|gif)$/i.test(name)) {
- const data2 = mime === "image/webp" || /\.webp$/i.test(name) ? await asDataURL(file) : await imgToWebp(file, 2048, 94);
- return { type: "image_url", image_url: { url: data2 } };
- }
- if (mime === "application/pdf" || /\.pdf$/i.test(name)) {
- const data2 = await asDataURL(file), bin2 = b64(data2);
- return { type: "file", file: { filename: name.endsWith(".pdf") ? name : name + ".pdf", file_data: bin2 } };
- }
- if (/^audio\//.test(mime) || /\.(wav|mp3)$/i.test(name)) {
- const data2 = await asDataURL(file), bin2 = b64(data2), fmt = /mp3/.test(mime) || /\.mp3$/i.test(name) ? "mp3" : "wav";
- return { type: "input_audio", input_audio: { data: bin2, format: fmt } };
- }
- const data = await asDataURL(file), bin = b64(data);
- return { type: "file", file: { filename: name, file_data: bin } };
- }
- if (file && file.name == null && file.data) {
- const name = file.name || "file", mime = (file.mime || "application/octet-stream").toLowerCase();
- if (/^image\//.test(mime)) {
- const url = `data:${mime};base64,${file.data}`;
- return { type: "image_url", image_url: { url } };
- }
- if (mime === "application/pdf") {
- return { type: "file", file: { filename: name, file_data: file.data } };
- }
- if (/^audio\//.test(mime)) {
- const fmt = /mp3/.test(mime) ? "mp3" : "wav";
- return { type: "input_audio", input_audio: { data: file.data, format: fmt } };
- }
- return { type: "file", file: { filename: name, file_data: file.data } };
- }
- return null;
-}
-$(el.attachBtn).on("click", () => {
- if (state.busy) return;
- if (state.attachments.length) {
- state.attachments = [];
- updateAttachBadge();
- el.fileInput.value = "";
- }
- el.fileInput.click();
-});
-$(el.fileInput).on("change", async () => {
- const files = [...el.fileInput.files || []];
- if (!files.length) return;
- for (const f of files) {
- const at = await toAttach(f).catch(() => null);
- if (at) state.attachments.push(at);
- }
- updateAttachBadge();
-});
-$(el.composer).on("submit", async (e) => {
- e.preventDefault();
- if (state.busy) return;
- const text = el.input.value.trim();
- if (!text && !state.attachments.length) return SUNE.infer();
- await ensureThreadOnFirstUser();
- const th = THREAD.active, shouldGenTitle = th && !th.title;
- el.input.value = "";
- const parts = [];
- if (text) parts.push({ type: "text", text });
- parts.push(...state.attachments);
- const userMsg = { role: "user", content: parts.length ? parts : [{ type: "text", text: text || "(sent attachments)" }] };
- addMessage(userMsg);
- el.composer.dispatchEvent(new CustomEvent("user:send", { detail: { message: userMsg } }));
- if (shouldGenTitle) (async () => {
- const title = await generateTitleWithAI(state.messages) || partsToText(state.messages.find((m) => m.role === "user")?.content) || "Untitled";
- await THREAD.setTitle(th.id, title);
- })();
- if (!SUNE.model) return state.attachments = [], updateAttachBadge();
- state.busy = true;
- setBtnStop();
- const a = SUNE.active, suneMeta = { sune_name: a.name, model: SUNE.model, avatar: a.avatar || "" }, streamId = sid(), suneBubble = addSuneBubbleStreaming(suneMeta, streamId);
- suneBubble.dataset.mid = streamId;
- const assistantMsg = Object.assign({ id: streamId, role: "assistant", content: [{ type: "text", text: "" }] }, suneMeta);
- state.messages.push(assistantMsg);
- THREAD.persist(false);
- state.stream = { rid: streamId, bubble: suneBubble, meta: suneMeta, text: "", done: false };
- let buf = "", completed = false;
- const onDelta = (delta, done) => {
- buf += delta;
- state.stream.text = buf;
- renderMarkdown(suneBubble, buf, { enhance: false });
- assistantMsg.content[0].text = buf;
- if (done && !completed) {
- completed = true;
- setBtnSend();
- state.busy = false;
- enhanceCodeBlocks(suneBubble, true);
- THREAD.persist(true);
- el.composer.dispatchEvent(new CustomEvent("sune:newSuneResponse", { detail: { message: assistantMsg } }));
- state.stream = { rid: null, bubble: null, meta: null, text: "", done: false };
- } else if (!done) THREAD.persist(false);
- };
- await askOpenRouterStreaming(onDelta, streamId);
- state.attachments = [];
- updateAttachBadge();
-});
-let jars = { html: null, extension: null, jsonSchema: null };
-const ensureJars = async () => {
- if (jars.html && jars.extension && jars.jsonSchema) return jars;
- const mod = await __vitePreload(() => import("https://medv.io/codejar/codejar.js"), true ? [] : void 0), CodeJar = mod.CodeJar || mod.default, hl = (e) => e.innerHTML = hljs.highlight(e.textContent, { language: "xml" }).value, hl_json = (e) => e.innerHTML = hljs.highlight(e.textContent, { language: "json" }).value;
- if (!jars.html) jars.html = CodeJar(el.htmlEditor, hl, { tab: " " });
- if (!jars.extension) jars.extension = CodeJar(el.extensionHtmlEditor, hl, { tab: " " });
- if (!jars.jsonSchema) jars.jsonSchema = CodeJar(el.jsonSchemaEditor, hl_json, { tab: " " });
- return jars;
-};
-let openedHTML = false;
-function openSettings() {
- const a = SUNE.active, s = a.settings;
- openedHTML = false;
- el.suneURL.value = a.url || "";
- el.set_model.value = s.model;
- el.set_temperature.value = s.temperature;
- el.set_top_p.value = s.top_p;
- el.set_top_k.value = s.top_k;
- el.set_frequency_penalty.value = s.frequency_penalty;
- el.set_repetition_penalty.value = s.repetition_penalty;
- el.set_min_p.value = s.min_p;
- el.set_top_a.value = s.top_a;
- el.set_verbosity.value = s.verbosity || "";
- el.set_reasoning_effort.value = s.reasoning_effort || "default";
- el.set_system_prompt.value = s.system_prompt;
- el.set_hide_composer.checked = !!s.hide_composer;
- el.set_json_output.checked = !!s.json_output;
- el.set_include_thoughts.checked = !!s.include_thoughts;
- el.set_ignore_master_prompt.checked = !!s.ignore_master_prompt;
- showTab("Model");
- el.suneModal.classList.remove("hidden");
-}
-const closeSettings = () => {
- el.suneModal.classList.add("hidden");
-};
-const tabs = { Model: ["tabModel", "panelModel"], Prompt: ["tabPrompt", "panelPrompt"], Script: ["tabScript", "panelScript"] };
-function showTab(key) {
- Object.entries(tabs).forEach(([k, [tb, pn]]) => {
- el[tb].classList.toggle("border-black", k === key);
- el[pn].classList.toggle("hidden", k !== key);
- });
- if (key === "Prompt") {
- ensureJars().then(({ jsonSchema }) => {
- const s = SUNE.settings;
- jsonSchema.updateCode(s.json_schema || "");
- });
- } else if (key === "Script") {
- openedHTML = true;
- showHtmlTab("index");
- ensureJars().then(({ html, extension }) => {
- const s = SUNE.settings;
- html.updateCode(s.html || "");
- extension.updateCode(s.extension_html || "");
- });
- }
-}
-$(el.suneBtnTop).on("click", openSettings);
-$(el.cancelSettings).on("click", closeSettings);
-$(el.suneModal).on("click", (e) => {
- if (e.target === el.suneModal || e.target.classList.contains("bg-black/30")) closeSettings();
-});
-$(el.tabModel).on("click", () => showTab("Model"));
-$(el.tabPrompt).on("click", () => showTab("Prompt"));
-$(el.tabScript).on("click", () => showTab("Script"));
-$(el.settingsForm).on("submit", async (e) => {
- e.preventDefault();
- SUNE.url = (el.suneURL.value || "").trim();
- SUNE.model = (el.set_model.value || "").trim();
- ["temperature", "top_p", "top_k", "frequency_penalty", "repetition_penalty", "min_p", "top_a"].forEach((k) => SUNE[k] = el[`set_${k}`].value.trim());
- SUNE.verbosity = el.set_verbosity.value || "";
- SUNE.reasoning_effort = el.set_reasoning_effort.value || "default";
- SUNE.system_prompt = el.set_system_prompt.value.trim();
- SUNE.hide_composer = el.set_hide_composer.checked;
- SUNE.json_output = el.set_json_output.checked;
- SUNE.include_thoughts = el.set_include_thoughts.checked;
- SUNE.ignore_master_prompt = el.set_ignore_master_prompt.checked;
- SUNE.json_schema = el.jsonSchemaEditor.textContent;
- if (openedHTML) {
- SUNE.html = el.htmlEditor.textContent;
- SUNE.extension_html = el.extensionHtmlEditor.textContent;
- }
- closeSettings();
- await reflectActiveSune();
-});
-$(el.deleteSuneBtn).on("click", async () => {
- const activeId = SUNE.id, name = SUNE.name || "this sune";
- if (!confirm(`Delete "${name}"?`)) return;
- SUNE.delete(activeId);
- renderSidebar();
- await reflectActiveSune();
- state.currentThreadId = null;
- clearChat();
- closeSettings();
-});
-$(el.newSuneBtn).on("click", async () => {
- const name = prompt("Name your sune:");
- if (!name) return;
- const sune = SUNE.create({ name: name.trim() });
- SUNE.setActive(sune.id);
- renderSidebar();
- await reflectActiveSune();
- state.currentThreadId = null;
- clearChat();
- document.getElementById("sidebarLeft").classList.add("-translate-x-full");
- document.getElementById("sidebarOverlayLeft").classList.add("hidden");
-});
-function dl(name, obj) {
- const blob = new Blob([JSON.stringify(obj, null, 2)], { type: name.endsWith(".sune") ? "application/octet-stream" : "application/json" }), url = URL.createObjectURL(blob), a = $("