diff --git a/go.sum b/go.sum index 0037db4..b3be62f 100644 --- a/go.sum +++ b/go.sum @@ -22,12 +22,6 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/revrost/go-openrouter v0.2.2 h1:7bOdLPKmw0iJB1AdpN+YaWUd2XC9cwfJKDY10iaSAzI= -github.com/revrost/go-openrouter v0.2.2/go.mod h1:ZH/UdpnDEdMmJwq8tbSTX1S5I07ee8KMlEYN4jmegU0= -github.com/revrost/go-openrouter v0.2.3 h1:ollIaPrgVWgqJyKbJGSX1jFs66eAWJs8Ojrxnd2i/E0= -github.com/revrost/go-openrouter v0.2.3/go.mod h1:ZH/UdpnDEdMmJwq8tbSTX1S5I07ee8KMlEYN4jmegU0= -github.com/revrost/go-openrouter v0.2.4-0.20250909110314-b8c4ee4c5861 h1:4XU64nIgj6l9659KJx+FOaABvdhM3YrytCgD8XoKu90= -github.com/revrost/go-openrouter v0.2.4-0.20250909110314-b8c4ee4c5861/go.mod h1:ZH/UdpnDEdMmJwq8tbSTX1S5I07ee8KMlEYN4jmegU0= github.com/revrost/go-openrouter v0.2.4 h1:ts9VMZGj8C6688xIgBU9/Tyw2WBl55WfdVP2zG+EV98= github.com/revrost/go-openrouter v0.2.4/go.mod h1:ZH/UdpnDEdMmJwq8tbSTX1S5I07ee8KMlEYN4jmegU0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= @@ -35,20 +29,14 @@ github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= -golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/static/css/chat.css b/static/css/chat.css index 4a8ee13..fbbf5c1 100644 --- a/static/css/chat.css +++ b/static/css/chat.css @@ -404,6 +404,10 @@ body:not(.loading) #loading { color: #ed8796; } +.message .text .error a { + color: #ee99a0; +} + .message.errored { border: 2px solid #ed8796; } diff --git a/static/index.html b/static/index.html index 672a729..04d4e7e 100644 --- a/static/index.html +++ b/static/index.html @@ -129,6 +129,7 @@ + diff --git a/static/js/chat.js b/static/js/chat.js index 050ccd5..983dd45 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -723,7 +723,7 @@ data.collapsed = true; } - if (!data.images?.length && !data.files?.length && !data.reasoning && !data.text && !data.tool) { + if (!data.error && !data.images?.length && !data.files?.length && !data.reasoning && !data.text && !data.tool) { return false; } @@ -868,14 +868,14 @@ this.#save(); } - showError(error) { - this.#error = error; + setError(error) { + this.#error = error || "Something went wrong"; - this.#_message.classList.add("errored"); + this.#_message.classList.add("errored", "has-text"); const _err = make("div", "error"); - _err.textContent = this.#error; + _err.innerHTML = renderInline(this.#error); this.#_text.appendChild(_err); @@ -1226,7 +1226,7 @@ break; case "error": - message.showError(chunk.text); + message.setError(chunk.text); break; } @@ -1326,7 +1326,7 @@ } async function loadData() { - const data = await json("/-/data"); + const [_, data] = await Promise.all([connectDB(), json("/-/data")]); if (!data) { notify("Failed to load data.", true); @@ -1394,6 +1394,12 @@ } function restore() { + const resizedHeight = loadValue("resized"); + + if (resizedHeight) { + $chat.style.height = `${resizedHeight}px`; + } + $message.value = loadValue("message", ""); $role.value = loadValue("role", "user"); $model.value = loadValue("model", modelList.length ? modelList[0].id : ""); @@ -1424,13 +1430,13 @@ loadValue("messages", []).forEach(message => { const obj = new Message(message.role, message.reasoning, message.text, message.tool, message.files || [], message.images || [], message.tags || [], message.collapsed); - if (message.error) { - obj.showError(message.error); - } - if (message.statistics) { obj.setStatistics(message.statistics); } + + if (message.error) { + obj.setError(message.error); + } }); chatTitle = loadValue("title"); @@ -1919,12 +1925,6 @@ dropdown($role); dropdown($reasoningEffort); - const resizedHeight = loadValue("resized"); - - if (resizedHeight) { - $chat.style.height = `${resizedHeight}px`; - } - loadData().then(() => { restore(); diff --git a/static/js/lib.js b/static/js/lib.js index aab1954..19344f0 100644 --- a/static/js/lib.js +++ b/static/js/lib.js @@ -1,35 +1,5 @@ /** biome-ignore-all lint/correctness/noUnusedVariables: utility */ -function storeValue(key, value = false) { - if (value === null || value === undefined || value === false) { - localStorage.removeItem(key); - - return; - } - - localStorage.setItem(key, JSON.stringify(value)); -} - -function loadValue(key, fallback = false) { - const raw = localStorage.getItem(key); - - if (raw === null) { - return fallback; - } - - try { - const value = JSON.parse(raw); - - if (value === null) { - throw new Error("no value"); - } - - return value; - } catch {} - - return fallback; -} - function schedule(cb) { if (document.visibilityState === "visible") { requestAnimationFrame(cb); diff --git a/static/js/markdown.js b/static/js/markdown.js index fdf883b..f3d885d 100644 --- a/static/js/markdown.js +++ b/static/js/markdown.js @@ -158,4 +158,8 @@ window.render = markdown => { return marked.parse(markdown); }; + + window.renderInline = markdown => { + return marked.parseInline(markdown.trim()); + }; })(); diff --git a/static/js/storage.js b/static/js/storage.js new file mode 100644 index 0000000..fbbb10c --- /dev/null +++ b/static/js/storage.js @@ -0,0 +1,156 @@ +/** biome-ignore-all lint/correctness/noUnusedVariables: utility */ + +(() => { + const DatabaseName = "whiskr", + StorageName = "chat"; + + function isNull(value) { + return value === null || value === false || value === undefined; + } + + class Database { + #database; + + #scheduled = new Map(); + #writes = new Map(); + #cache = new Map(); + + static async new() { + const db = new Database(); + + await db.#connect(); + await db.#load(); + + return db; + } + + #connect() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DatabaseName, 1); + + request.onerror = () => reject(request.error); + + request.onsuccess = () => { + this.#database = request.result; + + resolve(); + }; + + request.onupgradeneeded = event => { + const db = event.target.result; + + if (db.objectStoreNames.contains(StorageName)) { + return; + } + + db.createObjectStore(StorageName); + }; + }); + } + + #load() { + return new Promise((resolve, reject) => { + const transaction = this.#database.transaction(StorageName, "readonly"), + store = transaction.objectStore(StorageName), + request = store.openCursor(); + + request.onerror = () => reject(request.error); + + let total = 0; + + request.onsuccess = event => { + const cursor = event.target.result; + + if (cursor) { + if (!isNull(cursor.value)) { + this.#cache.set(cursor.key, cursor.value); + + total++; + } + + cursor.continue(); + } else { + console.info(`Loaded ${total} items from IndexedDB`); + + resolve(); + } + }; + }); + } + + #write(key, retry) { + if (this.#writes.has(key)) { + if (retry) { + this.#schedule(key); + } + + return; + } + + this.#writes.set(key, true); + + try { + const transaction = this.#database.transaction(StorageName, "readwrite"), + store = transaction.objectStore(StorageName); + + const value = this.#cache.get(key); + + if (isNull(value)) { + store.delete(key); + } else { + store.put(value, key); + } + + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + }); + } catch (error) { + console.error(`Failed to write to IndexedDB: ${error}`); + } finally { + this.#writes.delete(key); + } + } + + #schedule(key) { + if (this.#scheduled.has(key)) { + clearTimeout(this.#scheduled.get(key)); + } + + const timeout = setTimeout(() => { + this.#scheduled.delete(key); + + this.#write(key, true); + }, 500); + + this.#scheduled.set(key, timeout); + } + + store(key, value = false) { + if (isNull(value)) { + this.#cache.delete(key); + } else { + this.#cache.set(key, value); + } + + this.#schedule(key); + } + + load(key, fallback = false) { + if (!this.#cache.has(key)) { + return fallback; + } + + return this.#cache.get(key); + } + } + + let db; + + window.connectDB = async () => { + db = await Database.new(); + }; + + window.storeValue = (key, value = false) => db.store(key, value); + window.loadValue = (key, fallback = false) => db.load(key, fallback); +})();