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);
+})();