mirror of
https://github.com/multipleof4/sune.git
synced 2026-05-18 03:02:15 +00:00
Compare commits
19 Commits
5ab8737c3c
...
347156408e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
347156408e | ||
| 54f15149ae | |||
|
|
213a7a5e6e | ||
| 91696a34f0 | |||
| a5aacf8201 | |||
|
|
5be837156f | ||
| 2713b5db36 | |||
| 7767f9131f | |||
| 2cb3f0e80b | |||
| 21c8fdc906 | |||
| ffb40d5c05 | |||
| 14666c8a8e | |||
| 41e096f35e | |||
| e4db39d0ef | |||
| 69a27b80b6 | |||
|
|
0c48ba465e | ||
| 4825333260 | |||
|
|
3fc26f8101 | ||
| c7796dd08a |
@@ -235,78 +235,7 @@ var generateTitleWithAI = async (messages) => {
|
||||
}
|
||||
};
|
||||
//#endregion
|
||||
//#region \0vite/preload-helper.js
|
||||
var scriptRel = "modulepreload";
|
||||
var assetsURL = function(dep) {
|
||||
return "/" + dep;
|
||||
};
|
||||
var seen = {};
|
||||
var __vitePreload = function preload(baseModule, deps, importerUrl) {
|
||||
let promise = Promise.resolve();
|
||||
if (deps && deps.length > 0) {
|
||||
const links = document.getElementsByTagName("link");
|
||||
const cspNonceMeta = document.querySelector("meta[property=csp-nonce]");
|
||||
const cspNonce = cspNonceMeta?.nonce || cspNonceMeta?.getAttribute("nonce");
|
||||
function allSettled(promises) {
|
||||
return Promise.all(promises.map((p) => Promise.resolve(p).then((value) => ({
|
||||
status: "fulfilled",
|
||||
value
|
||||
}), (reason) => ({
|
||||
status: "rejected",
|
||||
reason
|
||||
}))));
|
||||
}
|
||||
promise = allSettled(deps.map((dep) => {
|
||||
dep = assetsURL(dep, importerUrl);
|
||||
if (dep in seen) return;
|
||||
seen[dep] = true;
|
||||
const isCss = dep.endsWith(".css");
|
||||
const cssSelector = isCss ? "[rel=\"stylesheet\"]" : "";
|
||||
if (!!importerUrl) for (let i = links.length - 1; i >= 0; i--) {
|
||||
const link = links[i];
|
||||
if (link.href === dep && (!isCss || link.rel === "stylesheet")) return;
|
||||
}
|
||||
else if (document.querySelector(`link[href="${dep}"]${cssSelector}`)) return;
|
||||
const link = document.createElement("link");
|
||||
link.rel = isCss ? "stylesheet" : scriptRel;
|
||||
if (!isCss) link.as = "script";
|
||||
link.crossOrigin = "";
|
||||
link.href = dep;
|
||||
if (cspNonce) link.setAttribute("nonce", cspNonce);
|
||||
document.head.appendChild(link);
|
||||
if (isCss) return new Promise((res, rej) => {
|
||||
link.addEventListener("load", res);
|
||||
link.addEventListener("error", () => rej(/* @__PURE__ */ new Error(`Unable to preload CSS for ${dep}`)));
|
||||
});
|
||||
}));
|
||||
}
|
||||
function handlePreloadError(err) {
|
||||
const e = new Event("vite:preloadError", { cancelable: true });
|
||||
e.payload = err;
|
||||
window.dispatchEvent(e);
|
||||
if (!e.defaultPrevented) throw err;
|
||||
}
|
||||
return promise.then((res) => {
|
||||
for (const item of res || []) {
|
||||
if (item.status !== "rejected") continue;
|
||||
handlePreloadError(item.reason);
|
||||
}
|
||||
return baseModule().catch(handlePreloadError);
|
||||
});
|
||||
};
|
||||
//#endregion
|
||||
//#region src/main.js
|
||||
(() => {
|
||||
let k, v = visualViewport;
|
||||
const f = () => {
|
||||
removeEventListener("popstate", f), document.activeElement?.blur();
|
||||
};
|
||||
v.onresize = () => {
|
||||
let o = v.height < innerHeight;
|
||||
o != k && ((k = o) ? (history.pushState({ k: 1 }, ""), addEventListener("popstate", f)) : (removeEventListener("popstate", f), history.state?.k && history.back()));
|
||||
};
|
||||
})();
|
||||
var DEFAULT_MODEL = "anthropic/claude-opus-4.6";
|
||||
//#region src/dom.js
|
||||
var el = window.el = Object.fromEntries([
|
||||
"topbar",
|
||||
"chat",
|
||||
@@ -411,17 +340,22 @@ var el = window.el = Object.fromEntries([
|
||||
"threadBackBtn",
|
||||
"threadFolderBtn",
|
||||
"threadSyncBtn"
|
||||
].map((id) => [id, $("#" + id)[0]]));
|
||||
var icons = () => window.lucide && lucide.createIcons();
|
||||
var haptic = () => /android/i.test(navigator.userAgent) && navigator.vibrate?.(1);
|
||||
var clamp = (v, min, max) => Math.max(min, Math.min(max, v)), num = (v, d) => v == null || v === "" || isNaN(+v) ? d : +v, int = (v, d) => v == null || v === "" || isNaN(parseInt(v)) ? d : parseInt(v), gid = () => Math.random().toString(36).slice(2, 9), esc = (s) => String(s).replace(/[&<>'"`]/g, (c) => ({
|
||||
].map((id) => [id, document.getElementById(id)]));
|
||||
//#endregion
|
||||
//#region src/utils.js
|
||||
var clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
||||
var num = (v, d) => v == null || v === "" || isNaN(+v) ? d : +v;
|
||||
var int = (v, d) => v == null || v === "" || isNaN(parseInt(v)) ? d : parseInt(v);
|
||||
var gid = () => Math.random().toString(36).slice(2, 9);
|
||||
var esc = (s) => String(s).replace(/[&<>'"`]/g, (c) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"\"": """,
|
||||
"'": "'",
|
||||
"`": "`"
|
||||
})[c]), positionPopover = (a, p) => {
|
||||
})[c]);
|
||||
var 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`;
|
||||
@@ -464,7 +398,129 @@ var imgToWebp = (f, D = 128, q = 80) => new Promise((r, j) => {
|
||||
i.src = URL.createObjectURL(f);
|
||||
});
|
||||
var b64 = (x) => x.split(",")[1] || "";
|
||||
var utob = (s) => btoa(unescape(encodeURIComponent(s))), btou = (s) => decodeURIComponent(escape(atob(s.replace(/\s/g, ""))));
|
||||
var utob = (s) => btoa(unescape(encodeURIComponent(s)));
|
||||
var btou = (s) => decodeURIComponent(escape(atob(s.replace(/\s/g, ""))));
|
||||
function partsToText(m) {
|
||||
if (!m) return "";
|
||||
const c = m.content, i = m.images;
|
||||
let t = Array.isArray(c) ? c.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") : String(c || "");
|
||||
if (Array.isArray(i)) t += i.map((x) => `\n\n`).join("");
|
||||
return t;
|
||||
}
|
||||
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 = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
var ts = () => {
|
||||
const d = /* @__PURE__ */ new Date(), p = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
||||
};
|
||||
//#endregion
|
||||
//#region src/user.js
|
||||
var USER = {
|
||||
get PAT() {
|
||||
return this.githubToken;
|
||||
},
|
||||
get name() {
|
||||
return localStorage.getItem("user_name") || "Anon";
|
||||
},
|
||||
set name(v) {
|
||||
localStorage.setItem("user_name", v || "");
|
||||
},
|
||||
get avatar() {
|
||||
return localStorage.getItem("user_avatar") || "";
|
||||
},
|
||||
set avatar(v) {
|
||||
localStorage.setItem("user_avatar", v || "");
|
||||
},
|
||||
get provider() {
|
||||
return localStorage.getItem("provider") || "openrouter";
|
||||
},
|
||||
set provider(v) {
|
||||
localStorage.setItem("provider", [
|
||||
"openai",
|
||||
"google",
|
||||
"claude"
|
||||
].includes(v) ? v : "openrouter");
|
||||
},
|
||||
get apiKeyOpenRouter() {
|
||||
return localStorage.getItem("openrouter_api_key") || "";
|
||||
},
|
||||
set apiKeyOpenRouter(v) {
|
||||
localStorage.setItem("openrouter_api_key", v || "");
|
||||
},
|
||||
get apiKeyOpenAI() {
|
||||
return localStorage.getItem("openai_api_key") || "";
|
||||
},
|
||||
set apiKeyOpenAI(v) {
|
||||
localStorage.setItem("openai_api_key", v || "");
|
||||
},
|
||||
get apiKeyGoogle() {
|
||||
return localStorage.getItem("google_api_key") || "";
|
||||
},
|
||||
set apiKeyGoogle(v) {
|
||||
localStorage.setItem("google_api_key", v || "");
|
||||
},
|
||||
get apiKeyClaude() {
|
||||
return localStorage.getItem("claude_api_key") || "";
|
||||
},
|
||||
set apiKeyClaude(v) {
|
||||
localStorage.setItem("claude_api_key", v || "");
|
||||
},
|
||||
get apiKeyCloudflare() {
|
||||
return localStorage.getItem("cloudflare_api_key") || "";
|
||||
},
|
||||
set apiKeyCloudflare(v) {
|
||||
localStorage.setItem("cloudflare_api_key", v || "");
|
||||
},
|
||||
get apiKey() {
|
||||
const p = this.provider;
|
||||
return p === "openai" ? this.apiKeyOpenAI : p === "google" ? this.apiKeyGoogle : p === "claude" ? this.apiKeyClaude : p === "cloudflare" ? this.apiKeyCloudflare : this.apiKeyOpenRouter;
|
||||
},
|
||||
set apiKey(v) {
|
||||
const p = this.provider;
|
||||
if (p === "openai") this.apiKeyOpenAI = v;
|
||||
else if (p === "google") this.apiKeyGoogle = v;
|
||||
else if (p === "claude") this.apiKeyClaude = v;
|
||||
else if (p === "cloudflare") this.apiKeyCloudflare = v;
|
||||
else this.apiKeyOpenRouter = v;
|
||||
},
|
||||
get masterPrompt() {
|
||||
return localStorage.getItem("master_prompt") || "Always respond using markdown.";
|
||||
},
|
||||
set masterPrompt(v) {
|
||||
localStorage.setItem("master_prompt", v || "");
|
||||
},
|
||||
get titleModel() {
|
||||
return localStorage.getItem("title_model") ?? "or:amazon/nova-micro-v1";
|
||||
},
|
||||
set titleModel(v) {
|
||||
localStorage.setItem("title_model", v || "");
|
||||
},
|
||||
get githubToken() {
|
||||
return localStorage.getItem("gh_token") || "";
|
||||
},
|
||||
set githubToken(v) {
|
||||
localStorage.setItem("gh_token", v || "");
|
||||
},
|
||||
get gcpSA() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("gcp_sa_json") || "null");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
set gcpSA(v) {
|
||||
localStorage.setItem("gcp_sa_json", v ? JSON.stringify(v) : "");
|
||||
}
|
||||
};
|
||||
//#endregion
|
||||
//#region src/github.js
|
||||
var ghApi = async (path, method = "GET", body = null) => {
|
||||
const t = USER.githubToken;
|
||||
if (!t) throw new Error("No GH token");
|
||||
@@ -490,6 +546,290 @@ var parseGhUrl = (u) => {
|
||||
apiPath: `${owner}/${repo}/contents${path ? "/" + path : ""}`
|
||||
};
|
||||
};
|
||||
//#endregion
|
||||
//#region src/markdown.js
|
||||
var md = window.md = window.markdownit({
|
||||
html: false,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
breaks: true
|
||||
}).use(mathjax3);
|
||||
function enhanceCodeBlocks(root, doHL = true) {
|
||||
window.$(root).find("pre>code").each((i, code) => {
|
||||
if (code.textContent.length > 2e5) return;
|
||||
const $pre = window.$(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 = window.$("<button class=\"bg-slate-900 text-white rounded-lg py-1 px-2 text-xs opacity-85\">Copy</button>").on("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(code.innerText);
|
||||
$btn.text("Copied");
|
||||
setTimeout(() => $btn.text("Copy"), 1200);
|
||||
} catch {}
|
||||
});
|
||||
const $container = window.$("<div class=\"code-actions absolute top-2 right-2 flex items-center gap-2\"></div>");
|
||||
$container.append(window.$(`<span class="text-xs text-gray-500">${countText} chars</span>`), $btn);
|
||||
$pre.append($container);
|
||||
}
|
||||
if (doHL && window.hljs && code.textContent.length < 1e5) window.hljs.highlightElement(code);
|
||||
});
|
||||
}
|
||||
var renderMarkdown = window.renderMarkdown = function(node, text, opt = {
|
||||
enhance: true,
|
||||
highlight: true
|
||||
}) {
|
||||
node.innerHTML = md.render(text);
|
||||
if (opt.enhance) enhanceCodeBlocks(node, opt.highlight);
|
||||
};
|
||||
//#endregion
|
||||
//#region src/keyboard.js
|
||||
function kbUpdate() {
|
||||
const vv = window.visualViewport;
|
||||
const overlap = vv ? Math.max(0, window.innerHeight - (vv.height + vv.offsetTop)) : 0;
|
||||
document.documentElement.style.setProperty("--kb", overlap + "px");
|
||||
const fh = el.footer.getBoundingClientRect().height;
|
||||
document.documentElement.style.setProperty("--footer-h", fh + "px");
|
||||
el.footer.style.transform = "translateY(" + -overlap + "px)";
|
||||
el.chat.style.scrollPaddingBottom = fh + overlap + 16 + "px";
|
||||
}
|
||||
function kbBind() {
|
||||
if (window.visualViewport) ["resize", "scroll"].forEach((ev) => window.visualViewport.addEventListener(ev, () => kbUpdate(), { passive: true }));
|
||||
window.$(window).on("resize orientationchange", () => setTimeout(kbUpdate, 50));
|
||||
window.$(el.input).on("focus click", () => {
|
||||
setTimeout(() => {
|
||||
kbUpdate();
|
||||
el.input.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth"
|
||||
});
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
//#region src/attachments.js
|
||||
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)) return {
|
||||
type: "image_url",
|
||||
image_url: { url: mime === "image/webp" || /\.webp$/i.test(name) ? await asDataURL(file) : await imgToWebp(file, 2048, 94) }
|
||||
};
|
||||
if (mime === "application/pdf" || /\.pdf$/i.test(name)) {
|
||||
const bin = b64(await asDataURL(file));
|
||||
return {
|
||||
type: "file",
|
||||
file: {
|
||||
filename: name.endsWith(".pdf") ? name : name + ".pdf",
|
||||
file_data: bin
|
||||
}
|
||||
};
|
||||
}
|
||||
if (/^audio\//.test(mime) || /\.(wav|mp3)$/i.test(name)) return {
|
||||
type: "input_audio",
|
||||
input_audio: {
|
||||
data: b64(await asDataURL(file)),
|
||||
format: /mp3/.test(mime) || /\.mp3$/i.test(name) ? "mp3" : "wav"
|
||||
}
|
||||
};
|
||||
return {
|
||||
type: "file",
|
||||
file: {
|
||||
filename: name,
|
||||
file_data: b64(await asDataURL(file))
|
||||
}
|
||||
};
|
||||
}
|
||||
if (file && file.name == null && file.data) {
|
||||
const name = file.name || "file", mime = (file.mime || "application/octet-stream").toLowerCase();
|
||||
if (/^image\//.test(mime)) return {
|
||||
type: "image_url",
|
||||
image_url: { url: `data:${mime};base64,${file.data}` }
|
||||
};
|
||||
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;
|
||||
}
|
||||
//#endregion
|
||||
//#region src/threads-utils.js
|
||||
var titleFrom = (t) => {
|
||||
if (!t) return "Untitled";
|
||||
return (typeof t === "string" ? t : Array.isArray(t) ? partsToText({ content: t }) : "Untitled").replace(/\s+/g, " ").trim().slice(0, 60) || "Untitled";
|
||||
};
|
||||
var serializeThreadName = (t) => {
|
||||
const s = (t.title || "Untitled").replace(/[^a-zA-Z0-9]/g, "_").slice(0, 150);
|
||||
return `${t.pinned ? "1" : "0"}-${t.updatedAt || Date.now()}-${t.id}-${s}.json`;
|
||||
};
|
||||
var deserializeThreadName = (n) => {
|
||||
const p = n.replace(".json", "").split("-");
|
||||
if (p.length < 4) return null;
|
||||
return {
|
||||
pinned: p[0] === "1",
|
||||
updatedAt: parseInt(p[1]),
|
||||
id: p[2],
|
||||
title: p.slice(3).join("-").replace(/_/g, " "),
|
||||
status: "synced",
|
||||
type: "thread"
|
||||
};
|
||||
};
|
||||
//#endregion
|
||||
//#region src/sune-html.js
|
||||
var resolveSuneSrc = (src) => {
|
||||
if (!src) return null;
|
||||
if (src.startsWith("gh://")) {
|
||||
const parts = src.substring(5).split("/");
|
||||
if (parts.length < 3) return null;
|
||||
const [owner, repo, ...filePathParts] = parts;
|
||||
return `https://raw.githubusercontent.com/${owner}/${repo}/main/${filePathParts.join("/")}`;
|
||||
}
|
||||
return src;
|
||||
};
|
||||
var processSuneIncludes = async (html, depth = 0) => {
|
||||
if (depth > 5) return "<!-- Sune include depth limit reached -->";
|
||||
if (!html) return "";
|
||||
const c = (Document.parseHTMLUnsafe ? Document.parseHTMLUnsafe(html) : new DOMParser().parseFromString(html, "text/html")).body;
|
||||
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;
|
||||
const subHtml = await processSuneIncludes([o?.settings?.extension_html || "", o?.settings?.html || ""].join("\n"), depth + 1);
|
||||
const subDoc = Document.parseHTMLUnsafe ? Document.parseHTMLUnsafe(subHtml) : new DOMParser().parseFromString(subHtml, "text/html");
|
||||
n.replaceWith(...Array.from(subDoc.body.childNodes));
|
||||
} catch (e) {
|
||||
n.replaceWith(document.createComment(` Fetch failed: ${esc(u)} `));
|
||||
}
|
||||
} else n.replaceWith(...Array.from(n.childNodes));
|
||||
return c.innerHTML;
|
||||
};
|
||||
var renderSuneHTML = async () => {
|
||||
const SUNE = window.SUNE;
|
||||
const h = await processSuneIncludes([SUNE.extension_html, SUNE.html].map((x) => (x || "").trim()).join("\n"));
|
||||
const c = el.suneHtml;
|
||||
c.innerHTML = "";
|
||||
const t = h.trim();
|
||||
c.classList.toggle("hidden", !t);
|
||||
if (t) {
|
||||
const doc = Document.parseHTMLUnsafe ? Document.parseHTMLUnsafe(h) : new DOMParser().parseFromString(h, "text/html");
|
||||
c.append(...Array.from(doc.body.childNodes));
|
||||
c.querySelectorAll("script").forEach((oldScript) => {
|
||||
const newScript = document.createElement("script");
|
||||
Array.from(oldScript.attributes).forEach((attr) => newScript.setAttribute(attr.name, attr.value));
|
||||
newScript.textContent = oldScript.textContent;
|
||||
if (!newScript.hasAttribute("async")) newScript.async = false;
|
||||
oldScript.replaceWith(newScript);
|
||||
});
|
||||
window.Alpine?.initTree(c);
|
||||
}
|
||||
};
|
||||
//#endregion
|
||||
//#region \0vite/preload-helper.js
|
||||
var scriptRel = "modulepreload";
|
||||
var assetsURL = function(dep) {
|
||||
return "/" + dep;
|
||||
};
|
||||
var seen = {};
|
||||
var __vitePreload = function preload(baseModule, deps, importerUrl) {
|
||||
let promise = Promise.resolve();
|
||||
if (deps && deps.length > 0) {
|
||||
const links = document.getElementsByTagName("link");
|
||||
const cspNonceMeta = document.querySelector("meta[property=csp-nonce]");
|
||||
const cspNonce = cspNonceMeta?.nonce || cspNonceMeta?.getAttribute("nonce");
|
||||
function allSettled(promises) {
|
||||
return Promise.all(promises.map((p) => Promise.resolve(p).then((value) => ({
|
||||
status: "fulfilled",
|
||||
value
|
||||
}), (reason) => ({
|
||||
status: "rejected",
|
||||
reason
|
||||
}))));
|
||||
}
|
||||
promise = allSettled(deps.map((dep) => {
|
||||
dep = assetsURL(dep, importerUrl);
|
||||
if (dep in seen) return;
|
||||
seen[dep] = true;
|
||||
const isCss = dep.endsWith(".css");
|
||||
const cssSelector = isCss ? "[rel=\"stylesheet\"]" : "";
|
||||
if (!!importerUrl) for (let i = links.length - 1; i >= 0; i--) {
|
||||
const link = links[i];
|
||||
if (link.href === dep && (!isCss || link.rel === "stylesheet")) return;
|
||||
}
|
||||
else if (document.querySelector(`link[href="${dep}"]${cssSelector}`)) return;
|
||||
const link = document.createElement("link");
|
||||
link.rel = isCss ? "stylesheet" : scriptRel;
|
||||
if (!isCss) link.as = "script";
|
||||
link.crossOrigin = "";
|
||||
link.href = dep;
|
||||
if (cspNonce) link.setAttribute("nonce", cspNonce);
|
||||
document.head.appendChild(link);
|
||||
if (isCss) return new Promise((res, rej) => {
|
||||
link.addEventListener("load", res);
|
||||
link.addEventListener("error", () => rej(/* @__PURE__ */ new Error(`Unable to preload CSS for ${dep}`)));
|
||||
});
|
||||
}));
|
||||
}
|
||||
function handlePreloadError(err) {
|
||||
const e = new Event("vite:preloadError", { cancelable: true });
|
||||
e.payload = err;
|
||||
window.dispatchEvent(e);
|
||||
if (!e.defaultPrevented) throw err;
|
||||
}
|
||||
return promise.then((res) => {
|
||||
for (const item of res || []) {
|
||||
if (item.status !== "rejected") continue;
|
||||
handlePreloadError(item.reason);
|
||||
}
|
||||
return baseModule().catch(handlePreloadError);
|
||||
});
|
||||
};
|
||||
//#endregion
|
||||
//#region src/main.js
|
||||
(() => {
|
||||
let k, v = visualViewport;
|
||||
const f = () => {
|
||||
removeEventListener("popstate", f), document.activeElement?.blur();
|
||||
};
|
||||
v.onresize = () => {
|
||||
let o = v.height < innerHeight;
|
||||
o != k && ((k = o) ? (history.pushState({ k: 1 }, ""), addEventListener("popstate", f)) : (removeEventListener("popstate", f), history.state?.k && history.back()));
|
||||
};
|
||||
})();
|
||||
var DEFAULT_MODEL = "anthropic/claude-opus-4.6";
|
||||
var icons = () => window.lucide && lucide.createIcons();
|
||||
var haptic = () => /android/i.test(navigator.userAgent) && navigator.vibrate?.(1);
|
||||
var su = {
|
||||
key: "sunes_v1",
|
||||
activeKey: "active_sune_id",
|
||||
@@ -731,49 +1071,6 @@ var getModelShort = (m) => {
|
||||
const mm = m || SUNE.model || "";
|
||||
return mm.includes("/") ? mm.split("/").pop() : mm;
|
||||
};
|
||||
var resolveSuneSrc = (src) => {
|
||||
if (!src) return null;
|
||||
if (src.startsWith("gh://")) {
|
||||
const parts = src.substring(5).split("/");
|
||||
if (parts.length < 3) return null;
|
||||
const [owner, repo, ...filePathParts] = parts;
|
||||
return `https://raw.githubusercontent.com/${owner}/${repo}/main/${filePathParts.join("/")}`;
|
||||
}
|
||||
return src;
|
||||
};
|
||||
var processSuneIncludes = async (html, depth = 0) => {
|
||||
if (depth > 5) return "<!-- Sune include depth limit reached -->";
|
||||
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;
|
||||
};
|
||||
var 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));
|
||||
};
|
||||
var reflectActiveSune = async () => {
|
||||
const a = SUNE.active;
|
||||
el.suneBtnTop.title = `Settings — ${a.name}`;
|
||||
@@ -788,33 +1085,6 @@ var renderSidebar = window.renderSidebar = () => {
|
||||
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 = $("<button class=\"bg-slate-900 text-white rounded-lg py-1 px-2 text-xs opacity-85\">Copy</button>").on("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(code.innerText);
|
||||
$btn.text("Copied");
|
||||
setTimeout(() => $btn.text("Copy"), 1200);
|
||||
} catch {}
|
||||
});
|
||||
const $container = $("<div class=\"code-actions absolute top-2 right-2 flex items-center gap-2\"></div>");
|
||||
$container.append($(`<span class="text-xs text-gray-500">${countText} chars</span>`), $btn);
|
||||
$pre.append($container);
|
||||
}
|
||||
if (doHL && window.hljs && code.textContent.length < 1e5) hljs.highlightElement(code);
|
||||
});
|
||||
}
|
||||
var md = window.markdownit({
|
||||
html: false,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
breaks: true
|
||||
}).use(mathjax3);
|
||||
var getSuneLabel = (m) => {
|
||||
return `${m && m.sune_name || SUNE.name} · ${getModelShort(m && m.model)}`;
|
||||
};
|
||||
@@ -858,20 +1128,6 @@ function msgRow(m) {
|
||||
});
|
||||
return $row.find(".msg-bubble")[0];
|
||||
}
|
||||
var 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(m) {
|
||||
if (!m) return "";
|
||||
const c = m.content, i = m.images;
|
||||
let t = Array.isArray(c) ? c.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") : String(c || "");
|
||||
if (Array.isArray(i)) t += i.map((x) => `\n\n`).join("");
|
||||
return t;
|
||||
}
|
||||
var addMessage = window.addMessage = function(m, track = true) {
|
||||
m.id = m.id || gid();
|
||||
if (!Array.isArray(m.content) && m.content != null) m.content = [{
|
||||
@@ -939,26 +1195,6 @@ function setBtnSend() {
|
||||
function localDemoReply() {
|
||||
return "Tip: open the sidebar → Account & Backup to set your API key.";
|
||||
}
|
||||
var titleFrom = (t) => {
|
||||
if (!t) return "Untitled";
|
||||
return (typeof t === "string" ? t : Array.isArray(t) ? partsToText({ content: t }) : "Untitled").replace(/\s+/g, " ").trim().slice(0, 60) || "Untitled";
|
||||
};
|
||||
var serializeThreadName = (t) => {
|
||||
const s = (t.title || "Untitled").replace(/[^a-zA-Z0-9]/g, "_").slice(0, 150);
|
||||
return `${t.pinned ? "1" : "0"}-${t.updatedAt || Date.now()}-${t.id}-${s}.json`;
|
||||
};
|
||||
var deserializeThreadName = (n) => {
|
||||
const p = n.replace(".json", "").split("-");
|
||||
if (p.length < 4) return null;
|
||||
return {
|
||||
pinned: p[0] === "1",
|
||||
updatedAt: parseInt(p[1]),
|
||||
id: p[2],
|
||||
title: p.slice(3).join("-").replace(/_/g, " "),
|
||||
status: "synced",
|
||||
type: "thread"
|
||||
};
|
||||
};
|
||||
var TKEY = "threads_v1", THREAD = window.THREAD = {
|
||||
list: [],
|
||||
load: async function() {
|
||||
@@ -1302,72 +1538,6 @@ function updateAttachBadge() {
|
||||
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)) return {
|
||||
type: "image_url",
|
||||
image_url: { url: mime === "image/webp" || /\.webp$/i.test(name) ? await asDataURL(file) : await imgToWebp(file, 2048, 94) }
|
||||
};
|
||||
if (mime === "application/pdf" || /\.pdf$/i.test(name)) {
|
||||
const bin = b64(await asDataURL(file));
|
||||
return {
|
||||
type: "file",
|
||||
file: {
|
||||
filename: name.endsWith(".pdf") ? name : name + ".pdf",
|
||||
file_data: bin
|
||||
}
|
||||
};
|
||||
}
|
||||
if (/^audio\//.test(mime) || /\.(wav|mp3)$/i.test(name)) return {
|
||||
type: "input_audio",
|
||||
input_audio: {
|
||||
data: b64(await asDataURL(file)),
|
||||
format: /mp3/.test(mime) || /\.mp3$/i.test(name) ? "mp3" : "wav"
|
||||
}
|
||||
};
|
||||
return {
|
||||
type: "file",
|
||||
file: {
|
||||
filename: name,
|
||||
file_data: b64(await asDataURL(file))
|
||||
}
|
||||
};
|
||||
}
|
||||
if (file && file.name == null && file.data) {
|
||||
const name = file.name || "file", mime = (file.mime || "application/octet-stream").toLowerCase();
|
||||
if (/^image\//.test(mime)) return {
|
||||
type: "image_url",
|
||||
image_url: { url: `data:${mime};base64,${file.data}` }
|
||||
};
|
||||
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) {
|
||||
@@ -1599,19 +1769,6 @@ $(el.newSuneBtn).on("click", async () => {
|
||||
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 = $("<a>").prop({
|
||||
href: url,
|
||||
download: name
|
||||
}).appendTo("body");
|
||||
a.get(0).click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
var ts = () => {
|
||||
const d = /* @__PURE__ */ new Date(), p = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
||||
};
|
||||
var importMode = null;
|
||||
$(el.sunesExportOption).on("click", () => {
|
||||
dl(`sunes-${ts()}.sune`, {
|
||||
@@ -1698,28 +1855,6 @@ $(el.importInput).on("change", async () => {
|
||||
importMode = null;
|
||||
}
|
||||
});
|
||||
function kbUpdate() {
|
||||
const vv = window.visualViewport;
|
||||
const overlap = vv ? Math.max(0, window.innerHeight - (vv.height + vv.offsetTop)) : 0;
|
||||
document.documentElement.style.setProperty("--kb", overlap + "px");
|
||||
const fh = el.footer.getBoundingClientRect().height;
|
||||
document.documentElement.style.setProperty("--footer-h", fh + "px");
|
||||
el.footer.style.transform = "translateY(" + -overlap + "px)";
|
||||
el.chat.style.scrollPaddingBottom = fh + overlap + 16 + "px";
|
||||
}
|
||||
function kbBind() {
|
||||
if (window.visualViewport) ["resize", "scroll"].forEach((ev) => visualViewport.addEventListener(ev, () => kbUpdate(), { passive: true }));
|
||||
$(window).on("resize orientationchange", () => setTimeout(kbUpdate, 50));
|
||||
$(el.input).on("focus click", () => {
|
||||
setTimeout(() => {
|
||||
kbUpdate();
|
||||
el.input.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth"
|
||||
});
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
function activeMeta() {
|
||||
return {
|
||||
sune_name: SUNE.name,
|
||||
@@ -1727,153 +1862,57 @@ function activeMeta() {
|
||||
avatar: SUNE.avatar
|
||||
};
|
||||
}
|
||||
var USER = window.USER = {
|
||||
log: async (s) => {
|
||||
const t = String(s ?? "").trim();
|
||||
if (!t) return;
|
||||
await ensureThreadOnFirstUser(t);
|
||||
addMessage({
|
||||
role: "user",
|
||||
content: [{
|
||||
type: "text",
|
||||
text: t
|
||||
}]
|
||||
window.USER = USER;
|
||||
USER.log = async (s) => {
|
||||
const t = String(s ?? "").trim();
|
||||
if (!t) return;
|
||||
await ensureThreadOnFirstUser(t);
|
||||
addMessage({
|
||||
role: "user",
|
||||
content: [{
|
||||
type: "text",
|
||||
text: t
|
||||
}]
|
||||
});
|
||||
await THREAD.persist();
|
||||
};
|
||||
USER.logMany = async (msgs) => {
|
||||
if (!Array.isArray(msgs) || !msgs.length) return;
|
||||
const clean = msgs.map((s) => String(s ?? "").trim()).filter(Boolean);
|
||||
if (!clean.length) return;
|
||||
await ensureThreadOnFirstUser(clean[0]);
|
||||
const newMsgs = clean.map((t) => ({
|
||||
id: gid(),
|
||||
role: "user",
|
||||
content: [{
|
||||
type: "text",
|
||||
text: t
|
||||
}]
|
||||
}));
|
||||
state.messages.push(...newMsgs);
|
||||
const frag = document.createDocumentFragment();
|
||||
const newEls = newMsgs.map((m) => {
|
||||
const $row = _createMessageRow(m), bubble = $row.find(".msg-bubble")[0];
|
||||
bubble.dataset.mid = m.id;
|
||||
return {
|
||||
rowEl: $row[0],
|
||||
bubbleEl: bubble,
|
||||
message: m
|
||||
};
|
||||
});
|
||||
newEls.forEach((item) => frag.appendChild(item.rowEl));
|
||||
el.messages.appendChild(frag);
|
||||
queueMicrotask(() => {
|
||||
newEls.forEach((item) => {
|
||||
renderMarkdown(item.bubbleEl, partsToText(item.message));
|
||||
});
|
||||
await THREAD.persist();
|
||||
},
|
||||
logMany: async (msgs) => {
|
||||
if (!Array.isArray(msgs) || !msgs.length) return;
|
||||
const clean = msgs.map((s) => String(s ?? "").trim()).filter(Boolean);
|
||||
if (!clean.length) return;
|
||||
await ensureThreadOnFirstUser(clean[0]);
|
||||
const newMsgs = clean.map((t) => ({
|
||||
id: gid(),
|
||||
role: "user",
|
||||
content: [{
|
||||
type: "text",
|
||||
text: t
|
||||
}]
|
||||
}));
|
||||
state.messages.push(...newMsgs);
|
||||
const frag = document.createDocumentFragment();
|
||||
const newEls = newMsgs.map((m) => {
|
||||
const $row = _createMessageRow(m), bubble = $row.find(".msg-bubble")[0];
|
||||
bubble.dataset.mid = m.id;
|
||||
return {
|
||||
rowEl: $row[0],
|
||||
bubbleEl: bubble,
|
||||
message: m
|
||||
};
|
||||
el.chat.scrollTo({
|
||||
top: el.chat.scrollHeight,
|
||||
behavior: "smooth"
|
||||
});
|
||||
newEls.forEach((item) => frag.appendChild(item.rowEl));
|
||||
el.messages.appendChild(frag);
|
||||
queueMicrotask(() => {
|
||||
newEls.forEach((item) => {
|
||||
renderMarkdown(item.bubbleEl, partsToText(item.message));
|
||||
});
|
||||
el.chat.scrollTo({
|
||||
top: el.chat.scrollHeight,
|
||||
behavior: "smooth"
|
||||
});
|
||||
icons();
|
||||
});
|
||||
await THREAD.persist();
|
||||
},
|
||||
get PAT() {
|
||||
return this.githubToken;
|
||||
},
|
||||
get name() {
|
||||
return localStorage.getItem("user_name") || "Anon";
|
||||
},
|
||||
set name(v) {
|
||||
localStorage.setItem("user_name", v || "");
|
||||
},
|
||||
get avatar() {
|
||||
return localStorage.getItem("user_avatar") || "";
|
||||
},
|
||||
set avatar(v) {
|
||||
localStorage.setItem("user_avatar", v || "");
|
||||
},
|
||||
get provider() {
|
||||
return localStorage.getItem("provider") || "openrouter";
|
||||
},
|
||||
set provider(v) {
|
||||
localStorage.setItem("provider", [
|
||||
"openai",
|
||||
"google",
|
||||
"claude"
|
||||
].includes(v) ? v : "openrouter");
|
||||
},
|
||||
get apiKeyOpenRouter() {
|
||||
return localStorage.getItem("openrouter_api_key") || "";
|
||||
},
|
||||
set apiKeyOpenRouter(v) {
|
||||
localStorage.setItem("openrouter_api_key", v || "");
|
||||
},
|
||||
get apiKeyOpenAI() {
|
||||
return localStorage.getItem("openai_api_key") || "";
|
||||
},
|
||||
set apiKeyOpenAI(v) {
|
||||
localStorage.setItem("openai_api_key", v || "");
|
||||
},
|
||||
get apiKeyGoogle() {
|
||||
return localStorage.getItem("google_api_key") || "";
|
||||
},
|
||||
set apiKeyGoogle(v) {
|
||||
localStorage.setItem("google_api_key", v || "");
|
||||
},
|
||||
get apiKeyClaude() {
|
||||
return localStorage.getItem("claude_api_key") || "";
|
||||
},
|
||||
set apiKeyClaude(v) {
|
||||
localStorage.setItem("claude_api_key", v || "");
|
||||
},
|
||||
get apiKeyCloudflare() {
|
||||
return localStorage.getItem("cloudflare_api_key") || "";
|
||||
},
|
||||
set apiKeyCloudflare(v) {
|
||||
localStorage.setItem("cloudflare_api_key", v || "");
|
||||
},
|
||||
get apiKey() {
|
||||
const p = this.provider;
|
||||
return p === "openai" ? this.apiKeyOpenAI : p === "google" ? this.apiKeyGoogle : p === "claude" ? this.apiKeyClaude : p === "cloudflare" ? this.apiKeyCloudflare : this.apiKeyOpenRouter;
|
||||
},
|
||||
set apiKey(v) {
|
||||
const p = this.provider;
|
||||
if (p === "openai") this.apiKeyOpenAI = v;
|
||||
else if (p === "google") this.apiKeyGoogle = v;
|
||||
else if (p === "claude") this.apiKeyClaude = v;
|
||||
else if (p === "cloudflare") this.apiKeyCloudflare = v;
|
||||
else this.apiKeyOpenRouter = v;
|
||||
},
|
||||
get masterPrompt() {
|
||||
return localStorage.getItem("master_prompt") || "Always respond using markdown.";
|
||||
},
|
||||
set masterPrompt(v) {
|
||||
localStorage.setItem("master_prompt", v || "");
|
||||
},
|
||||
get titleModel() {
|
||||
return localStorage.getItem("title_model") ?? "or:amazon/nova-micro-v1";
|
||||
},
|
||||
set titleModel(v) {
|
||||
localStorage.setItem("title_model", v || "");
|
||||
},
|
||||
get githubToken() {
|
||||
return localStorage.getItem("gh_token") || "";
|
||||
},
|
||||
set githubToken(v) {
|
||||
localStorage.setItem("gh_token", v || "");
|
||||
},
|
||||
get gcpSA() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("gcp_sa_json") || "null");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
set gcpSA(v) {
|
||||
localStorage.setItem("gcp_sa_json", v ? JSON.stringify(v) : "");
|
||||
}
|
||||
icons();
|
||||
});
|
||||
await THREAD.persist();
|
||||
};
|
||||
async function init() {
|
||||
const u = localStorage.getItem("thread_repo_url") || "";
|
||||
2
dist/index.html
vendored
2
dist/index.html
vendored
@@ -14,7 +14,7 @@
|
||||
<script defer src="https://c.planetrenox.com/tracker.js"></script>
|
||||
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-uVgFAg1W.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-T6x9HO8d.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CLEI5Rwr.css">
|
||||
<link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
|
||||
<body class="bg-white text-gray-900 selection:bg-black/10" x-data @click.window="if($event.target.closest('button')) haptic(); if(!document.getElementById('threadPopover').contains($event.target)&&!$event.target.closest('[data-thread-menu]')) hideThreadPopover(); if(!document.getElementById('sunePopover').contains($event.target)&&!$event.target.closest('[data-sune-menu]')) hideSunePopover(); if(!document.getElementById('userMenu').contains($event.target)&&!document.getElementById('userMenuBtn').contains($event.target)) document.getElementById('userMenu').classList.add('hidden')">
|
||||
|
||||
2
dist/sw.js
vendored
2
dist/sw.js
vendored
@@ -1 +1 @@
|
||||
if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const t=e||("document"in self?document.currentScript.src:"")||location.href;if(s[t])return;let o={};const d=e=>i(e,t),l={module:{uri:t},exports:o,require:d};s[t]=Promise.all(n.map(e=>l[e]||d(e))).then(e=>(r(...e),o))}}define(["./workbox-8c29f6e4"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"registerSW.js",revision:"1872c500de691dce40960bb85481de07"},{url:"index.html",revision:"f88bfd9d7b620afd81de89ec21767e69"},{url:"assets/index-uVgFAg1W.js",revision:null},{url:"assets/index-CLEI5Rwr.css",revision:null},{url:"manifest.webmanifest",revision:"7a6c5c6ab9cb5d3605d21df44c6b17a2"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("index.html")))});
|
||||
if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const t=e||("document"in self?document.currentScript.src:"")||location.href;if(s[t])return;let o={};const d=e=>i(e,t),c={module:{uri:t},exports:o,require:d};s[t]=Promise.all(n.map(e=>c[e]||d(e))).then(e=>(r(...e),o))}}define(["./workbox-8c29f6e4"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"registerSW.js",revision:"1872c500de691dce40960bb85481de07"},{url:"index.html",revision:"e4e2608d422a0977cc751922dd4297f8"},{url:"assets/index-T6x9HO8d.js",revision:null},{url:"assets/index-CLEI5Rwr.css",revision:null},{url:"manifest.webmanifest",revision:"7a6c5c6ab9cb5d3605d21df44c6b17a2"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("index.html")))});
|
||||
|
||||
38
src/attachments.js
Normal file
38
src/attachments.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { asDataURL, imgToWebp, b64 } from './utils.js';
|
||||
|
||||
export 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 data = mime === 'image/webp' || /\.webp$/i.test(name) ? await asDataURL(file) : await imgToWebp(file, 2048, 94);
|
||||
return { type: 'image_url', image_url: { url: data } };
|
||||
}
|
||||
if (mime === 'application/pdf' || /\.pdf$/i.test(name)) {
|
||||
const data = await asDataURL(file), bin = b64(data);
|
||||
return { type: 'file', file: { filename: name.endsWith('.pdf') ? name : name + '.pdf', file_data: bin } };
|
||||
}
|
||||
if (/^audio\//.test(mime) || /\.(wav|mp3)$/i.test(name)) {
|
||||
const data = await asDataURL(file), bin = b64(data), fmt = /mp3/.test(mime) || /\.mp3$/i.test(name) ? 'mp3' : 'wav';
|
||||
return { type: 'input_audio', input_audio: { data: bin, 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;
|
||||
}
|
||||
24
src/dom.js
Normal file
24
src/dom.js
Normal file
@@ -0,0 +1,24 @@
|
||||
export const el = window.el = Object.fromEntries(
|
||||
[
|
||||
'topbar','chat','messages','composer','input','sendBtn','suneBtnTop','suneModal','suneURL',
|
||||
'settingsForm','closeSettings','cancelSettings','tabModel','tabPrompt','tabScript',
|
||||
'panelModel','panelPrompt','panelScript','set_model','set_temperature','set_top_p','set_top_k',
|
||||
'set_frequency_penalty','set_repetition_penalty','set_min_p','set_top_a','set_verbosity',
|
||||
'set_reasoning_effort','set_system_prompt','set_hide_composer','set_include_thoughts',
|
||||
'set_json_output','set_img_output','set_aspect_ratio','set_image_size','aspectRatioContainer',
|
||||
'set_ignore_master_prompt','deleteSuneBtn','sidebarLeft','sidebarOverlayLeft','sidebarBtnLeft',
|
||||
'suneList','newSuneBtn','userMenuBtn','userMenu','accountSettingsOption','sunesImportOption',
|
||||
'sunesExportOption','threadsImportOption','importInput','sidebarBtnRight','sidebarRight',
|
||||
'sidebarOverlayRight','threadList','closeThreads','threadPopover','sunePopover','footer',
|
||||
'attachBtn','attachBadge','fileInput','htmlEditor','extensionHtmlEditor','jsonSchemaEditor',
|
||||
'htmlTab_index','htmlTab_extension','suneHtml','accountSettingsModal','accountSettingsForm',
|
||||
'closeAccountSettings','cancelAccountSettings','set_master_prompt','set_provider',
|
||||
'set_api_key_or','set_api_key_oai','set_api_key_g','set_api_key_claude','set_api_key_cf',
|
||||
'set_title_model','copySystemPrompt','pasteSystemPrompt','copyHTML','pasteHTML',
|
||||
'accountTabGeneral','accountTabAPI','accountPanelGeneral','accountPanelAPI','set_gh_token',
|
||||
'gcpSAInput','gcpSAUploadBtn','importAccountSettings','exportAccountSettings',
|
||||
'importAccountSettingsInput','accountTabUser','accountPanelUser','set_user_name',
|
||||
'userAvatarPreview','setUserAvatarBtn','userAvatarInput','threadRepoInput','threadBackBtn',
|
||||
'threadFolderBtn','threadSyncBtn'
|
||||
].map(id => [id, document.getElementById(id)])
|
||||
);
|
||||
24
src/github.js
Normal file
24
src/github.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { USER } from './user.js';
|
||||
|
||||
export const ghApi = async (path, method = 'GET', body = null) => {
|
||||
const t = USER.githubToken;
|
||||
if (!t) throw new Error('No GH token');
|
||||
const r = await fetch(`https://api.github.com/repos/${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `token ${t}`,
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: body ? JSON.stringify(body) : null
|
||||
});
|
||||
if (!r.ok && r.status !== 404) throw new Error(`GH API ${r.status}`);
|
||||
return r.status === 404 ? null : r.json();
|
||||
};
|
||||
|
||||
export const parseGhUrl = u => {
|
||||
const p = u.substring(5).split('/'), owner = p[0], repoPart = p[1] || '',
|
||||
branch = repoPart.includes('@') ? repoPart.split('@')[1] : 'main',
|
||||
repo = repoPart.split('@')[0], path = p.slice(2).join('/').replace(/\/$/, '');
|
||||
return { owner, repo, branch, path, apiPath: `${owner}/${repo}/contents${path ? '/' + path : ''}` };
|
||||
};
|
||||
24
src/keyboard.js
Normal file
24
src/keyboard.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { el } from './dom.js';
|
||||
|
||||
export function kbUpdate() {
|
||||
const vv = window.visualViewport;
|
||||
const overlap = vv ? Math.max(0, (window.innerHeight - (vv.height + vv.offsetTop))) : 0;
|
||||
document.documentElement.style.setProperty('--kb', overlap + 'px');
|
||||
const fh = el.footer.getBoundingClientRect().height;
|
||||
document.documentElement.style.setProperty('--footer-h', fh + 'px');
|
||||
el.footer.style.transform = 'translateY(' + (-overlap) + 'px)';
|
||||
el.chat.style.scrollPaddingBottom = (fh + overlap + 16) + 'px';
|
||||
}
|
||||
|
||||
export function kbBind() {
|
||||
if (window.visualViewport) {
|
||||
['resize', 'scroll'].forEach(ev => window.visualViewport.addEventListener(ev, () => kbUpdate(), { passive: true }));
|
||||
}
|
||||
window.$(window).on('resize orientationchange', () => setTimeout(kbUpdate, 50));
|
||||
window.$(el.input).on('focus click', () => {
|
||||
setTimeout(() => {
|
||||
kbUpdate();
|
||||
el.input.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
70
src/main.js
70
src/main.js
@@ -2,22 +2,20 @@ import {streamChat,HTTP_BASE} from './streaming.js';
|
||||
import {SUNE_LOGO_SVG} from './sune-logo.js';
|
||||
import {STICKY_SUNES} from './sticky-sunes.js';
|
||||
import {generateTitleWithAI} from './title-generator.js';
|
||||
import mathjax3 from 'https://esm.sh/markdown-it-mathjax3';
|
||||
import { el } from './dom.js';
|
||||
import { clamp, num, int, gid, esc, positionPopover, sid, fmtSize, asDataURL, imgToWebp, b64, utob, btou, dl, ts, partsToText } from './utils.js';
|
||||
import { ghApi, parseGhUrl } from './github.js';
|
||||
import { USER } from './user.js';
|
||||
import { md, enhanceCodeBlocks, renderMarkdown } from './markdown.js';
|
||||
import { kbUpdate, kbBind } from './keyboard.js';
|
||||
import { toAttach } from './attachments.js';
|
||||
import { titleFrom, serializeThreadName, deserializeThreadName } from './threads-utils.js';
|
||||
import { resolveSuneSrc, processSuneIncludes, renderSuneHTML } from './sune-html.js';
|
||||
|
||||
(()=>{let k,v=visualViewport;const f=()=>{removeEventListener('popstate',f),document.activeElement?.blur()};v.onresize=()=>{let o=v.height<innerHeight;o!=k&&((k=o)?(history.pushState({k:1},''),addEventListener('popstate',f)):(removeEventListener('popstate',f),history.state?.k&&history.back()))}})()
|
||||
const DEFAULT_MODEL='anthropic/claude-opus-4.6'
|
||||
const el=window.el=Object.fromEntries(['topbar','chat','messages','composer','input','sendBtn','suneBtnTop','suneModal','suneURL','settingsForm','closeSettings','cancelSettings','tabModel','tabPrompt','tabScript','panelModel','panelPrompt','panelScript','set_model','set_temperature','set_top_p','set_top_k','set_frequency_penalty','set_repetition_penalty','set_min_p','set_top_a','set_verbosity','set_reasoning_effort','set_system_prompt','set_hide_composer','set_include_thoughts','set_json_output','set_img_output','set_aspect_ratio','set_image_size','aspectRatioContainer','set_ignore_master_prompt','deleteSuneBtn','sidebarLeft','sidebarOverlayLeft','sidebarBtnLeft','suneList','newSuneBtn','userMenuBtn','userMenu','accountSettingsOption','sunesImportOption','sunesExportOption','threadsImportOption','importInput','sidebarBtnRight','sidebarRight','sidebarOverlayRight','threadList','closeThreads','threadPopover','sunePopover','footer','attachBtn','attachBadge','fileInput','htmlEditor','extensionHtmlEditor','jsonSchemaEditor','htmlTab_index','htmlTab_extension','suneHtml','accountSettingsModal','accountSettingsForm','closeAccountSettings','cancelAccountSettings','set_master_prompt','set_provider','set_api_key_or','set_api_key_oai','set_api_key_g','set_api_key_claude','set_api_key_cf','set_title_model','copySystemPrompt','pasteSystemPrompt','copyHTML','pasteHTML','accountTabGeneral','accountTabAPI','accountPanelGeneral','accountPanelAPI','set_gh_token','gcpSAInput','gcpSAUploadBtn','importAccountSettings','exportAccountSettings','importAccountSettingsInput','accountTabUser','accountPanelUser','set_user_name','userAvatarPreview','setUserAvatarBtn','userAvatarInput','threadRepoInput','threadBackBtn','threadFolderBtn','threadSyncBtn'].map(id=>[id,$('#'+id)[0]]))
|
||||
const icons=()=>window.lucide&&lucide.createIcons()
|
||||
const haptic=()=>/android/i.test(navigator.userAgent)&&navigator.vibrate?.(1)
|
||||
const clamp=(v,min,max)=>Math.max(min,Math.min(max,v)),num=(v,d)=>v==null||v===''||isNaN(+v)?d:+v,int=(v,d)=>v==null||v===''||isNaN(parseInt(v))?d:parseInt(v),gid=()=>Math.random().toString(36).slice(2,9),esc=s=>String(s).replace(/[&<>'"`]/g,c=>({"&":"&","<":"<",">":">","\"":""","'":"'","`":"`"}[c])),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 sid=()=>Date.now().toString(36)+Math.random().toString(36).slice(2,6)
|
||||
const fmtSize=b=>{const u=['B','KB','MB','GB','TB'];let i=0,x=b;while(x>=1024&&i<u.length-1){x/=1024;i++}return (x>=10?Math.round(x):Math.round(x*10)/10)+' '+u[i]}
|
||||
const asDataURL=f=>new Promise(r=>{const fr=new FileReader();fr.onload=()=>r(String(fr.result||''));fr.readAsDataURL(f)})
|
||||
const imgToWebp=(f,D=128,q=80)=>new Promise((r,j)=>{if(!f)return j();const i=new Image;i.onload=()=>{const c=document.createElement('canvas'),x=c.getContext('2d');let w=i.width,h=i.height;if(D>0&&Math.max(w,h)>D)w>h?(h=D*h/w,w=D):(w=D*w/h,h=D);c.width=w;c.height=h;x.drawImage(i,0,0,w,h);r(c.toDataURL('image/webp',clamp(q,0,100)/100));URL.revokeObjectURL(i.src)};i.onerror=j;i.src=URL.createObjectURL(f)});
|
||||
const b64=x=>x.split(',')[1]||''
|
||||
const utob=s=>btoa(unescape(encodeURIComponent(s))),btou=s=>decodeURIComponent(escape(atob(s.replace(/\s/g,''))))
|
||||
|
||||
const ghApi=async(path,method='GET',body=null)=>{const t=USER.githubToken;if(!t)throw new Error('No GH token');const r=await fetch(`https://api.github.com/repos/${path}`,{method,headers:{'Authorization':`token ${t}`,'Accept':'application/vnd.github.v3+json','Content-Type':'application/json'},body:body?JSON.stringify(body):null});if(!r.ok&&r.status!==404)throw new Error(`GH API ${r.status}`);return r.status===404?null:r.json()};
|
||||
const parseGhUrl=u=>{const p=u.substring(5).split('/'),owner=p[0],repoPart=p[1]||'',branch=repoPart.includes('@')?repoPart.split('@')[1]:'main',repo=repoPart.split('@')[0],path=p.slice(2).join('/').replace(/\/$/,'');return{owner,repo,branch,path,apiPath:`${owner}/${repo}/contents${path?'/'+path:''}`}};
|
||||
|
||||
const su={key:'sunes_v1',activeKey:'active_sune_id',load(){try{return JSON.parse(localStorage.getItem(this.key)||'[]')}catch{return[]}},save(list){localStorage.setItem(this.key,JSON.stringify(list||[]))},getActiveId(){return localStorage.getItem(this.activeKey)||null},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:"<sune src='https://raw.githubusercontent.com/sune-org/store/refs/heads/main/sync.sune' private></sune>",hide_composer:false,include_thoughts:false,json_output:false,img_output:false,aspect_ratio:'1:1',image_size:'1K',ignore_master_prompt:false,json_schema:''}
|
||||
@@ -27,19 +25,12 @@ const SUNE=window.SUNE=new Proxy({get list(){return sunes},get id(){return su.ge
|
||||
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'<!-- Sune include depth limit reached -->';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?`<img src="${esc(a.avatar)}" alt="" class="h-8 w-8 rounded-full object-cover"/>`:'✺';el.footer.classList.toggle('hidden',!!a.settings.hide_composer);await renderSuneHTML();icons()}
|
||||
const suneRow=a=>`<div class="relative flex items-center gap-2 px-3 py-2 ${a.pinned?'bg-yellow-50':''}"><button data-sune-id="${a.id}" class="flex-1 text-left flex items-center gap-2 ${a.id===SUNE.id?'font-medium':''}">${a.avatar?`<img src="${esc(a.avatar)}" alt="" class="h-8 w-8 rounded-full object-cover"/>`:`<span class="h-6 w-6 rounded-full bg-gray-200 flex items-center justify-center">✺</span>`}<span class="truncate">${a.pinned?'📌 ':''}${esc(a.name)}</span></button><button data-sune-menu="${a.id}" class="h-8 w-8 rounded hover:bg-gray-100 flex items-center justify-center" title="More"><i data-lucide="more-horizontal" class="h-4 w-4"></i></button></div>`
|
||||
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>200000)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=$('<button class="bg-slate-900 text-white rounded-lg py-1 px-2 text-xs opacity-85">Copy</button>').on('click',async e=>{e.stopPropagation();try{await navigator.clipboard.writeText(code.innerText);$btn.text('Copied');setTimeout(()=>$btn.text('Copy'),1200)}catch{}});const $container=$('<div class="code-actions absolute top-2 right-2 flex items-center gap-2"></div>');$container.append($(`<span class="text-xs text-gray-500">${countText} chars</span>`),$btn);$pre.append($container)}if(doHL&&window.hljs&&code.textContent.length<100000)hljs.highlightElement(code)})}
|
||||
const md=window.markdownit({html:false,linkify:true,typographer:true,breaks:true}).use(mathjax3)
|
||||
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=$('<div class="flex flex-col gap-2"></div>'),$head=$('<div class="flex items-center gap-2 px-4"></div>'),$avatar=$('<div></div>');const uAva=isUser?USER.avatar:meta.avatar;uAva?$avatar.attr('class','msg-avatar shrink-0 h-7 w-7 rounded-full overflow-hidden').html(`<img src="${esc(uAva)}" class="h-full w-full object-cover">`):$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=$('<div class="text-xs font-medium text-gray-500"></div>').text(isUser?USER.name:getSuneLabel(meta));const $deleteBtn=$('<button class="p-1.5 rounded-lg hover:bg-gray-200 text-gray-400 hover:text-red-500" title="Delete message"><i data-lucide="x" class="h-4 w-4"></i></button>').on('click',async e=>{e.stopPropagation();state.messages=state.messages.filter(msg=>msg.id!==m.id);$row.remove();await THREAD.persist()});const $copyBtn=$('<button class="ml-auto p-1.5 rounded-lg hover:bg-gray-200 text-gray-400 hover:text-gray-600" title="Copy message"><i data-lucide="copy" class="h-4 w-4"></i></button>').on('click',async function(e){e.stopPropagation();try{await navigator.clipboard.writeText(partsToText(m));$(this).html('<i data-lucide="check" class="h-4 w-4 text-green-500"></i>');icons();setTimeout(()=>{$(this).html('<i data-lucide="copy" class="h-4 w-4"></i>');icons()},1200)}catch{}});$head.append($avatar,$name,$copyBtn,$deleteBtn);const $bubble=$(`<div class="${(isUser?'bg-gray-50 border border-gray-200':'bg-gray-100')+' msg-bubble markdown-body rounded-none px-4 py-3 w-full'}"></div>`);$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(m){if(!m)return'';const c=m.content,i=m.images;let t=Array.isArray(c)?c.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'):String(c||'');if(Array.isArray(i))t+=i.map(x=>`\n\n`).join('');return t}
|
||||
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));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=''}
|
||||
@@ -47,9 +38,6 @@ const payloadWithSampling=b=>{const o=Object.assign({},b),s=SUNE,p={temperature:
|
||||
function setBtnStop(){const b=el.sendBtn;b.dataset.mode='stop';b.type='button';b.setAttribute('aria-label','Stop');b.innerHTML='<i data-lucide="square" class="h-5 w-5"></i>';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='<i data-lucide="sparkles" class="h-5 w-5"></i>';icons();b.onclick=null}
|
||||
function localDemoReply(){return 'Tip: open the sidebar → Account & Backup to set your API key.'}
|
||||
const titleFrom=t=>{if(!t)return'Untitled';const s=typeof t==='string'?t:(Array.isArray(t)?partsToText({content:t}):'Untitled');return s.replace(/\s+/g,' ').trim().slice(0,60)||'Untitled'}
|
||||
const serializeThreadName=t=>{const s=(t.title||'Untitled').replace(/[^a-zA-Z0-9]/g,'_').slice(0,150);return `${t.pinned?'1':'0'}-${t.updatedAt||Date.now()}-${t.id}-${s}.json`}
|
||||
const deserializeThreadName=n=>{const p=n.replace('.json','').split('-');if(p.length<4)return null;return {pinned:p[0]==='1',updatedAt:parseInt(p[1]),id:p[2],title:p.slice(3).join('-').replace(/_/g,' '),status:'synced',type:'thread'}}
|
||||
const TKEY='threads_v1',THREAD=window.THREAD={list:[],load:async function(){const u=el.threadRepoInput.value.trim();if(u.startsWith('gh://')){this.list=await localforage.getItem('rem_index_'+u.substring(5)).then(v=>Array.isArray(v)?v:[])||[]}else{this.list=await localforage.getItem(TKEY).then(v=>Array.isArray(v)?v:[])||[]}},save:async function(){const u=el.threadRepoInput.value.trim();if(u.startsWith('gh://')){await localforage.setItem('rem_index_'+u.substring(5),this.list.map(t=>{const n={...t};delete n.messages;return n}))}else{await localforage.setItem(TKEY,this.list.map(t=>{const n={...t};delete n.messages;return n}))}},get:function(id){return this.list.find(t=>t.id===id)},get active(){return this.get(state.currentThreadId)},persist:async function(full=true){const id=state.currentThreadId;if(!id)return;const meta=this.get(id);if(!meta)return;const u=el.threadRepoInput.value.trim(),prefix=u.startsWith('gh://')?'rem_t_':'t_';await localforage.setItem(prefix+id,[...state.messages]);if(full){meta.updatedAt=Date.now();if(u.startsWith('gh://')&&meta.status!=='new')meta.status='modified';await this.save();await renderThreads()}},setTitle:async function(id,title){const th=this.get(id);if(!th||!title)return;th.title=titleFrom(title);th.updatedAt=Date.now();const u=el.threadRepoInput.value.trim();if(u.startsWith('gh://')&&th.status!=='new')th.status='modified';await this.save();await renderThreads()},getLastAssistantMessageId:()=>{const a=[...el.messages.querySelectorAll('.msg-bubble')];for(let i=a.length-1;i>=0;i--){const b=a[i],h=b.previousElementSibling;if(!h)continue;if(!/^\s*You\b/.test(h.textContent||''))return b.dataset.mid||null}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(),u=el.threadRepoInput.value.trim(),th={id,title:'',pinned:false,updatedAt:now,type:'thread'};if(u.startsWith('gh://'))th.status='new';state.currentThreadId=id;THREAD.list.unshift(th);await THREAD.save();const prefix=u.startsWith('gh://')?'rem_t_':'t_';await localforage.setItem(prefix+id,[]);await renderThreads()}
|
||||
@@ -87,7 +75,6 @@ $(el.threadPopover).on('click',async e=>{const act=e.target.closest('[data-actio
|
||||
$(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 data=mime==='image/webp'||/\.webp$/i.test(name)?await asDataURL(file):await imgToWebp(file,2048,94);return{type:'image_url',image_url:{url:data}}}if(mime==='application/pdf'||/\.pdf$/i.test(name)){const data=await asDataURL(file),bin=b64(data);return{type:'file',file:{filename:name.endsWith('.pdf')?name:name+'.pdf',file_data:bin}}}if(/^audio\//.test(mime)||/\.(wav|mp3)$/i.test(name)){const data=await asDataURL(file),bin=b64(data),fmt=/mp3/.test(mime)||/\.mp3$/i.test(name)?'mp3':'wav';return{type:'input_audio',input_audio:{data:bin,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(text||'(attachments)');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')).replace(/!\[\]\(data:[^\)]+\)/g,'[Image]')||'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;suneBubble.innerHTML=SUNE_LOGO_SVG;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,imgs)=>{if(imgs){if(!assistantMsg.images)assistantMsg.images=[];assistantMsg.images.push(...imgs)}buf+=delta;state.stream.text=buf;renderMarkdown(suneBubble,partsToText(assistantMsg),{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 streamChat(onDelta,streamId);state.attachments=[];updateAttachBadge()})
|
||||
@@ -107,17 +94,43 @@ $(el.set_img_output).on('change',e=>el.aspectRatioContainer.classList.toggle('hi
|
||||
$(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.img_output=el.set_img_output.checked;SUNE.aspect_ratio=el.set_aspect_ratio.value;SUNE.image_size=el.set_image_size.value;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=$('<a>').prop({href:url,download:name}).appendTo('body');a.get(0).click();a.remove();URL.revokeObjectURL(url)}
|
||||
const ts=()=>{const d=new Date(),p=n=>String(n).padStart(2,'0');return `${d.getFullYear()}${p(d.getMonth()+1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`}
|
||||
let importMode=null
|
||||
$(el.sunesExportOption).on('click',()=>{dl(`sunes-${ts()}.sune`,{version:1,sunes:SUNE.list,activeId:SUNE.id});el.userMenu.classList.add('hidden')})
|
||||
$(el.sunesImportOption).on('click',()=>{importMode='sunes';el.importInput.value='';el.importInput.click()})
|
||||
$(el.threadsImportOption).on('click',()=>{importMode='threads';el.importInput.value='';el.importInput.click()})
|
||||
$(el.importInput).on('change',async()=>{const file=el.importInput.files?.[0];if(!file)return;try{const text=await file.text();const data=JSON.parse(text);if(importMode==='sunes'){const list=Array.isArray(data)?data:(Array.isArray(data.sunes)?data.sunes:[]);if(!list.length)throw new Error('No sunes');const incoming=list.map(a=>makeSune(a||{}));const map={};incoming.forEach(s=>{if(!s.id)s.id=gid();const k=s.id,prev=map[k];map[k]=!prev||(+s.updatedAt>+prev.updatedAt)?s:prev});let added=0,updated=0;const idx=Object.fromEntries(sunes.map(s=>[s.id,s]));Object.values(map).forEach(s=>{const ex=idx[s.id];if(!ex){sunes.push(s);added++}else if(+s.updatedAt>+ex.updatedAt){Object.assign(ex,s);updated++}});SUNE.save();if(data.activeId&&sunes.some(x=>x.id===data.activeId))SUNE.setActive(data.activeId);renderSidebar();await reflectActiveSune();state.currentThreadId=null;clearChat();alert(`${added} new, ${updated} updated.`)}else if(importMode==='threads'){if(!data||!data.id||!Array.isArray(data.messages))throw new Error('Invalid thread format');const u=el.threadRepoInput.value.trim(),prefix=u.startsWith('gh://')?'rem_t_':'t_';const norm=t=>({id:t.id||gid(),title:titleFrom(t.title||t.messages),pinned:!!t.pinned,updatedAt:num(t.updatedAt,Date.now()),type:'thread',...(u.startsWith('gh://')?{status:'new'}:{})});const n=norm(data),msgs=data.messages,idx=THREAD.list.findIndex(x=>x.id===n.id);if(idx>-1){if(n.updatedAt>THREAD.list[idx].updatedAt){THREAD.list[idx]=n;await localforage.setItem(prefix+n.id,msgs)}}else{THREAD.list.unshift(n);await localforage.setItem(prefix+n.id,msgs)}await THREAD.save();await renderThreads();alert('Thread imported.')}el.userMenu.classList.add('hidden')}catch{alert('Import failed')}finally{importMode=null}})
|
||||
function kbUpdate(){const vv=window.visualViewport;const overlap=vv?Math.max(0,(window.innerHeight-(vv.height+vv.offsetTop))):0;document.documentElement.style.setProperty('--kb',overlap+'px');const fh=el.footer.getBoundingClientRect().height;document.documentElement.style.setProperty('--footer-h',fh+'px');el.footer.style.transform='translateY('+(-overlap)+'px)';el.chat.style.scrollPaddingBottom=(fh+overlap+16)+'px'}
|
||||
function kbBind(){if(window.visualViewport){['resize','scroll'].forEach(ev=>visualViewport.addEventListener(ev,()=>kbUpdate(),{passive:true}))}$(window).on('resize orientationchange',()=>setTimeout(kbUpdate,50));$(el.input).on('focus click',()=>{setTimeout(()=>{kbUpdate();el.input.scrollIntoView({block:'nearest',behavior:'smooth'})},0)})}
|
||||
function activeMeta(){return {sune_name:SUNE.name,model:SUNE.model,avatar:SUNE.avatar}}
|
||||
const USER=window.USER={log:async s=>{const t=String(s??'').trim();if(!t)return;await ensureThreadOnFirstUser(t);addMessage({role:'user',content:[{type:'text',text:t}]});await THREAD.persist()},logMany:async msgs=>{if(!Array.isArray(msgs)||!msgs.length)return;const clean=msgs.map(s=>String(s??'').trim()).filter(Boolean);if(!clean.length)return;await ensureThreadOnFirstUser(clean[0]);const newMsgs=clean.map(t=>({id:gid(),role:'user',content:[{type:'text',text:t}]}));state.messages.push(...newMsgs);const frag=document.createDocumentFragment();const newEls=newMsgs.map(m=>{const $row=_createMessageRow(m),bubble=$row.find('.msg-bubble')[0];bubble.dataset.mid=m.id;return{rowEl:$row[0],bubbleEl:bubble,message:m}});newEls.forEach(item=>frag.appendChild(item.rowEl));el.messages.appendChild(frag);queueMicrotask(()=>{newEls.forEach(item=>{renderMarkdown(item.bubbleEl,partsToText(item.message))});el.chat.scrollTo({top:el.chat.scrollHeight,behavior:'smooth'});icons()});await THREAD.persist()},get PAT(){return this.githubToken},get name(){return localStorage.getItem('user_name')||'Anon'},set name(v){localStorage.setItem('user_name',v||'')},get avatar(){return localStorage.getItem('user_avatar')||''},set avatar(v){localStorage.setItem('user_avatar',v||'')},get provider(){return localStorage.getItem('provider')||'openrouter'},set provider(v){localStorage.setItem('provider',['openai','google','claude'].includes(v)?v:'openrouter')},get apiKeyOpenRouter(){return localStorage.getItem('openrouter_api_key')||''},set apiKeyOpenRouter(v){localStorage.setItem('openrouter_api_key',v||'')},get apiKeyOpenAI(){return localStorage.getItem('openai_api_key')||''},set apiKeyOpenAI(v){localStorage.setItem('openai_api_key',v||'')},get apiKeyGoogle(){return localStorage.getItem('google_api_key')||''},set apiKeyGoogle(v){localStorage.setItem('google_api_key',v||'')},get apiKeyClaude(){return localStorage.getItem('claude_api_key')||''},set apiKeyClaude(v){localStorage.setItem('claude_api_key',v||'')},get apiKeyCloudflare(){return localStorage.getItem('cloudflare_api_key')||''},set apiKeyCloudflare(v){localStorage.setItem('cloudflare_api_key',v||'')},get apiKey(){const p=this.provider;return p==='openai'?this.apiKeyOpenAI:p==='google'?this.apiKeyGoogle:p==='claude'?this.apiKeyClaude:p==='cloudflare'?this.apiKeyCloudflare:this.apiKeyOpenRouter},set apiKey(v){const p=this.provider;if(p==='openai')this.apiKeyOpenAI=v;else if(p==='google')this.apiKeyGoogle=v;else if(p==='claude')this.apiKeyClaude=v;else if(p==='cloudflare')this.apiKeyCloudflare=v;else this.apiKeyOpenRouter=v},get masterPrompt(){return localStorage.getItem('master_prompt')||'Always respond using markdown.'},set masterPrompt(v){localStorage.setItem('master_prompt',v||'')},get titleModel(){return localStorage.getItem('title_model')??'or:amazon/nova-micro-v1'},set titleModel(v){localStorage.setItem('title_model',v||'')},get githubToken(){return localStorage.getItem('gh_token')||''},set githubToken(v){localStorage.setItem('gh_token',v||'')},get gcpSA(){try{return JSON.parse(localStorage.getItem('gcp_sa_json')||'null')}catch{return null}},set gcpSA(v){localStorage.setItem('gcp_sa_json',v?JSON.stringify(v):'')}}
|
||||
|
||||
window.USER = USER;
|
||||
USER.log = async s => {
|
||||
const t = String(s ?? '').trim();
|
||||
if (!t) return;
|
||||
await ensureThreadOnFirstUser(t);
|
||||
addMessage({ role: 'user', content: [{ type: 'text', text: t }] });
|
||||
await THREAD.persist();
|
||||
};
|
||||
USER.logMany = async msgs => {
|
||||
if (!Array.isArray(msgs) || !msgs.length) return;
|
||||
const clean = msgs.map(s => String(s ?? '').trim()).filter(Boolean);
|
||||
if (!clean.length) return;
|
||||
await ensureThreadOnFirstUser(clean[0]);
|
||||
const newMsgs = clean.map(t => ({ id: gid(), role: 'user', content: [{ type: 'text', text: t }] }));
|
||||
state.messages.push(...newMsgs);
|
||||
const frag = document.createDocumentFragment();
|
||||
const newEls = newMsgs.map(m => {
|
||||
const $row = _createMessageRow(m), bubble = $row.find('.msg-bubble')[0];
|
||||
bubble.dataset.mid = m.id; return { rowEl: $row[0], bubbleEl: bubble, message: m };
|
||||
});
|
||||
newEls.forEach(item => frag.appendChild(item.rowEl));
|
||||
el.messages.appendChild(frag);
|
||||
queueMicrotask(() => {
|
||||
newEls.forEach(item => { renderMarkdown(item.bubbleEl, partsToText(item.message)) });
|
||||
el.chat.scrollTo({ top: el.chat.scrollHeight, behavior: 'smooth' });
|
||||
icons();
|
||||
});
|
||||
await THREAD.persist();
|
||||
};
|
||||
|
||||
async function init(){const u=localStorage.getItem('thread_repo_url')||'';el.threadRepoInput.value=u;el.threadFolderBtn.classList.toggle('hidden',!u.startsWith('gh://'));el.threadBackBtn.classList.toggle('hidden',!u.startsWith('gh://')||u.split('/').length<=3);await THREAD.load();await renderThreads();await Promise.allSettled(STICKY_SUNES.map(s=>SUNE.fetchDotSune(s)));renderSidebar();await reflectActiveSune();clearChat();icons();kbBind();kbUpdate()}
|
||||
$(window).on('resize',()=>{hideThreadPopover();hideSunePopover()})
|
||||
const htmlTabs={index:['htmlTab_index','htmlEditor'],extension:['htmlTab_extension','extensionHtmlEditor']};function showHtmlTab(key){Object.entries(htmlTabs).forEach(([k,[tb,pn]])=>{const a=k===key;el[tb].classList.toggle('border-black',a);el[tb].classList.toggle('border-transparent',!a);el[tb].classList.toggle('hover:border-gray-300',!a);el[pn].classList.toggle('hidden',!a)})}
|
||||
@@ -154,4 +167,5 @@ $(el.pasteSystemPrompt).on('click',async()=>{try{el.set_system_prompt.value=awai
|
||||
const getActiveHtmlParts=()=>!el.htmlEditor.classList.contains('hidden')?[el.htmlEditor,jars.html]:[el.extensionHtmlEditor,jars.extension]
|
||||
$(el.copyHTML).on('click',async()=>{try{await navigator.clipboard.writeText(getActiveHtmlParts()[0].textContent||'')}catch{}})
|
||||
$(el.pasteHTML).on('click',async()=>{try{const t=await navigator.clipboard.readText();const[editor,jar]=getActiveHtmlParts();if(jar&&jar.updateCode)jar.updateCode(t);else if(editor)editor.textContent=t}catch{}})
|
||||
|
||||
Object.assign(window,{icons,haptic,clamp,num,int,gid,esc,positionPopover,sid,fmtSize,asDataURL,b64,makeSune,getModelShort,resolveSuneSrc,processSuneIncludes,renderSuneHTML,reflectActiveSune,suneRow,enhanceCodeBlocks,getSuneLabel,_createMessageRow,msgRow,partsToText,addSuneBubbleStreaming,clearChat,payloadWithSampling,setBtnStop,setBtnSend,localDemoReply,titleFrom,serializeThreadName,deserializeThreadName,ensureThreadOnFirstUser,generateTitleWithAI,threadRow,renderThreads,hideThreadPopover,showThreadPopover,hideSunePopover,showSunePopover,updateAttachBadge,toAttach,ensureJars,openSettings,closeSettings,showTab,dl,ts,kbUpdate,kbBind,activeMeta,init,showHtmlTab,showAccountTab,openAccountSettings,closeAccountSettings,getBubbleById,syncActiveThread,syncWhileBusy,onForeground,getActiveHtmlParts,imgToWebp,cacheStore,ghApi,parseGhUrl,pullThreads});
|
||||
|
||||
30
src/markdown.js
Normal file
30
src/markdown.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import mathjax3 from 'https://esm.sh/markdown-it-mathjax3';
|
||||
|
||||
export const md = window.md = window.markdownit({ html: false, linkify: true, typographer: true, breaks: true }).use(mathjax3);
|
||||
|
||||
export function enhanceCodeBlocks(root, doHL = true) {
|
||||
window.$(root).find('pre>code').each((i, code) => {
|
||||
if (code.textContent.length > 200000) return;
|
||||
const $pre = window.$(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 = window.$('<button class="bg-slate-900 text-white rounded-lg py-1 px-2 text-xs opacity-85">Copy</button>').on('click', async e => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(code.innerText);
|
||||
$btn.text('Copied');
|
||||
setTimeout(() => $btn.text('Copy'), 1200);
|
||||
} catch { }
|
||||
});
|
||||
const $container = window.$('<div class="code-actions absolute top-2 right-2 flex items-center gap-2"></div>');
|
||||
$container.append(window.$(`<span class="text-xs text-gray-500">${countText} chars</span>`), $btn);
|
||||
$pre.append($container);
|
||||
}
|
||||
if (doHL && window.hljs && code.textContent.length < 100000) window.hljs.highlightElement(code);
|
||||
});
|
||||
}
|
||||
|
||||
export const renderMarkdown = window.renderMarkdown = function (node, text, opt = { enhance: true, highlight: true }) {
|
||||
node.innerHTML = md.render(text);
|
||||
if (opt.enhance) enhanceCodeBlocks(node, opt.highlight);
|
||||
};
|
||||
78
src/sune-html.js
Normal file
78
src/sune-html.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import { el } from './dom.js';
|
||||
import { esc } from './utils.js';
|
||||
|
||||
export 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;
|
||||
};
|
||||
|
||||
export const processSuneIncludes = async (html, depth = 0) => {
|
||||
if (depth > 5) return '<!-- Sune include depth limit reached -->';
|
||||
if (!html) return '';
|
||||
|
||||
// Bypass Sanitizer API by parsing into an inert document
|
||||
const doc = Document.parseHTMLUnsafe ? Document.parseHTMLUnsafe(html) : new DOMParser().parseFromString(html, 'text/html');
|
||||
const c = doc.body;
|
||||
|
||||
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');
|
||||
|
||||
const subHtml = await processSuneIncludes(h, depth + 1);
|
||||
const subDoc = Document.parseHTMLUnsafe ? Document.parseHTMLUnsafe(subHtml) : new DOMParser().parseFromString(subHtml, 'text/html');
|
||||
n.replaceWith(...Array.from(subDoc.body.childNodes));
|
||||
} catch (e) {
|
||||
n.replaceWith(document.createComment(` Fetch failed: ${esc(u)} `));
|
||||
}
|
||||
} else {
|
||||
n.replaceWith(...Array.from(n.childNodes));
|
||||
}
|
||||
}
|
||||
return c.innerHTML;
|
||||
};
|
||||
|
||||
export const renderSuneHTML = async () => {
|
||||
const SUNE = window.SUNE;
|
||||
const h = await processSuneIncludes([SUNE.extension_html, SUNE.html].map(x => (x || '').trim()).join('\n'));
|
||||
const c = el.suneHtml;
|
||||
c.innerHTML = '';
|
||||
const t = h.trim();
|
||||
c.classList.toggle('hidden', !t);
|
||||
|
||||
if (t) {
|
||||
const doc = Document.parseHTMLUnsafe ? Document.parseHTMLUnsafe(h) : new DOMParser().parseFromString(h, 'text/html');
|
||||
c.append(...Array.from(doc.body.childNodes));
|
||||
|
||||
// Explicitly re-create script tags so they execute, bypassing contextual fragment blocks
|
||||
c.querySelectorAll('script').forEach(oldScript => {
|
||||
const newScript = document.createElement('script');
|
||||
Array.from(oldScript.attributes).forEach(attr => newScript.setAttribute(attr.name, attr.value));
|
||||
newScript.textContent = oldScript.textContent;
|
||||
|
||||
// Preserve execution order for external scripts matching standard parser behavior
|
||||
if (!newScript.hasAttribute('async')) newScript.async = false;
|
||||
|
||||
oldScript.replaceWith(newScript);
|
||||
});
|
||||
|
||||
window.Alpine?.initTree(c);
|
||||
}
|
||||
};
|
||||
18
src/threads-utils.js
Normal file
18
src/threads-utils.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { partsToText } from './utils.js';
|
||||
|
||||
export const titleFrom = t => {
|
||||
if (!t) return 'Untitled';
|
||||
const s = typeof t === 'string' ? t : (Array.isArray(t) ? partsToText({ content: t }) : 'Untitled');
|
||||
return s.replace(/\s+/g, ' ').trim().slice(0, 60) || 'Untitled';
|
||||
};
|
||||
|
||||
export const serializeThreadName = t => {
|
||||
const s = (t.title || 'Untitled').replace(/[^a-zA-Z0-9]/g, '_').slice(0, 150);
|
||||
return `${t.pinned ? '1' : '0'}-${t.updatedAt || Date.now()}-${t.id}-${s}.json`;
|
||||
};
|
||||
|
||||
export const deserializeThreadName = n => {
|
||||
const p = n.replace('.json', '').split('-');
|
||||
if (p.length < 4) return null;
|
||||
return { pinned: p[0] === '1', updatedAt: parseInt(p[1]), id: p[2], title: p.slice(3).join('-').replace(/_/g, ' '), status: 'synced', type: 'thread' };
|
||||
};
|
||||
41
src/user.js
Normal file
41
src/user.js
Normal file
@@ -0,0 +1,41 @@
|
||||
export const USER = {
|
||||
get PAT() { return this.githubToken; },
|
||||
get name() { return localStorage.getItem('user_name') || 'Anon'; },
|
||||
set name(v) { localStorage.setItem('user_name', v || ''); },
|
||||
get avatar() { return localStorage.getItem('user_avatar') || ''; },
|
||||
set avatar(v) { localStorage.setItem('user_avatar', v || ''); },
|
||||
get provider() { return localStorage.getItem('provider') || 'openrouter'; },
|
||||
set provider(v) { localStorage.setItem('provider', ['openai', 'google', 'claude'].includes(v) ? v : 'openrouter'); },
|
||||
get apiKeyOpenRouter() { return localStorage.getItem('openrouter_api_key') || ''; },
|
||||
set apiKeyOpenRouter(v) { localStorage.setItem('openrouter_api_key', v || ''); },
|
||||
get apiKeyOpenAI() { return localStorage.getItem('openai_api_key') || ''; },
|
||||
set apiKeyOpenAI(v) { localStorage.setItem('openai_api_key', v || ''); },
|
||||
get apiKeyGoogle() { return localStorage.getItem('google_api_key') || ''; },
|
||||
set apiKeyGoogle(v) { localStorage.setItem('google_api_key', v || ''); },
|
||||
get apiKeyClaude() { return localStorage.getItem('claude_api_key') || ''; },
|
||||
set apiKeyClaude(v) { localStorage.setItem('claude_api_key', v || ''); },
|
||||
get apiKeyCloudflare() { return localStorage.getItem('cloudflare_api_key') || ''; },
|
||||
set apiKeyCloudflare(v) { localStorage.setItem('cloudflare_api_key', v || ''); },
|
||||
get apiKey() {
|
||||
const p = this.provider;
|
||||
return p === 'openai' ? this.apiKeyOpenAI : p === 'google' ? this.apiKeyGoogle : p === 'claude' ? this.apiKeyClaude : p === 'cloudflare' ? this.apiKeyCloudflare : this.apiKeyOpenRouter;
|
||||
},
|
||||
set apiKey(v) {
|
||||
const p = this.provider;
|
||||
if (p === 'openai') this.apiKeyOpenAI = v;
|
||||
else if (p === 'google') this.apiKeyGoogle = v;
|
||||
else if (p === 'claude') this.apiKeyClaude = v;
|
||||
else if (p === 'cloudflare') this.apiKeyCloudflare = v;
|
||||
else this.apiKeyOpenRouter = v;
|
||||
},
|
||||
get masterPrompt() { return localStorage.getItem('master_prompt') || 'Always respond using markdown.'; },
|
||||
set masterPrompt(v) { localStorage.setItem('master_prompt', v || ''); },
|
||||
get titleModel() { return localStorage.getItem('title_model') ?? 'or:amazon/nova-micro-v1'; },
|
||||
set titleModel(v) { localStorage.setItem('title_model', v || ''); },
|
||||
get githubToken() { return localStorage.getItem('gh_token') || ''; },
|
||||
set githubToken(v) { localStorage.setItem('gh_token', v || ''); },
|
||||
get gcpSA() {
|
||||
try { return JSON.parse(localStorage.getItem('gcp_sa_json') || 'null'); } catch { return null; }
|
||||
},
|
||||
set gcpSA(v) { localStorage.setItem('gcp_sa_json', v ? JSON.stringify(v) : ''); }
|
||||
};
|
||||
59
src/utils.js
Normal file
59
src/utils.js
Normal file
@@ -0,0 +1,59 @@
|
||||
export const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
||||
export const num = (v, d) => v == null || v === '' || isNaN(+v) ? d : +v;
|
||||
export const int = (v, d) => v == null || v === '' || isNaN(parseInt(v)) ? d : parseInt(v);
|
||||
export const gid = () => Math.random().toString(36).slice(2, 9);
|
||||
export const esc = s => String(s).replace(/[&<>'"`]/g, c => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'", "`": "`" }[c]));
|
||||
export 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`;
|
||||
};
|
||||
export const sid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
export const fmtSize = b => {
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let i = 0, x = b;
|
||||
while (x >= 1024 && i < u.length - 1) { x /= 1024; i++; }
|
||||
return (x >= 10 ? Math.round(x) : Math.round(x * 10) / 10) + ' ' + u[i];
|
||||
};
|
||||
export const asDataURL = f => new Promise(r => { const fr = new FileReader(); fr.onload = () => r(String(fr.result || '')); fr.readAsDataURL(f); });
|
||||
export const imgToWebp = (f, D = 128, q = 80) => new Promise((r, j) => {
|
||||
if (!f) return j();
|
||||
const i = new Image;
|
||||
i.onload = () => {
|
||||
const c = document.createElement('canvas'), x = c.getContext('2d');
|
||||
let w = i.width, h = i.height;
|
||||
if (D > 0 && Math.max(w, h) > D) w > h ? (h = D * h / w, w = D) : (w = D * w / h, h = D);
|
||||
c.width = w; c.height = h; x.drawImage(i, 0, 0, w, h);
|
||||
r(c.toDataURL('image/webp', clamp(q, 0, 100) / 100));
|
||||
URL.revokeObjectURL(i.src);
|
||||
};
|
||||
i.onerror = j; i.src = URL.createObjectURL(f);
|
||||
});
|
||||
export const b64 = x => x.split(',')[1] || '';
|
||||
export const utob = s => btoa(unescape(encodeURIComponent(s)));
|
||||
export const btou = s => decodeURIComponent(escape(atob(s.replace(/\s/g, ''))));
|
||||
|
||||
export function partsToText(m) {
|
||||
if (!m) return '';
|
||||
const c = m.content, i = m.images;
|
||||
let t = Array.isArray(c) ? c.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') : String(c || '');
|
||||
if (Array.isArray(i)) t += i.map(x => `\n\n`).join('');
|
||||
return t;
|
||||
}
|
||||
|
||||
export 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 = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export const ts = () => {
|
||||
const d = new Date(), p = n => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
||||
};
|
||||
Reference in New Issue
Block a user