70 Commits

Author SHA1 Message Date
github-actions[bot]
25b4b982ac This build was committed by a bot. 2026-09-17 03:56:13 +00:00
916f41606c Submit composer with Enter on desktop 2026-09-16 20:55:57 -07:00
a1fd2d5a05 Add license agreement for software usage and reuse
Added a comprehensive license agreement outlining usage, ownership, and reuse conditions for the software.
2026-09-14 21:15:24 -07:00
9ce77bd05e chore: update GitHub Sponsors funding 2026-09-14 18:20:24 -07:00
github-actions[bot]
4cf6926453 This build was committed by a bot. 2026-09-11 00:00:17 +00:00
a73ee38f01 Support clipboard file paste in composer and attaching during stream 2026-09-10 16:57:26 -07:00
github-actions[bot]
18aa73e9c8 This build was committed by a bot. 2026-09-10 05:04:58 +00:00
599989fbed Shorten model name placeholder hint 2026-09-09 22:03:58 -07:00
27cbdea778 Support model name placeholder in system prompts 2026-09-09 21:59:49 -07:00
github-actions[bot]
4dd5916862 This build was committed by a bot. 2026-09-08 04:03:35 +00:00
5d27b9904a Style: Adjust topbar padding to py-2
Co-authored-by: gemini-3.8-flash <noreply@google.com>
2026-09-07 21:03:19 -07:00
github-actions[bot]
0bc73c7e80 This build was committed by a bot. 2026-09-08 04:02:49 +00:00
22f578dbd1 Feat: Update topbar avatar image to h-10 w-10
Co-authored-by: gemini-3.8-flash <noreply@google.com>
2026-09-07 21:02:33 -07:00
5c9fe41f1c Feat: Set topbar Sune avatar to h-10 w-10
Co-authored-by: gemini-3.8-flash <noreply@google.com>
2026-09-07 21:02:31 -07:00
github-actions[bot]
c028b505c9 This build was committed by a bot. 2026-09-08 03:52:11 +00:00
567a082fda Perf: Default avatar resize to 96px and 75q
Co-authored-by: gemini-3.8-flash <noreply@google.com>
2026-09-07 20:51:52 -07:00
1a134c82ca Feat: Update topbar avatar image to h-9 w-9
Co-authored-by: gemini-3.8-flash <noreply@google.com>
2026-09-07 20:51:37 -07:00
c362744e39 Feat: Enlarge topbar Sune avatar to h-9 w-9
Co-authored-by: gemini-3.8-flash <noreply@google.com>
2026-09-07 20:51:35 -07:00
github-actions[bot]
3c2fd49227 This build was committed by a bot. 2026-09-08 03:18:00 +00:00
2e840b4b86 refactor(utils): update imgToWebp defaults to D=96 and q=75 2026-09-07 20:17:35 -07:00
github-actions[bot]
4b6ef90d3a This build was committed by a bot. 2026-09-06 04:03:51 +00:00
6c4376ca71 Set omitted reasoning_effort to empty string 2026-09-05 20:59:46 -07:00
github-actions[bot]
dec4bd1fb3 This build was committed by a bot. 2026-09-06 01:23:14 +00:00
a4f04d3aac Replace verbosity setting with max tokens (output) 2026-09-05 18:20:23 -07:00
8b9adc8d64 Update readme.md 2026-09-05 14:34:40 -07:00
8128ce5c63 Merge pull request #23 from sune-org/upgrade_github_sync_logic
Upgrade GitHub sync logic
2026-09-04 11:56:45 -07:00
github-actions[bot]
63ce1e7294 This build was committed by a bot. 2026-09-04 18:51:03 +00:00
5975a88b31 Fix: Prevent recursive dirty marking on system keys, resolve popover ID collisions, and add activeId fallback on pull 2026-09-04 11:50:43 -07:00
fa745efbab Merge pull request #22 from sune-org/upgrade_github_sync_logic
Upgrade GitHub sync logic
2026-09-04 09:53:30 -07:00
github-actions[bot]
007f07652b This build was committed by a bot. 2026-09-04 16:50:23 +00:00
5b410bfe40 Feat: Upgrade GitHub Sune Sync with status indicator, auto-pull, and popover actions 2026-09-04 09:49:53 -07:00
14ff090068 Add .suneignore to exclude public and dist directories 2026-09-03 08:48:06 -07:00
github-actions[bot]
ded71012f4 This build was committed by a bot. 2026-09-02 03:04:12 +00:00
0d7264eb29 Feat: Enable fuzzyLink for markdown-it v15
Co-authored-by: gemini-3.7-flash <noreply@google.com>
2026-09-01 20:03:55 -07:00
799baf2079 Fix: Correct markdown-it v15 UMD CDN path
Co-authored-by: gemini-3.7-flash <noreply@google.com>
2026-09-01 20:03:48 -07:00
github-actions[bot]
8db03da16f This build was committed by a bot. 2026-09-02 02:56:25 +00:00
487400d174 upgrade highlightjs to 11.12.0 2026-09-01 19:41:41 -07:00
github-actions[bot]
555ceedee9 This build was committed by a bot. 2026-09-02 02:30:11 +00:00
a0132cd48b upgrade gh css version 2026-09-01 19:29:54 -07:00
github-actions[bot]
8d9e6d6352 This build was committed by a bot. 2026-09-02 02:17:31 +00:00
1acd908afc Feat: Implement Global Sune Sync logic
Co-authored-by: gemini-3-flash-preview <noreply@google.com>
2026-08-31 17:24:57 -07:00
0b3f97246b Feat: Add Sune repo sync UI to left sidebar
Co-authored-by: gemini-3-flash-preview <noreply@google.com>
2026-08-31 17:24:51 -07:00
c9663c037b Feat: Add Sune sync DOM elements
Co-authored-by: gemini-3-flash-preview <noreply@google.com>
2026-08-31 17:24:48 -07:00
github-actions[bot]
553fe1a835 This build was committed by a bot. 2026-08-31 22:52:30 +00:00
c727f40d91 Fix: Non-greedy sune ID matching in gcStorage
Co-authored-by: gemini-3.7-flash <noreply@google.com>
2026-08-31 15:52:13 -07:00
github-actions[bot]
2faea0aa01 This build was committed by a bot. 2026-08-31 21:42:05 +00:00
c8d9d66e7f Feat: Add Sune storage API and storage GC
Co-authored-by: gemini-3.7-flash <noreply@google.com>
2026-08-31 14:41:52 -07:00
github-actions[bot]
e7ffe2d606 This build was committed by a bot. 2026-08-31 21:09:57 +00:00
a023ad0423 Refactor: Remove storage key from sune
Co-authored-by: gemini-3.7-flash <noreply@google.com>
2026-08-31 14:09:41 -07:00
github-actions[bot]
a4a2c2c0d2 This build was committed by a bot. 2026-08-30 18:19:46 +00:00
cc8b523816 Fix: Fetch unloaded thread content before GH sync
Co-authored-by: gemini-3.7-flash <noreply@google.com>
2026-08-30 11:19:33 -07:00
github-actions[bot]
4a16233c23 This build was committed by a bot. 2026-08-30 03:47:52 +00:00
731b6a2dcc Fix: Omit attached base64 images on copy
Co-authored-by: gemini-3.7-flash <noreply@google.com>
2026-08-29 20:47:34 -07:00
1365e6c4ca Fix: Resolve latest message and omit base64 on copy
Co-authored-by: gemini-3.7-flash <noreply@google.com>
2026-08-29 20:47:26 -07:00
2b9a7b4c25 Fix: Use robust copy fallback in code blocks
Co-authored-by: gemini-3.7-flash <noreply@google.com>
2026-08-29 20:47:22 -07:00
66a9921d86 Docs: Update Account & Backup to User in sync guide
Co-authored-by: gemini-3.7-flash <noreply@google.com>
2026-08-21 12:02:22 -07:00
github-actions[bot]
253975bad3 This build was committed by a bot. 2026-08-21 19:02:21 +00:00
2b8fbc5059 Fix: Update demo reply tip to reference User menu
Co-authored-by: gemini-3.7-flash <noreply@google.com>
2026-08-21 12:02:06 -07:00
github-actions[bot]
82ed6c6396 This build was committed by a bot. 2026-08-21 18:55:52 +00:00
a0b249e718 Feat: Sync User avatar with sidebar and bind upload
Co-authored-by: gemini-3.7-flash <noreply@google.com>
2026-08-21 11:55:37 -07:00
9da342601c Feat: Add userMenuAvatar to DOM map
Co-authored-by: gemini-3.7-flash <noreply@google.com>
2026-08-21 11:55:33 -07:00
df8269f01c Feat: Add GitHub button & user avatar container
Co-authored-by: gemini-3.7-flash <noreply@google.com>
2026-08-21 11:55:30 -07:00
github-actions[bot]
a6d12bfaf5 This build was committed by a bot. 2026-08-21 18:51:08 +00:00
53287e518d Feat: Add GitHub repo button and rename User menu
Co-authored-by: gemini-3.7-flash <noreply@google.com>
2026-08-21 11:50:55 -07:00
github-actions[bot]
9f9835182b This build was committed by a bot. 2026-08-21 18:21:38 +00:00
60fc9d90dd Refactor: default username to User
Co-authored-by: gemini-3.7-flash <noreply@google.com>
2026-08-21 11:21:13 -07:00
github-actions[bot]
dba9f23586 This build was committed by a bot. 2026-08-17 02:58:00 +00:00
59d72da2a9 Fix: Order folders, md files, then chat threads
Co-authored-by: gemini-3.7-flash <noreply@google.com>
2026-08-16 19:57:43 -07:00
github-actions[bot]
7d921e5289 This build was committed by a bot. 2026-07-24 23:12:50 +00:00
0d8fd6c931 Feat: Add None option to reasoning effort
Co-authored-by: gemini-3.6-flash <noreply@google.com>
2026-07-24 16:12:32 -07:00
19 changed files with 854 additions and 147 deletions

2
.github/FUNDING.yml vendored
View File

@@ -1 +1 @@
custom: https://paypal.me/planetrenox github: multipleof4

2
.suneignore Normal file
View File

@@ -0,0 +1,2 @@
public/
dist/

85
LICENSE Normal file
View File

@@ -0,0 +1,85 @@
1. Scope
The copyright holders offering software, source code, assets, or other content
under these terms ("we") grant the permissions below to every individual, team,
company, organization, and other entity ("you"). "Work" means the software,
source code, assets, and other content we offer under these terms.
2. Using the product and owning your output
You may use the customer-facing software or product for any purpose, including
commercial purposes, without payment, acknowledgment, or a copy of these terms.
This includes installing, running, accessing, and self-hosting it, and compiling
it solely to run it.
Whatever you create through ordinary use of the product is yours. We claim no
ownership of your output and impose no payment, acknowledgment, or licensing
conditions on it. You may use, sell, share, or license it however you choose.
Merely copying or extracting the underlying codebase or assetbase is reuse
under section 3.
3. Reusing code or assets
Subject to section 4, you have worldwide, nonexclusive permission to copy,
modify, combine, publish, distribute, sublicense, sell, and otherwise reuse any
or all of the Work, for any purpose, in source or other forms.
4. Finding common ground
If you reuse the codebase or assetbase, you are responsible for satisfying one
of the following conditions when you begin that reuse:
- If you cannot reasonably afford a monetary payment, give a simple
acknowledgment of the original software or repository in an appropriate
place, such as your README, source code, website, or documentation. A name
mention or a link is enough. No particular wording or placement is required.
- If you can afford a monetary payment, sponsor the original
repository through its Sponsor button. You alone decide the minimum
reasonable amount you can afford, considering the circumstances of the entity
reusing the Work. Sponsorship may be one-time or monthly. If you pay any
positive amount, acknowledgment and links are optional.
An entity may satisfy this condition through someone acting on its behalf.
5. Final settlement
Any positive amount successfully paid through that sponsorship route fully
and permanently satisfies the reuse condition for the entity making it or on
whose behalf it is made. Your chosen amount is accepted as sufficient,
regardless of your financial means.
We will not challenge its adequacy, request financial evidence, or demand a
larger amount. A record of the completed payment is sufficient proof; no
separate approval or agreement is needed.
The first completed monthly payment is enough. Later payments are optional,
and you may cancel recurring sponsorship without losing your permissions.
Likewise, an appropriate acknowledgment fully satisfies the condition for an
entity using the acknowledgment option.
Once satisfied, the condition covers all reuse by that entity of the Work
offered under these terms in the same original repository, including later
versions. No payment or acknowledgment is required again for additional uses,
copies, releases, or products. The permissions granted are then irrevocable
for that Work.
6. Choosing another license
After satisfying section 4, you may distribute or sublicense the Work,
modified or unmodified, under any license you choose. You do not have to include
these terms, keep this license, or require anyone else to follow it. These
terms require no retained copyright or license notices; the acknowledgment
option in section 4 is the only acknowledgment requirement.
Recipients of the Work you distribute under another license follow that
license and owe us no payment or acknowledgment under these terms for that
Work. Relicensing grants permissions; it does not transfer ownership of
the original Work.
7. Warranty
To the extent permitted by law, the Work is provided "as is," without any
warranty, including merchantability, fitness for a particular purpose, or
noninfringement. We are not liable for any claim, loss, or damages arising from
the Work or its use.

View File

@@ -40,7 +40,8 @@ var buildBody = () => {
role: "system", role: "system",
content: mPrompt content: mPrompt
}); });
const sPrompt = (SUNE.system_prompt || "").trim(); const modelName = SUNE.model.replace(/^(or:|oai:|g:|cla:|cf:)/, "").replace(/^[^/]*\//, "").replace(/(?::online|:free)+$/, "");
const sPrompt = (SUNE.system_prompt || "").trim().replaceAll("{{model_name}}", () => modelName);
if (sPrompt) msgs.push({ if (sPrompt) msgs.push({
role: "system", role: "system",
content: sPrompt content: sPrompt
@@ -70,7 +71,6 @@ var buildBody = () => {
...SUNE.reasoning_effort && SUNE.reasoning_effort !== "default" ? { effort: SUNE.reasoning_effort } : {}, ...SUNE.reasoning_effort && SUNE.reasoning_effort !== "default" ? { effort: SUNE.reasoning_effort } : {},
exclude: !SUNE.include_thoughts exclude: !SUNE.include_thoughts
}; };
if (SUNE.verbosity) b.verbosity = SUNE.verbosity;
if (SUNE.img_output) { if (SUNE.img_output) {
b.modalities = ["image"]; b.modalities = ["image"];
b.image_config = { b.image_config = {
@@ -250,7 +250,7 @@ var el = window.el = Object.fromEntries([
"set_repetition_penalty", "set_repetition_penalty",
"set_min_p", "set_min_p",
"set_top_a", "set_top_a",
"set_verbosity", "set_max_tokens",
"set_reasoning_effort", "set_reasoning_effort",
"set_system_prompt", "set_system_prompt",
"set_hide_composer", "set_hide_composer",
@@ -267,6 +267,7 @@ var el = window.el = Object.fromEntries([
"suneList", "suneList",
"newSuneBtn", "newSuneBtn",
"userMenuBtn", "userMenuBtn",
"userMenuAvatar",
"userMenu", "userMenu",
"accountSettingsOption", "accountSettingsOption",
"sunesImportOption", "sunesImportOption",
@@ -323,7 +324,13 @@ var el = window.el = Object.fromEntries([
"threadRepoInput", "threadRepoInput",
"threadBackBtn", "threadBackBtn",
"threadFolderBtn", "threadFolderBtn",
"threadSyncBtn" "threadSyncBtn",
"suneRepoInput",
"suneSyncBtn",
"suneSyncBadge",
"sunesSyncPopover",
"sunesSyncUploadBtn",
"sunesSyncDownloadBtn"
].map((id) => [id, document.getElementById(id)])); ].map((id) => [id, document.getElementById(id)]));
//#endregion //#endregion
//#region src/utils.js //#region src/utils.js
@@ -365,7 +372,7 @@ var asDataURL = (f) => new Promise((r) => {
fr.onload = () => r(String(fr.result || "")); fr.onload = () => r(String(fr.result || ""));
fr.readAsDataURL(f); fr.readAsDataURL(f);
}); });
var imgToWebp = (f, D = 128, q = 80) => new Promise((r, j) => { var imgToWebp = (f, D = 96, q = 75) => new Promise((r, j) => {
if (!f) return j(); if (!f) return j();
const i = new Image(); const i = new Image();
i.onload = () => { i.onload = () => {
@@ -384,12 +391,45 @@ var imgToWebp = (f, D = 128, q = 80) => new Promise((r, j) => {
var b64 = (x) => x.split(",")[1] || ""; var b64 = (x) => x.split(",")[1] || "";
var utob = (s) => btoa(unescape(encodeURIComponent(s))); var utob = (s) => btoa(unescape(encodeURIComponent(s)));
var btou = (s) => decodeURIComponent(escape(atob(s.replace(/\s/g, "")))); var btou = (s) => decodeURIComponent(escape(atob(s.replace(/\s/g, ""))));
function partsToText(m) { async function copyToClipboard(text) {
if (typeof text !== "string") text = String(text ?? "");
if (navigator.clipboard?.writeText) try {
await navigator.clipboard.writeText(text);
return true;
} catch {}
try {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.focus();
ta.select();
const ok = document.execCommand("copy");
ta.remove();
return ok;
} catch {
return false;
}
}
function partsToText(m, stripData = false) {
if (!m) return ""; if (!m) return "";
const c = m.content, i = m.images; const c = m.content, i = m.images, out = [];
let t = Array.isArray(c) ? c.map((p) => p?.type === "text" ? p.text : p?.type === "image_url" ? `![](${p.image_url?.url || ""})` : p?.type === "file" ? `[${p.file?.filename || "file"}]` : p?.type === "input_audio" ? `(audio:${p.input_audio?.format || ""})` : "").join("\n") : String(c || ""); if (Array.isArray(c)) {
if (Array.isArray(i)) t += i.map((x) => `\n![](${x.image_url?.url})\n`).join(""); for (const p of c) if (p?.type === "text") {
return t; if (p.text) out.push(p.text);
} else if (p?.type === "image_url") {
const u = p.image_url?.url || "";
if (!stripData || !u.startsWith("data:")) out.push(`![](${u})`);
} else if (p?.type === "file") out.push(`[${p.file?.filename || "file"}]`);
else if (p?.type === "input_audio") out.push(`(audio:${p.input_audio?.format || ""})`);
} else if (c != null) out.push(String(c));
if (Array.isArray(i)) for (const x of i) {
const u = x.image_url?.url || "";
if (!stripData || !u.startsWith("data:")) out.push(`![](${u})`);
}
return out.join("\n");
} }
function dl(name, obj) { 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"); 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");
@@ -411,7 +451,7 @@ var USER = {
return this.githubToken; return this.githubToken;
}, },
get name() { get name() {
return localStorage.getItem("user_name") || "Anon"; return localStorage.getItem("user_name") || "User";
}, },
set name(v) { set name(v) {
localStorage.setItem("user_name", v || ""); localStorage.setItem("user_name", v || "");
@@ -566,6 +606,7 @@ var md = window.md = window.markdownit({
typographer: true, typographer: true,
breaks: true breaks: true
}).use(mathjax3); }).use(mathjax3);
md.linkify.set({ fuzzyLink: true });
function enhanceCodeBlocks(root, doHL = true) { function enhanceCodeBlocks(root, doHL = true) {
window.$(root).find("pre>code").each((i, code) => { window.$(root).find("pre>code").each((i, code) => {
if (code.textContent.length > 2e5) return; if (code.textContent.length > 2e5) return;
@@ -574,11 +615,10 @@ function enhanceCodeBlocks(root, doHL = true) {
const len = code.textContent.length, countText = len >= 1e3 ? (len / 1e3).toFixed(1) + "K" : len; 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) => { 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(); e.stopPropagation();
try { if (await copyToClipboard(code.innerText)) {
await navigator.clipboard.writeText(code.innerText);
$btn.text("Copied"); $btn.text("Copied");
setTimeout(() => $btn.text("Copy"), 1200); setTimeout(() => $btn.text("Copy"), 1200);
} catch {} }
}); });
const $container = window.$("<div class=\"code-actions absolute top-2 right-2 flex items-center gap-2\"></div>"); 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); $container.append(window.$(`<span class="text-xs text-gray-500">${countText} chars</span>`), $btn);
@@ -606,6 +646,11 @@ function kbUpdate() {
el.chat.style.scrollPaddingBottom = fh + overlap + 16 + "px"; el.chat.style.scrollPaddingBottom = fh + overlap + 16 + "px";
} }
function kbBind() { function kbBind() {
el.input.addEventListener("keydown", (e) => {
if (e.key !== "Enter" || e.shiftKey || e.isComposing || e.keyCode === 229 || e.repeat || !matchMedia("(hover: hover) and (pointer: fine)").matches) return;
e.preventDefault();
el.composer.requestSubmit();
});
if (window.visualViewport) ["resize", "scroll"].forEach((ev) => window.visualViewport.addEventListener(ev, () => kbUpdate(), { passive: true })); if (window.visualViewport) ["resize", "scroll"].forEach((ev) => window.visualViewport.addEventListener(ev, () => kbUpdate(), { passive: true }));
window.$(window).on("resize orientationchange", () => setTimeout(kbUpdate, 50)); window.$(window).on("resize orientationchange", () => setTimeout(kbUpdate, 50));
window.$(el.input).on("focus click", () => { window.$(el.input).on("focus click", () => {
@@ -842,6 +887,121 @@ var __vitePreload = function preload(baseModule, deps, importerUrl) {
var DEFAULT_MODEL = "openrouter/free"; var DEFAULT_MODEL = "openrouter/free";
var icons = () => window.lucide && lucide.createIcons(); var icons = () => window.lucide && lucide.createIcons();
var haptic = () => /android/i.test(navigator.userAgent) && navigator.vibrate?.(1); var haptic = () => /android/i.test(navigator.userAgent) && navigator.vibrate?.(1);
var SYSTEM_KEYS = new Set([
"sunes_v1",
"active_sune_id",
"thread_repo_url",
"sune_repo_url",
"sunes_updated_at",
"user_name",
"user_avatar",
"provider",
"openrouter_api_key",
"openai_api_key",
"google_api_key",
"claude_api_key",
"cloudflare_api_key",
"master_prompt",
"title_model",
"gh_token",
"custom_key_1"
]);
var gcStorage = () => {
try {
const alive = new Set(sunes.map((s) => s.id));
Object.keys(localStorage).forEach((k) => {
if (SYSTEM_KEYS.has(k) || /^(t_|rem_t_|rem_index_|localforage|threads_)/.test(k)) return;
const m = k.match(/^sune_([^_]+)_/);
if (m && alive.has(m[1])) return;
localStorage.removeItem(k);
});
} catch {}
};
var cleanSuneStorage = (id) => {
if (!id) return;
const p = `sune_${id}_`;
Object.keys(localStorage).forEach((k) => {
if (k.startsWith(p)) localStorage.removeItem(k);
});
};
var isSyncPulling = false;
var getLocalSunesUpdatedAt = () => num(localStorage.getItem("sunes_updated_at"), 0);
var setLocalSunesUpdatedAt = (ts = Date.now()) => localStorage.setItem("sunes_updated_at", ts);
var markLocalDirty = () => {
if (isSyncPulling) return;
setLocalSunesUpdatedAt();
setSuneSyncStatus("desynced");
};
var isSuneStorageKey = (k) => !SYSTEM_KEYS.has(k) && /^sune_[^_]+_/.test(k);
var _origSetItem = localStorage.setItem.bind(localStorage), _origRemoveItem = localStorage.removeItem.bind(localStorage);
localStorage.setItem = (k, v) => {
_origSetItem(k, v);
if (!isSyncPulling && isSuneStorageKey(k)) markLocalDirty();
};
localStorage.removeItem = (k) => {
_origRemoveItem(k);
if (!isSyncPulling && isSuneStorageKey(k)) markLocalDirty();
};
function setSuneSyncStatus(status) {
const b = el.suneSyncBadge;
if (!b) return;
if (!(el.suneRepoInput?.value || "").trim().startsWith("gh://")) {
b.className = "hidden";
return;
}
b.className = "absolute -top-1 -right-1 block h-2.5 w-2.5 rounded-full ring-2 ring-white";
switch (status) {
case "checking":
b.classList.add("bg-blue-500", "animate-pulse");
break;
case "synced":
b.classList.add("bg-green-500");
break;
case "desynced":
b.classList.add("bg-amber-500");
break;
case "error":
b.classList.add("bg-red-500");
break;
default: b.classList.add("bg-gray-400");
}
el.suneSyncBtn?.querySelector("svg")?.classList.toggle("animate-spin", status === "checking");
}
var suneStorage = {
get(k, def = null) {
const id = SUNE.id;
if (!id) return def;
const v = localStorage.getItem(`sune_${id}_${k}`);
if (v === null) return def;
try {
return JSON.parse(v);
} catch {
return v;
}
},
set(k, v) {
const id = SUNE.id;
if (id) localStorage.setItem(`sune_${id}_${k}`, JSON.stringify(v));
},
remove(k) {
const id = SUNE.id;
if (id) localStorage.removeItem(`sune_${id}_${k}`);
},
clear() {
const id = SUNE.id;
if (id) cleanSuneStorage(id);
},
keys() {
const id = SUNE.id;
if (!id) return [];
const p = `sune_${id}_`;
return Object.keys(localStorage).filter((k) => k.startsWith(p)).map((k) => k.slice(p.length));
},
key(k) {
const id = SUNE.id;
return id ? `sune_${id}_${k}` : k;
}
};
var su = { var su = {
key: "sunes_v1", key: "sunes_v1",
activeKey: "active_sune_id", activeKey: "active_sune_id",
@@ -871,8 +1031,8 @@ var defaultSettings = {
repetition_penalty: "", repetition_penalty: "",
min_p: "", min_p: "",
top_a: "", top_a: "",
verbosity: "", max_tokens: "",
reasoning_effort: "default", reasoning_effort: "",
system_prompt: "", system_prompt: "",
html: "", html: "",
extension_html: "<sune src='https://raw.githubusercontent.com/sune-org/store/refs/heads/main/sync.sune' private></sune>", extension_html: "<sune src='https://raw.githubusercontent.com/sune-org/store/refs/heads/main/sync.sune' private></sune>",
@@ -890,11 +1050,11 @@ var makeSune = (p = {}) => ({
avatar: p.avatar || "", avatar: p.avatar || "",
url: p.url || "", url: p.url || "",
updatedAt: p.updatedAt || Date.now(), updatedAt: p.updatedAt || Date.now(),
settings: Object.assign({}, defaultSettings, p.settings || {}), settings: Object.assign({}, defaultSettings, p.settings || {})
storage: p.storage || {}
}); });
var sunes = (su.load() || []).map(makeSune); var sunes = (su.load() || []).map(makeSune);
var SUNE = window.SUNE = new Proxy({ var SUNE = window.SUNE = new Proxy({
storage: suneStorage,
get list() { get list() {
return sunes; return sunes;
}, },
@@ -910,12 +1070,16 @@ var SUNE = window.SUNE = new Proxy({
const s = makeSune(p); const s = makeSune(p);
sunes.unshift(s); sunes.unshift(s);
su.save(sunes); su.save(sunes);
markLocalDirty();
return s; return s;
}, },
delete(id) { delete(id) {
const curId = this.id; const curId = this.id;
sunes = sunes.filter((s) => s.id !== id); sunes = sunes.filter((s) => s.id !== id);
su.save(sunes); su.save(sunes);
cleanSuneStorage(id);
gcStorage();
markLocalDirty();
if (sunes.length === 0) { if (sunes.length === 0) {
const def = this.create({ name: "Default" }); const def = this.create({ name: "Default" });
this.setActive(def.id); this.setActive(def.id);
@@ -1045,7 +1209,7 @@ var SUNE = window.SUNE = new Proxy({
if (!a) return false; if (!a) return false;
const i = sunes.findIndex((s) => s.id === a.id); const i = sunes.findIndex((s) => s.id === a.id);
if (i < 0) return false; if (i < 0) return false;
const isTopLevel = /^(name|avatar|url|pinned|storage)$/.test(p), target = isTopLevel ? sunes[i] : sunes[i].settings; const isTopLevel = /^(name|avatar|url|pinned)$/.test(p), target = isTopLevel ? sunes[i] : sunes[i].settings;
let value = v; let value = v;
if (!isTopLevel) { if (!isTopLevel) {
if (p === "system_prompt") value = v || ""; if (p === "system_prompt") value = v || "";
@@ -1054,6 +1218,7 @@ var SUNE = window.SUNE = new Proxy({
target[p] = value; target[p] = value;
sunes[i].updatedAt = Date.now(); sunes[i].updatedAt = Date.now();
su.save(sunes); su.save(sunes);
markLocalDirty();
} }
return true; return true;
} }
@@ -1084,7 +1249,7 @@ var getModelShort = (m) => {
var reflectActiveSune = async () => { var reflectActiveSune = async () => {
const a = SUNE.active; const a = SUNE.active;
el.suneBtnTop.title = `Settings — ${a.name}`; 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.suneBtnTop.innerHTML = a.avatar ? `<img src="${esc(a.avatar)}" alt="" class="h-10 w-10 rounded-full object-cover"/>` : "✺";
el.footer.classList.toggle("hidden", !!a.settings.hide_composer); el.footer.classList.toggle("hidden", !!a.settings.hide_composer);
await renderSuneHTML(); await renderSuneHTML();
icons(); icons();
@@ -1095,6 +1260,17 @@ var renderSidebar = window.renderSidebar = () => {
el.suneList.innerHTML = list.map(suneRow).join(""); el.suneList.innerHTML = list.map(suneRow).join("");
icons(); icons();
}; };
var renderUserUI = window.renderUserUI = () => {
if (!el.userMenuAvatar) return;
const a = USER.avatar;
if (a) {
el.userMenuAvatar.className = "h-6 w-6 rounded-full overflow-hidden shrink-0";
el.userMenuAvatar.innerHTML = `<img src="${esc(a)}" class="h-full w-full object-cover"/>`;
} else {
el.userMenuAvatar.className = "h-6 w-6 rounded-full bg-gray-900 text-white flex items-center justify-center shrink-0 text-xs";
el.userMenuAvatar.textContent = "👤";
}
};
var getSuneLabel = (m) => { var getSuneLabel = (m) => {
return `${m && m.sune_name || SUNE.name} · ${getModelShort(m && m.model)}`; return `${m && m.sune_name || SUNE.name} · ${getModelShort(m && m.model)}`;
}; };
@@ -1111,15 +1287,14 @@ function _createMessageRow(m) {
}); });
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) { 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(); e.stopPropagation();
try { if (await copyToClipboard(partsToText(state.messages.find((x) => x.id === m.id) || m, true))) {
await navigator.clipboard.writeText(partsToText(m));
$(this).html("<i data-lucide=\"check\" class=\"h-4 w-4 text-green-500\"></i>"); $(this).html("<i data-lucide=\"check\" class=\"h-4 w-4 text-green-500\"></i>");
icons(); icons();
setTimeout(() => { setTimeout(() => {
$(this).html("<i data-lucide=\"copy\" class=\"h-4 w-4\"></i>"); $(this).html("<i data-lucide=\"copy\" class=\"h-4 w-4\"></i>");
icons(); icons();
}, 1200); }, 1200);
} catch {} }
}); });
$head.append($avatar, $name, $copyBtn, $deleteBtn); $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>`); 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>`);
@@ -1171,7 +1346,8 @@ var payloadWithSampling = (b) => {
frequency_penalty: num(s.frequency_penalty, null), frequency_penalty: num(s.frequency_penalty, null),
repetition_penalty: num(s.repetition_penalty, null), repetition_penalty: num(s.repetition_penalty, null),
min_p: num(s.min_p, null), min_p: num(s.min_p, null),
top_a: num(s.top_a, null) top_a: num(s.top_a, null),
max_tokens: int(s.max_tokens, null) > 0 ? int(s.max_tokens, null) : null
}; };
Object.keys(p).forEach((k) => { Object.keys(p).forEach((k) => {
const v = p[k]; const v = p[k];
@@ -1203,7 +1379,7 @@ function setBtnSend() {
b.onclick = null; b.onclick = null;
} }
function localDemoReply() { function localDemoReply() {
return "Tip: open the sidebar → Account & Backup to set your API key."; return "Tip: open the sidebar → User to set your API key.";
} }
var TKEY = "threads_v1", THREAD = window.THREAD = { var TKEY = "threads_v1", THREAD = window.THREAD = {
list: [], list: [],
@@ -1296,9 +1472,8 @@ var sortedThreads = [], isAddingThreads = false;
var THREAD_PAGE_SIZE = 50; var THREAD_PAGE_SIZE = 50;
async function renderThreads() { async function renderThreads() {
sortedThreads = [...THREAD.list].filter((t) => t.status !== "deleted").sort((a, b) => { sortedThreads = [...THREAD.list].filter((t) => t.status !== "deleted").sort((a, b) => {
if (a.type === "file" && b.type !== "file") return -1; const r = (t) => t.type === "folder" ? 0 : t.type === "file" ? 1 : 2;
if (a.type !== "file" && b.type === "file") return 1; return r(a) - r(b) || b.pinned - a.pinned || b.updatedAt - a.updatedAt;
return b.pinned - a.pinned || b.updatedAt - a.updatedAt;
}); });
el.threadList.innerHTML = sortedThreads.slice(0, THREAD_PAGE_SIZE).map(threadRow).join(""); el.threadList.innerHTML = sortedThreads.slice(0, THREAD_PAGE_SIZE).map(threadRow).join("");
el.threadList.scrollTop = 0; el.threadList.scrollTop = 0;
@@ -1327,6 +1502,15 @@ function showSunePopover(btn, id) {
positionPopover(btn, el.sunePopover); positionPopover(btn, el.sunePopover);
icons(); icons();
} }
var hideSunesSyncPopover = () => {
el.sunesSyncPopover.classList.add("hidden");
}, hideSuneSyncPopover = hideSunesSyncPopover;
function showSunesSyncPopover(btn) {
el.sunesSyncPopover.classList.remove("hidden");
positionPopover(btn || el.suneSyncBtn, el.sunesSyncPopover);
icons();
}
var showSuneSyncPopover = showSunesSyncPopover;
$(el.threadList).on("click", async (e) => { $(el.threadList).on("click", async (e) => {
const openBtn = e.target.closest("[data-open-thread]"), menuBtn = e.target.closest("[data-thread-menu]"); const openBtn = e.target.closest("[data-open-thread]"), menuBtn = e.target.closest("[data-thread-menu]");
if (openBtn) { if (openBtn) {
@@ -1474,10 +1658,7 @@ $(el.threadPopover).on("click", async (e) => {
const u = el.threadRepoInput.value.trim(); const u = el.threadRepoInput.value.trim();
if (u.startsWith("gh://")) { if (u.startsWith("gh://")) {
const info = parseGhUrl(u); const info = parseGhUrl(u);
try { if (await copyToClipboard(`${info.owner}/${info.repo}@${info.branch}/${th.id}`)) alert("Path copied.");
await navigator.clipboard.writeText(`${info.owner}/${info.repo}@${info.branch}/${th.id}`);
alert("Path copied.");
} catch {}
} }
} }
hideThreadPopover(); hideThreadPopover();
@@ -1520,6 +1701,7 @@ $(el.sunePopover).on("click", async (e) => {
SUNE.save(); SUNE.save();
renderSidebar(); renderSidebar();
await reflectActiveSune(); await reflectActiveSune();
markLocalDirty();
}; };
if (act === "pin") { if (act === "pin") {
s.pinned = !s.pinned; s.pinned = !s.pinned;
@@ -1546,13 +1728,20 @@ $(el.sunePopover).on("click", async (e) => {
} else if (act === "export") dl(`sune-${(s.name || "sune").replace(/\W/g, "_")}-${ts()}.sune`, [s]); } else if (act === "export") dl(`sune-${(s.name || "sune").replace(/\W/g, "_")}-${ts()}.sune`, [s]);
hideSunePopover(); hideSunePopover();
}); });
$(el.sunesSyncUploadBtn).on("click", () => {
hideSunesSyncPopover();
performSuneUpload();
});
$(el.sunesSyncDownloadBtn).on("click", () => {
hideSunesSyncPopover();
performSuneDownload(false);
});
function updateAttachBadge() { function updateAttachBadge() {
const n = state.attachments.length; const n = state.attachments.length;
el.attachBadge.textContent = String(n); el.attachBadge.textContent = String(n);
el.attachBadge.classList.toggle("hidden", n === 0); el.attachBadge.classList.toggle("hidden", n === 0);
} }
$(el.attachBtn).on("click", () => { $(el.attachBtn).on("click", () => {
if (state.busy) return;
if (state.attachments.length) { if (state.attachments.length) {
state.attachments = []; state.attachments = [];
updateAttachBadge(); updateAttachBadge();
@@ -1569,6 +1758,18 @@ $(el.fileInput).on("change", async () => {
} }
updateAttachBadge(); updateAttachBadge();
}); });
$(el.composer).on("paste", async (e) => {
const files = [...(e.clipboardData || e.originalEvent?.clipboardData)?.files || []];
if (!files.length) return;
e.preventDefault();
state.attachments = [];
el.fileInput.value = "";
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) => { $(el.composer).on("submit", async (e) => {
e.preventDefault(); e.preventDefault();
if (state.busy) return; if (state.busy) return;
@@ -1583,6 +1784,9 @@ $(el.composer).on("submit", async (e) => {
text text
}); });
parts.push(...state.attachments); parts.push(...state.attachments);
state.attachments = [];
updateAttachBadge();
el.fileInput.value = "";
const userMsg = { const userMsg = {
role: "user", role: "user",
content: parts.length ? parts : [{ content: parts.length ? parts : [{
@@ -1596,7 +1800,7 @@ $(el.composer).on("submit", async (e) => {
const title = await generateTitleWithAI(state.messages) || partsToText(state.messages.find((m) => m.role === "user")).replace(/!\[\]\(data:[^\)]+\)/g, "[Image]") || "Untitled"; 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); await THREAD.setTitle(th.id, title);
})(); })();
if (!SUNE.model) return state.attachments = [], updateAttachBadge(); if (!SUNE.model) return;
state.busy = true; state.busy = true;
setBtnStop(); setBtnStop();
const a = SUNE.active, suneMeta = { const a = SUNE.active, suneMeta = {
@@ -1650,8 +1854,6 @@ $(el.composer).on("submit", async (e) => {
} else if (!done) THREAD.persist(false); } else if (!done) THREAD.persist(false);
}; };
await streamChat(onDelta, streamId); await streamChat(onDelta, streamId);
state.attachments = [];
updateAttachBadge();
}); });
var jars = { var jars = {
html: null, html: null,
@@ -1681,8 +1883,8 @@ function openSettings() {
el.set_repetition_penalty.value = s.repetition_penalty; el.set_repetition_penalty.value = s.repetition_penalty;
el.set_min_p.value = s.min_p; el.set_min_p.value = s.min_p;
el.set_top_a.value = s.top_a; el.set_top_a.value = s.top_a;
el.set_verbosity.value = s.verbosity || ""; el.set_max_tokens.value = s.max_tokens || "";
el.set_reasoning_effort.value = s.reasoning_effort || "default"; el.set_reasoning_effort.value = s.reasoning_effort || "";
el.set_system_prompt.value = s.system_prompt; el.set_system_prompt.value = s.system_prompt;
el.set_hide_composer.checked = !!s.hide_composer; el.set_hide_composer.checked = !!s.hide_composer;
el.set_img_output.checked = !!s.img_output; el.set_img_output.checked = !!s.img_output;
@@ -1737,10 +1939,10 @@ $(el.settingsForm).on("submit", async (e) => {
"frequency_penalty", "frequency_penalty",
"repetition_penalty", "repetition_penalty",
"min_p", "min_p",
"top_a" "top_a",
"max_tokens"
].forEach((k) => SUNE[k] = el[`set_${k}`].value.trim()); ].forEach((k) => SUNE[k] = el[`set_${k}`].value.trim());
SUNE.verbosity = el.set_verbosity.value || ""; SUNE.reasoning_effort = el.set_reasoning_effort.value || "";
SUNE.reasoning_effort = el.set_reasoning_effort.value || "default";
SUNE.system_prompt = el.set_system_prompt.value.trim(); SUNE.system_prompt = el.set_system_prompt.value.trim();
SUNE.hide_composer = el.set_hide_composer.checked; SUNE.hide_composer = el.set_hide_composer.checked;
SUNE.img_output = el.set_img_output.checked; SUNE.img_output = el.set_img_output.checked;
@@ -1923,15 +2125,19 @@ USER.logMany = async (msgs) => {
await THREAD.persist(); await THREAD.persist();
}; };
async function init() { async function init() {
const u = localStorage.getItem("thread_repo_url") || ""; gcStorage();
const u = localStorage.getItem("thread_repo_url") || "", suR = localStorage.getItem("sune_repo_url") || "";
el.threadRepoInput.value = u; el.threadRepoInput.value = u;
el.suneRepoInput.value = suR;
el.threadFolderBtn.classList.toggle("hidden", !u.startsWith("gh://")); el.threadFolderBtn.classList.toggle("hidden", !u.startsWith("gh://"));
el.threadBackBtn.classList.toggle("hidden", !u.startsWith("gh://") || u.split("/").length <= 3); el.threadBackBtn.classList.toggle("hidden", !u.startsWith("gh://") || u.split("/").length <= 3);
await THREAD.load(); await THREAD.load();
await renderThreads(); await renderThreads();
await Promise.allSettled(STICKY_SUNES.map((s) => SUNE.fetchDotSune(s))); await Promise.allSettled(STICKY_SUNES.map((s) => SUNE.fetchDotSune(s)));
renderSidebar(); renderSidebar();
renderUserUI();
await reflectActiveSune(); await reflectActiveSune();
if (suR.startsWith("gh://")) checkSuneSyncStatus();
clearChat(); clearChat();
icons(); icons();
kbBind(); kbBind();
@@ -1940,6 +2146,10 @@ async function init() {
$(window).on("resize", () => { $(window).on("resize", () => {
hideThreadPopover(); hideThreadPopover();
hideSunePopover(); hideSunePopover();
hideSunesSyncPopover();
});
$(document).on("click", (e) => {
if (el.sunesSyncPopover && !el.sunesSyncPopover.classList.contains("hidden") && !el.sunesSyncPopover.contains(e.target) && !el.suneSyncBtn.contains(e.target)) hideSunesSyncPopover();
}); });
var htmlTabs = { var htmlTabs = {
index: ["htmlTab_index", "htmlEditor"], index: ["htmlTab_index", "htmlEditor"],
@@ -2062,7 +2272,17 @@ $(el.threadSyncBtn).on("click", async () => {
} }
if (t.type !== "thread") continue; if (t.type !== "thread") continue;
if (t.status === "modified" || t.status === "new") { if (t.status === "modified" || t.status === "new") {
const newName = serializeThreadName(t), msgs = await localforage.getItem("rem_t_" + t.id); const newName = serializeThreadName(t);
let msgs = await localforage.getItem("rem_t_" + t.id);
if ((!msgs || !Array.isArray(msgs)) && remoteMap[t.id]) {
const text = await ghGetFileContent(info, remoteMap[t.id].name);
if (text) try {
msgs = JSON.parse(text);
await localforage.setItem("rem_t_" + t.id, msgs);
} catch (e) {
console.error(e);
}
}
if (remoteMap[t.id] && remoteMap[t.id].name !== newName) await ghApi(`${info.apiPath}/${remoteMap[t.id].name}`, "DELETE", { if (remoteMap[t.id] && remoteMap[t.id].name !== newName) await ghApi(`${info.apiPath}/${remoteMap[t.id].name}`, "DELETE", {
message: `Rename thread ${t.id}`, message: `Rename thread ${t.id}`,
sha: remoteMap[t.id].sha, sha: remoteMap[t.id].sha,
@@ -2071,7 +2291,7 @@ $(el.threadSyncBtn).on("click", async () => {
const x = await ghApi(`${info.apiPath}/${newName}?ref=${info.branch}`); const x = await ghApi(`${info.apiPath}/${newName}?ref=${info.branch}`);
await ghApi(`${info.apiPath}/${newName}`, "PUT", { await ghApi(`${info.apiPath}/${newName}`, "PUT", {
message: `Sync thread ${t.id}`, message: `Sync thread ${t.id}`,
content: utob(JSON.stringify(msgs, null, 2)), content: utob(JSON.stringify(msgs || [], null, 2)),
branch: info.branch, branch: info.branch,
sha: x?.sha sha: x?.sha
}); });
@@ -2090,6 +2310,139 @@ $(el.threadSyncBtn).on("click", async () => {
alert("Sync failed: " + e.message); alert("Sync failed: " + e.message);
} }
}); });
var suneSyncBusy = false;
var checkSuneSyncStatus = async () => {
const u = el.suneRepoInput.value.trim();
if (!u.startsWith("gh://")) return setSuneSyncStatus("idle");
if (suneSyncBusy) return;
suneSyncBusy = true;
setSuneSyncStatus("checking");
const info = parseGhUrl(u);
try {
const res = await ghApi(`${info.apiPath}/sunes.json?ref=${info.branch}`);
if (!res) return setSuneSyncStatus("desynced");
res.sha;
let remoteData = null;
if (res.content && res.encoding === "base64") try {
remoteData = JSON.parse(btou(res.content));
} catch {}
if (!remoteData && res.sha) {
const text = await ghGetFileContent(info, "sunes.json");
if (text) try {
remoteData = JSON.parse(text);
} catch {}
}
if (!remoteData) return setSuneSyncStatus("error");
const diff = num(remoteData.updatedAt, 0) - getLocalSunesUpdatedAt();
if (diff > 1e4) await performSuneDownload(true, remoteData, res.sha);
else if (diff < -1e4) setSuneSyncStatus("desynced");
else setSuneSyncStatus("synced");
} catch {
setSuneSyncStatus("error");
} finally {
suneSyncBusy = false;
}
};
var performSuneDownload = async (isAuto = false, prefData = null, prefSha = null) => {
const u = el.suneRepoInput.value.trim();
if (!u.startsWith("gh://")) return;
if (!isAuto && !confirm("Overwrite local sunes with remote version from GitHub?")) return;
setSuneSyncStatus("checking");
const info = parseGhUrl(u);
try {
let data = prefData;
if (!data) {
const res = await ghApi(`${info.apiPath}/sunes.json?ref=${info.branch}`);
if (!res) throw new Error("sunes.json not found");
res.sha;
if (res.content && res.encoding === "base64") try {
data = JSON.parse(btou(res.content));
} catch {}
if (!data) {
const text = await ghGetFileContent(info, "sunes.json");
if (!text) throw new Error("Could not read sunes.json");
data = JSON.parse(text);
}
}
if (!data) throw new Error("Invalid data");
isSyncPulling = true;
try {
if (Array.isArray(data.sunes)) {
sunes = data.sunes.map(makeSune);
su.save(sunes);
const nextActive = data.activeId || (sunes.some((s) => s.id === su.getActiveId()) ? su.getActiveId() : sunes[0]?.id);
if (nextActive) SUNE.setActive(nextActive);
}
if (data.storage && typeof data.storage === "object") {
Object.keys(localStorage).forEach((k) => {
if (isSuneStorageKey(k)) localStorage.removeItem(k);
});
Object.entries(data.storage).forEach(([k, v]) => {
if (isSuneStorageKey(k)) localStorage.setItem(k, v);
});
}
setLocalSunesUpdatedAt(num(data.updatedAt, Date.now()));
} finally {
isSyncPulling = false;
}
renderSidebar();
await reflectActiveSune();
setSuneSyncStatus("synced");
if (!isAuto) alert("Sunes pulled.");
} catch (e) {
setSuneSyncStatus("error");
if (!isAuto) alert("Pull failed: " + e.message);
}
};
var performSuneUpload = async () => {
const u = el.suneRepoInput.value.trim();
if (!u.startsWith("gh://")) return;
if (!confirm("Overwrite remote file on GitHub with local version?")) return;
setSuneSyncStatus("checking");
const info = parseGhUrl(u);
try {
const now = Date.now();
const data = {
version: 1,
updatedAt: now,
sunes: SUNE.list,
activeId: SUNE.id,
storage: {}
};
SUNE.list.forEach((s) => {
const p = `sune_${s.id}_`;
Object.keys(localStorage).forEach((k) => {
if (k.startsWith(p)) data.storage[k] = localStorage.getItem(k);
});
});
const x = await ghApi(`${info.apiPath}/sunes.json?ref=${info.branch}`);
const res = await ghApi(`${info.apiPath}/sunes.json`, "PUT", {
message: "Sync Sunes",
content: utob(JSON.stringify(data, null, 2)),
branch: info.branch,
sha: x?.sha
});
setLocalSunesUpdatedAt(now);
res?.content?.sha;
setSuneSyncStatus("synced");
alert("Sunes pushed.");
} catch (e) {
setSuneSyncStatus("error");
alert("Push failed: " + e.message);
}
};
$(el.suneRepoInput).on("change", () => {
localStorage.setItem("sune_repo_url", el.suneRepoInput.value.trim());
checkSuneSyncStatus();
});
$(el.suneSyncBtn).on("click", (e) => {
e.stopPropagation();
if (!el.suneRepoInput.value.trim().startsWith("gh://")) return;
showSunesSyncPopover(el.suneSyncBtn);
});
$(el.sidebarBtnLeft).on("click", () => {
if (el.suneRepoInput.value.trim().startsWith("gh://")) checkSuneSyncStatus();
});
init(); init();
var accountTabs = { var accountTabs = {
General: ["accountTabGeneral", "accountPanelGeneral"], General: ["accountTabGeneral", "accountPanelGeneral"],
@@ -2110,6 +2463,7 @@ function openAccountSettings() {
el.set_api_key_claude.value = USER.apiKeyClaude || ""; el.set_api_key_claude.value = USER.apiKeyClaude || "";
el.set_api_key_cf.value = USER.apiKeyCloudflare || ""; el.set_api_key_cf.value = USER.apiKeyCloudflare || "";
el.set_api_key_custom1.value = USER.customKey1 || ""; el.set_api_key_custom1.value = USER.customKey1 || "";
el.set_api_key_custom1.value = USER.customKey1 || "";
el.set_master_prompt.value = USER.masterPrompt || ""; el.set_master_prompt.value = USER.masterPrompt || "";
el.set_title_model.value = USER.titleModel; el.set_title_model.value = USER.titleModel;
el.set_gh_token.value = USER.githubToken || ""; el.set_gh_token.value = USER.githubToken || "";
@@ -2131,6 +2485,18 @@ $(el.cancelAccountSettings).on("click", closeAccountSettings);
$(el.accountSettingsModal).on("click", (e) => { $(el.accountSettingsModal).on("click", (e) => {
if (e.target === el.accountSettingsModal || e.target.classList.contains("bg-black/30")) closeAccountSettings(); if (e.target === el.accountSettingsModal || e.target.classList.contains("bg-black/30")) closeAccountSettings();
}); });
$(el.setUserAvatarBtn).on("click", () => el.userAvatarInput.click());
$(el.userAvatarInput).on("change", async () => {
const f = el.userAvatarInput.files?.[0];
if (!f) return;
try {
const v = await imgToWebp(f);
USER.avatar = v;
el.userAvatarPreview.src = v;
el.userAvatarPreview.classList.remove("bg-gray-200");
renderUserUI();
} catch {}
});
$(el.accountSettingsForm).on("submit", (e) => { $(el.accountSettingsForm).on("submit", (e) => {
e.preventDefault(); e.preventDefault();
USER.provider = el.set_provider.value || "openrouter"; USER.provider = el.set_provider.value || "openrouter";
@@ -2144,6 +2510,7 @@ $(el.accountSettingsForm).on("submit", (e) => {
USER.titleModel = String(el.set_title_model.value || "").trim(); USER.titleModel = String(el.set_title_model.value || "").trim();
USER.githubToken = String(el.set_gh_token.value || "").trim(); USER.githubToken = String(el.set_gh_token.value || "").trim();
USER.name = String(el.set_user_name.value || "").trim(); USER.name = String(el.set_user_name.value || "").trim();
renderUserUI();
closeAccountSettings(); closeAccountSettings();
}); });
$(el.accountPanelAPI).on("click", (e) => { $(el.accountPanelAPI).on("click", (e) => {
@@ -2189,8 +2556,8 @@ el.importAccountSettingsInput.onchange = async (e) => {
apiKeyOpenRouter: "apiKeyOR", apiKeyOpenRouter: "apiKeyOR",
apiKeyOpenAI: "apiKeyOAI", apiKeyOpenAI: "apiKeyOAI",
apiKeyGoogle: "apiKeyG", apiKeyGoogle: "apiKeyG",
apiKeyClaude: "apiKeyC", apiKeyClaude: "apiKeyClaude",
apiKeyCloudflare: "apiKeyCF", apiKeyCloudflare: "apiKeyCloudflare",
customKey1: "customKey1", customKey1: "customKey1",
masterPrompt: "masterPrompt", masterPrompt: "masterPrompt",
titleModel: "titleModel", titleModel: "titleModel",
@@ -2201,6 +2568,7 @@ el.importAccountSettingsInput.onchange = async (e) => {
const v = d[p] ?? d[k]; const v = d[p] ?? d[k];
if (typeof v === "string") USER[p] = v; if (typeof v === "string") USER[p] = v;
}); });
renderUserUI();
openAccountSettings(); openAccountSettings();
alert("Imported."); alert("Imported.");
} catch { } catch {
@@ -2329,11 +2697,7 @@ var onForeground = () => {
if (state.busy) syncWhileBusy(); if (state.busy) syncWhileBusy();
}; };
$(document).on("visibilitychange", onForeground); $(document).on("visibilitychange", onForeground);
$(el.copySystemPrompt).on("click", async () => { $(el.copySystemPrompt).on("click", async () => await copyToClipboard(el.set_system_prompt.value || ""));
try {
await navigator.clipboard.writeText(el.set_system_prompt.value || "");
} catch {}
});
$(el.pasteSystemPrompt).on("click", async () => { $(el.pasteSystemPrompt).on("click", async () => {
try { try {
el.set_system_prompt.value = await navigator.clipboard.readText(); el.set_system_prompt.value = await navigator.clipboard.readText();
@@ -2341,10 +2705,8 @@ $(el.pasteSystemPrompt).on("click", async () => {
}); });
var getActiveJar = () => !el.htmlEditor.classList.contains("hidden") ? jars.html : jars.extension; var getActiveJar = () => !el.htmlEditor.classList.contains("hidden") ? jars.html : jars.extension;
$(el.copyHTML).on("click", async () => { $(el.copyHTML).on("click", async () => {
try {
const jar = getActiveJar(); const jar = getActiveJar();
await navigator.clipboard.writeText(jar ? jar.toString() : ""); await copyToClipboard(jar ? jar.toString() : "");
} catch {}
}); });
$(el.pasteHTML).on("click", async () => { $(el.pasteHTML).on("click", async () => {
try { try {
@@ -2373,11 +2735,13 @@ Object.assign(window, {
renderSuneHTML, renderSuneHTML,
reflectActiveSune, reflectActiveSune,
suneRow, suneRow,
renderUserUI,
enhanceCodeBlocks, enhanceCodeBlocks,
getSuneLabel, getSuneLabel,
_createMessageRow, _createMessageRow,
msgRow, msgRow,
partsToText, partsToText,
copyToClipboard,
addSuneBubbleStreaming, addSuneBubbleStreaming,
clearChat, clearChat,
payloadWithSampling, payloadWithSampling,
@@ -2395,6 +2759,14 @@ Object.assign(window, {
showThreadPopover, showThreadPopover,
hideSunePopover, hideSunePopover,
showSunePopover, showSunePopover,
hideSuneSyncPopover,
showSuneSyncPopover,
hideSunesSyncPopover,
showSunesSyncPopover,
setSuneSyncStatus,
checkSuneSyncStatus,
performSuneDownload,
performSuneUpload,
updateAttachBadge, updateAttachBadge,
toAttach, toAttach,
ensureJars, ensureJars,
@@ -2421,6 +2793,9 @@ Object.assign(window, {
ghApi, ghApi,
parseGhUrl, parseGhUrl,
ghGetFileContent, ghGetFileContent,
pullThreads pullThreads,
suneStorage,
gcStorage,
cleanSuneStorage
}); });
//#endregion //#endregion

41
dist/index.html vendored
View File

@@ -7,21 +7,21 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/tiny-ripple@0.2.0"></script> <script src="https://cdn.jsdelivr.net/npm/tiny-ripple@0.2.0"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5.8.1/github-markdown-light.min.css"/> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5.9.0/github-markdown-light.min.css"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/styles/github.min.css"/> <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/styles/github.min.css"/>
<script defer src="https://cdn.jsdelivr.net/npm/cash-dom/dist/cash.min.js"></script> <script defer src="https://cdn.jsdelivr.net/npm/cash-dom/dist/cash.min.js"></script>
<script defer src="//unpkg.com/alpinejs"></script> <script defer src="//unpkg.com/alpinejs"></script>
<script type="module" crossorigin src="/assets/index-Cu66jDo0.js"></script> <script type="module" crossorigin src="/assets/index-CWYcSOgO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DUC1RW1F.css"> <link rel="stylesheet" crossorigin href="/assets/index-DUC1RW1F.css">
<link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head> <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')"> <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')">
<div class="flex flex-col h-dvh max-h-dvh overflow-hidden"> <div class="flex flex-col h-dvh max-h-dvh overflow-hidden">
<header id="topbar" class="sticky top-0 z-20 bg-white/80 backdrop-blur border-b border-gray-200"> <header id="topbar" class="sticky top-0 z-20 bg-white/80 backdrop-blur border-b border-gray-200">
<div class="mx-auto w-full max-w-none px-4 py-3 grid grid-cols-3 items-center"> <div class="mx-auto w-full max-w-none px-4 py-2 grid grid-cols-3 items-center">
<button id="sidebarBtnLeft" class="h-8 w-8 rounded-xl bg-gray-100 hover:bg-gray-200 active:scale-[.99] transition flex items-center justify-center" title="Sunes" @click="document.getElementById('sidebarLeft').classList.remove('-translate-x-full');document.getElementById('sidebarOverlayLeft').classList.remove('hidden')"><i data-lucide="panel-left" class="h-5 w-5"></i></button> <button id="sidebarBtnLeft" class="h-8 w-8 rounded-xl bg-gray-100 hover:bg-gray-200 active:scale-[.99] transition flex items-center justify-center" title="Sunes" @click="document.getElementById('sidebarLeft').classList.remove('-translate-x-full');document.getElementById('sidebarOverlayLeft').classList.remove('hidden')"><i data-lucide="panel-left" class="h-5 w-5"></i></button>
<button id="suneBtnTop" class="justify-self-center h-8 w-8 rounded-full bg-gray-200 text-gray-900 flex items-center justify-center hover:bg-gray-300 active:scale-[.99] transition" title="Sune settings"></button> <button id="suneBtnTop" class="justify-self-center h-10 w-10 rounded-full bg-gray-200 text-gray-900 flex items-center justify-center hover:bg-gray-300 active:scale-[.99] transition" title="Sune settings"></button>
<div class="justify-self-end"><button id="sidebarBtnRight" class="h-8 w-8 rounded-xl bg-gray-100 hover:bg-gray-200 active:scale-[.99] transition flex items-center justify-center" title="Threads" @click="renderThreads();document.getElementById('sidebarRight').classList.remove('translate-x-full');document.getElementById('sidebarOverlayRight').classList.remove('hidden')"><i data-lucide="panel-right" class="h-5 w-5"></i></button></div> <div class="justify-self-end"><button id="sidebarBtnRight" class="h-8 w-8 rounded-xl bg-gray-100 hover:bg-gray-200 active:scale-[.99] transition flex items-center justify-center" title="Threads" @click="renderThreads();document.getElementById('sidebarRight').classList.remove('translate-x-full');document.getElementById('sidebarOverlayRight').classList.remove('hidden')"><i data-lucide="panel-right" class="h-5 w-5"></i></button></div>
</div> </div>
</header> </header>
@@ -42,12 +42,19 @@
</footer> </footer>
</div> </div>
<div id="sidebarOverlayLeft" class="fixed inset-0 z-40 bg-black/20 hidden" @click="document.getElementById('sidebarLeft').classList.add('-translate-x-full');$el.classList.add('hidden');document.getElementById('sidebarRight').classList.add('translate-x-full');document.getElementById('sidebarOverlayRight').classList.add('hidden');hideThreadPopover();hideSunePopover()"></div> <div id="sidebarOverlayLeft" class="fixed inset-0 z-40 bg-black/20 hidden" @click="document.getElementById('sidebarLeft').classList.add('-translate-x-full');$el.classList.add('hidden');document.getElementById('sidebarRight').classList.add('translate-x-full');document.getElementById('sidebarOverlayRight').classList.add('hidden');hideThreadPopover();hideSunePopover();hideSunesSyncPopover()"></div>
<aside id="sidebarLeft" class="fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] bg-white border-r border-gray-200 shadow-xl transform -translate-x-full transition-transform duration-200 ease-out flex flex-col"> <aside id="sidebarLeft" class="fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] bg-white border-r border-gray-200 shadow-xl transform -translate-x-full transition-transform duration-200 ease-out flex flex-col">
<div class="p-3 border-b flex items-center gap-2"><button id="newSuneBtn" class="px-3 py-2 rounded-xl bg-black text-white text-sm hover:bg-black/90">New sune</button><span class="text-xs text-gray-500">Click name to equip</span></div> <div class="p-2 border-b flex flex-col gap-2">
<input id="suneRepoInput" type="text" placeholder="gh://owner/sunes" class="w-full h-9 rounded-lg border-0 bg-gray-100 px-3 text-xs font-mono focus:ring-2 focus:ring-black focus:bg-white"/>
<div class="flex items-center justify-between gap-2">
<button id="newSuneBtn" class="px-3 py-1.5 rounded-lg bg-black text-white text-[10px] font-bold uppercase tracking-wider hover:bg-black/90 transition">New Sune</button>
<button id="suneSyncBtn" class="relative px-3 py-1.5 rounded-lg bg-gray-100 text-gray-900 text-[10px] font-bold uppercase tracking-wider hover:bg-gray-200 transition flex items-center gap-1"><i data-lucide="refresh-cw" class="h-3 w-3"></i><span>Sync</span><span id="suneSyncBadge" class="absolute -top-1 -right-1 block h-2.5 w-2.5 rounded-full ring-2 ring-white hidden"></span></button>
</div>
</div>
<div id="suneList" class="flex-1 overflow-y-auto divide-y"></div> <div id="suneList" class="flex-1 overflow-y-auto divide-y"></div>
<div class="p-3 border-t relative"> <div class="p-3 border-t relative flex items-center gap-2">
<button id="userMenuBtn" class="w-full flex items-center justify-between px-3 py-2 rounded-xl bg-gray-100 hover:bg-gray-200 active:scale-[.99] transition" @click.stop="document.getElementById('userMenu').classList.toggle('hidden')"><span class="flex items-center gap-2"><span class="h-6 w-6 rounded-full bg-gray-900 text-white flex items-center justify-center">👤</span><span class="text-sm">Account & Backup</span></span><i data-lucide="chevron-down" class="h-4 w-4"></i></button> <button id="userMenuBtn" class="flex-1 flex items-center justify-between px-3 py-2 rounded-xl bg-gray-100 hover:bg-gray-200 active:scale-[.99] transition" @click.stop="document.getElementById('userMenu').classList.toggle('hidden')"><span class="flex items-center gap-2"><span id="userMenuAvatar" class="h-6 w-6 rounded-full bg-gray-900 text-white flex items-center justify-center overflow-hidden shrink-0">👤</span><span class="text-sm">User</span></span><i data-lucide="chevron-down" class="h-4 w-4"></i></button>
<a href="https://github.com/sune-org/sune" target="_blank" rel="noopener noreferrer" class="h-10 w-10 shrink-0 rounded-xl bg-gray-100 hover:bg-gray-200 active:scale-[.99] transition flex items-center justify-center text-gray-700 hover:text-black" title="Visit the repository"><svg viewBox="0 0 24 24" class="h-5 w-5 fill-current" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.53 1.032 1.53 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"/></svg></a>
<div id="userMenu" class="absolute left-3 right-3 bottom-16 translate-y-2 rounded-xl border border-gray-200 bg-white shadow-lg hidden overflow-hidden"> <div id="userMenu" class="absolute left-3 right-3 bottom-16 translate-y-2 rounded-xl border border-gray-200 bg-white shadow-lg hidden overflow-hidden">
<button id="accountSettingsOption" class="menu-item"><i data-lucide="settings" class="h-4 w-4"></i><span>Settings</span></button> <button id="accountSettingsOption" class="menu-item"><i data-lucide="settings" class="h-4 w-4"></i><span>Settings</span></button>
<button id="sunesImportOption" class="menu-item">Import sunes (.sune)</button> <button id="sunesImportOption" class="menu-item">Import sunes (.sune)</button>
@@ -87,6 +94,10 @@
<button data-action="pfp" class="menu-item"><i data-lucide="image" class="h-4 w-4"></i><span>Change pfp</span></button> <button data-action="pfp" class="menu-item"><i data-lucide="image" class="h-4 w-4"></i><span>Change pfp</span></button>
<button data-action="export" class="menu-item"><i data-lucide="download" class="h-4 w-4"></i><span>Export sune (.sune)</span></button> <button data-action="export" class="menu-item"><i data-lucide="download" class="h-4 w-4"></i><span>Export sune (.sune)</span></button>
</div> </div>
<div id="sunesSyncPopover" class="menu-card hidden">
<button id="sunesSyncUploadBtn" class="menu-item"><i data-lucide="upload-cloud" class="h-4 w-4"></i><span>Upload to GitHub</span></button>
<button id="sunesSyncDownloadBtn" class="menu-item"><i data-lucide="download-cloud" class="h-4 w-4"></i><span>Download from GitHub</span></button>
</div>
<div id="suneModal" class="hidden fixed inset-0 z-50"> <div id="suneModal" class="hidden fixed inset-0 z-50">
<div class="absolute inset-0 bg-black/30"></div> <div class="absolute inset-0 bg-black/30"></div>
<div class="absolute inset-x-0 top-12 mx-auto w-full max-w-md px-4"> <div class="absolute inset-x-0 top-12 mx-auto w-full max-w-md px-4">
@@ -95,7 +106,7 @@
<form id="settingsForm" class="text-sm"> <form id="settingsForm" class="text-sm">
<div class="border-b flex text-xs font-medium"><button type="button" id="tabModel" class="flex-1 py-2 px-3 text-center border-b-2 border-black">Model & Sampling</button><button type="button" id="tabPrompt" class="flex-1 py-2 px-3 text-center border-b-2 border-transparent hover:border-gray-300">System Prompt</button><button type="button" id="tabScript" class="flex-1 py-2 px-3 text-center border-b-2 border-transparent hover:border-gray-300">HTML</button></div> <div class="border-b flex text-xs font-medium"><button type="button" id="tabModel" class="flex-1 py-2 px-3 text-center border-b-2 border-black">Model & Sampling</button><button type="button" id="tabPrompt" class="flex-1 py-2 px-3 text-center border-b-2 border-transparent hover:border-gray-300">System Prompt</button><button type="button" id="tabScript" class="flex-1 py-2 px-3 text-center border-b-2 border-transparent hover:border-gray-300">HTML</button></div>
<div id="panelModel" class="p-4 space-y-4"> <div id="panelModel" class="p-4 space-y-4">
<div class="grid grid-cols-2 gap-3"><div><label class="block text-gray-700 font-medium mb-1">Model name</label><input id="set_model" type="text" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="google/gemini-3-pro-preview"/></div><div><label class="block text-gray-700 font-medium mb-1">Reasoning Effort</label><select id="set_reasoning_effort" class="w-full rounded-xl border border-gray-300 px-3 py-2"><option value="default">Omitted</option><option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option></select></div></div> <div class="grid grid-cols-2 gap-3"><div><label class="block text-gray-700 font-medium mb-1">Model name</label><input id="set_model" type="text" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="google/gemini-3-pro-preview"/></div><div><label class="block text-gray-700 font-medium mb-1">Reasoning Effort</label><select id="set_reasoning_effort" class="w-full rounded-xl border border-gray-300 px-3 py-2"><option value="">Omitted</option><option value="none">None</option><option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option></select></div></div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div><label class="block text-gray-700 font-medium mb-1">Temperature <span class="text-gray-400">(02)</span></label><input id="set_temperature" type="number" min="0" max="2" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/></div> <div><label class="block text-gray-700 font-medium mb-1">Temperature <span class="text-gray-400">(02)</span></label><input id="set_temperature" type="number" min="0" max="2" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/></div>
<div><label class="block text-gray-700 font-medium mb-1">Top P <span class="text-gray-400">(01)</span></label><input id="set_top_p" type="number" min="0" max="1" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/></div> <div><label class="block text-gray-700 font-medium mb-1">Top P <span class="text-gray-400">(01)</span></label><input id="set_top_p" type="number" min="0" max="1" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/></div>
@@ -104,7 +115,7 @@
<div><label class="block text-gray-700 font-medium mb-1">Repetition Penalty <span class="text-gray-400">(02)</span></label><input id="set_repetition_penalty" type="number" min="0" max="2" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/></div> <div><label class="block text-gray-700 font-medium mb-1">Repetition Penalty <span class="text-gray-400">(02)</span></label><input id="set_repetition_penalty" type="number" min="0" max="2" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/></div>
<div><label class="block text-gray-700 font-medium mb-1">Min P <span class="text-gray-400">(01)</span></label><input id="set_min_p" type="number" min="0" max="1" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/></div> <div><label class="block text-gray-700 font-medium mb-1">Min P <span class="text-gray-400">(01)</span></label><input id="set_min_p" type="number" min="0" max="1" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/></div>
<div><label class="block text-gray-700 font-medium mb-1">Top A <span class="text-gray-400">(01)</span></label><input id="set_top_a" type="number" min="0" max="1" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/></div> <div><label class="block text-gray-700 font-medium mb-1">Top A <span class="text-gray-400">(01)</span></label><input id="set_top_a" type="number" min="0" max="1" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/></div>
<div><label class="block text-gray-700 font-medium mb-1">Verbosity</label><select id="set_verbosity" class="w-full rounded-xl border border-gray-300 px-3 py-2"><option value="">Omitted</option><option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option></select></div> <div><label class="block text-gray-700 font-medium mb-1">Max Tokens (output)</label><input id="set_max_tokens" type="number" min="1" step="1" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder=""/></div>
</div> </div>
<div class="flex flex-wrap items-center gap-2 pt-2"> <div class="flex flex-wrap items-center gap-2 pt-2">
<div><input id="set_include_thoughts" type="checkbox" class="sr-only peer"><label for="set_include_thoughts" class="inline-flex cursor-pointer items-center rounded-full border border-slate-300 bg-transparent py-0.5 px-3 text-xs text-slate-500 peer-checked:border-gray-300 peer-checked:bg-gray-200 peer-checked:text-slate-800">Include thoughts</label></div> <div><input id="set_include_thoughts" type="checkbox" class="sr-only peer"><label for="set_include_thoughts" class="inline-flex cursor-pointer items-center rounded-full border border-slate-300 bg-transparent py-0.5 px-3 text-xs text-slate-500 peer-checked:border-gray-300 peer-checked:bg-gray-200 peer-checked:text-slate-800">Include thoughts</label></div>
@@ -140,6 +151,7 @@
</div> </div>
<div id="panelPrompt" class="p-4 space-y-4 hidden"> <div id="panelPrompt" class="p-4 space-y-4 hidden">
<div><div class="flex items-center justify-between mb-1"><label for="set_system_prompt" class="block text-gray-700 font-medium">System Prompt</label><div class="flex gap-2"><button type="button" id="copySystemPrompt" class="px-2 py-1 text-xs rounded-md bg-gray-100 hover:bg-gray-200">Copy</button><button type="button" id="pasteSystemPrompt" class="px-2 py-1 text-xs rounded-md bg-gray-100 hover:bg-gray-200">Paste</button></div></div><textarea id="set_system_prompt" rows="8" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="Enter a system prompt to guide the sune"></textarea></div> <div><div class="flex items-center justify-between mb-1"><label for="set_system_prompt" class="block text-gray-700 font-medium">System Prompt</label><div class="flex gap-2"><button type="button" id="copySystemPrompt" class="px-2 py-1 text-xs rounded-md bg-gray-100 hover:bg-gray-200">Copy</button><button type="button" id="pasteSystemPrompt" class="px-2 py-1 text-xs rounded-md bg-gray-100 hover:bg-gray-200">Paste</button></div></div><textarea id="set_system_prompt" rows="8" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="Enter a system prompt to guide the sune"></textarea></div>
<p class="text-xs text-gray-500">Use <code>{{model_name}}</code> to insert the model name.</p>
</div> </div>
<div id="panelScript" class="p-1 hidden"> <div id="panelScript" class="p-1 hidden">
<div class="border-b flex text-xs font-medium"><button type="button" id="htmlTab_index" class="flex-1 py-2 px-3 text-center border-b-2"></button><button type="button" id="htmlTab_extension" class="flex-1 py-2 px-3 text-center border-b-2"></button></div> <div class="border-b flex text-xs font-medium"><button type="button" id="htmlTab_index" class="flex-1 py-2 px-3 text-center border-b-2"></button><button type="button" id="htmlTab_extension" class="flex-1 py-2 px-3 text-center border-b-2"></button></div>
@@ -188,11 +200,8 @@
<input id="importAccountSettingsInput" type="file" class="hidden" accept="application/json,.json"> <input id="importAccountSettingsInput" type="file" class="hidden" accept="application/json,.json">
<script src="https://unpkg.com/lucide@latest"></script> <script src="https://unpkg.com/lucide@latest"></script>
<script src="https://cdn.jsdelivr.net/npm/markdown-it@14.1.1/dist/markdown-it.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/markdown-it@15.0.1/dist/browser/markdown-it.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/highlight.min.js"></script> <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/highlight.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/localforage@1.10.0/dist/localforage.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/localforage@1.10.0/dist/localforage.min.js"></script>
</body> </body>
</html> </html>

2
dist/sw.js vendored
View File

@@ -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} didnt 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-9c191d2f"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"registerSW.js",revision:"1872c500de691dce40960bb85481de07"},{url:"index.html",revision:"7b534141e72adae9028c64d6d22b3c40"},{url:"assets/index-DUC1RW1F.css",revision:null},{url:"assets/index-Cu66jDo0.js",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} didnt 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 c=e=>i(e,t),l={module:{uri:t},exports:o,require:c};s[t]=Promise.all(n.map(e=>l[e]||c(e))).then(e=>(r(...e),o))}}define(["./workbox-9c191d2f"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"registerSW.js",revision:"1872c500de691dce40960bb85481de07"},{url:"index.html",revision:"f8c767b8fe6e11fa13e704d51b97ffbc"},{url:"assets/index-DUC1RW1F.css",revision:null},{url:"assets/index-CWYcSOgO.js",revision:null},{url:"manifest.webmanifest",revision:"7a6c5c6ab9cb5d3605d21df44c6b17a2"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("index.html")))});

View File

@@ -12,12 +12,9 @@
<load src="/src/parts/sidebars.html" /> <load src="/src/parts/sidebars.html" />
<load src="/src/parts/modals.html" /> <load src="/src/parts/modals.html" />
<script src="https://unpkg.com/lucide@latest"></script> <script src="https://unpkg.com/lucide@latest"></script>
<script src="https://cdn.jsdelivr.net/npm/markdown-it@14.1.1/dist/markdown-it.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/markdown-it@15.0.1/dist/browser/markdown-it.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/highlight.min.js"></script> <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/highlight.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/localforage@1.10.0/dist/localforage.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/localforage@1.10.0/dist/localforage.min.js"></script>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,30 +1,31 @@
[![Download For Android (.apk)](https://img.shields.io/badge/Download-For%20Android%20(.apk)-green?style=for-the-badge&logo=android)](https://github.com/multipleof4/sune/releases/download/v0.23.0/sune-v0.23.0.apk) [![Download For Android (.apk)](https://img.shields.io/badge/Download-For%20Android%20(.apk)-green?style=for-the-badge&logo=android)](https://github.com/multipleof4/sune/releases/download/v0.23.0/sune-v0.23.0.apk)
![Main](./public/appstore_content/screenshot1.jpg) <p align="center">
<img src="./public/appstore_content/screenshot1.jpg" alt="Main" width="280" />
</p>
> Each sune is like a module. You can have many. And share them. > Each sune is like a module. You can have many. And share them.
![Sunes](./public/appstore_content/sune_sidebar.png) <p align="center">
<img src="./public/appstore_content/sune_sidebar.png" alt="Sunes" width="280" />
![Setting](./public/appstore_content/screenshot3.jpg) <img src="./public/appstore_content/screenshot3.jpg" alt="Setting" width="280" />
</p>
💠 New! 💠 New!
> You can have scripts which run on the page of each sune — either to function call or extend functionality of the app or sune. > You can have scripts which run on the page of each sune — either to function call or extend functionality of the app or sune.
![Scripting](./public/appstore_content/screenshot4.jpg) <p align="center">
<img src="./public/appstore_content/screenshot4.jpg" alt="Scripting" width="280" />
<img src="./public/appstore_content/screenshot_miku.png" alt="Miku" width="280" />
</p>
> Image support. > There is a marketplace and LaTeX support out of the box.
![Miku](./public/appstore_content/screenshot_miku.png) <p align="center">
<img src="./public/appstore_content/screenshot_marketplace.jpg" alt="Marketplace" width="280" />
> There is a marketplace. <img src="./public/appstore_content/latex.png" alt="LaTeX" width="280" />
</p>
![Marketplace](./public/appstore_content/screenshot_marketplace.jpg)
> LaTeX support out of the box.
![LaTeX](./public/appstore_content/latex.png)
--- ---
@@ -32,7 +33,9 @@
Never lose a conversation again. Sune can sync all your threads to a GitHub repo. Never lose a conversation again. Sune can sync all your threads to a GitHub repo.
![Sync](./public/appstore_content/sync.png) <p align="center">
<img src="./public/appstore_content/sync.png" alt="Sync" width="320" />
</p>
### Setup for Syncing Chats ### Setup for Syncing Chats
@@ -44,7 +47,7 @@ Never lose a conversation again. Sune can sync all your threads to a GitHub repo
- Copy the token - Copy the token
3. **Add your token in Sune** 3. **Add your token in Sune**
- Open the left sidebar → **Account & Backup****Settings** - Open the left sidebar → **User****Settings**
- Go to the **API** tab - Go to the **API** tab
- Paste your token into the **Github Token** field - Paste your token into the **Github Token** field
- Hit **Save** - Hit **Save**

View File

@@ -3,11 +3,11 @@ export const el = window.el = Object.fromEntries(
'topbar','chat','messages','composer','input','sendBtn','suneBtnTop','suneModal','suneURL', 'topbar','chat','messages','composer','input','sendBtn','suneBtnTop','suneModal','suneURL',
'settingsForm','closeSettings','cancelSettings','tabModel','tabPrompt','tabScript', 'settingsForm','closeSettings','cancelSettings','tabModel','tabPrompt','tabScript',
'panelModel','panelPrompt','panelScript','set_model','set_temperature','set_top_p','set_top_k', '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_frequency_penalty','set_repetition_penalty','set_min_p','set_top_a','set_max_tokens',
'set_reasoning_effort','set_system_prompt','set_hide_composer','set_include_thoughts', 'set_reasoning_effort','set_system_prompt','set_hide_composer','set_include_thoughts',
'set_img_output','set_aspect_ratio','set_image_size','aspectRatioContainer', 'set_img_output','set_aspect_ratio','set_image_size','aspectRatioContainer',
'set_ignore_master_prompt','deleteSuneBtn','sidebarLeft','sidebarOverlayLeft','sidebarBtnLeft', 'set_ignore_master_prompt','deleteSuneBtn','sidebarLeft','sidebarOverlayLeft','sidebarBtnLeft',
'suneList','newSuneBtn','userMenuBtn','userMenu','accountSettingsOption','sunesImportOption', 'suneList','newSuneBtn','userMenuBtn','userMenuAvatar','userMenu','accountSettingsOption','sunesImportOption',
'sunesExportOption','threadsImportOption','importInput','sidebarBtnRight','sidebarRight', 'sunesExportOption','threadsImportOption','importInput','sidebarBtnRight','sidebarRight',
'sidebarOverlayRight','threadList','closeThreads','threadPopover','sunePopover','footer', 'sidebarOverlayRight','threadList','closeThreads','threadPopover','sunePopover','footer',
'attachBtn','attachBadge','fileInput','htmlEditor','extensionHtmlEditor', 'attachBtn','attachBadge','fileInput','htmlEditor','extensionHtmlEditor',
@@ -19,6 +19,7 @@ export const el = window.el = Object.fromEntries(
'importAccountSettings','exportAccountSettings', 'importAccountSettings','exportAccountSettings',
'importAccountSettingsInput','accountTabUser','accountPanelUser','set_user_name', 'importAccountSettingsInput','accountTabUser','accountPanelUser','set_user_name',
'userAvatarPreview','setUserAvatarBtn','userAvatarInput','threadRepoInput','threadBackBtn', 'userAvatarPreview','setUserAvatarBtn','userAvatarInput','threadRepoInput','threadBackBtn',
'threadFolderBtn','threadSyncBtn' 'threadFolderBtn','threadSyncBtn','suneRepoInput','suneSyncBtn',
'suneSyncBadge','sunesSyncPopover','sunesSyncUploadBtn','sunesSyncDownloadBtn'
].map(id => [id, document.getElementById(id)]) ].map(id => [id, document.getElementById(id)])
); );

View File

@@ -11,6 +11,11 @@ export function kbUpdate() {
} }
export function kbBind() { export function kbBind() {
el.input.addEventListener('keydown', e => {
if (e.key !== 'Enter' || e.shiftKey || e.isComposing || e.keyCode === 229 || e.repeat || !matchMedia('(hover: hover) and (pointer: fine)').matches) return;
e.preventDefault();
el.composer.requestSubmit();
});
if (window.visualViewport) { if (window.visualViewport) {
['resize', 'scroll'].forEach(ev => window.visualViewport.addEventListener(ev, () => kbUpdate(), { passive: true })); ['resize', 'scroll'].forEach(ev => window.visualViewport.addEventListener(ev, () => kbUpdate(), { passive: true }));
} }

View File

@@ -3,7 +3,7 @@ import {SUNE_LOGO_SVG} from './sune-logo.js';
import {STICKY_SUNES} from './sticky-sunes.js'; import {STICKY_SUNES} from './sticky-sunes.js';
import {generateTitleWithAI} from './title-generator.js'; import {generateTitleWithAI} from './title-generator.js';
import { el } from './dom.js'; 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 { clamp, num, int, gid, esc, positionPopover, sid, fmtSize, asDataURL, imgToWebp, b64, utob, btou, dl, ts, partsToText, copyToClipboard } from './utils.js';
import { ghApi, parseGhUrl, ghGetFileContent } from './github.js'; import { ghApi, parseGhUrl, ghGetFileContent } from './github.js';
import { USER } from './user.js'; import { USER } from './user.js';
import { md, enhanceCodeBlocks, renderMarkdown } from './markdown.js'; import { md, enhanceCodeBlocks, renderMarkdown } from './markdown.js';
@@ -17,27 +17,67 @@ const DEFAULT_MODEL='openrouter/free'
const icons=()=>window.lucide&&lucide.createIcons() const icons=()=>window.lucide&&lucide.createIcons()
const haptic=()=>/android/i.test(navigator.userAgent)&&navigator.vibrate?.(1) const haptic=()=>/android/i.test(navigator.userAgent)&&navigator.vibrate?.(1)
const SYSTEM_KEYS=new Set(['sunes_v1','active_sune_id','thread_repo_url','sune_repo_url','sunes_updated_at','user_name','user_avatar','provider','openrouter_api_key','openai_api_key','google_api_key','claude_api_key','cloudflare_api_key','master_prompt','title_model','gh_token','custom_key_1'])
const gcStorage=()=>{try{const alive=new Set(sunes.map(s=>s.id));Object.keys(localStorage).forEach(k=>{if(SYSTEM_KEYS.has(k)||/^(t_|rem_t_|rem_index_|localforage|threads_)/.test(k))return;const m=k.match(/^sune_([^_]+)_/);if(m&&alive.has(m[1]))return;localStorage.removeItem(k)})}catch{}}
const cleanSuneStorage=id=>{if(!id)return;const p=`sune_${id}_`;Object.keys(localStorage).forEach(k=>{if(k.startsWith(p))localStorage.removeItem(k)})}
let isSyncPulling=false,suneSyncStatus='idle',remoteSha=null;
const getLocalSunesUpdatedAt=()=>num(localStorage.getItem('sunes_updated_at'),0);
const setLocalSunesUpdatedAt=(ts=Date.now())=>localStorage.setItem('sunes_updated_at',ts);
const markLocalDirty=()=>{if(isSyncPulling)return;setLocalSunesUpdatedAt();setSuneSyncStatus('desynced')};
const isSuneStorageKey=k=>!SYSTEM_KEYS.has(k)&&/^sune_[^_]+_/.test(k);
const _origSetItem=localStorage.setItem.bind(localStorage),_origRemoveItem=localStorage.removeItem.bind(localStorage);
localStorage.setItem=(k,v)=>{_origSetItem(k,v);if(!isSyncPulling&&isSuneStorageKey(k))markLocalDirty()};
localStorage.removeItem=k=>{_origRemoveItem(k);if(!isSyncPulling&&isSuneStorageKey(k))markLocalDirty()};
function setSuneSyncStatus(status){
suneSyncStatus=status;
const b=el.suneSyncBadge;
if(!b)return;
const u=(el.suneRepoInput?.value||'').trim();
if(!u.startsWith('gh://')){b.className='hidden';return}
b.className='absolute -top-1 -right-1 block h-2.5 w-2.5 rounded-full ring-2 ring-white';
switch(status){
case 'checking':b.classList.add('bg-blue-500','animate-pulse');break;
case 'synced':b.classList.add('bg-green-500');break;
case 'desynced':b.classList.add('bg-amber-500');break;
case 'error':b.classList.add('bg-red-500');break;
default:b.classList.add('bg-gray-400');
}
el.suneSyncBtn?.querySelector('svg')?.classList.toggle('animate-spin',status==='checking');
}
const suneStorage={
get(k,def=null){const id=SUNE.id;if(!id)return def;const v=localStorage.getItem(`sune_${id}_${k}`);if(v===null)return def;try{return JSON.parse(v)}catch{return v}},
set(k,v){const id=SUNE.id;if(id)localStorage.setItem(`sune_${id}_${k}`,JSON.stringify(v))},
remove(k){const id=SUNE.id;if(id)localStorage.removeItem(`sune_${id}_${k}`)},
clear(){const id=SUNE.id;if(id)cleanSuneStorage(id)},
keys(){const id=SUNE.id;if(!id)return[];const p=`sune_${id}_`;return Object.keys(localStorage).filter(k=>k.startsWith(p)).map(k=>k.slice(p.length))},
key(k){const id=SUNE.id;return id?`sune_${id}_${k}`:k}
}
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 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,img_output:false,aspect_ratio:'1:1',image_size:'1K',ignore_master_prompt:false} const defaultSettings={model:DEFAULT_MODEL,temperature:'',top_p:'',top_k:'',frequency_penalty:'',repetition_penalty:'',min_p:'',top_a:'',max_tokens:'',reasoning_effort:'',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,img_output:false,aspect_ratio:'1:1',image_size:'1K',ignore_master_prompt:false}
const makeSune=(p={})=>({id:p.id||gid(),name:p.name?.trim()||'Default',pinned:!!p.pinned,avatar:p.avatar||'',url:p.url||'',updatedAt:p.updatedAt||Date.now(),settings:Object.assign({},defaultSettings,p.settings||{}),storage:p.storage||{}}) const makeSune=(p={})=>({id:p.id||gid(),name:p.name?.trim()||'Default',pinned:!!p.pinned,avatar:p.avatar||'',url:p.url||'',updatedAt:p.updatedAt||Date.now(),settings:Object.assign({},defaultSettings,p.settings||{})})
let sunes=(su.load()||[]).map(makeSune) let sunes=(su.load()||[]).map(makeSune)
const SUNE=window.SUNE=new Proxy({get list(){return sunes},get id(){return su.getActiveId()},get active(){return sunes.find(a=>a.id===su.getActiveId())||sunes[0]},get:id=>sunes.find(s=>s.id===id),setActive:id=>su.setActiveId(id||''),create(p={}){const s=makeSune(p);sunes.unshift(s);su.save(sunes);return s},delete(id){const curId=this.id;sunes=sunes.filter(s=>s.id!==id);su.save(sunes);if(sunes.length===0){const def=this.create({name:'Default'});this.setActive(def.id)}else if(curId===id)this.setActive(sunes[0].id)},save:()=>su.save(sunes)},{get(t,p){if(p==='fetchDotSune')return async g=>{try{const u=g.startsWith('http')?g:(()=>{const[a,b]=g.split('@'),[c,d]=a.split('/'),[e,...f]=b.split('/');return`https://raw.githubusercontent.com/${c}/${d}/${e}/${f.join('/')}`})(),j=await(await fetch(u)).json(),l=sunes.length;sunes.unshift(...(Array.isArray(j)?j:j?.sunes||[]).filter(s=>s?.id&&!t.get(s.id)).map(s=>makeSune(s)));sunes.length>l&&t.save()}catch{}};if(p==='attach')return async files=>{const arr=[];for(const f of files||[])arr.push(await toAttach(f));const clean=arr.filter(Boolean);if(!clean.length)return;await ensureThreadOnFirstUser('(attachments)');addMessage({role:'assistant',content:clean,...activeMeta()});await THREAD.persist()};if(p==='log')return async s=>{const t=String(s??'').trim();if(!t)return;await ensureThreadOnFirstUser(t);addMessage({role:'assistant',content:[{type:'text',text:t}],...activeMeta()});await THREAD.persist()};if(p==='lastReply')return [...state.messages].reverse().find(m=>m.role==='assistant');if(p==='infer')return async()=>{if(state.busy||!SUNE.model||state.abortRequested){state.abortRequested=false;return};await ensureThreadOnFirstUser('Sune Inference');const th=THREAD.active;if(th&&!th.title)(async()=>THREAD.setTitle(th.id,await generateTitleWithAI(state.messages)||'Sune Inference'))();state.busy=true;setBtnStop();const 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:null,bubble:null,meta:null,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;assistantMsg.content[0].text=buf;renderMarkdown(suneBubble,partsToText(assistantMsg),{enhance:false});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)};if(p==='getByName')return n=>sunes.find(s=>s.name.toLowerCase()===(n||'').trim().toLowerCase());if(p==='handoff')return async n=>{await new Promise(r=>setTimeout(r,4000));const s=sunes.find(s=>s.name.toLowerCase()===(n||'').trim().toLowerCase());if(!s)return;SUNE.setActive(s.id);renderSidebar();await reflectActiveSune();await SUNE.infer()};if(p in t)return t[p];const a=t.active;if(!a)return;if(p in a.settings)return a.settings[p];if(p in a)return a[p]},set(t,p,v){const a=t.active;if(!a)return false;const i=sunes.findIndex(s=>s.id===a.id);if(i<0)return false;const isTopLevel=/^(name|avatar|url|pinned|storage)$/.test(p),target=isTopLevel?sunes[i]:sunes[i].settings;let value=v;if(!isTopLevel){if(p==='system_prompt')value=v||''}if(target[p]!==value){target[p]=value;sunes[i].updatedAt=Date.now();su.save(sunes)}return true}}) const SUNE=window.SUNE=new Proxy({storage:suneStorage,get list(){return sunes},get id(){return su.getActiveId()},get active(){return sunes.find(a=>a.id===su.getActiveId())||sunes[0]},get:id=>sunes.find(s=>s.id===id),setActive:id=>su.setActiveId(id||''),create(p={}){const s=makeSune(p);sunes.unshift(s);su.save(sunes);markLocalDirty();return s},delete(id){const curId=this.id;sunes=sunes.filter(s=>s.id!==id);su.save(sunes);cleanSuneStorage(id);gcStorage();markLocalDirty();if(sunes.length===0){const def=this.create({name:'Default'});this.setActive(def.id)}else if(curId===id)this.setActive(sunes[0].id)},save:()=>su.save(sunes)},{get(t,p){if(p==='fetchDotSune')return async g=>{try{const u=g.startsWith('http')?g:(()=>{const[a,b]=g.split('@'),[c,d]=a.split('/'),[e,...f]=b.split('/');return`https://raw.githubusercontent.com/${c}/${d}/${e}/${f.join('/')}`})(),j=await(await fetch(u)).json(),l=sunes.length;sunes.unshift(...(Array.isArray(j)?j:j?.sunes||[]).filter(s=>s?.id&&!t.get(s.id)).map(s=>makeSune(s)));sunes.length>l&&t.save()}catch{}};if(p==='attach')return async files=>{const arr=[];for(const f of files||[])arr.push(await toAttach(f));const clean=arr.filter(Boolean);if(!clean.length)return;await ensureThreadOnFirstUser('(attachments)');addMessage({role:'assistant',content:clean,...activeMeta()});await THREAD.persist()};if(p==='log')return async s=>{const t=String(s??'').trim();if(!t)return;await ensureThreadOnFirstUser(t);addMessage({role:'assistant',content:[{type:'text',text:t}],...activeMeta()});await THREAD.persist()};if(p==='lastReply')return [...state.messages].reverse().find(m=>m.role==='assistant');if(p==='infer')return async()=>{if(state.busy||!SUNE.model||state.abortRequested){state.abortRequested=false;return};await ensureThreadOnFirstUser('Sune Inference');const th=THREAD.active;if(th&&!th.title)(async()=>THREAD.setTitle(th.id,await generateTitleWithAI(state.messages)||'Sune Inference'))();state.busy=true;setBtnStop();const 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:null,bubble:null,meta:null,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;assistantMsg.content[0].text=buf;renderMarkdown(suneBubble,partsToText(assistantMsg),{enhance:false});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)};if(p==='getByName')return n=>sunes.find(s=>s.name.toLowerCase()===(n||'').trim().toLowerCase());if(p==='handoff')return async n=>{await new Promise(r=>setTimeout(r,4000));const s=sunes.find(s=>s.name.toLowerCase()===(n||'').trim().toLowerCase());if(!s)return;SUNE.setActive(s.id);renderSidebar();await reflectActiveSune();await SUNE.infer()};if(p in t)return t[p];const a=t.active;if(!a)return;if(p in a.settings)return a.settings[p];if(p in a)return a[p]},set(t,p,v){const a=t.active;if(!a)return false;const i=sunes.findIndex(s=>s.id===a.id);if(i<0)return false;const isTopLevel=/^(name|avatar|url|pinned)$/.test(p),target=isTopLevel?sunes[i]:sunes[i].settings;let value=v;if(!isTopLevel){if(p==='system_prompt')value=v||''}if(target[p]!==value){target[p]=value;sunes[i].updatedAt=Date.now();su.save(sunes);markLocalDirty()}return true}})
if(!sunes.length){const def=SUNE.create({name:'Default'});SUNE.setActive(def.id)} 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 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 getModelShort=m=>{const mm=m||SUNE.model||'';return mm.includes('/')?mm.split('/').pop():mm}
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 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-10 w-10 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 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()} const renderSidebar=window.renderSidebar=()=>{const list=[...SUNE.list].sort((a,b)=>(b.pinned-a.pinned));el.suneList.innerHTML=list.map(suneRow).join('');icons()}
const renderUserUI=window.renderUserUI=()=>{if(!el.userMenuAvatar)return;const a=USER.avatar;if(a){el.userMenuAvatar.className='h-6 w-6 rounded-full overflow-hidden shrink-0';el.userMenuAvatar.innerHTML=`<img src="${esc(a)}" class="h-full w-full object-cover"/>`}else{el.userMenuAvatar.className='h-6 w-6 rounded-full bg-gray-900 text-white flex items-center justify-center shrink-0 text-xs';el.userMenuAvatar.textContent='👤'}}
const getSuneLabel=m=>{const name=(m&&m.sune_name)||SUNE.name,modelShort=getModelShort(m&&m.model);return `${name} · ${modelShort}`} 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 _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();const cur=state.messages.find(x=>x.id===m.id)||m;if(await copyToClipboard(partsToText(cur,true))){$(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)}});$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]} 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 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 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 addSuneBubbleStreaming=(meta,id)=>msgRow(Object.assign({role:'assistant',id},meta))
const clearChat=()=>{el.suneHtml.dispatchEvent(new CustomEvent('sune:unmount'));state.messages=[];el.messages.innerHTML='';state.attachments=[];updateAttachBadge();el.fileInput.value=''} const clearChat=()=>{el.suneHtml.dispatchEvent(new CustomEvent('sune:unmount'));state.messages=[];el.messages.innerHTML='';state.attachments=[];updateAttachBadge();el.fileInput.value=''}
const payloadWithSampling=b=>{const o=Object.assign({},b),s=SUNE,p={temperature:num(s.temperature,null),top_p:num(s.top_p,null),top_k:int(s.top_k,null),frequency_penalty:num(s.frequency_penalty,null),repetition_penalty:num(s.repetition_penalty,null),min_p:num(s.min_p,null),top_a:num(s.top_a,null)};Object.keys(p).forEach(k=>{const v=p[k];if(v!==null)o[k]=v});return o} const payloadWithSampling=b=>{const o=Object.assign({},b),s=SUNE,p={temperature:num(s.temperature,null),top_p:num(s.top_p,null),top_k:int(s.top_k,null),frequency_penalty:num(s.frequency_penalty,null),repetition_penalty:num(s.repetition_penalty,null),min_p:num(s.min_p,null),top_a:num(s.top_a,null),max_tokens:int(s.max_tokens,null)>0?int(s.max_tokens,null):null};Object.keys(p).forEach(k=>{const v=p[k];if(v!==null)o[k]=v});return o}
function setBtnStop(){const b=el.sendBtn;b.dataset.mode='stop';b.type='button';b.setAttribute('aria-label','Stop');b.innerHTML='<i data-lucide="square" class="h-5 w-5"></i>';icons();b.onclick=()=>{state.abortRequested=true;state.controller?.abort?.();state.busy=false;setBtnSend()}} 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 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.'} function localDemoReply(){return 'Tip: open the sidebar → User to set your API key.'}
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 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'}); 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()} 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()}
@@ -45,9 +85,8 @@ const threadRow=t=>{const icon=t.type==='folder'?'folder':(t.type==='file'?'file
let sortedThreads=[],isAddingThreads=false;const THREAD_PAGE_SIZE=50; let sortedThreads=[],isAddingThreads=false;const THREAD_PAGE_SIZE=50;
async function renderThreads(){ async function renderThreads(){
sortedThreads=[...THREAD.list].filter(t=>t.status!=='deleted').sort((a,b)=>{ sortedThreads=[...THREAD.list].filter(t=>t.status!=='deleted').sort((a,b)=>{
if(a.type==='file'&&b.type!=='file')return -1; const r=t=>t.type==='folder'?0:t.type==='file'?1:2;
if(a.type!=='file'&&b.type==='file')return 1; return (r(a)-r(b))||(b.pinned-a.pinned)||(b.updatedAt-a.updatedAt);
return (b.pinned-a.pinned)||(b.updatedAt-a.updatedAt);
}); });
el.threadList.innerHTML=sortedThreads.slice(0,THREAD_PAGE_SIZE).map(threadRow).join(''); el.threadList.innerHTML=sortedThreads.slice(0,THREAD_PAGE_SIZE).map(threadRow).join('');
el.threadList.scrollTop=0; el.threadList.scrollTop=0;
@@ -58,6 +97,9 @@ let menuThreadId=null;const hideThreadPopover=()=>{el.threadPopover.classList.ad
function showThreadPopover(btn,id){menuThreadId=id;el.threadPopover.classList.remove('hidden');positionPopover(btn,el.threadPopover);icons()} function showThreadPopover(btn,id){menuThreadId=id;el.threadPopover.classList.remove('hidden');positionPopover(btn,el.threadPopover);icons()}
let menuSuneId=null;const hideSunePopover=()=>{el.sunePopover.classList.add('hidden');menuSuneId=null} let menuSuneId=null;const hideSunePopover=()=>{el.sunePopover.classList.add('hidden');menuSuneId=null}
function showSunePopover(btn,id){menuSuneId=id;el.sunePopover.classList.remove('hidden');positionPopover(btn,el.sunePopover);icons()} function showSunePopover(btn,id){menuSuneId=id;el.sunePopover.classList.remove('hidden');positionPopover(btn,el.sunePopover);icons()}
const hideSunesSyncPopover=()=>{el.sunesSyncPopover.classList.add('hidden')},hideSuneSyncPopover=hideSunesSyncPopover;
function showSunesSyncPopover(btn){el.sunesSyncPopover.classList.remove('hidden');positionPopover(btn||el.suneSyncBtn,el.sunesSyncPopover);icons()}
const showSuneSyncPopover=showSunesSyncPopover;
$(el.threadList).on('click',async e=>{const openBtn=e.target.closest('[data-open-thread]'),menuBtn=e.target.closest('[data-thread-menu]');if(openBtn){const id=openBtn.getAttribute('data-open-thread'),type=openBtn.getAttribute('data-type');if(type==='file'){const u=el.threadRepoInput.value.trim();if(u.startsWith('gh://')){const info=parseGhUrl(u);window.open(`https://github.com/${info.owner}/${info.repo}/blob/${info.branch}/${id}`,'_blank')}return}if(type==='folder'){const u=el.threadRepoInput.value.trim();el.threadRepoInput.value=u+(u.endsWith('/')?'':'/')+id;el.threadRepoInput.dispatchEvent(new Event('change'));return}if(id!==state.currentThreadId&&state.busy){state.controller?.disconnect?.();setBtnSend();state.busy=false;state.controller=null}const th=THREAD.get(id);if(!th)return;if(id===state.currentThreadId){el.sidebarRight.classList.add('translate-x-full');el.sidebarOverlayRight.classList.add('hidden');hideThreadPopover();return}state.currentThreadId=id;clearChat();const u=el.threadRepoInput.value.trim(),prefix=u.startsWith('gh://')?'rem_t_':'t_';let msgs=await localforage.getItem(prefix+id);if((!msgs||!Array.isArray(msgs)||!msgs.length)&&u.startsWith('gh://')){try{const info=parseGhUrl(u),fileName=serializeThreadName(th),text=await ghGetFileContent(info,fileName);if(text){try{msgs=JSON.parse(text);await localforage.setItem(prefix+id,msgs);th.status='synced';await THREAD.save()}catch(pe){console.error('[Sune] Thread JSON parse failed for',fileName,'len',text.length,pe)}}else{console.warn('[Sune] Remote thread returned no content:',fileName)}}catch(e){console.error('[Sune] Remote fetch failed',e)}}state.messages=Array.isArray(msgs)?[...msgs]:[];for(const m of state.messages){const b=msgRow(m);b.dataset.mid=m.id||'';renderMarkdown(b,partsToText(m))}await renderSuneHTML();syncWhileBusy();queueMicrotask(()=>el.chat.scrollTo({top:el.chat.scrollHeight,behavior:'smooth'}));el.sidebarRight.classList.add('translate-x-full');el.sidebarOverlayRight.classList.add('hidden');hideThreadPopover();return}if(menuBtn){e.stopPropagation();showThreadPopover(menuBtn,menuBtn.getAttribute('[data-thread-menu]')?menuBtn.getAttribute('[data-thread-menu]'):menuBtn.getAttribute('data-thread-menu'))}}) $(el.threadList).on('click',async e=>{const openBtn=e.target.closest('[data-open-thread]'),menuBtn=e.target.closest('[data-thread-menu]');if(openBtn){const id=openBtn.getAttribute('data-open-thread'),type=openBtn.getAttribute('data-type');if(type==='file'){const u=el.threadRepoInput.value.trim();if(u.startsWith('gh://')){const info=parseGhUrl(u);window.open(`https://github.com/${info.owner}/${info.repo}/blob/${info.branch}/${id}`,'_blank')}return}if(type==='folder'){const u=el.threadRepoInput.value.trim();el.threadRepoInput.value=u+(u.endsWith('/')?'':'/')+id;el.threadRepoInput.dispatchEvent(new Event('change'));return}if(id!==state.currentThreadId&&state.busy){state.controller?.disconnect?.();setBtnSend();state.busy=false;state.controller=null}const th=THREAD.get(id);if(!th)return;if(id===state.currentThreadId){el.sidebarRight.classList.add('translate-x-full');el.sidebarOverlayRight.classList.add('hidden');hideThreadPopover();return}state.currentThreadId=id;clearChat();const u=el.threadRepoInput.value.trim(),prefix=u.startsWith('gh://')?'rem_t_':'t_';let msgs=await localforage.getItem(prefix+id);if((!msgs||!Array.isArray(msgs)||!msgs.length)&&u.startsWith('gh://')){try{const info=parseGhUrl(u),fileName=serializeThreadName(th),text=await ghGetFileContent(info,fileName);if(text){try{msgs=JSON.parse(text);await localforage.setItem(prefix+id,msgs);th.status='synced';await THREAD.save()}catch(pe){console.error('[Sune] Thread JSON parse failed for',fileName,'len',text.length,pe)}}else{console.warn('[Sune] Remote thread returned no content:',fileName)}}catch(e){console.error('[Sune] Remote fetch failed',e)}}state.messages=Array.isArray(msgs)?[...msgs]:[];for(const m of state.messages){const b=msgRow(m);b.dataset.mid=m.id||'';renderMarkdown(b,partsToText(m))}await renderSuneHTML();syncWhileBusy();queueMicrotask(()=>el.chat.scrollTo({top:el.chat.scrollHeight,behavior:'smooth'}));el.sidebarRight.classList.add('translate-x-full');el.sidebarOverlayRight.classList.add('hidden');hideThreadPopover();return}if(menuBtn){e.stopPropagation();showThreadPopover(menuBtn,menuBtn.getAttribute('[data-thread-menu]')?menuBtn.getAttribute('[data-thread-menu]'):menuBtn.getAttribute('data-thread-menu'))}})
$(el.threadList).on('scroll',()=>{ $(el.threadList).on('scroll',()=>{
if(isAddingThreads||el.threadList.scrollTop+el.threadList.clientHeight<el.threadList.scrollHeight-200)return; if(isAddingThreads||el.threadList.scrollTop+el.threadList.clientHeight<el.threadList.scrollHeight-200)return;
@@ -71,13 +113,24 @@ $(el.threadList).on('scroll',()=>{
} }
isAddingThreads=false; isAddingThreads=false;
}); });
$(el.threadPopover).on('click',async e=>{const act=e.target.closest('[data-action]')?.getAttribute('data-action');if(!act||!menuThreadId)return;const th=THREAD.get(menuThreadId);if(!th)return;const u=el.threadRepoInput.value.trim(),prefix=u.startsWith('gh://')?'rem_t_':'t_';if(act==='pin'){th.pinned=!th.pinned;if(u.startsWith('gh://')&&th.status!=='new')th.status='modified'}else if(act==='rename'){const nv=prompt('Rename to:',th.title);if(nv!=null){th.title=titleFrom(nv);th.updatedAt=Date.now();if(u.startsWith('gh://')&&th.status!=='new')th.status='modified'}}else if(act==='duplicate'){const newId=gid(),msgs=await localforage.getItem(prefix+th.id)||[];const newTh={...th,id:newId,title:th.title+' (Copy)',updatedAt:Date.now()};if(u.startsWith('gh://'))newTh.status='new';THREAD.list.unshift(newTh);await localforage.setItem(prefix+newId,msgs);await THREAD.save();await renderThreads()}else if(act==='delete'){if(confirm('Delete this chat?')){if(u.startsWith('gh://')){th.status='deleted';th.updatedAt=Date.now()}else{THREAD.list=THREAD.list.filter(x=>!th.id!==th.id);await localforage.removeItem(prefix+th.id)}if(state.currentThreadId===th.id){state.currentThreadId=null;clearChat()}}}else if(act==='count_tokens'){const msgs=await localforage.getItem(prefix+th.id)||[];let totalChars=0;for(const m of msgs){if(!m||!m.role||m.role==='system')continue;totalChars+=String(partsToText(m)||'').length}const tokens=Math.max(0,Math.ceil(totalChars/4));const k=tokens>=1000?Math.round(tokens/1000)+'k':String(tokens);alert(tokens+' tokens ('+k+')')}else if(act==='export'){const msgs=await localforage.getItem(prefix+th.id)||[];dl(`thread-${(th.title||'thread').replace(/\W/g,'_')}-${ts()}.json`,{...th,messages:msgs})}else if(act==='copy_path'){const u=el.threadRepoInput.value.trim();if(u.startsWith('gh://')){const info=parseGhUrl(u);try{await navigator.clipboard.writeText(`${info.owner}/${info.repo}@${info.branch}/${th.id}`);alert('Path copied.')}catch{}}}hideThreadPopover();await THREAD.save();renderThreads()}) $(el.threadPopover).on('click',async e=>{const act=e.target.closest('[data-action]')?.getAttribute('data-action');if(!act||!menuThreadId)return;const th=THREAD.get(menuThreadId);if(!th)return;const u=el.threadRepoInput.value.trim(),prefix=u.startsWith('gh://')?'rem_t_':'t_';if(act==='pin'){th.pinned=!th.pinned;if(u.startsWith('gh://')&&th.status!=='new')th.status='modified';}else if(act==='rename'){const nv=prompt('Rename to:',th.title);if(nv!=null){th.title=titleFrom(nv);th.updatedAt=Date.now();if(u.startsWith('gh://')&&th.status!=='new')th.status='modified'}}else if(act==='duplicate'){const newId=gid(),msgs=await localforage.getItem(prefix+th.id)||[];const newTh={...th,id:newId,title:th.title+' (Copy)',updatedAt:Date.now()};if(u.startsWith('gh://'))newTh.status='new';THREAD.list.unshift(newTh);await localforage.setItem(prefix+newId,msgs);await THREAD.save();await renderThreads()}else if(act==='delete'){if(confirm('Delete this chat?')){if(u.startsWith('gh://')){th.status='deleted';th.updatedAt=Date.now()}else{THREAD.list=THREAD.list.filter(x=>!th.id!==th.id);await localforage.removeItem(prefix+th.id)}if(state.currentThreadId===th.id){state.currentThreadId=null;clearChat()}}}else if(act==='count_tokens'){const msgs=await localforage.getItem(prefix+th.id)||[];let totalChars=0;for(const m of msgs){if(!m||!m.role||m.role==='system')continue;totalChars+=String(partsToText(m)||'').length}const tokens=Math.max(0,Math.ceil(totalChars/4));const k=tokens>=1000?Math.round(tokens/1000)+'k':String(tokens);alert(tokens+' tokens ('+k+')')}else if(act==='export'){const msgs=await localforage.getItem(prefix+th.id)||[];dl(`thread-${(th.title||'thread').replace(/\W/g,'_')}-${ts()}.json`,{...th,messages:msgs})}else if(act==='copy_path'){const u=el.threadRepoInput.value.trim();if(u.startsWith('gh://')){const info=parseGhUrl(u);if(await copyToClipboard(`${info.owner}/${info.repo}@${info.branch}/${th.id}`))alert('Path copied.')}}hideThreadPopover();await THREAD.save();renderThreads()})
$(el.suneList).on('click',async e=>{const menuBtn=e.target.closest('[data-sune-menu]');if(menuBtn){e.stopPropagation();showSunePopover(menuBtn,menuBtn.getAttribute('[data-sune-menu]')?menuBtn.getAttribute('[data-sune-menu]'):menuBtn.getAttribute('data-sune-menu'));return}const btn=e.target.closest('[data-sune-id]');if(!btn)return;const id=btn.getAttribute('data-sune-id');if(id){if(state.busy){state.controller?.disconnect?.();setBtnSend();state.busy=false;state.controller=null};SUNE.setActive(id);renderSidebar();await reflectActiveSune();state.currentThreadId=null;clearChat();document.getElementById('sidebarLeft').classList.add('-translate-x-full');document.getElementById('sidebarOverlayLeft').classList.add('hidden')}}) $(el.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()}) $(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();markLocalDirty()};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()})
$(el.sunesSyncUploadBtn).on('click',()=>{hideSunesSyncPopover();performSuneUpload()});
$(el.sunesSyncDownloadBtn).on('click',()=>{hideSunesSyncPopover();performSuneDownload(false)});
function updateAttachBadge(){const n=state.attachments.length;el.attachBadge.textContent=String(n);el.attachBadge.classList.toggle('hidden',n===0)} function updateAttachBadge(){const n=state.attachments.length;el.attachBadge.textContent=String(n);el.attachBadge.classList.toggle('hidden',n===0)}
$(el.attachBtn).on('click',()=>{if(state.busy)return;if(state.attachments.length){state.attachments=[];updateAttachBadge();el.fileInput.value=''};el.fileInput.click()}) $(el.attachBtn).on('click',()=>{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.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;assistantMsg.content[0].text=buf;renderMarkdown(suneBubble,partsToText(assistantMsg),{enhance:false});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()}) $(el.composer).on('paste',async e=>{
const cbd=e.clipboardData||e.originalEvent?.clipboardData,files=[...(cbd?.files||[])];
if(!files.length)return;
e.preventDefault();
state.attachments=[];
el.fileInput.value='';
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);state.attachments=[];updateAttachBadge();el.fileInput.value='';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.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;assistantMsg.content[0].text=buf;renderMarkdown(suneBubble,partsToText(assistantMsg),{enhance:false});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)})
let jars={html:null,extension:null}; let jars={html:null,extension:null};
const ensureJars=async()=>{ const ensureJars=async()=>{
if(jars.html&&jars.extension)return jars; if(jars.html&&jars.extension)return jars;
@@ -91,7 +144,7 @@ const ensureJars=async()=>{
return jars; return jars;
} }
let openedHTML=false let openedHTML=false
function openSettings(){const a=SUNE.active,s=a.settings;openedHTML=false;el.suneURL.value=a.url||'';el.set_model.value=s.model;el.set_temperature.value=s.temperature;el.set_top_p.value=s.top_p;el.set_top_k.value=s.top_k;el.set_frequency_penalty.value=s.frequency_penalty;el.set_repetition_penalty.value=s.repetition_penalty;el.set_min_p.value=s.min_p;el.set_top_a.value=s.top_a;el.set_verbosity.value=s.verbosity||'';el.set_reasoning_effort.value=s.reasoning_effort||'default';el.set_system_prompt.value=s.system_prompt;el.set_hide_composer.checked=!!s.hide_composer;el.set_img_output.checked=!!s.img_output;el.set_aspect_ratio.value=s.aspect_ratio||'1:1';el.set_image_size.value=s.image_size||'1K';el.aspectRatioContainer.classList.toggle('hidden',!s.img_output);el.set_include_thoughts.checked=!!s.include_thoughts;el.set_ignore_master_prompt.checked=!!s.ignore_master_prompt;showTab('Model');el.suneModal.classList.remove('hidden')} function openSettings(){const a=SUNE.active,s=a.settings;openedHTML=false;el.suneURL.value=a.url||'';el.set_model.value=s.model;el.set_temperature.value=s.temperature;el.set_top_p.value=s.top_p;el.set_top_k.value=s.top_k;el.set_frequency_penalty.value=s.frequency_penalty;el.set_repetition_penalty.value=s.repetition_penalty;el.set_min_p.value=s.min_p;el.set_top_a.value=s.top_a;el.set_max_tokens.value=s.max_tokens||'';el.set_reasoning_effort.value=s.reasoning_effort||'';el.set_system_prompt.value=s.system_prompt;el.set_hide_composer.checked=!!s.hide_composer;el.set_img_output.checked=!!s.img_output;el.set_aspect_ratio.value=s.aspect_ratio||'1:1';el.set_image_size.value=s.image_size||'1K';el.aspectRatioContainer.classList.toggle('hidden',!s.img_output);el.set_include_thoughts.checked=!!s.include_thoughts;el.set_ignore_master_prompt.checked=!!s.ignore_master_prompt;showTab('Model');el.suneModal.classList.remove('hidden')}
const closeSettings=()=>{el.suneModal.classList.add('hidden')} const closeSettings=()=>{el.suneModal.classList.add('hidden')}
const tabs={Model:['tabModel','panelModel'],Prompt:['tabPrompt','panelPrompt'],Script:['tabScript','panelScript']} const tabs={Model:['tabModel','panelModel'],Prompt:['tabPrompt','panelPrompt'],Script:['tabScript','panelScript']}
function showTab(key){Object.entries(tabs).forEach(([k,[tb,pn]])=>{el[tb].classList.toggle('border-black',k===key);el[pn].classList.toggle('hidden',k!==key)});if(key==='Script'){openedHTML=true;showHtmlTab('index');ensureJars().then(({html,extension})=>{const s=SUNE.settings;html.updateCode(s.html||'');extension.updateCode(s.extension_html||'')})}} function showTab(key){Object.entries(tabs).forEach(([k,[tb,pn]])=>{el[tb].classList.toggle('border-black',k===key);el[pn].classList.toggle('hidden',k!==key)});if(key==='Script'){openedHTML=true;showHtmlTab('index');ensureJars().then(({html,extension})=>{const s=SUNE.settings;html.updateCode(s.html||'');extension.updateCode(s.extension_html||'')})}}
@@ -102,7 +155,7 @@ $(el.tabModel).on('click',()=>showTab('Model'))
$(el.tabPrompt).on('click',()=>showTab('Prompt')) $(el.tabPrompt).on('click',()=>showTab('Prompt'))
$(el.tabScript).on('click',()=>showTab('Script')) $(el.tabScript).on('click',()=>showTab('Script'))
$(el.set_img_output).on('change',e=>el.aspectRatioContainer.classList.toggle('hidden',!e.target.checked)) $(el.set_img_output).on('change',e=>el.aspectRatioContainer.classList.toggle('hidden',!e.target.checked))
$(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.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;if(openedHTML){SUNE.html=jars.html.toString();SUNE.extension_html=jars.extension.toString()}closeSettings();await reflectActiveSune()}) $(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','max_tokens'].forEach(k=>SUNE[k]=el[`set_${k}`].value.trim());SUNE.reasoning_effort=(el.set_reasoning_effort.value||'');SUNE.system_prompt=el.set_system_prompt.value.trim();SUNE.hide_composer=el.set_hide_composer.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;if(openedHTML){SUNE.html=jars.html.toString();SUNE.extension_html=jars.extension.toString()}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.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')}) $(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')})
let importMode=null let importMode=null
@@ -142,8 +195,9 @@ USER.logMany = async msgs => {
await THREAD.persist(); 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()} async function init(){gcStorage();const u=localStorage.getItem('thread_repo_url')||'',suR=localStorage.getItem('sune_repo_url')||'';el.threadRepoInput.value=u;el.suneRepoInput.value=suR;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();renderUserUI();await reflectActiveSune();if(suR.startsWith('gh://'))checkSuneSyncStatus();clearChat();icons();kbBind();kbUpdate()}
$(window).on('resize',()=>{hideThreadPopover();hideSunePopover()}) $(window).on('resize',()=>{hideThreadPopover();hideSunePopover();hideSunesSyncPopover()})
$(document).on('click',e=>{if(el.sunesSyncPopover&&!el.sunesSyncPopover.classList.contains('hidden')&&!el.sunesSyncPopover.contains(e.target)&&!el.suneSyncBtn.contains(e.target))hideSunesSyncPopover()});
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)})} 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)})}
el.htmlTab_index.textContent='index.html';el.htmlTab_extension.textContent='extension.html'; el.htmlTab_index.textContent='index.html';el.htmlTab_extension.textContent='extension.html';
el.htmlTab_index.onclick=()=>showHtmlTab('index');el.htmlTab_extension.onclick=()=>showHtmlTab('extension'); el.htmlTab_index.onclick=()=>showHtmlTab('index');el.htmlTab_extension.onclick=()=>showHtmlTab('extension');
@@ -151,31 +205,154 @@ const pullThreads=async()=>{const u=el.threadRepoInput.value.trim();if(!u.starts
$(el.threadRepoInput).on('change',async()=>{const u=el.threadRepoInput.value.trim();localStorage.setItem('thread_repo_url',u);if(state.currentThreadId){state.currentThreadId=null;clearChat()}el.threadFolderBtn.classList.toggle('hidden',!u.startsWith('gh://'));el.threadBackBtn.classList.toggle('hidden',!u.startsWith('gh://')||u.split('/').length<=3);if(u.startsWith('gh://'))await pullThreads();else{await THREAD.load();await renderThreads()}}); $(el.threadRepoInput).on('change',async()=>{const u=el.threadRepoInput.value.trim();localStorage.setItem('thread_repo_url',u);if(state.currentThreadId){state.currentThreadId=null;clearChat()}el.threadFolderBtn.classList.toggle('hidden',!u.startsWith('gh://'));el.threadBackBtn.classList.toggle('hidden',!u.startsWith('gh://')||u.split('/').length<=3);if(u.startsWith('gh://'))await pullThreads();else{await THREAD.load();await renderThreads()}});
$(el.threadBackBtn).on('click',()=>{const u=el.threadRepoInput.value.trim();if(!u.startsWith('gh://'))return;const p=u.split('/');if(p.length>3){p.pop();el.threadRepoInput.value=p.join('/');el.threadRepoInput.dispatchEvent(new Event('change'))}}); $(el.threadBackBtn).on('click',()=>{const u=el.threadRepoInput.value.trim();if(!u.startsWith('gh://'))return;const p=u.split('/');if(p.length>3){p.pop();el.threadRepoInput.value=p.join('/');el.threadRepoInput.dispatchEvent(new Event('change'))}});
$(el.threadFolderBtn).on('click',async()=>{const n=prompt('Folder name:');if(!n)return;THREAD.list.unshift({id:n.trim(),title:n.trim(),type:'folder',updatedAt:Date.now()});await THREAD.save();await renderThreads()}); $(el.threadFolderBtn).on('click',async()=>{const n=prompt('Folder name:');if(!n)return;THREAD.list.unshift({id:n.trim(),title:n.trim(),type:'folder',updatedAt:Date.now()});await THREAD.save();await renderThreads()});
$(el.threadSyncBtn).on('click',async()=>{const u=el.threadRepoInput.value.trim();if(!u.startsWith('gh://'))return;const mode=confirm('Sync Threads:\nOK = Upload (Push)\nCancel = Download (Pull)');const info=parseGhUrl(u);try{if(mode){const remoteItems=await ghApi(`${info.apiPath}?ref=${info.branch}`)||[],remoteMap={};remoteItems.forEach(i=>{const d=deserializeThreadName(i.name);if(d)remoteMap[d.id]={name:i.name,sha:i.sha}});const toRemove=[];for(const t of THREAD.list){if(t.status==='deleted'){if(remoteMap[t.id]){await ghApi(`${info.apiPath}/${remoteMap[t.id].name}`,'DELETE',{message:`Delete thread ${t.id}`,sha:remoteMap[t.id].sha,branch:info.branch});await localforage.removeItem('rem_t_'+t.id)}toRemove.push(t.id);continue}if(t.type!=='thread')continue;if(t.status==='modified'||t.status==='new'){const newName=serializeThreadName(t),msgs=await localforage.getItem('rem_t_'+t.id);if(remoteMap[t.id]&&remoteMap[t.id].name!==newName){await ghApi(`${info.apiPath}/${remoteMap[t.id].name}`,'DELETE',{message:`Rename thread ${t.id}`,sha:remoteMap[t.id].sha,branch:info.branch})}const x=await ghApi(`${info.apiPath}/${newName}?ref=${info.branch}`);await ghApi(`${info.apiPath}/${newName}`,'PUT',{message:`Sync thread ${t.id}`,content:utob(JSON.stringify(msgs,null,2)),branch:info.branch,sha:x?.sha});t.status='synced'}}THREAD.list=THREAD.list.filter(x=>!toRemove.includes(x.id));await THREAD.save();alert('Pushed to GitHub.')}else{await pullThreads();alert('Pulled from GitHub.')}await renderThreads()}catch(e){alert('Sync failed: '+e.message)}}); $(el.threadSyncBtn).on('click',async()=>{const u=el.threadRepoInput.value.trim();if(!u.startsWith('gh://'))return;const mode=confirm('Sync Threads:\nOK = Upload (Push)\nCancel = Download (Pull)');const info=parseGhUrl(u);try{if(mode){const remoteItems=await ghApi(`${info.apiPath}?ref=${info.branch}`)||[],remoteMap={};remoteItems.forEach(i=>{const d=deserializeThreadName(i.name);if(d)remoteMap[d.id]={name:i.name,sha:i.sha}});const toRemove=[];for(const t of THREAD.list){if(t.status==='deleted'){if(remoteMap[t.id]){await ghApi(`${info.apiPath}/${remoteMap[t.id].name}`,'DELETE',{message:`Delete thread ${t.id}`,sha:remoteMap[t.id].sha,branch:info.branch});await localforage.removeItem('rem_t_'+t.id)}toRemove.push(t.id);continue}if(t.type!=='thread')continue;if(t.status==='modified'||t.status==='new'){const newName=serializeThreadName(t);let msgs=await localforage.getItem('rem_t_'+t.id);if((!msgs||!Array.isArray(msgs))&&remoteMap[t.id]){const text=await ghGetFileContent(info,remoteMap[t.id].name);if(text){try{msgs=JSON.parse(text);await localforage.setItem('rem_t_'+t.id,msgs)}catch(e){console.error(e)}}}if(remoteMap[t.id]&&remoteMap[t.id].name!==newName){await ghApi(`${info.apiPath}/${remoteMap[t.id].name}`,'DELETE',{message:`Rename thread ${t.id}`,sha:remoteMap[t.id].sha,branch:info.branch})}const x=await ghApi(`${info.apiPath}/${newName}?ref=${info.branch}`);await ghApi(`${info.apiPath}/${newName}`,'PUT',{message:`Sync thread ${t.id}`,content:utob(JSON.stringify(msgs||[],null,2)),branch:info.branch,sha:x?.sha});t.status='synced'}}THREAD.list=THREAD.list.filter(x=>!toRemove.includes(x.id));await THREAD.save();alert('Pushed to GitHub.')}else{await pullThreads();alert('Pulled from GitHub.')}await renderThreads()}catch(e){alert('Sync failed: '+e.message)}});
let suneSyncBusy=false;
const checkSuneSyncStatus=async()=>{
const u=el.suneRepoInput.value.trim();
if(!u.startsWith('gh://'))return setSuneSyncStatus('idle');
if(suneSyncBusy)return;
suneSyncBusy=true;
setSuneSyncStatus('checking');
const info=parseGhUrl(u);
try{
const res=await ghApi(`${info.apiPath}/sunes.json?ref=${info.branch}`);
if(!res){remoteSha=null;return setSuneSyncStatus('desynced')}
remoteSha=res.sha;
let remoteData=null;
if(res.content&&res.encoding==='base64'){try{remoteData=JSON.parse(btou(res.content))}catch{}}
if(!remoteData&&res.sha){
const text=await ghGetFileContent(info,'sunes.json');
if(text){try{remoteData=JSON.parse(text)}catch{}}
}
if(!remoteData)return setSuneSyncStatus('error');
const remoteUp=num(remoteData.updatedAt,0),localUp=getLocalSunesUpdatedAt(),diff=remoteUp-localUp;
if(diff>10000)await performSuneDownload(true,remoteData,res.sha);
else if(diff<-10000)setSuneSyncStatus('desynced');
else setSuneSyncStatus('synced');
}catch{
setSuneSyncStatus('error');
}finally{
suneSyncBusy=false;
}
};
const performSuneDownload=async(isAuto=false,prefData=null,prefSha=null)=>{
const u=el.suneRepoInput.value.trim();
if(!u.startsWith('gh://'))return;
if(!isAuto&&!confirm('Overwrite local sunes with remote version from GitHub?'))return;
setSuneSyncStatus('checking');
const info=parseGhUrl(u);
try{
let data=prefData,sha=prefSha;
if(!data){
const res=await ghApi(`${info.apiPath}/sunes.json?ref=${info.branch}`);
if(!res)throw new Error('sunes.json not found');
sha=res.sha;
if(res.content&&res.encoding==='base64'){try{data=JSON.parse(btou(res.content))}catch{}}
if(!data){
const text=await ghGetFileContent(info,'sunes.json');
if(!text)throw new Error('Could not read sunes.json');
data=JSON.parse(text);
}
}
if(!data)throw new Error('Invalid data');
isSyncPulling=true;
try{
if(Array.isArray(data.sunes)){
sunes=data.sunes.map(makeSune);
su.save(sunes);
const nextActive=data.activeId||(sunes.some(s=>s.id===su.getActiveId())?su.getActiveId():sunes[0]?.id);
if(nextActive)SUNE.setActive(nextActive);
}
if(data.storage&&typeof data.storage==='object'){
Object.keys(localStorage).forEach(k=>{if(isSuneStorageKey(k))localStorage.removeItem(k)});
Object.entries(data.storage).forEach(([k,v])=>{if(isSuneStorageKey(k))localStorage.setItem(k,v)});
}
setLocalSunesUpdatedAt(num(data.updatedAt,Date.now()));
remoteSha=sha;
}finally{
isSyncPulling=false;
}
renderSidebar();
await reflectActiveSune();
setSuneSyncStatus('synced');
if(!isAuto)alert('Sunes pulled.');
}catch(e){
setSuneSyncStatus('error');
if(!isAuto)alert('Pull failed: '+e.message);
}
};
const performSuneUpload=async()=>{
const u=el.suneRepoInput.value.trim();
if(!u.startsWith('gh://'))return;
if(!confirm('Overwrite remote file on GitHub with local version?'))return;
setSuneSyncStatus('checking');
const info=parseGhUrl(u);
try{
const now=Date.now();
const data={version:1,updatedAt:now,sunes:SUNE.list,activeId:SUNE.id,storage:{}};
SUNE.list.forEach(s=>{
const p=`sune_${s.id}_`;
Object.keys(localStorage).forEach(k=>{if(k.startsWith(p))data.storage[k]=localStorage.getItem(k)});
});
const x=await ghApi(`${info.apiPath}/sunes.json?ref=${info.branch}`);
const res=await ghApi(`${info.apiPath}/sunes.json`,'PUT',{
message:'Sync Sunes',
content:utob(JSON.stringify(data,null,2)),
branch:info.branch,
sha:x?.sha
});
setLocalSunesUpdatedAt(now);
remoteSha=res?.content?.sha||null;
setSuneSyncStatus('synced');
alert('Sunes pushed.');
}catch(e){
setSuneSyncStatus('error');
alert('Push failed: '+e.message);
}
};
$(el.suneRepoInput).on('change',()=>{
localStorage.setItem('sune_repo_url',el.suneRepoInput.value.trim());
checkSuneSyncStatus();
});
$(el.suneSyncBtn).on('click',e=>{
e.stopPropagation();
const u=el.suneRepoInput.value.trim();
if(!u.startsWith('gh://'))return;
showSunesSyncPopover(el.suneSyncBtn);
});
$(el.sidebarBtnLeft).on('click',()=>{
if(el.suneRepoInput.value.trim().startsWith('gh://'))checkSuneSyncStatus();
});
init() init()
const accountTabs={General:['accountTabGeneral','accountPanelGeneral'],API:['accountTabAPI','accountPanelAPI'],User:['accountTabUser','accountPanelUser']};function showAccountTab(key){Object.entries(accountTabs).forEach(([k,[tb,pn]])=>{el[tb].classList.toggle('border-black',k===key);el[pn].classList.toggle('hidden',k!==key)})} const accountTabs={General:['accountTabGeneral','accountPanelGeneral'],API:['accountTabAPI','accountPanelAPI'],User:['accountTabUser','accountPanelUser']};function showAccountTab(key){Object.entries(accountTabs).forEach(([k,[tb,pn]])=>{el[tb].classList.toggle('border-black',k===key);el[pn].classList.toggle('hidden',k!==key)})}
function openAccountSettings(){el.set_provider.value=USER.provider||'openrouter';el.set_api_key_or.value=USER.apiKeyOpenRouter||'';el.set_api_key_oai.value=USER.apiKeyOpenAI||'';el.set_api_key_g.value=USER.apiKeyGoogle||'';el.set_api_key_claude.value=USER.apiKeyClaude||'';el.set_api_key_cf.value=USER.apiKeyCloudflare||'';el.set_api_key_custom1.value=USER.customKey1||'';el.set_master_prompt.value=USER.masterPrompt||'';el.set_title_model.value=USER.titleModel;el.set_gh_token.value=USER.githubToken||'';el.set_user_name.value=USER.name;el.userAvatarPreview.src=USER.avatar||'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=';el.userAvatarPreview.classList.toggle('bg-gray-200',!USER.avatar);showAccountTab('General');el.accountSettingsModal.classList.remove('hidden')} function openAccountSettings(){el.set_provider.value=USER.provider||'openrouter';el.set_api_key_or.value=USER.apiKeyOpenRouter||'';el.set_api_key_oai.value=USER.apiKeyOpenAI||'';el.set_api_key_g.value=USER.apiKeyGoogle||'';el.set_api_key_claude.value=USER.apiKeyClaude||'';el.set_api_key_cf.value=USER.apiKeyCloudflare||'';el.set_api_key_custom1.value=USER.customKey1||'';el.set_api_key_custom1.value=USER.customKey1||'';el.set_master_prompt.value=USER.masterPrompt||'';el.set_title_model.value=USER.titleModel;el.set_gh_token.value=USER.githubToken||'';el.set_user_name.value=USER.name;el.userAvatarPreview.src=USER.avatar||'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=';el.userAvatarPreview.classList.toggle('bg-gray-200',!USER.avatar);showAccountTab('General');el.accountSettingsModal.classList.remove('hidden')}
function closeAccountSettings(){el.accountSettingsModal.classList.add('hidden')} function closeAccountSettings(){el.accountSettingsModal.classList.add('hidden')}
$(el.accountSettingsOption).on('click',()=>{el.userMenu.classList.add('hidden');openAccountSettings()}) $(el.accountSettingsOption).on('click',()=>{el.userMenu.classList.add('hidden');openAccountSettings()})
$(el.closeAccountSettings).on('click',closeAccountSettings) $(el.closeAccountSettings).on('click',closeAccountSettings)
$(el.cancelAccountSettings).on('click',closeAccountSettings) $(el.cancelAccountSettings).on('click',closeAccountSettings)
$(el.accountSettingsModal).on('click',e=>{if(e.target===el.accountSettingsModal||e.target.classList.contains('bg-black/30'))closeAccountSettings()}) $(el.accountSettingsModal).on('click',e=>{if(e.target===el.accountSettingsModal||e.target.classList.contains('bg-black/30'))closeAccountSettings()})
$(el.accountSettingsForm).on('submit',e=>{e.preventDefault();USER.provider=el.set_provider.value||'openrouter';USER.apiKeyOpenRouter=String(el.set_api_key_or.value||'').trim();USER.apiKeyOpenAI=String(el.set_api_key_oai.value||'').trim();USER.apiKeyGoogle=String(el.set_api_key_g.value||'').trim();USER.apiKeyClaude=String(el.set_api_key_claude.value||'').trim();USER.apiKeyCloudflare=String(el.set_api_key_cf.value||'').trim();USER.customKey1=String(el.set_api_key_custom1.value||'').trim();USER.masterPrompt=String(el.set_master_prompt.value||'').trim();USER.titleModel=String(el.set_title_model.value||'').trim();USER.githubToken=String(el.set_gh_token.value||'').trim();USER.name=String(el.set_user_name.value||'').trim();closeAccountSettings()}) $(el.setUserAvatarBtn).on('click',()=>el.userAvatarInput.click());
$(el.userAvatarInput).on('change',async()=>{const f=el.userAvatarInput.files?.[0];if(!f)return;try{const v=await imgToWebp(f);USER.avatar=v;el.userAvatarPreview.src=v;el.userAvatarPreview.classList.remove('bg-gray-200');renderUserUI()}catch{}});
$(el.accountSettingsForm).on('submit',e=>{e.preventDefault();USER.provider=el.set_provider.value||'openrouter';USER.apiKeyOpenRouter=String(el.set_api_key_or.value||'').trim();USER.apiKeyOpenAI=String(el.set_api_key_oai.value||'').trim();USER.apiKeyGoogle=String(el.set_api_key_g.value||'').trim();USER.apiKeyClaude=String(el.set_api_key_claude.value||'').trim();USER.apiKeyCloudflare=String(el.set_api_key_cf.value||'').trim();USER.customKey1=String(el.set_api_key_custom1.value||'').trim();USER.masterPrompt=String(el.set_master_prompt.value||'').trim();USER.titleModel=String(el.set_title_model.value||'').trim();USER.githubToken=String(el.set_gh_token.value||'').trim();USER.name=String(el.set_user_name.value||'').trim();renderUserUI();closeAccountSettings()})
$(el.accountPanelAPI).on('click',e=>{const b=e.target.closest('[data-reveal-for]');if(!b)return;const i=document.getElementById(b.dataset.revealFor);if(!i)return;const p=i.type==='password';i.type=p?'text':'password';b.querySelector('i').setAttribute('data-lucide',p?'eye-off':'eye');lucide.createIcons()}); $(el.accountPanelAPI).on('click',e=>{const b=e.target.closest('[data-reveal-for]');if(!b)return;const i=document.getElementById(b.dataset.revealFor);if(!i)return;const p=i.type==='password';i.type=p?'text':'password';b.querySelector('i').setAttribute('data-lucide',p?'eye-off':'eye');lucide.createIcons()});
el.accountTabGeneral.onclick=()=>showAccountTab('General');el.accountTabAPI.onclick=()=>showAccountTab('API');el.accountTabUser.onclick=()=>showAccountTab('User') el.accountTabGeneral.onclick=()=>showAccountTab('General');el.accountTabAPI.onclick=()=>showAccountTab('API');el.accountTabUser.onclick=()=>showAccountTab('User')
el.exportAccountSettings.onclick=()=>dl(`sune-account-${ts()}.json`,{v:1,provider:USER.provider,apiKeyOpenRouter:USER.apiKeyOpenRouter,apiKeyOpenAI:USER.apiKeyOpenAI,apiKeyGoogle:USER.apiKeyGoogle,apiKeyClaude:USER.apiKeyClaude,apiKeyCloudflare:USER.apiKeyCloudflare,customKey1:USER.customKey1,masterPrompt:USER.masterPrompt,titleModel:USER.titleModel,githubToken:USER.githubToken,userName:USER.name,userAvatar:USER.avatar}); el.exportAccountSettings.onclick=()=>dl(`sune-account-${ts()}.json`,{v:1,provider:USER.provider,apiKeyOpenRouter:USER.apiKeyOpenRouter,apiKeyOpenAI:USER.apiKeyOpenAI,apiKeyGoogle:USER.apiKeyGoogle,apiKeyClaude:USER.apiKeyClaude,apiKeyCloudflare:USER.apiKeyCloudflare,customKey1:USER.customKey1,masterPrompt:USER.masterPrompt,titleModel:USER.titleModel,githubToken:USER.githubToken,userName:USER.name,userAvatar:USER.avatar});
el.importAccountSettings.onclick=()=>{el.importAccountSettingsInput.value='';el.importAccountSettingsInput.click()}; el.importAccountSettings.onclick=()=>{el.importAccountSettingsInput.value='';el.importAccountSettingsInput.click()};
el.importAccountSettingsInput.onchange=async e=>{const f=e.target.files?.[0];if(!f)return;try{const d=JSON.parse(await f.text());if(!d||typeof d!=='object')throw new Error('Invalid');const m={provider:'provider',apiKeyOpenRouter:'apiKeyOR',apiKeyOpenAI:'apiKeyOAI',apiKeyGoogle:'apiKeyG',apiKeyClaude:'apiKeyC',apiKeyCloudflare:'apiKeyCF',customKey1:'customKey1',masterPrompt:'masterPrompt',titleModel:'titleModel',githubToken:'ghToken',name:'userName',avatar:'userAvatar'};Object.entries(m).forEach(([p,k])=>{const v=d[p]??d[k];if(typeof v==='string')USER[p]=v});openAccountSettings();alert('Imported.')}catch{alert('Import failed')}}; el.importAccountSettingsInput.onchange=async e=>{const f=e.target.files?.[0];if(!f)return;try{const d=JSON.parse(await f.text());if(!d||typeof d!=='object')throw new Error('Invalid');const m={provider:'provider',apiKeyOpenRouter:'apiKeyOR',apiKeyOpenAI:'apiKeyOAI',apiKeyGoogle:'apiKeyG',apiKeyClaude:'apiKeyClaude',apiKeyCloudflare:'apiKeyCloudflare',customKey1:'customKey1',masterPrompt:'masterPrompt',titleModel:'titleModel',githubToken:'ghToken',name:'userName',avatar:'userAvatar'};Object.entries(m).forEach(([p,k])=>{const v=d[p]??d[k];if(typeof v==='string')USER[p]=v});renderUserUI();openAccountSettings();alert('Imported.')}catch{alert('Import failed')}};
const getBubbleById=id=>el.messages.querySelector(`.msg-bubble[data-mid="${CSS.escape(id)}"]`) const getBubbleById=id=>el.messages.querySelector(`.msg-bubble[data-mid="${CSS.escape(id)}"]`)
async function syncActiveThread(){const id=THREAD.getLastAssistantMessageId();if(!id)return false;if(await cacheStore.getItem(id)==='done'){if(state.busy){setBtnSend();state.busy=false;state.controller=null}return false}if(!state.busy){state.busy=true;state.controller={abort:()=>{const ws=new WebSocket(HTTP_BASE.replace('https','wss'));ws.onopen=function(){this.send(JSON.stringify({type:'stop',rid:id}));this.close()}}};setBtnStop()}const bubble=getBubbleById(id);if(!bubble){if(state.busy){setBtnSend();state.busy=false;state.controller=null;}return false;}const msgIdx=state.messages.findIndex(x=>x.id===id);const localText=msgIdx>=0?partsToText(state.messages[msgIdx]):(bubble.textContent||'');const j=await(fetch(HTTP_BASE+'?uid='+encodeURIComponent(id)).then(r=>r.ok?r.json():null).catch(()=>null));const finalise=(t,c,imgs)=>{const tempMsg={content:c,images:imgs};renderMarkdown(bubble,partsToText(tempMsg),{enhance:false});enhanceCodeBlocks(bubble,true);if(msgIdx>=0){state.messages[msgIdx].content=c;state.messages[msgIdx].images=imgs}else state.messages.push({id,role:'assistant',content:c,images:imgs,...activeMeta()});THREAD.persist();setBtnSend();state.busy=false;cacheStore.setItem(id,'done');state.controller=null;el.composer.dispatchEvent(new CustomEvent('sune:newSuneResponse',{detail:{message:state.messages.find(m=>m.id===id)}}))};if(!j||j.rid!==id){if(j&&j.error){const t=localText+'\n\n'+j.error;finalise(t,[{type:'text',text:t}])}else{await cacheStore.setItem(id,'done');if(state.busy){setBtnSend();state.busy=false;state.controller=null;}}return false}const serverText=j.text||'',isDone=j.error||j.done||j.phase==='done';const finalText=(serverText.length>=localText.length||isDone)?serverText:localText;const display=partsToText({content:[{type:'text',text:finalText}],images:j.images});if(display)renderMarkdown(bubble,display,{enhance:false});if(isDone){if(finalText!==localText){finalise(finalText,[{type:'text',text:finalText}],j.images)}else{await cacheStore.setItem(id,'done');if(state.busy){setBtnSend();state.busy=false;state.controller=null}}return false}await cacheStore.setItem(id,'busy');return true} async function syncActiveThread(){const id=THREAD.getLastAssistantMessageId();if(!id)return false;if(await cacheStore.getItem(id)==='done'){if(state.busy){setBtnSend();state.busy=false;state.controller=null}return false}if(!state.busy){state.busy=true;state.controller={abort:()=>{const ws=new WebSocket(HTTP_BASE.replace('https','wss'));ws.onopen=function(){this.send(JSON.stringify({type:'stop',rid:id}));this.close()}}};setBtnStop()}const bubble=getBubbleById(id);if(!bubble){if(state.busy){setBtnSend();state.busy=false;state.controller=null;}return false;}const msgIdx=state.messages.findIndex(x=>x.id===id);const localText=msgIdx>=0?partsToText(state.messages[msgIdx]):(bubble.textContent||'');const j=await(fetch(HTTP_BASE+'?uid='+encodeURIComponent(id)).then(r=>r.ok?r.json():null).catch(()=>null));const finalise=(t,c,imgs)=>{const tempMsg={content:c,images:imgs};renderMarkdown(bubble,partsToText(tempMsg),{enhance:false});enhanceCodeBlocks(bubble,true);if(msgIdx>=0){state.messages[msgIdx].content=c;state.messages[msgIdx].images=imgs}else state.messages.push({id,role:'assistant',content:c,images:imgs,...activeMeta()});THREAD.persist();setBtnSend();state.busy=false;cacheStore.setItem(id,'done');state.controller=null;el.composer.dispatchEvent(new CustomEvent('sune:newSuneResponse',{detail:{message:state.messages.find(m=>m.id===id)}}))};if(!j||j.rid!==id){if(j&&j.error){const t=localText+'\n\n'+j.error;finalise(t,[{type:'text',text:t}])}else{await cacheStore.setItem(id,'done');if(state.busy){setBtnSend();state.busy=false;state.controller=null;}}return false}const serverText=j.text||'',isDone=j.error||j.done||j.phase==='done';const finalText=(serverText.length>=localText.length||isDone)?serverText:localText;const display=partsToText({content:[{type:'text',text:finalText}],images:j.images});if(display)renderMarkdown(bubble,display,{enhance:false});if(isDone){if(finalText!==localText){finalise(finalText,[{type:'text',text:finalText}],j.images)}else{await cacheStore.setItem(id,'done');if(state.busy){setBtnSend();state.busy=false;state.controller=null}}return false}await cacheStore.setItem(id,'busy');return true}
let syncLoopRunning=false let syncLoopRunning=false
async function syncWhileBusy(){if(syncLoopRunning||document.visibilityState==='hidden')return;syncLoopRunning=true;try{while(await syncActiveThread())await new Promise(r=>setTimeout(r,1500))}finally{syncLoopRunning=false}} async function syncWhileBusy(){if(syncLoopRunning||document.visibilityState==='hidden')return;syncLoopRunning=true;try{while(await syncActiveThread())await new Promise(r=>setTimeout(r,1500))}finally{syncLoopRunning=false}}
const onForeground=()=>{if(document.visibilityState!=='visible')return;state.controller?.disconnect?.();if(state.busy)syncWhileBusy()} const onForeground=()=>{if(document.visibilityState!=='visible')return;state.controller?.disconnect?.();if(state.busy)syncWhileBusy()}
$(document).on('visibilitychange',onForeground) $(document).on('visibilitychange',onForeground)
$(el.copySystemPrompt).on('click',async()=>{try{await navigator.clipboard.writeText(el.set_system_prompt.value||'')}catch{}}) $(el.copySystemPrompt).on('click',async()=>await copyToClipboard(el.set_system_prompt.value||''))
$(el.pasteSystemPrompt).on('click',async()=>{try{el.set_system_prompt.value=await navigator.clipboard.readText()}catch{}}) $(el.pasteSystemPrompt).on('click',async()=>{try{el.set_system_prompt.value=await navigator.clipboard.readText()}catch{}})
const getActiveJar=()=>!el.htmlEditor.classList.contains('hidden')?jars.html:jars.extension const getActiveJar=()=>!el.htmlEditor.classList.contains('hidden')?jars.html:jars.extension
$(el.copyHTML).on('click',async()=>{try{const jar=getActiveJar();await navigator.clipboard.writeText(jar?jar.toString():'')}catch{}}) $(el.copyHTML).on('click',async()=>{const jar=getActiveJar();await copyToClipboard(jar?jar.toString():'')})
$(el.pasteHTML).on('click',async()=>{try{const t=await navigator.clipboard.readText();const jar=getActiveJar();if(jar)jar.updateCode(t)}catch{}}) $(el.pasteHTML).on('click',async()=>{try{const t=await navigator.clipboard.readText();const jar=getActiveJar();if(jar)jar.updateCode(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,getActiveJar,imgToWebp,cacheStore,ghApi,parseGhUrl,ghGetFileContent,pullThreads}); Object.assign(window,{icons,haptic,clamp,num,int,gid,esc,positionPopover,sid,fmtSize,asDataURL,b64,makeSune,getModelShort,resolveSuneSrc,processSuneIncludes,renderSuneHTML,reflectActiveSune,suneRow,renderUserUI,enhanceCodeBlocks,getSuneLabel,_createMessageRow,msgRow,partsToText,copyToClipboard,addSuneBubbleStreaming,clearChat,payloadWithSampling,setBtnStop,setBtnSend,localDemoReply,titleFrom,serializeThreadName,deserializeThreadName,ensureThreadOnFirstUser,generateTitleWithAI,threadRow,renderThreads,hideThreadPopover,showThreadPopover,hideSunePopover,showSunePopover,hideSuneSyncPopover,showSuneSyncPopover,hideSunesSyncPopover,showSunesSyncPopover,setSuneSyncStatus,checkSuneSyncStatus,performSuneDownload,performSuneUpload,updateAttachBadge,toAttach,ensureJars,openSettings,closeSettings,showTab,dl,ts,kbUpdate,kbBind,activeMeta,init,showHtmlTab,showAccountTab,openAccountSettings,closeAccountSettings,getBubbleById,syncActiveThread,syncWhileBusy,onForeground,getActiveJar,imgToWebp,cacheStore,ghApi,parseGhUrl,ghGetFileContent,pullThreads,suneStorage,gcStorage,cleanSuneStorage});

View File

@@ -1,6 +1,8 @@
import mathjax3 from 'https://esm.sh/markdown-it-mathjax3'; import mathjax3 from 'https://esm.sh/markdown-it-mathjax3';
import { copyToClipboard } from './utils.js';
export const md = window.md = window.markdownit({ html: false, linkify: true, typographer: true, breaks: true }).use(mathjax3); export const md = window.md = window.markdownit({ html: false, linkify: true, typographer: true, breaks: true }).use(mathjax3);
md.linkify.set({ fuzzyLink: true });
export function enhanceCodeBlocks(root, doHL = true) { export function enhanceCodeBlocks(root, doHL = true) {
window.$(root).find('pre>code').each((i, code) => { window.$(root).find('pre>code').each((i, code) => {
@@ -10,11 +12,10 @@ export function enhanceCodeBlocks(root, doHL = true) {
const len = code.textContent.length, countText = len >= 1e3 ? (len / 1e3).toFixed(1) + 'K' : len; 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 => { 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(); e.stopPropagation();
try { if (await copyToClipboard(code.innerText)) {
await navigator.clipboard.writeText(code.innerText);
$btn.text('Copied'); $btn.text('Copied');
setTimeout(() => $btn.text('Copy'), 1200); setTimeout(() => $btn.text('Copy'), 1200);
} catch { } }
}); });
const $container = window.$('<div class="code-actions absolute top-2 right-2 flex items-center gap-2"></div>'); 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); $container.append(window.$(`<span class="text-xs text-gray-500">${countText} chars</span>`), $btn);

View File

@@ -4,8 +4,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/tiny-ripple@0.2.0"></script> <script src="https://cdn.jsdelivr.net/npm/tiny-ripple@0.2.0"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5.8.1/github-markdown-light.min.css"/> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5.9.0/github-markdown-light.min.css"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/styles/github.min.css"/> <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/styles/github.min.css"/>
<link rel="stylesheet" href="/src/style.css"/> <link rel="stylesheet" href="/src/style.css"/>
<script defer src="https://cdn.jsdelivr.net/npm/cash-dom/dist/cash.min.js"></script> <script defer src="https://cdn.jsdelivr.net/npm/cash-dom/dist/cash.min.js"></script>
<script defer src="//unpkg.com/alpinejs"></script> <script defer src="//unpkg.com/alpinejs"></script>

View File

@@ -13,6 +13,10 @@
<button data-action="pfp" class="menu-item"><i data-lucide="image" class="h-4 w-4"></i><span>Change pfp</span></button> <button data-action="pfp" class="menu-item"><i data-lucide="image" class="h-4 w-4"></i><span>Change pfp</span></button>
<button data-action="export" class="menu-item"><i data-lucide="download" class="h-4 w-4"></i><span>Export sune (.sune)</span></button> <button data-action="export" class="menu-item"><i data-lucide="download" class="h-4 w-4"></i><span>Export sune (.sune)</span></button>
</div> </div>
<div id="sunesSyncPopover" class="menu-card hidden">
<button id="sunesSyncUploadBtn" class="menu-item"><i data-lucide="upload-cloud" class="h-4 w-4"></i><span>Upload to GitHub</span></button>
<button id="sunesSyncDownloadBtn" class="menu-item"><i data-lucide="download-cloud" class="h-4 w-4"></i><span>Download from GitHub</span></button>
</div>
<div id="suneModal" class="hidden fixed inset-0 z-50"> <div id="suneModal" class="hidden fixed inset-0 z-50">
<div class="absolute inset-0 bg-black/30"></div> <div class="absolute inset-0 bg-black/30"></div>
<div class="absolute inset-x-0 top-12 mx-auto w-full max-w-md px-4"> <div class="absolute inset-x-0 top-12 mx-auto w-full max-w-md px-4">
@@ -21,7 +25,7 @@
<form id="settingsForm" class="text-sm"> <form id="settingsForm" class="text-sm">
<div class="border-b flex text-xs font-medium"><button type="button" id="tabModel" class="flex-1 py-2 px-3 text-center border-b-2 border-black">Model & Sampling</button><button type="button" id="tabPrompt" class="flex-1 py-2 px-3 text-center border-b-2 border-transparent hover:border-gray-300">System Prompt</button><button type="button" id="tabScript" class="flex-1 py-2 px-3 text-center border-b-2 border-transparent hover:border-gray-300">HTML</button></div> <div class="border-b flex text-xs font-medium"><button type="button" id="tabModel" class="flex-1 py-2 px-3 text-center border-b-2 border-black">Model & Sampling</button><button type="button" id="tabPrompt" class="flex-1 py-2 px-3 text-center border-b-2 border-transparent hover:border-gray-300">System Prompt</button><button type="button" id="tabScript" class="flex-1 py-2 px-3 text-center border-b-2 border-transparent hover:border-gray-300">HTML</button></div>
<div id="panelModel" class="p-4 space-y-4"> <div id="panelModel" class="p-4 space-y-4">
<div class="grid grid-cols-2 gap-3"><div><label class="block text-gray-700 font-medium mb-1">Model name</label><input id="set_model" type="text" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="google/gemini-3-pro-preview"/></div><div><label class="block text-gray-700 font-medium mb-1">Reasoning Effort</label><select id="set_reasoning_effort" class="w-full rounded-xl border border-gray-300 px-3 py-2"><option value="default">Omitted</option><option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option></select></div></div> <div class="grid grid-cols-2 gap-3"><div><label class="block text-gray-700 font-medium mb-1">Model name</label><input id="set_model" type="text" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="google/gemini-3-pro-preview"/></div><div><label class="block text-gray-700 font-medium mb-1">Reasoning Effort</label><select id="set_reasoning_effort" class="w-full rounded-xl border border-gray-300 px-3 py-2"><option value="">Omitted</option><option value="none">None</option><option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option></select></div></div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div><label class="block text-gray-700 font-medium mb-1">Temperature <span class="text-gray-400">(02)</span></label><input id="set_temperature" type="number" min="0" max="2" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/></div> <div><label class="block text-gray-700 font-medium mb-1">Temperature <span class="text-gray-400">(02)</span></label><input id="set_temperature" type="number" min="0" max="2" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/></div>
<div><label class="block text-gray-700 font-medium mb-1">Top P <span class="text-gray-400">(01)</span></label><input id="set_top_p" type="number" min="0" max="1" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/></div> <div><label class="block text-gray-700 font-medium mb-1">Top P <span class="text-gray-400">(01)</span></label><input id="set_top_p" type="number" min="0" max="1" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/></div>
@@ -30,7 +34,7 @@
<div><label class="block text-gray-700 font-medium mb-1">Repetition Penalty <span class="text-gray-400">(02)</span></label><input id="set_repetition_penalty" type="number" min="0" max="2" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/></div> <div><label class="block text-gray-700 font-medium mb-1">Repetition Penalty <span class="text-gray-400">(02)</span></label><input id="set_repetition_penalty" type="number" min="0" max="2" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="1.0"/></div>
<div><label class="block text-gray-700 font-medium mb-1">Min P <span class="text-gray-400">(01)</span></label><input id="set_min_p" type="number" min="0" max="1" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/></div> <div><label class="block text-gray-700 font-medium mb-1">Min P <span class="text-gray-400">(01)</span></label><input id="set_min_p" type="number" min="0" max="1" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/></div>
<div><label class="block text-gray-700 font-medium mb-1">Top A <span class="text-gray-400">(01)</span></label><input id="set_top_a" type="number" min="0" max="1" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/></div> <div><label class="block text-gray-700 font-medium mb-1">Top A <span class="text-gray-400">(01)</span></label><input id="set_top_a" type="number" min="0" max="1" step="0.01" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="0.0"/></div>
<div><label class="block text-gray-700 font-medium mb-1">Verbosity</label><select id="set_verbosity" class="w-full rounded-xl border border-gray-300 px-3 py-2"><option value="">Omitted</option><option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option></select></div> <div><label class="block text-gray-700 font-medium mb-1">Max Tokens (output)</label><input id="set_max_tokens" type="number" min="1" step="1" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder=""/></div>
</div> </div>
<div class="flex flex-wrap items-center gap-2 pt-2"> <div class="flex flex-wrap items-center gap-2 pt-2">
<div><input id="set_include_thoughts" type="checkbox" class="sr-only peer"><label for="set_include_thoughts" class="inline-flex cursor-pointer items-center rounded-full border border-slate-300 bg-transparent py-0.5 px-3 text-xs text-slate-500 peer-checked:border-gray-300 peer-checked:bg-gray-200 peer-checked:text-slate-800">Include thoughts</label></div> <div><input id="set_include_thoughts" type="checkbox" class="sr-only peer"><label for="set_include_thoughts" class="inline-flex cursor-pointer items-center rounded-full border border-slate-300 bg-transparent py-0.5 px-3 text-xs text-slate-500 peer-checked:border-gray-300 peer-checked:bg-gray-200 peer-checked:text-slate-800">Include thoughts</label></div>
@@ -66,6 +70,7 @@
</div> </div>
<div id="panelPrompt" class="p-4 space-y-4 hidden"> <div id="panelPrompt" class="p-4 space-y-4 hidden">
<div><div class="flex items-center justify-between mb-1"><label for="set_system_prompt" class="block text-gray-700 font-medium">System Prompt</label><div class="flex gap-2"><button type="button" id="copySystemPrompt" class="px-2 py-1 text-xs rounded-md bg-gray-100 hover:bg-gray-200">Copy</button><button type="button" id="pasteSystemPrompt" class="px-2 py-1 text-xs rounded-md bg-gray-100 hover:bg-gray-200">Paste</button></div></div><textarea id="set_system_prompt" rows="8" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="Enter a system prompt to guide the sune"></textarea></div> <div><div class="flex items-center justify-between mb-1"><label for="set_system_prompt" class="block text-gray-700 font-medium">System Prompt</label><div class="flex gap-2"><button type="button" id="copySystemPrompt" class="px-2 py-1 text-xs rounded-md bg-gray-100 hover:bg-gray-200">Copy</button><button type="button" id="pasteSystemPrompt" class="px-2 py-1 text-xs rounded-md bg-gray-100 hover:bg-gray-200">Paste</button></div></div><textarea id="set_system_prompt" rows="8" class="w-full rounded-xl border border-gray-300 px-3 py-2" placeholder="Enter a system prompt to guide the sune"></textarea></div>
<p class="text-xs text-gray-500">Use <code>{{model_name}}</code> to insert the model name.</p>
</div> </div>
<div id="panelScript" class="p-1 hidden"> <div id="panelScript" class="p-1 hidden">
<div class="border-b flex text-xs font-medium"><button type="button" id="htmlTab_index" class="flex-1 py-2 px-3 text-center border-b-2"></button><button type="button" id="htmlTab_extension" class="flex-1 py-2 px-3 text-center border-b-2"></button></div> <div class="border-b flex text-xs font-medium"><button type="button" id="htmlTab_index" class="flex-1 py-2 px-3 text-center border-b-2"></button><button type="button" id="htmlTab_extension" class="flex-1 py-2 px-3 text-center border-b-2"></button></div>

View File

@@ -1,9 +1,16 @@
<div id="sidebarOverlayLeft" class="fixed inset-0 z-40 bg-black/20 hidden" @click="document.getElementById('sidebarLeft').classList.add('-translate-x-full');$el.classList.add('hidden');document.getElementById('sidebarRight').classList.add('translate-x-full');document.getElementById('sidebarOverlayRight').classList.add('hidden');hideThreadPopover();hideSunePopover()"></div> <div id="sidebarOverlayLeft" class="fixed inset-0 z-40 bg-black/20 hidden" @click="document.getElementById('sidebarLeft').classList.add('-translate-x-full');$el.classList.add('hidden');document.getElementById('sidebarRight').classList.add('translate-x-full');document.getElementById('sidebarOverlayRight').classList.add('hidden');hideThreadPopover();hideSunePopover();hideSunesSyncPopover()"></div>
<aside id="sidebarLeft" class="fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] bg-white border-r border-gray-200 shadow-xl transform -translate-x-full transition-transform duration-200 ease-out flex flex-col"> <aside id="sidebarLeft" class="fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] bg-white border-r border-gray-200 shadow-xl transform -translate-x-full transition-transform duration-200 ease-out flex flex-col">
<div class="p-3 border-b flex items-center gap-2"><button id="newSuneBtn" class="px-3 py-2 rounded-xl bg-black text-white text-sm hover:bg-black/90">New sune</button><span class="text-xs text-gray-500">Click name to equip</span></div> <div class="p-2 border-b flex flex-col gap-2">
<input id="suneRepoInput" type="text" placeholder="gh://owner/sunes" class="w-full h-9 rounded-lg border-0 bg-gray-100 px-3 text-xs font-mono focus:ring-2 focus:ring-black focus:bg-white"/>
<div class="flex items-center justify-between gap-2">
<button id="newSuneBtn" class="px-3 py-1.5 rounded-lg bg-black text-white text-[10px] font-bold uppercase tracking-wider hover:bg-black/90 transition">New Sune</button>
<button id="suneSyncBtn" class="relative px-3 py-1.5 rounded-lg bg-gray-100 text-gray-900 text-[10px] font-bold uppercase tracking-wider hover:bg-gray-200 transition flex items-center gap-1"><i data-lucide="refresh-cw" class="h-3 w-3"></i><span>Sync</span><span id="suneSyncBadge" class="absolute -top-1 -right-1 block h-2.5 w-2.5 rounded-full ring-2 ring-white hidden"></span></button>
</div>
</div>
<div id="suneList" class="flex-1 overflow-y-auto divide-y"></div> <div id="suneList" class="flex-1 overflow-y-auto divide-y"></div>
<div class="p-3 border-t relative"> <div class="p-3 border-t relative flex items-center gap-2">
<button id="userMenuBtn" class="w-full flex items-center justify-between px-3 py-2 rounded-xl bg-gray-100 hover:bg-gray-200 active:scale-[.99] transition" @click.stop="document.getElementById('userMenu').classList.toggle('hidden')"><span class="flex items-center gap-2"><span class="h-6 w-6 rounded-full bg-gray-900 text-white flex items-center justify-center">👤</span><span class="text-sm">Account & Backup</span></span><i data-lucide="chevron-down" class="h-4 w-4"></i></button> <button id="userMenuBtn" class="flex-1 flex items-center justify-between px-3 py-2 rounded-xl bg-gray-100 hover:bg-gray-200 active:scale-[.99] transition" @click.stop="document.getElementById('userMenu').classList.toggle('hidden')"><span class="flex items-center gap-2"><span id="userMenuAvatar" class="h-6 w-6 rounded-full bg-gray-900 text-white flex items-center justify-center overflow-hidden shrink-0">👤</span><span class="text-sm">User</span></span><i data-lucide="chevron-down" class="h-4 w-4"></i></button>
<a href="https://github.com/sune-org/sune" target="_blank" rel="noopener noreferrer" class="h-10 w-10 shrink-0 rounded-xl bg-gray-100 hover:bg-gray-200 active:scale-[.99] transition flex items-center justify-center text-gray-700 hover:text-black" title="Visit the repository"><svg viewBox="0 0 24 24" class="h-5 w-5 fill-current" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.53 1.032 1.53 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"/></svg></a>
<div id="userMenu" class="absolute left-3 right-3 bottom-16 translate-y-2 rounded-xl border border-gray-200 bg-white shadow-lg hidden overflow-hidden"> <div id="userMenu" class="absolute left-3 right-3 bottom-16 translate-y-2 rounded-xl border border-gray-200 bg-white shadow-lg hidden overflow-hidden">
<button id="accountSettingsOption" class="menu-item"><i data-lucide="settings" class="h-4 w-4"></i><span>Settings</span></button> <button id="accountSettingsOption" class="menu-item"><i data-lucide="settings" class="h-4 w-4"></i><span>Settings</span></button>
<button id="sunesImportOption" class="menu-item">Import sunes (.sune)</button> <button id="sunesImportOption" class="menu-item">Import sunes (.sune)</button>

View File

@@ -1,7 +1,7 @@
<header id="topbar" class="sticky top-0 z-20 bg-white/80 backdrop-blur border-b border-gray-200"> <header id="topbar" class="sticky top-0 z-20 bg-white/80 backdrop-blur border-b border-gray-200">
<div class="mx-auto w-full max-w-none px-4 py-3 grid grid-cols-3 items-center"> <div class="mx-auto w-full max-w-none px-4 py-2 grid grid-cols-3 items-center">
<button id="sidebarBtnLeft" class="h-8 w-8 rounded-xl bg-gray-100 hover:bg-gray-200 active:scale-[.99] transition flex items-center justify-center" title="Sunes" @click="document.getElementById('sidebarLeft').classList.remove('-translate-x-full');document.getElementById('sidebarOverlayLeft').classList.remove('hidden')"><i data-lucide="panel-left" class="h-5 w-5"></i></button> <button id="sidebarBtnLeft" class="h-8 w-8 rounded-xl bg-gray-100 hover:bg-gray-200 active:scale-[.99] transition flex items-center justify-center" title="Sunes" @click="document.getElementById('sidebarLeft').classList.remove('-translate-x-full');document.getElementById('sidebarOverlayLeft').classList.remove('hidden')"><i data-lucide="panel-left" class="h-5 w-5"></i></button>
<button id="suneBtnTop" class="justify-self-center h-8 w-8 rounded-full bg-gray-200 text-gray-900 flex items-center justify-center hover:bg-gray-300 active:scale-[.99] transition" title="Sune settings"></button> <button id="suneBtnTop" class="justify-self-center h-10 w-10 rounded-full bg-gray-200 text-gray-900 flex items-center justify-center hover:bg-gray-300 active:scale-[.99] transition" title="Sune settings"></button>
<div class="justify-self-end"><button id="sidebarBtnRight" class="h-8 w-8 rounded-xl bg-gray-100 hover:bg-gray-200 active:scale-[.99] transition flex items-center justify-center" title="Threads" @click="renderThreads();document.getElementById('sidebarRight').classList.remove('translate-x-full');document.getElementById('sidebarOverlayRight').classList.remove('hidden')"><i data-lucide="panel-right" class="h-5 w-5"></i></button></div> <div class="justify-self-end"><button id="sidebarBtnRight" class="h-8 w-8 rounded-xl bg-gray-100 hover:bg-gray-200 active:scale-[.99] transition flex items-center justify-center" title="Threads" @click="renderThreads();document.getElementById('sidebarRight').classList.remove('translate-x-full');document.getElementById('sidebarOverlayRight').classList.remove('hidden')"><i data-lucide="panel-right" class="h-5 w-5"></i></button></div>
</div> </div>
</header> </header>

View File

@@ -9,7 +9,8 @@ export const buildBody=()=>{
msgs.push({role:'system', content: mPrompt}); msgs.push({role:'system', content: mPrompt});
} }
const sPrompt = (SUNE.system_prompt || '').trim(); const modelName=SUNE.model.replace(/^(or:|oai:|g:|cla:|cf:)/,'').replace(/^[^/]*\//,'').replace(/(?::online|:free)+$/,'');
const sPrompt = (SUNE.system_prompt || '').trim().replaceAll('{{model_name}}',()=>modelName);
if(sPrompt) { if(sPrompt) {
msgs.push({role:'system', content: sPrompt}); msgs.push({role:'system', content: sPrompt});
} }
@@ -38,7 +39,6 @@ export const buildBody=()=>{
const b=payloadWithSampling({model:SUNE.model.replace(/^(or:|oai:|g:|cla:|cf:)/,''),messages:msgs,stream:true}); const b=payloadWithSampling({model:SUNE.model.replace(/^(or:|oai:|g:|cla:|cf:)/,''),messages:msgs,stream:true});
b.reasoning={...(SUNE.reasoning_effort&&SUNE.reasoning_effort!=='default'?{effort:SUNE.reasoning_effort}:{}),exclude:!SUNE.include_thoughts}; b.reasoning={...(SUNE.reasoning_effort&&SUNE.reasoning_effort!=='default'?{effort:SUNE.reasoning_effort}:{}),exclude:!SUNE.include_thoughts};
if(SUNE.verbosity)b.verbosity=SUNE.verbosity;
if(SUNE.img_output){b.modalities=['image'];b.image_config={aspect_ratio:SUNE.aspect_ratio||'1:1',image_size:SUNE.image_size||'1K'}} if(SUNE.img_output){b.modalities=['image'];b.image_config={aspect_ratio:SUNE.aspect_ratio||'1:1',image_size:SUNE.image_size||'1K'}}
return b return b
} }

View File

@@ -1,6 +1,6 @@
export const USER = { export const USER = {
get PAT() { return this.githubToken; }, get PAT() { return this.githubToken; },
get name() { return localStorage.getItem('user_name') || 'Anon'; }, get name() { return localStorage.getItem('user_name') || 'User'; },
set name(v) { localStorage.setItem('user_name', v || ''); }, set name(v) { localStorage.setItem('user_name', v || ''); },
get avatar() { return localStorage.getItem('user_avatar') || ''; }, get avatar() { return localStorage.getItem('user_avatar') || ''; },
set avatar(v) { localStorage.setItem('user_avatar', v || ''); }, set avatar(v) { localStorage.setItem('user_avatar', v || ''); },

View File

@@ -16,7 +16,7 @@ export const fmtSize = b => {
return (x >= 10 ? Math.round(x) : Math.round(x * 10) / 10) + ' ' + u[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 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) => { export const imgToWebp = (f, D = 96, q = 75) => new Promise((r, j) => {
if (!f) return j(); if (!f) return j();
const i = new Image; const i = new Image;
i.onload = () => { i.onload = () => {
@@ -33,12 +33,52 @@ export const b64 = x => x.split(',')[1] || '';
export const utob = s => btoa(unescape(encodeURIComponent(s))); export const utob = s => btoa(unescape(encodeURIComponent(s)));
export const btou = s => decodeURIComponent(escape(atob(s.replace(/\s/g, '')))); export const btou = s => decodeURIComponent(escape(atob(s.replace(/\s/g, ''))));
export function partsToText(m) { export async function copyToClipboard(text) {
if (typeof text !== 'string') text = String(text ?? '');
if (navigator.clipboard?.writeText) {
try { await navigator.clipboard.writeText(text); return true; } catch {}
}
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.focus();
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return ok;
} catch { return false; }
}
export function partsToText(m, stripData = false) {
if (!m) return ''; if (!m) return '';
const c = m.content, i = m.images; const c = m.content, i = m.images, out = [];
let t = Array.isArray(c) ? c.map(p => p?.type === 'text' ? p.text : (p?.type === 'image_url' ? `![](${p.image_url?.url || ''})` : (p?.type === 'file' ? `[${p.file?.filename || 'file'}]` : (p?.type === 'input_audio' ? `(audio:${p.input_audio?.format || ''})` : '')))).join('\n') : String(c || ''); if (Array.isArray(c)) {
if (Array.isArray(i)) t += i.map(x => `\n![](${x.image_url?.url})\n`).join(''); for (const p of c) {
return t; if (p?.type === 'text') {
if (p.text) out.push(p.text);
} else if (p?.type === 'image_url') {
const u = p.image_url?.url || '';
if (!stripData || !u.startsWith('data:')) out.push(`![](${u})`);
} else if (p?.type === 'file') {
out.push(`[${p.file?.filename || 'file'}]`);
} else if (p?.type === 'input_audio') {
out.push(`(audio:${p.input_audio?.format || ''})`);
}
}
} else if (c != null) {
out.push(String(c));
}
if (Array.isArray(i)) {
for (const x of i) {
const u = x.image_url?.url || '';
if (!stripData || !u.startsWith('data:')) out.push(`![](${u})`);
}
}
return out.join('\n');
} }
export function dl(name, obj) { export function dl(name, obj) {