1
0
Fork 0

Compare commits

..

2 Commits

Author SHA1 Message Date
Pabloader 23bc808cca Styling 2026-04-09 19:49:44 +00:00
Pabloader 6be67d71f8 Horde ported 2026-04-09 19:12:49 +00:00
55 changed files with 1639 additions and 3281 deletions

View File

@ -0,0 +1,28 @@
:root {
/* Monokai-inspired palette */
--bg: #272822;
--bg-panel: #1e1f1a;
--bg-hover: #3e3d32;
--bg-active: #49483e;
--border: #3e3d32;
--accent: #f92672;
--accent-alt: #a6e22e;
--text: #f8f8f2;
--text-muted: #75715e;
--text-dim: #cfcfc2;
--yellow: #e6db74;
--orange: #fd971f;
--blue: #66d9ef;
--purple: #ae81ff;
--radius: 4px;
--transition: 0.15s ease;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
scrollbar-width: thin;
scrollbar-color: var(--bg-active) transparent;
}

View File

@ -80,7 +80,7 @@ export const ContentEditable = ({ value, placeholder, autoLines, onInput, class:
range.deleteContents();
const endsWithNewline = ref.current.textContent?.endsWith('\n');
const caretAtEnd = getCaretOffset(ref.current) === ref.current.textContent.length;
const caretAtEnd = getCaretOffset(ref.current) === (ref.current.textContent?.length ?? 0);
const newline = document.createTextNode('\n'.repeat((endsWithNewline || !caretAtEnd) ? 1 : 2));
range.insertNode(newline);

View File

@ -3,7 +3,8 @@ export function loadImageData(dataView: DataView, pointer: number) {
const height = dataView.getUint16(pointer + 2, true);
const dataPtr = dataView.getUint32(pointer + 4, true);
const imageBuffer = new Uint8Array(dataView.buffer, dataPtr, width * height * 4);
const imageBuffer = new Uint8ClampedArray(dataView.buffer, dataPtr, width * height * 4);
// @ts-ignore — Bun's Uint8ClampedArray typedef incorrectly narrows the buffer type
const imageData = new ImageData(imageBuffer, width, height);
return imageData;

View File

@ -110,3 +110,34 @@ export const throttle = function <T, A extends unknown[], R>(func: F<T, A, R>, m
export const callUpdater = <T>(f: StateUpdater<T>, prev: T) =>
typeof f === 'function' ? (f as Function)(prev) : f;
export const formatNumber = (n: number): string => {
if (n >= 1_000_000_000_000) return `${(n / 1_000_000_000_000).toFixed(2)}T`;
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B`;
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(2)}k`;
return String(n);
};
export const formatTime = (seconds: number): string => {
const y = Math.floor(seconds / 31536000);
const mo = Math.floor((seconds % 31536000) / 2592000);
const w = Math.floor((seconds % 2592000) / 604800);
const d = Math.floor((seconds % 604800) / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const mi = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const parts: string[] = [];
if (y > 0) parts.push(`${y}y`);
if (mo > 0) parts.push(`${mo}m`);
if (w > 0) parts.push(`${w}w`);
if (d > 0) parts.push(`${d}d`);
const hasBigParts = parts.length > 0;
const hasTime = h > 0 || mi > 0 || s > 0;
if (hasBigParts || hasTime) {
parts.push(`${h}:${String(mi).padStart(2, '0')}:${String(s).padStart(2, '0')}`);
}
return parts.join(' ') || '0:00:00';
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

View File

@ -1 +0,0 @@
isApp: true

View File

@ -1,219 +0,0 @@
/* [0] */
@font-face {
font-family: 'Noto Color Emoji';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/notocoloremoji/v32/Yq6P-KqIXTD0t4D9z1ESnKM3-HpFabsE4tq3luCC7p-aXxcn.0.woff2) format('woff2');
unicode-range: U+1f1e6-1f1ff;
}
/* [1] */
@font-face {
font-family: 'Noto Color Emoji';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/notocoloremoji/v32/Yq6P-KqIXTD0t4D9z1ESnKM3-HpFabsE4tq3luCC7p-aXxcn.1.woff2) format('woff2');
unicode-range: U+200d, U+2620, U+26a7, U+fe0f, U+1f308, U+1f38c, U+1f3c1, U+1f3f3-1f3f4, U+1f6a9, U+e0062-e0063, U+e0065, U+e0067, U+e006c, U+e006e, U+e0073-e0074, U+e0077, U+e007f;
}
/* [2] */
@font-face {
font-family: 'Noto Color Emoji';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/notocoloremoji/v32/Yq6P-KqIXTD0t4D9z1ESnKM3-HpFabsE4tq3luCC7p-aXxcn.2.woff2) format('woff2');
unicode-range: U+23, U+2a, U+30-39, U+a9, U+ae, U+200d, U+203c, U+2049, U+20e3, U+2122, U+2139, U+2194-2199, U+21a9-21aa, U+23cf, U+23e9-23ef, U+23f8-23fa, U+24c2, U+25aa-25ab, U+25b6, U+25c0, U+25fb-25fe, U+2611, U+2622-2623, U+2626, U+262a, U+262e-262f, U+2638, U+2640, U+2642, U+2648-2653, U+2660, U+2663, U+2665-2666, U+2668, U+267b, U+267e-267f, U+2695, U+269b-269c, U+26a0, U+26a7, U+26aa-26ab, U+26ce, U+26d4, U+2705, U+2714, U+2716, U+271d, U+2721, U+2733-2734, U+2747, U+274c, U+274e, U+2753-2755, U+2757, U+2764, U+2795-2797, U+27a1, U+27b0, U+27bf, U+2934-2935, U+2b05-2b07, U+2b1b-2b1c, U+2b55, U+3030, U+303d, U+3297, U+3299, U+fe0f, U+1f170-1f171, U+1f17e-1f17f, U+1f18e, U+1f191-1f19a, U+1f201-1f202, U+1f21a, U+1f22f, U+1f232-1f23a, U+1f250-1f251, U+1f310, U+1f3a6, U+1f3b5-1f3b6, U+1f3bc, U+1f3e7, U+1f441, U+1f499-1f49c, U+1f49f-1f4a0, U+1f4a2, U+1f4ac-1f4ad, U+1f4b1-1f4b2, U+1f4b9, U+1f4db, U+1f4f2-1f4f6, U+1f500-1f50a, U+1f515, U+1f518-1f524, U+1f52f-1f53d, U+1f549, U+1f54e, U+1f5a4, U+1f5e8, U+1f5ef, U+1f6ab, U+1f6ad-1f6b1, U+1f6b3, U+1f6b7-1f6bc, U+1f6be, U+1f6c2-1f6c5, U+1f6d0-1f6d1, U+1f6d7, U+1f6dc, U+1f7e0-1f7eb, U+1f7f0, U+1f90d-1f90e, U+1f9e1, U+1fa75-1fa77, U+1faaf;
}
/* [3] */
@font-face {
font-family: 'Noto Color Emoji';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/notocoloremoji/v32/Yq6P-KqIXTD0t4D9z1ESnKM3-HpFabsE4tq3luCC7p-aXxcn.3.woff2) format('woff2');
unicode-range: U+231a-231b, U+2328, U+23f0-23f3, U+2602, U+260e, U+2692, U+2694, U+2696-2697, U+2699, U+26b0-26b1, U+26cf, U+26d1, U+26d3, U+2702, U+2709, U+270f, U+2712, U+fe0f, U+1f302, U+1f321, U+1f392-1f393, U+1f3a9, U+1f3bd, U+1f3ee, U+1f3f7, U+1f3fa, U+1f451-1f462, U+1f484, U+1f489-1f48a, U+1f48c-1f48e, U+1f4a1, U+1f4a3, U+1f4b0, U+1f4b3-1f4b8, U+1f4bb-1f4da, U+1f4dc-1f4f1, U+1f4ff, U+1f50b-1f514, U+1f516-1f517, U+1f526-1f529, U+1f52c-1f52e, U+1f550-1f567, U+1f56f-1f570, U+1f576, U+1f587, U+1f58a-1f58d, U+1f5a5, U+1f5a8, U+1f5b1-1f5b2, U+1f5c2-1f5c4, U+1f5d1-1f5d3, U+1f5dc-1f5de, U+1f5e1, U+1f5f3, U+1f6aa, U+1f6ac, U+1f6bd, U+1f6bf, U+1f6c1, U+1f6cb, U+1f6cd-1f6cf, U+1f6d2, U+1f6e0-1f6e1, U+1f6f0, U+1f97b-1f97f, U+1f9af, U+1f9ba, U+1f9e2-1f9e6, U+1f9ea-1f9ec, U+1f9ee-1f9f4, U+1f9f7-1f9ff, U+1fa71-1fa74, U+1fa79-1fa7b, U+1fa86, U+1fa91-1fa93, U+1fa96, U+1fa99-1faa0, U+1faa2-1faa7, U+1faaa-1faae;
}
/* [4] */
@font-face {
font-family: 'Noto Color Emoji';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/notocoloremoji/v32/Yq6P-KqIXTD0t4D9z1ESnKM3-HpFabsE4tq3luCC7p-aXxcn.4.woff2) format('woff2');
unicode-range: U+265f, U+26bd-26be, U+26f3, U+26f8, U+fe0f, U+1f004, U+1f0cf, U+1f380-1f384, U+1f386-1f38b, U+1f38d-1f391, U+1f396-1f397, U+1f399-1f39b, U+1f39e-1f39f, U+1f3a3-1f3a5, U+1f3a7-1f3a9, U+1f3ab-1f3b4, U+1f3b7-1f3bb, U+1f3bd-1f3c0, U+1f3c5-1f3c6, U+1f3c8-1f3c9, U+1f3cf-1f3d3, U+1f3f8-1f3f9, U+1f47e, U+1f4e2, U+1f4f7-1f4fd, U+1f52b, U+1f579, U+1f58c-1f58d, U+1f5bc, U+1f6f7, U+1f6f9, U+1f6fc, U+1f93f, U+1f941, U+1f945, U+1f947-1f94f, U+1f9e7-1f9e9, U+1f9f5-1f9f6, U+1fa70-1fa71, U+1fa80-1fa81, U+1fa83-1fa85, U+1fa87-1fa88, U+1fa94-1fa95, U+1fa97-1fa98, U+1faa1, U+1faa9;
}
/* [5] */
@font-face {
font-family: 'Noto Color Emoji';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/notocoloremoji/v32/Yq6P-KqIXTD0t4D9z1ESnKM3-HpFabsE4tq3luCC7p-aXxcn.5.woff2) format('woff2');
unicode-range: U+2693, U+26e9-26ea, U+26f1-26f2, U+26f4-26f5, U+26fa, U+26fd, U+2708, U+fe0f, U+1f301, U+1f303, U+1f306-1f307, U+1f309, U+1f310, U+1f3a0-1f3a2, U+1f3aa, U+1f3cd-1f3ce, U+1f3d5, U+1f3d7-1f3db, U+1f3df-1f3e6, U+1f3e8-1f3ed, U+1f3ef-1f3f0, U+1f488, U+1f492, U+1f4ba, U+1f54b-1f54d, U+1f5fa-1f5ff, U+1f680-1f6a2, U+1f6a4-1f6a8, U+1f6b2, U+1f6d1, U+1f6d5-1f6d6, U+1f6dd-1f6df, U+1f6e2-1f6e5, U+1f6e9, U+1f6eb-1f6ec, U+1f6f3-1f6f6, U+1f6f8, U+1f6fa-1f6fb, U+1f9bc-1f9bd, U+1f9ed, U+1f9f3, U+1fa7c;
}
/* [6] */
@font-face {
font-family: 'Noto Color Emoji';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/notocoloremoji/v32/Yq6P-KqIXTD0t4D9z1ESnKM3-HpFabsE4tq3luCC7p-aXxcn.6.woff2) format('woff2');
unicode-range: U+2615, U+fe0f, U+1f32d-1f330, U+1f336, U+1f33d, U+1f345-1f37f, U+1f382, U+1f52a, U+1f942-1f944, U+1f950-1f96f, U+1f99e, U+1f9aa, U+1f9c0-1f9cb, U+1fad0-1fadb;
}
/* [7] */
@font-face {
font-family: 'Noto Color Emoji';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/notocoloremoji/v32/Yq6P-KqIXTD0t4D9z1ESnKM3-HpFabsE4tq3luCC7p-aXxcn.7.woff2) format('woff2');
unicode-range: U+200d, U+2600-2601, U+2603-2604, U+2614, U+2618, U+26a1, U+26c4-26c5, U+26c8, U+26f0, U+2728, U+2744, U+2b1b, U+2b50, U+fe0f, U+1f300, U+1f304-1f305, U+1f308, U+1f30a-1f30f, U+1f311-1f321, U+1f324-1f32c, U+1f331-1f335, U+1f337-1f33c, U+1f33e-1f344, U+1f3d4, U+1f3d6, U+1f3dc-1f3de, U+1f3f5, U+1f400-1f43f, U+1f490, U+1f4a7, U+1f4ab, U+1f4ae, U+1f525, U+1f54a, U+1f573, U+1f577-1f578, U+1f648-1f64a, U+1f940, U+1f980-1f9ae, U+1f9ba, U+1fa90, U+1faa8, U+1fab0-1fabd, U+1fabf, U+1face-1facf, U+1fae7;
}
/* [8] */
@font-face {
font-family: 'Noto Color Emoji';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/notocoloremoji/v32/Yq6P-KqIXTD0t4D9z1ESnKM3-HpFabsE4tq3luCC7p-aXxcn.8.woff2) format('woff2');
unicode-range: U+200d, U+2640, U+2642, U+2695-2696, U+26f7, U+26f9, U+2708, U+2764, U+fe0f, U+1f33e, U+1f373, U+1f37c, U+1f384-1f385, U+1f393, U+1f3a4, U+1f3a8, U+1f3c2-1f3c4, U+1f3c7, U+1f3ca-1f3cc, U+1f3eb, U+1f3ed, U+1f3fb-1f3ff, U+1f466-1f478, U+1f47c, U+1f481-1f483, U+1f486-1f487, U+1f48b, U+1f48f, U+1f491, U+1f4bb-1f4bc, U+1f527, U+1f52c, U+1f574-1f575, U+1f57a, U+1f645-1f647, U+1f64b, U+1f64d-1f64e, U+1f680, U+1f692, U+1f6a3, U+1f6b4-1f6b6, U+1f6c0, U+1f6cc, U+1f91d, U+1f926, U+1f930-1f931, U+1f934-1f93a, U+1f93c-1f93e, U+1f977, U+1f9af-1f9b3, U+1f9b8-1f9b9, U+1f9bc-1f9bd, U+1f9cc-1f9cf, U+1f9d1-1f9df, U+1fa82, U+1fac3-1fac5;
}
/* [9] */
@font-face {
font-family: 'Noto Color Emoji';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/notocoloremoji/v32/Yq6P-KqIXTD0t4D9z1ESnKM3-HpFabsE4tq3luCC7p-aXxcn.9.woff2) format('woff2');
unicode-range: U+200d, U+261d, U+2620, U+2639-263a, U+2665, U+270a-270d, U+2728, U+2763-2764, U+2b50, U+fe0f, U+1f31a-1f31f, U+1f32b, U+1f383, U+1f389, U+1f3fb-1f3ff, U+1f440-1f450, U+1f463-1f465, U+1f479-1f47b, U+1f47d-1f480, U+1f485, U+1f48b-1f48c, U+1f493-1f49f, U+1f4a4-1f4a6, U+1f4a8-1f4ab, U+1f4af, U+1f525, U+1f573, U+1f590, U+1f595-1f596, U+1f5a4, U+1f5e3, U+1f600-1f644, U+1f648-1f64a, U+1f64c, U+1f64f, U+1f90c-1f925, U+1f927-1f92f, U+1f932-1f933, U+1f970-1f976, U+1f978-1f97a, U+1f9a0, U+1f9b4-1f9b7, U+1f9bb, U+1f9be-1f9bf, U+1f9d0, U+1f9e0-1f9e1, U+1fa75-1fa79, U+1fac0-1fac2, U+1fae0-1fae6, U+1fae8, U+1faf0-1faf8;
}
/* [10] */
@font-face {
font-family: 'Noto Color Emoji';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/notocoloremoji/v32/Yq6P-KqIXTD0t4D9z1ESnKM3-HpFabsE4tq3luCC7p-aXxcn.10.woff2) format('woff2');
unicode-range: U+200d, U+2194-2195, U+2640, U+2642, U+26d3, U+27a1, U+fe0f, U+1f344, U+1f34b, U+1f3c3, U+1f3fb-1f3ff, U+1f426, U+1f468-1f469, U+1f4a5, U+1f525, U+1f642, U+1f6b6, U+1f7e9, U+1f7eb, U+1f9af, U+1f9bc-1f9bd, U+1f9ce, U+1f9d1-1f9d2;
}
/* [11] */
@font-face {
font-family: 'Noto Color Emoji';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/notocoloremoji/v32/Yq6P-KqIXTD0t4D9z1ESnKM3-HpFabsE4tq3luCC7p-aXxcn.11.woff2) format('woff2');
unicode-range: U+1fa89, U+1fa8f, U+1fabe, U+1fac6, U+1fadc, U+1fadf, U+1fae9;
}
/* [0] */
@font-face {
font-family: 'Noto Emoji';
font-style: normal;
font-weight: 300 700;
font-display: swap;
src: url(https://fonts.gstatic.com/s/notoemoji/v51/bMrymSyK7YY-MEu6aWjPFMHQUnEOtg_Uy9ZzkQ.0.woff2) format('woff2');
unicode-range: U+1f1e6-1f1ff;
}
/* [1] */
@font-face {
font-family: 'Noto Emoji';
font-style: normal;
font-weight: 300 700;
font-display: swap;
src: url(https://fonts.gstatic.com/s/notoemoji/v51/bMrymSyK7YY-MEu6aWjPFMHQUnEOtg_Uy9ZzkQ.1.woff2) format('woff2');
unicode-range: U+200d, U+2620, U+26a7, U+fe0f, U+1f308, U+1f38c, U+1f3c1, U+1f3f3-1f3f4, U+1f6a9, U+e0062-e0063, U+e0065, U+e0067, U+e006c, U+e006e, U+e0073-e0074, U+e0077, U+e007f;
}
/* [2] */
@font-face {
font-family: 'Noto Emoji';
font-style: normal;
font-weight: 300 700;
font-display: swap;
src: url(https://fonts.gstatic.com/s/notoemoji/v51/bMrymSyK7YY-MEu6aWjPFMHQUnEOtg_Uy9ZzkQ.2.woff2) format('woff2');
unicode-range: U+23, U+2a, U+30-39, U+a9, U+ae, U+200d, U+203c, U+2049, U+20e3, U+2122, U+2139, U+2194-2199, U+21a9-21aa, U+23cf, U+23e9-23ef, U+23f8-23fa, U+24c2, U+25aa-25ab, U+25b6, U+25c0, U+25fb-25fe, U+2611, U+2622-2623, U+2626, U+262a, U+262e-262f, U+2638, U+2640, U+2642, U+2648-2653, U+2660, U+2663, U+2665-2666, U+2668, U+267b, U+267e-267f, U+2695, U+269b-269c, U+26a0, U+26a7, U+26aa-26ab, U+26ce, U+26d4, U+2705, U+2714, U+2716, U+271d, U+2721, U+2733-2734, U+2747, U+274c, U+274e, U+2753-2755, U+2757, U+2764, U+2795-2797, U+27a1, U+27b0, U+27bf, U+2934-2935, U+2b05-2b07, U+2b1b-2b1c, U+2b55, U+3030, U+303d, U+3297, U+3299, U+fe0f, U+1f170-1f171, U+1f17e-1f17f, U+1f18e, U+1f191-1f19a, U+1f201-1f202, U+1f21a, U+1f22f, U+1f232-1f23a, U+1f250-1f251, U+1f310, U+1f3a6, U+1f3b5-1f3b6, U+1f3bc, U+1f3e7, U+1f441, U+1f499-1f49c, U+1f49f-1f4a0, U+1f4a2, U+1f4ac-1f4ad, U+1f4b1-1f4b2, U+1f4b9, U+1f4db, U+1f4f2-1f4f6, U+1f500-1f50a, U+1f515, U+1f518-1f524, U+1f52f-1f53d, U+1f549, U+1f54e, U+1f5a4, U+1f5e8, U+1f5ef, U+1f6ab, U+1f6ad-1f6b1, U+1f6b3, U+1f6b7-1f6bc, U+1f6be, U+1f6c2-1f6c5, U+1f6d0-1f6d1, U+1f6d7, U+1f6dc, U+1f7e0-1f7eb, U+1f7f0, U+1f90d-1f90e, U+1f9e1, U+1fa75-1fa77, U+1faaf;
}
/* [3] */
@font-face {
font-family: 'Noto Emoji';
font-style: normal;
font-weight: 300 700;
font-display: swap;
src: url(https://fonts.gstatic.com/s/notoemoji/v51/bMrymSyK7YY-MEu6aWjPFMHQUnEOtg_Uy9ZzkQ.3.woff2) format('woff2');
unicode-range: U+231a-231b, U+2328, U+23f0-23f3, U+2602, U+260e, U+2692, U+2694, U+2696-2697, U+2699, U+26b0-26b1, U+26cf, U+26d1, U+26d3, U+2702, U+2709, U+270f, U+2712, U+fe0f, U+1f302, U+1f321, U+1f392-1f393, U+1f3a9, U+1f3bd, U+1f3ee, U+1f3f7, U+1f3fa, U+1f451-1f462, U+1f484, U+1f489-1f48a, U+1f48c-1f48e, U+1f4a1, U+1f4a3, U+1f4b0, U+1f4b3-1f4b8, U+1f4bb-1f4da, U+1f4dc-1f4f1, U+1f4ff, U+1f50b-1f514, U+1f516-1f517, U+1f526-1f529, U+1f52c-1f52e, U+1f550-1f567, U+1f56f-1f570, U+1f576, U+1f587, U+1f58a-1f58d, U+1f5a5, U+1f5a8, U+1f5b1-1f5b2, U+1f5c2-1f5c4, U+1f5d1-1f5d3, U+1f5dc-1f5de, U+1f5e1, U+1f5f3, U+1f6aa, U+1f6ac, U+1f6bd, U+1f6bf, U+1f6c1, U+1f6cb, U+1f6cd-1f6cf, U+1f6d2, U+1f6e0-1f6e1, U+1f6f0, U+1f97b-1f97f, U+1f9af, U+1f9ba, U+1f9e2-1f9e6, U+1f9ea-1f9ec, U+1f9ee-1f9f4, U+1f9f7-1f9ff, U+1fa71-1fa74, U+1fa79-1fa7b, U+1fa86, U+1fa91-1fa93, U+1fa96, U+1fa99-1faa0, U+1faa2-1faa7, U+1faaa-1faae;
}
/* [4] */
@font-face {
font-family: 'Noto Emoji';
font-style: normal;
font-weight: 300 700;
font-display: swap;
src: url(https://fonts.gstatic.com/s/notoemoji/v51/bMrymSyK7YY-MEu6aWjPFMHQUnEOtg_Uy9ZzkQ.4.woff2) format('woff2');
unicode-range: U+265f, U+26bd-26be, U+26f3, U+26f8, U+fe0f, U+1f004, U+1f0cf, U+1f380-1f384, U+1f386-1f38b, U+1f38d-1f391, U+1f396-1f397, U+1f399-1f39b, U+1f39e-1f39f, U+1f3a3-1f3a5, U+1f3a7-1f3a9, U+1f3ab-1f3b4, U+1f3b7-1f3bb, U+1f3bd-1f3c0, U+1f3c5-1f3c6, U+1f3c8-1f3c9, U+1f3cf-1f3d3, U+1f3f8-1f3f9, U+1f47e, U+1f4e2, U+1f4f7-1f4fd, U+1f52b, U+1f579, U+1f58c-1f58d, U+1f5bc, U+1f6f7, U+1f6f9, U+1f6fc, U+1f93f, U+1f941, U+1f945, U+1f947-1f94f, U+1f9e7-1f9e9, U+1f9f5-1f9f6, U+1fa70-1fa71, U+1fa80-1fa81, U+1fa83-1fa85, U+1fa87-1fa88, U+1fa94-1fa95, U+1fa97-1fa98, U+1faa1, U+1faa9;
}
/* [5] */
@font-face {
font-family: 'Noto Emoji';
font-style: normal;
font-weight: 300 700;
font-display: swap;
src: url(https://fonts.gstatic.com/s/notoemoji/v51/bMrymSyK7YY-MEu6aWjPFMHQUnEOtg_Uy9ZzkQ.5.woff2) format('woff2');
unicode-range: U+2693, U+26e9-26ea, U+26f1-26f2, U+26f4-26f5, U+26fa, U+26fd, U+2708, U+fe0f, U+1f301, U+1f303, U+1f306-1f307, U+1f309, U+1f310, U+1f3a0-1f3a2, U+1f3aa, U+1f3cd-1f3ce, U+1f3d5, U+1f3d7-1f3db, U+1f3df-1f3e6, U+1f3e8-1f3ed, U+1f3ef-1f3f0, U+1f488, U+1f492, U+1f4ba, U+1f54b-1f54d, U+1f5fa-1f5ff, U+1f680-1f6a2, U+1f6a4-1f6a8, U+1f6b2, U+1f6d1, U+1f6d5-1f6d6, U+1f6dd-1f6df, U+1f6e2-1f6e5, U+1f6e9, U+1f6eb-1f6ec, U+1f6f3-1f6f6, U+1f6f8, U+1f6fa-1f6fb, U+1f9bc-1f9bd, U+1f9ed, U+1f9f3, U+1fa7c;
}
/* [6] */
@font-face {
font-family: 'Noto Emoji';
font-style: normal;
font-weight: 300 700;
font-display: swap;
src: url(https://fonts.gstatic.com/s/notoemoji/v51/bMrymSyK7YY-MEu6aWjPFMHQUnEOtg_Uy9ZzkQ.6.woff2) format('woff2');
unicode-range: U+2615, U+fe0f, U+1f32d-1f330, U+1f336, U+1f33d, U+1f345-1f37f, U+1f382, U+1f52a, U+1f942-1f944, U+1f950-1f96f, U+1f99e, U+1f9aa, U+1f9c0-1f9cb, U+1fad0-1fadb;
}
/* [7] */
@font-face {
font-family: 'Noto Emoji';
font-style: normal;
font-weight: 300 700;
font-display: swap;
src: url(https://fonts.gstatic.com/s/notoemoji/v51/bMrymSyK7YY-MEu6aWjPFMHQUnEOtg_Uy9ZzkQ.7.woff2) format('woff2');
unicode-range: U+200d, U+2600-2601, U+2603-2604, U+2614, U+2618, U+26a1, U+26c4-26c5, U+26c8, U+26f0, U+2728, U+2744, U+2b1b, U+2b50, U+fe0f, U+1f300, U+1f304-1f305, U+1f308, U+1f30a-1f30f, U+1f311-1f321, U+1f324-1f32c, U+1f331-1f335, U+1f337-1f33c, U+1f33e-1f344, U+1f3d4, U+1f3d6, U+1f3dc-1f3de, U+1f3f5, U+1f400-1f43f, U+1f490, U+1f4a7, U+1f4ab, U+1f4ae, U+1f525, U+1f54a, U+1f573, U+1f577-1f578, U+1f648-1f64a, U+1f940, U+1f980-1f9ae, U+1f9ba, U+1fa90, U+1faa8, U+1fab0-1fabd, U+1fabf, U+1face-1facf, U+1fae7;
}
/* [8] */
@font-face {
font-family: 'Noto Emoji';
font-style: normal;
font-weight: 300 700;
font-display: swap;
src: url(https://fonts.gstatic.com/s/notoemoji/v51/bMrymSyK7YY-MEu6aWjPFMHQUnEOtg_Uy9ZzkQ.8.woff2) format('woff2');
unicode-range: U+200d, U+2640, U+2642, U+2695-2696, U+26f7, U+26f9, U+2708, U+2764, U+fe0f, U+1f33e, U+1f373, U+1f37c, U+1f384-1f385, U+1f393, U+1f3a4, U+1f3a8, U+1f3c2-1f3c4, U+1f3c7, U+1f3ca-1f3cc, U+1f3eb, U+1f3ed, U+1f3fb-1f3ff, U+1f466-1f478, U+1f47c, U+1f481-1f483, U+1f486-1f487, U+1f48b, U+1f48f, U+1f491, U+1f4bb-1f4bc, U+1f527, U+1f52c, U+1f574-1f575, U+1f57a, U+1f645-1f647, U+1f64b, U+1f64d-1f64e, U+1f680, U+1f692, U+1f6a3, U+1f6b4-1f6b6, U+1f6c0, U+1f6cc, U+1f91d, U+1f926, U+1f930-1f931, U+1f934-1f93a, U+1f93c-1f93e, U+1f977, U+1f9af-1f9b3, U+1f9b8-1f9b9, U+1f9bc-1f9bd, U+1f9cc-1f9cf, U+1f9d1-1f9df, U+1fa82, U+1fac3-1fac5;
}
/* [9] */
@font-face {
font-family: 'Noto Emoji';
font-style: normal;
font-weight: 300 700;
font-display: swap;
src: url(https://fonts.gstatic.com/s/notoemoji/v51/bMrymSyK7YY-MEu6aWjPFMHQUnEOtg_Uy9ZzkQ.9.woff2) format('woff2');
unicode-range: U+200d, U+261d, U+2620, U+2639-263a, U+2665, U+270a-270d, U+2728, U+2763-2764, U+2b50, U+fe0f, U+1f31a-1f31f, U+1f32b, U+1f383, U+1f389, U+1f3fb-1f3ff, U+1f440-1f450, U+1f463-1f465, U+1f479-1f47b, U+1f47d-1f480, U+1f485, U+1f48b-1f48c, U+1f493-1f49f, U+1f4a4-1f4a6, U+1f4a8-1f4ab, U+1f4af, U+1f525, U+1f573, U+1f590, U+1f595-1f596, U+1f5a4, U+1f5e3, U+1f600-1f644, U+1f648-1f64a, U+1f64c, U+1f64f, U+1f90c-1f925, U+1f927-1f92f, U+1f932-1f933, U+1f970-1f976, U+1f978-1f97a, U+1f9a0, U+1f9b4-1f9b7, U+1f9bb, U+1f9be-1f9bf, U+1f9d0, U+1f9e0-1f9e1, U+1fa75-1fa79, U+1fac0-1fac2, U+1fae0-1fae6, U+1fae8, U+1faf0-1faf8;
}
/* [10] */
@font-face {
font-family: 'Noto Emoji';
font-style: normal;
font-weight: 300 700;
font-display: swap;
src: url(https://fonts.gstatic.com/s/notoemoji/v51/bMrymSyK7YY-MEu6aWjPFMHQUnEOtg_Uy9ZzkQ.10.woff2) format('woff2');
unicode-range: U+200d, U+2194-2195, U+2640, U+2642, U+26d3, U+27a1, U+fe0f, U+1f344, U+1f34b, U+1f3c3, U+1f3fb-1f3ff, U+1f426, U+1f468-1f469, U+1f4a5, U+1f525, U+1f642, U+1f6b6, U+1f7e9, U+1f7eb, U+1f9af, U+1f9bc-1f9bd, U+1f9ce, U+1f9d1-1f9d2;
}
/* [11] */
@font-face {
font-family: 'Noto Emoji';
font-style: normal;
font-weight: 300 700;
font-display: swap;
src: url(https://fonts.gstatic.com/s/notoemoji/v51/bMrymSyK7YY-MEu6aWjPFMHQUnEOtg_Uy9ZzkQ.11.woff2) format('woff2');
unicode-range: U+1fa89, U+1fa8f, U+1fabe, U+1fac6, U+1fadc, U+1fadf, U+1fae9;
}
body {
--google-font-color-notocoloremoji:colrv1;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

View File

@ -1,162 +0,0 @@
:root {
--backgroundColorDark: rgba(0, 0, 0, 0.3);
--backgroundColor: rgba(51, 51, 51, 0.9);
--color: #DCDCD2;
--italicColor: #AFAFAF;
--quoteColor: #D4E5FF;
--green: #AFAFAF;
--red: #7F0000;
--green: #007F00;
--brightRed: #DD0000;
--brightGreen: #00DD00;
--shadeColor: rgba(0, 128, 128, 0.3);
--border: 1px solid var(--color);
--border-radius: 4px;
--emojiFont: "Noto Emoji", sans-serif;
--emojiColorFont: "Noto Color Emoji", sans-serif;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--color) transparent;
}
textarea,
input,
select {
color: var(--color);
border: var(--border);
background-color: var(--backgroundColorDark);
font-size: 1em;
font-family: sans-serif;
outline: none;
}
option, optgroup {
background-color: var(--backgroundColor);
}
textarea {
resize: vertical;
width: 100%;
min-height: 100px;
padding: 4px;
line-height: 1.5;
}
button {
border: var(--border);
background-color: var(--backgroundColor);
color: var(--color);
cursor: pointer;
user-select: none;
&.disabled {
pointer-events: none;
opacity: 0.5;
}
&.icon {
font-family: var(--emojiFont);
font-size: 20px;
border: none;
background: none;
padding: 0;
&.color {
font-family: var(--emojiColorFont);
}
}
}
body {
color: var(--color);
width: 100dvw;
height: 100dvh;
font-size: 16px;
line-height: 1.5;
touch-action: none;
.root {
background-size: cover;
background-position: center;
background-repeat: no-repeat;
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
justify-content: center;
background-color: var(--backgroundColor);
}
.app {
display: flex;
flex-direction: column;
background-color: var(--backgroundColor);
width: 100%;
max-width: 1200px;
height: 100%;
max-height: 100dvh;
>.chat {
display: flex;
flex-direction: column;
height: 100%;
flex-grow: 1;
max-width: 100%;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--color) transparent;
border: var(--border);
border-bottom: none;
border-top: none;
}
>.chat-input {
display: flex;
flex-direction: row;
height: auto;
width: 100%;
>textarea {
min-height: 48px;
resize: none;
background-color: var(--backgroundColor);
}
}
}
}
.ace_editor {
background-color: var(--backgroundColorDark) !important;
border: var(--border) !important;
}
@keyframes swipe-from-left {
0% {
position: relative;
left: -100%;
}
100% {
position: relative;
left: 0;
}
}
@keyframes swipe-from-right {
0% {
position: relative;
right: -100%;
}
100% {
position: relative;
right: 0;
}
}

View File

@ -1,61 +0,0 @@
import { useEffect, useMemo, useRef } from "preact/hooks";
import ace from "ace-builds";
import { useIsVisible } from "@common/hooks/useIsVisible";
import "ace-builds/src-noconflict/mode-django";
import "ace-builds/src-noconflict/theme-terminal";
interface IAceProps {
value: string;
onInput: (e: InputEvent | string) => void;
}
export const Ace = ({ value, onInput }: IAceProps) => {
const ref = useRef<HTMLDivElement>(null);
const isVisible = useIsVisible(ref);
const editor = useMemo(() => {
if (ref.current) {
const e = ace.edit(ref.current, {
theme: 'ace/theme/terminal',
mode: 'ace/mode/django',
showGutter: false,
showPrintMargin: false,
highlightActiveLine: false,
displayIndentGuides: false,
fontSize: 16,
maxLines: Infinity,
tabSize: 2,
useSoftTabs: true,
wrap: "free",
});
return e;
}
}, [isVisible]);
useEffect(() => {
if (editor) {
if (editor.getValue() !== value) {
const pos = editor.getCursorPosition();
editor.setValue(value);
editor.selection.clearSelection();
editor.moveCursorToPosition(pos);
}
}
}, [editor, value]);
useEffect(() => {
if (onInput && editor) {
const e = editor;
const handler = () => onInput(e.getValue());
e.on('input', handler);
return () => e.off('input', handler);
}
}, [editor, onInput]);
return (
<div ref={ref} />
);
}

View File

@ -1,17 +0,0 @@
import { Header } from "./header/header";
import { Chat } from "./chat";
import { Input } from "./input";
import bgImage from '../assets/bg.jpg';
export const App = () => {
return (
<div class='root' style={{ backgroundImage: `url('${bgImage.src}')` }}>
<div class='app'>
<Header />
<Chat />
<Input />
</div>
</div>
);
};

View File

@ -1,22 +0,0 @@
import { useEffect, useRef } from "preact/hooks";
import type { JSX } from "preact/jsx-runtime"
import { useIsVisible } from '@common/hooks/useIsVisible';
import { DOMTools } from "../tools/dom";
export const AutoTextarea = (props: JSX.HTMLAttributes<HTMLTextAreaElement>) => {
const { value } = props;
const ref = useRef<HTMLTextAreaElement>(null);
const isVisible = useIsVisible(ref);
useEffect(() => {
if (ref.current && isVisible) {
const area = ref.current;
const { height } = DOMTools.calculateNodeHeight(area);
area.style.height = `${height}px`;
}
}, [value, isVisible]);
return <textarea {...props} ref={ref} />
};

View File

@ -1,32 +0,0 @@
import { useContext, useEffect, useRef } from "preact/hooks";
import { StateContext } from "../contexts/state";
import { Message } from "./message/message";
import { MessageTools } from "../tools/messages";
import { DOMTools } from "../tools/dom";
export const Chat = () => {
const { messages } = useContext(StateContext);
const chatRef = useRef<HTMLDivElement>(null);
const lastMessage = messages.at(-1);
const lastMessageSwipe = MessageTools.getSwipe(lastMessage);
const lastMessageContent = lastMessageSwipe?.content;
const lastUserId = messages.findLastIndex(m => m.role === 'user');
const lastAssistantId = messages.findLastIndex(m => m.role === 'assistant');
useEffect(() => {
DOMTools.scrollDown(chatRef.current);
}, [messages.length, lastMessageContent]);
return (
<div class="chat" ref={chatRef}>
{messages.map((m, i) => (
<Message
message={m}
key={i} index={i}
isLastUser={i === lastUserId} isLastAssistant={i === lastAssistantId}
/>
))}
</div>
);
}

View File

@ -1,160 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
import { useInputState } from '@common/hooks/useInputState';
import { useInputCallback } from '@common/hooks/useInputCallback';
import { useAsyncEffect } from '@common/hooks/useAsyncEffect';
import { usePrevious } from '@common/hooks/usePrevious';
import { Connection, HORDE_ANON_KEY, type IConnection, type IHordeModel } from '../../tools/connection';
import { Huggingface } from '../../tools/huggingface';
import { INSTRUCTS } from '../../contexts/state';
import styles from './header.module.css';
interface IProps {
connection: IConnection;
setConnection: (c: IConnection) => void;
}
export const ConnectionEditor = ({ connection, setConnection }: IProps) => {
// kobold
const [connectionUrl, setConnectionUrl] = useInputState('');
// horde
const [apiKey, setApiKey] = useInputState(HORDE_ANON_KEY);
const [modelName, setModelName] = useInputState('');
const prevModelName = usePrevious(modelName);
const [instruct, setInstruct] = useInputState('');
const [modelTemplate, setModelTemplate] = useInputState('');
const [hordeModels, setHordeModels] = useState<IHordeModel[]>([]);
const [contextLength, setContextLength] = useState<number>(0);
const isOnline = useMemo(() => contextLength > 0, [contextLength]);
useEffect(() => {
setInstruct(connection.instruct);
connection.url && setConnectionUrl(connection.url);
connection.model && setModelName(connection.model);
setApiKey(connection.apiKey || HORDE_ANON_KEY);
if (connection.type === 'kobold') {
Connection.getContextLength(connection).then(setContextLength);
} else if (connection.type === 'horde') {
Connection.getHordeModels()
.then(m => setHordeModels(Array.from(
m.values())
.sort((a, b) =>
b.maxContext - a.maxContext || a.name.localeCompare(b.name)
)
));
}
}, [connection]);
useAsyncEffect(async () => {
if (!modelName) return;
const template = await Huggingface.findModelTemplate(modelName);
if (!template) return;
setModelTemplate(template);
if (prevModelName) {
setInstruct(template);
}
}, [modelName]);
const setBackendType = useInputCallback((type) => {
switch (type) {
case 'kobold':
case 'horde':
setConnection({
type,
instruct,
url: connectionUrl,
apiKey,
model: modelName,
});
break;
}
}, [setConnection, connectionUrl, apiKey, modelName, instruct]);
const handleSetInstruct = useInputCallback((instruct: string) => {
setConnection({ ...connection, instruct });
}, [setConnection, connection]);
const handleBlurUrl = useCallback(() => {
const regex = /^(?:http(s?):\/\/)?(.*?)\/?$/i;
const url = connectionUrl.replace(regex, 'http$1://$2');
setConnection({
type: 'kobold',
instruct,
url,
apiKey,
model: modelName,
});
}, [connectionUrl, instruct, setConnection]);
const handleBlurHorde = useCallback(() => {
setConnection({
type: 'horde',
instruct,
url: connectionUrl,
apiKey,
model: modelName,
});
}, [apiKey, modelName, instruct, setConnection]);
return (
<div class={styles.connectionEditor}>
<select value={connection.type} onChange={setBackendType}>
<option value='kobold'>Kobold CPP</option>
<option value='horde'>Horde</option>
</select>
<select value={instruct} onChange={handleSetInstruct} title='Instruct template'>
{modelName && modelTemplate && <optgroup label='Native model template'>
<option value={modelTemplate} title='Native for model'>{modelName}</option>
</optgroup>}
<optgroup label='Manual templates'>
{Object.entries(INSTRUCTS).map(([label, value]) => (
<option value={value} key={value}>
{label}
</option>
))}
</optgroup>
{instruct !== modelTemplate
&& !Object.values(INSTRUCTS).includes(instruct)
&& <optgroup label='Custom'>
<option value={connection.instruct}>Custom</option>
</optgroup>
}
</select>
{connection.type === 'kobold' && <input
value={connectionUrl}
onInput={setConnectionUrl}
onBlur={handleBlurUrl}
class={isOnline ? styles.valid : styles.invalid}
/>}
{connection.type === 'horde' && <>
<input
placeholder='Horde API key'
title='Horde API key'
value={apiKey}
onInput={setApiKey}
onBlur={handleBlurHorde}
/>
<select
value={modelName}
onChange={setModelName}
onBlur={handleBlurHorde}
title='Horde model'
>
{hordeModels.map((m) => (
<option value={m.name} key={m.name}>
{m.name} ({m.maxLength}/{m.maxContext})
</option>
))}
</select>
</>}
</div>
);
};

View File

@ -1,97 +0,0 @@
.header {
display: flex;
flex-direction: row;
justify-content: space-between;
height: 36px;
width: 100%;
border: var(--border);
.valid {
background-color: var(--green);
}
.invalid {
background-color: var(--red);
}
.inputs {
display: flex;
flex-direction: row;
}
.info {
margin: 0 8px;
line-height: 36px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 16px;
.notImportant {
@media (width <= 600px) {
display: none;
}
}
}
.buttons {
display: flex;
flex-direction: row;
gap: 8px;
padding: 0 8px;
.online {
color: var(--brightGreen);
}
.offline {
color: var(--brightRed);
}
}
}
.modalTitle {
margin: 0;
}
.scrollPane {
overflow-x: hidden;
overflow-y: auto;
margin: 8px 0;
textarea {
overflow: hidden;
}
}
.connectionEditor {
display: flex;
flex-direction: row;
gap: 8px;
flex-wrap: wrap;
}
.modal {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 80dvh;
}
.currentStory {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 10px;
.storiesSelector {
height: 24px;
flex-grow: 1;
}
}
.loreText {
flex-grow: 1;
}

View File

@ -1,197 +0,0 @@
import { useCallback, useContext, useMemo } from "preact/hooks";
import { useBool } from "@common/hooks/useBool";
import { Modal } from "@common/components/modal/Modal";
import { useInputCallback } from "@common/hooks/useInputCallback";
import { DEFAULT_STORY, StateContext } from "../../contexts/state";
import { LLMContext } from "../../contexts/llm";
import { MiniChat } from "../minichat/minichat";
import { AutoTextarea } from "../autoTextarea";
import { Ace } from "../ace";
import { ConnectionEditor } from "./connectionEditor";
import styles from './header.module.css';
export const Header = () => {
const { contextLength, promptTokens, modelName, spentKudos, hasToolCalls } = useContext(LLMContext);
const {
messages,
connection,
systemPrompt,
lore,
userPrompt,
bannedWords,
summarizePrompt,
summaryEnabled,
totalSpentKudos,
stories,
currentStory,
setSystemPrompt,
setLore,
setUserPrompt,
addSwipe,
setBannedWords,
setInstruct,
setSummarizePrompt,
setSummaryEnabled,
setConnection,
setCurrentStory,
createStory,
deleteStory,
} = useContext(StateContext);
const connectionsOpen = useBool();
const loreOpen = useBool();
const promptsOpen = useBool();
const genparamsOpen = useBool();
const assistantOpen = useBool();
const isOnline = useMemo(() => contextLength > 0, [contextLength]);
const bannedWordsInput = useMemo(() => bannedWords.join('\n'), [bannedWords]);
const handleAssistantAddSwipe = useCallback((answer: string) => {
const index = messages.findLastIndex(m => m.role === 'assistant');
addSwipe(index, answer);
assistantOpen.setFalse();
}, [addSwipe, messages]);
const handleSetBannedWords = useInputCallback((text) => {
const words = text.split('\n');
setBannedWords(words);
}, [setBannedWords]);
const handleBlurBannedWords = useInputCallback((text) => {
const words = text.toLowerCase().split('\n').sort();
setBannedWords(words);
}, [setBannedWords]);
const handleSetSummaryEnabled = useCallback((e: Event) => {
if (e.target instanceof HTMLInputElement) {
setSummaryEnabled(e.target.checked);
}
}, [setSummaryEnabled]);
const handleChangeStory = useInputCallback((story) => {
setCurrentStory(story);
}, []);
const handleDeleteStory = useCallback(() => {
if (confirm(`Delete story "${currentStory}"?`)) {
deleteStory(currentStory);
}
}, [currentStory]);
const handleDuplicateStory = useCallback(() => {
const id = prompt('Story id');
if (id) {
createStory(id, currentStory);
setCurrentStory(id);
}
}, [currentStory]);
return (
<div class={styles.header}>
<div class={styles.inputs}>
<div class={styles.buttons}>
<button class={`icon ${isOnline ? styles.online : styles.offline}`} onClick={connectionsOpen.setTrue} title='Connection settings'>
🔌
</button>
</div>
<div class={styles.info}>
<span class={styles.notImportant}>{modelName}</span>
<span>📃{promptTokens}/{contextLength}</span>
{connection.type === 'horde' ? <>
<span class={styles.notImportant}>💲{spentKudos}</span>
<span class={styles.notImportant}>💰{totalSpentKudos}</span>
</> : null}
</div>
</div>
<div class={styles.buttons}>
<button class='icon color' title='Edit lore' onClick={loreOpen.setTrue}>
🌍
</button>
<button class='icon color' title='Generation parameters' onClick={genparamsOpen.setTrue}>
</button>
<button class='icon color' title='Edit prompts' onClick={promptsOpen.setTrue}>
📃
</button>
</div>
<div class={styles.buttons}>
<button class='icon' onClick={assistantOpen.setTrue} title='Ask assistant'>
</button>
</div>
<Modal open={connectionsOpen.value} onClose={connectionsOpen.setFalse}>
<h3 class={styles.modalTitle}>Connection settings</h3>
<ConnectionEditor connection={connection} setConnection={setConnection} />
</Modal>
<Modal open={loreOpen.value} onClose={loreOpen.setFalse} class={styles.modal}>
<h3 class={styles.modalTitle}>Lore Editor</h3>
<div class={styles.currentStory}>
<select value={currentStory} onChange={handleChangeStory} class={styles.storiesSelector}>
{Object.keys(stories).map((story) => (
<option key={story} value={story}>{story}</option>
))}
</select>
<button class='icon' onClick={handleDuplicateStory}>
</button>
{currentStory !== DEFAULT_STORY
? <button class='icon' onClick={handleDeleteStory}>
🗑
</button>
: null}
</div>
<AutoTextarea
value={lore}
onInput={setLore}
placeholder="Describe your world, for example: World of Awoo has big mountains and wide rivers."
class={styles.loreText}
/>
</Modal>
<Modal open={genparamsOpen.value} onClose={genparamsOpen.setFalse} class={styles.modal}>
<h3 class={styles.modalTitle}>Generation Parameters</h3>
<div className={styles.scrollPane}>
<h4 class={styles.modalTitle}>Banned phrases</h4>
<AutoTextarea
placeholder="Each phrase on separate line"
value={bannedWordsInput}
onInput={handleSetBannedWords}
onBlur={handleBlurBannedWords}
class={styles.template}
/>
</div>
</Modal>
<Modal open={promptsOpen.value} onClose={promptsOpen.setFalse}>
<h3 class={styles.modalTitle}>Prompts Editor</h3>
<div className={styles.scrollPane}>
<h4 class={styles.modalTitle}>System prompt</h4>
<AutoTextarea value={systemPrompt} onInput={setSystemPrompt} />
<hr />
<h4 class={styles.modalTitle}>User prompt template</h4>
<Ace value={userPrompt} onInput={setUserPrompt} />
<hr />
<h4 class={styles.modalTitle}>Summary template</h4>
<Ace value={summarizePrompt} onInput={setSummarizePrompt} />
<label>
<input type='checkbox' checked={summaryEnabled} onChange={handleSetSummaryEnabled} />
&nbsp;Enable summarization
</label>
<hr />
<h4 class={styles.modalTitle}>
Instruct template
{hasToolCalls && <small> (tool calls)</small>}
</h4>
<Ace value={connection.instruct} onInput={setInstruct} />
</div>
</Modal>
<MiniChat
history={messages}
open={assistantOpen.value}
onClose={assistantOpen.setFalse}
buttons={{ 'Add swipe': handleAssistantAddSwipe }}
/>
</div>
);
}

View File

@ -1,38 +0,0 @@
import { useCallback, useContext } from "preact/hooks";
import { StateContext } from "../contexts/state";
import { LLMContext } from "../contexts/llm";
import { AutoTextarea } from "./autoTextarea";
export const Input = () => {
const { input, setInput, addMessage, continueMessage } = useContext(StateContext);
const { generating, stopGeneration } = useContext(LLMContext);
const handleSend = useCallback(async () => {
if (!generating) {
const newInput = input.trim();
if (newInput) {
addMessage(newInput, 'user', true);
setInput('');
} else {
continueMessage();
}
}
}, [input, setInput, generating]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}, [handleSend]);
return (
<div class="chat-input">
<AutoTextarea onInput={setInput} onKeyDown={handleKeyDown} value={input} />
{generating
? <button onClick={stopGeneration}>Stop</button>
: <button onClick={handleSend}>{input ? 'Send' : 'Continue'}</button>
}
</div>
);
}

View File

@ -1,20 +0,0 @@
import { useMemo } from "preact/hooks";
import { MessageTools } from "../../tools/messages";
import styles from './message.module.css';
interface IFormattedMessageProps {
children: string;
class?: string;
className?: string
}
export const FormattedMessage = ({ children, ['class']: cls, className }: IFormattedMessageProps) => {
const __html = useMemo(() => MessageTools.format(children), [children]);
return <div
style={{ whiteSpace: 'pre-wrap' }}
dangerouslySetInnerHTML={{ __html }}
class={`${cls ?? className ?? ''} ${styles.text}`}
/>;
};

View File

@ -1,80 +0,0 @@
.message {
width: 100%;
padding: 12px;
&.user {
background-color: var(--shadeColor);
&:not(.lastUser) .content .text {
opacity: 0.5;
font-size: 12px;
}
}
&.assistant {
border-top: 1px solid var(--backgroundColorDark);
}
.content {
white-space: pre-wrap;
line-height: 1.5;
display: flex;
flex-direction: column;
width: 100%;
gap: 8px;
>textarea {
border: var(--border);
min-height: 100px;
height: unset;
line-height: 1.5;
padding: 5px;
border-radius: var(--border-radius);
overflow: hidden;
}
.summary {
opacity: 0.7;
}
.buttons {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 8px;
.swipes {
display: flex;
width: 100%;
flex-direction: row;
justify-content: space-between;
gap: 8px;
>div {
cursor: pointer;
font-size: 20px;
}
}
}
}
}
.text {
flex-grow: 1;
width: 100%;
animation-duration: 300ms;
:global(.bold) {
font-weight: bold;
}
:global(.italic) {
font-style: italic;
color: var(--italicColor);
}
:global(.quote) {
color: var(--quoteColor);
}
}

View File

@ -1,108 +0,0 @@
import { useCallback, useContext, useMemo, useRef, useState } from "preact/hooks";
import { MessageTools, type IMessage } from "../../tools/messages";
import { StateContext } from "../../contexts/state";
import { DOMTools } from "../../tools/dom";
import styles from './message.module.css';
import { AutoTextarea } from "../autoTextarea";
import { useInputState } from "@common/hooks/useInputState";
interface IProps {
message: IMessage;
index: number;
isLastUser: boolean;
isLastAssistant: boolean;
}
export const Message = ({ message, index, isLastUser, isLastAssistant }: IProps) => {
const { messages, editMessage, editSummary, deleteMessage, setCurrentSwipe, setMessages, continueMessage } = useContext(StateContext);
const [editing, setEditing] = useState(false);
const [editedMessage, setEditedMessage] = useInputState('');
const textRef = useRef<HTMLDivElement>(null);
const swipe = useMemo(() => MessageTools.getSwipe(message), [message]);
const content = swipe?.content;
const summary = swipe?.summary;
const cost = swipe?.cost ?? 0;
const htmlContent = useMemo(() => MessageTools.format(content ?? ''), [content]);
const handleEnableEdit = useCallback(() => {
setEditing(true);
setEditedMessage(content ?? '');
}, [content]);
const handleSaveEdit = useCallback(() => {
editMessage(index, editedMessage.trim(), 0);
editSummary(index, '', 0);
setEditing(false);
}, [editMessage, editSummary, index, editedMessage, cost]);
const handleCancelEdit = useCallback(() => {
setEditing(false);
}, [editMessage, index]);
const handleDeleteMessage = useCallback(() => {
if (confirm('Delete message?')) {
setEditing(false);
deleteMessage(index);
}
}, [deleteMessage, index]);
const handleStopHere = useCallback(() => {
if (confirm('Delete all messages after that?')) {
setMessages(messages.filter((_, i) => i <= index));
setEditing(false);
}
}, [messages, setMessages, index]);
const handleSwipeLeft = useCallback(() => {
setCurrentSwipe(index, message.currentSwipe - 1);
DOMTools.animate(textRef.current, 'swipe-from-left');
}, [setCurrentSwipe, index, message]);
const handleSwipeRight = useCallback(() => {
setCurrentSwipe(index, message.currentSwipe + 1);
DOMTools.animate(textRef.current, 'swipe-from-right');
}, [setCurrentSwipe, index, message]);
const handleContinueMessage = useCallback(() => {
continueMessage(true);
}, [continueMessage]);
return (
<div class={`${styles.message} ${styles[message.role]} ${isLastUser ? styles.lastUser : ''}`}>
<div class={styles.content}>
{editing
? <AutoTextarea onInput={setEditedMessage} value={editedMessage} />
: <>
<div class={styles.text} dangerouslySetInnerHTML={{ __html: htmlContent }} ref={textRef} />
{summary && <small class={styles.summary}>{summary}</small>}
{cost > 0 && <small class={styles.summary}>💲&nbsp;{cost}</small>}
</>
}
<div class={styles.buttons}>
{editing
? <>
<button class='icon' onClick={handleSaveEdit} title='Save'></button>
<button class='icon' onClick={handleDeleteMessage} title='Delete'>🗑</button>
<button class='icon' onClick={handleStopHere} title='Stop here'></button>
<button class='icon' onClick={handleCancelEdit} title='Cancel'></button>
</>
: <>
{isLastAssistant && <>
<div class={styles.swipes}>
<div onClick={handleSwipeLeft}></div>
<div>{message.currentSwipe + 1}/{message.swipes.length}</div>
<div onClick={handleSwipeRight}></div>
</div>
<button class='icon' onClick={handleContinueMessage} title="Continue"></button>
</>}
<button class='icon' onClick={handleEnableEdit} title="Edit">🖊</button>
</>
}
</div>
</div>
</div>
);
};

View File

@ -1,35 +0,0 @@
.minichat {
display: flex;
flex-direction: column;
gap: 10px;
height: 100%;
overflow-x: hidden;
overflow-y: auto;
.user {
background-color: var(--shadeColor);
}
.messages {
display: flex;
flex-direction: column;
gap: 8px;
.message {
padding: 5px;
}
}
textarea {
overflow: hidden;
min-height: 60px;
}
}
.buttons {
margin-top: 8px;
height: 24px;
display: flex;
flex-direction: row;
gap: 8px;
}

View File

@ -1,137 +0,0 @@
import { MessageTools, type IMessage } from "../../tools/messages"
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { Modal } from "@common/components/modal/Modal";
import { DOMTools } from "../../tools/dom";
import styles from './minichat.module.css';
import { LLMContext } from "../../contexts/llm";
import { FormattedMessage } from "../message/formattedMessage";
import { AutoTextarea } from "../autoTextarea";
import { useBool } from "@common/hooks/useBool";
interface IProps {
open: boolean;
onClose: () => void;
history?: IMessage[];
buttons?: Record<string, (answer: string) => void>;
}
export const MiniChat = ({ history = [], buttons = {}, open, onClose }: IProps) => {
const { stopGeneration, generate, compilePrompt } = useContext(LLMContext);
const [messages, setMessages] = useState<IMessage[]>([]);
const ref = useRef<HTMLDivElement>(null);
const generating = useBool();
const answer = useMemo(() =>
MessageTools.getSwipe(messages.filter(m => m.role === 'assistant').at(-1))?.content,
[messages]
);
const handleInit = useCallback((force = false) => {
if (force || confirm('Clear chat?')) {
setMessages([MessageTools.create('', 'user', true)]);
}
}, []);
useEffect(() => {
setTimeout(() => DOMTools.scrollDown(ref.current, false), 100);
}, [generating.value, open]);
useEffect(() => {
DOMTools.scrollDown(ref.current, false);
}, [MessageTools.getSwipe(messages.at(-1))?.content]);
useEffect(() => {
if (messages.length === 0) {
handleInit(true);
}
DOMTools.scrollDown(ref.current);
}, [messages.length, handleInit]);
const handleGenerate = useCallback(async () => {
if (messages.length > 0 && !generating.value) {
const promptMessages: IMessage[] = [...history, ...messages];
const { prompt } = await compilePrompt(promptMessages, { keepUsers: messages.length + 1, continueLast: true });
let text = '';
const messageId = messages.length;
const newMessages = [...messages, MessageTools.create('', 'assistant', true)];
setMessages(newMessages);
generating.setTrue();
for await (const chunk of generate(prompt)) {
text += chunk.text;
setMessages(MessageTools.updateSwipe(
newMessages,
messageId,
{
content: text.trim(),
cost: chunk.cost,
}));
}
generating.setFalse();
setMessages([
...MessageTools.updateSwipe(newMessages, messageId, { content: MessageTools.trimSentence(text) }),
MessageTools.create('', 'user', true),
]);
MessageTools.playReady();
}
}, [messages, history, generating]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleGenerate();
}
}, [handleGenerate]);
const handleChange = useCallback((i: number, e: InputEvent) => {
if (e.target instanceof HTMLTextAreaElement) {
setMessages(MessageTools.updateSwipe(messages, i, { content: e.target.value }));
}
}, [messages]);
if (!open) {
return null;
}
return (
<Modal open onClose={onClose}>
<div class={styles.minichat} ref={ref}>
<div class={styles.messages}>
{messages.map((m, i) => (
generating.value
? <FormattedMessage key={i} class={`${styles[m.role]} ${styles.message}`}>
{MessageTools.getSwipe(m)?.content ?? ''}
</FormattedMessage>
: <AutoTextarea
key={i}
class={styles[m.role]}
value={MessageTools.getSwipe(m)?.content ?? ''}
onInput={(e) => handleChange(i, e)}
onKeyDown={handleKeyDown}
/>
))}
</div>
</div>
<div class={styles.buttons}>
{generating.value
? <button onClick={stopGeneration}>Stop</button>
: <button onClick={handleGenerate}>Generate</button>
}
<button onClick={() => handleInit()} class={`${generating.value ? 'disabled' : ''}`}>
Clear
</button>
{Object.entries(buttons).map(([label, onClick], i) => (
<button
key={i}
onClick={() => onClick(answer ?? '')}
class={`${(generating.value || !answer) ? 'disabled' : ''}`}
>
{label}
</button>
))}
</div>
</Modal>
)
}

View File

@ -1,327 +0,0 @@
import { createContext } from "preact";
import { useCallback, useContext, useEffect, useMemo, useState } from "preact/hooks";
import { MessageTools, type IMessage } from "../tools/messages";
import { StateContext } from "./state";
import { useBool } from "@common/hooks/useBool";
import { Huggingface } from "../tools/huggingface";
import { Connection, type IGenerationSettings } from "../tools/connection";
import { throttle } from "@common/utils";
import { useAsyncEffect } from "@common/hooks/useAsyncEffect";
import { approximateTokens, normalizeModel } from "../tools/model";
interface ICompileArgs {
keepUsers?: number;
continueLast?: boolean;
}
interface ICompiledPrompt {
prompt: string;
isContinue: boolean;
isRegen: boolean;
}
interface IContext {
generating: boolean;
modelName: string;
hasToolCalls: boolean;
promptTokens: number;
contextLength: number;
spentKudos: number;
}
const MESSAGES_TO_KEEP = 10;
interface IActions {
compilePrompt: (messages: IMessage[], args?: ICompileArgs) => Promise<ICompiledPrompt>;
generate: (prompt: string, extraSettings?: IGenerationSettings) => AsyncGenerator<Connection.TextChunk>;
stopGeneration: () => void;
summarize: (content: string) => Promise<Connection.TextChunk>;
countTokens: (prompt: string) => Promise<number>;
}
export type ILLMContext = IContext & IActions;
export const LLMContext = createContext<ILLMContext>({} as ILLMContext);
const processing = {
tokenizing: false,
summarizing: false,
}
export const LLMContextProvider = ({ children }: { children?: any }) => {
const {
connection, messages, triggerNext, continueLast, lore, userPrompt, systemPrompt, bannedWords, summarizePrompt, summaryEnabled,
setTriggerNext, setContinueLast, addMessage, editMessage, editSummary, setTotalSpentKudos,
} = useContext(StateContext);
const generating = useBool(false);
const [promptTokens, setPromptTokens] = useState(0);
const [contextLength, setContextLength] = useState(0);
const [modelName, setModelName] = useState('');
const [hasToolCalls, setHasToolCalls] = useState(false);
const [spentKudos, setSpentKudos] = useState(0);
const isOnline = useMemo(() => contextLength > 0, [contextLength]);
const actions: IActions = useMemo(() => ({
compilePrompt: async (messages, { keepUsers, continueLast = false } = {}) => {
const lastMessage = messages.at(-1);
const lastMessageContent = MessageTools.getSwipe(lastMessage)?.content;
const isAssistantLast = lastMessage?.role === 'assistant';
let isRegen = continueLast;
if (!isAssistantLast) {
isRegen = false;
} else if (!lastMessageContent) {
isRegen = true;
}
const isContinue = isAssistantLast && !isRegen;
const promptMessages = continueLast ? messages.slice(0, -1) : messages.slice();
if (isContinue) {
promptMessages.push(MessageTools.create(Huggingface.applyTemplate(userPrompt, {})));
}
const userMessages = promptMessages.filter(m => m.role === 'user');
const lastUserMessage = userMessages.at(-1);
const firstUserMessage = userMessages.at(0);
const templateMessages: Huggingface.ITemplateMessage[] = [
{ role: 'system', content: systemPrompt.trim() },
];
if (keepUsers) {
let usersRemaining = messages.filter(m => m.role === 'user').length;
let wasStory = false;
for (const message of messages) {
const { role } = message;
const swipe = MessageTools.getSwipe(message);
let content = swipe?.content ?? '';
if (role === 'user' && usersRemaining > keepUsers) {
usersRemaining--;
} else if (role === 'assistant' && templateMessages.at(-1)!.role === 'assistant') {
wasStory = true;
templateMessages.at(-1)!.content += '\n\n' + content;
} else if (role === 'user' && !message.technical) {
templateMessages.push({
role: message.role,
content: Huggingface.applyTemplate(userPrompt, { prompt: content, isStart: !wasStory }),
});
} else {
if (role === 'assistant') {
wasStory = true;
}
templateMessages.push({ role, content });
}
}
} else {
const story = promptMessages.filter(m => m.role === 'assistant')
.map((m, i, msgs) => {
const swipe = MessageTools.getSwipe(m);
if (!swipe) return '';
let { content, summary } = swipe;
if (summary && i < msgs.length - MESSAGES_TO_KEEP) {
content = summary;
}
return content;
}).join('\n\n');
if (story.length > 0) {
const prompt = MessageTools.getSwipe(firstUserMessage)?.content;
templateMessages.push({ role: 'user', content: Huggingface.applyTemplate(userPrompt, { prompt, isStart: true }) });
templateMessages.push({ role: 'assistant', content: story });
}
let userMessage = MessageTools.getSwipe(lastUserMessage)?.content;
if (!lastUserMessage?.technical && !isContinue && userMessage) {
userMessage = Huggingface.applyTemplate(userPrompt, { prompt: userMessage, isStart: story.length === 0 });
}
if (userMessage) {
templateMessages.push({ role: 'user', content: userMessage });
}
}
if (templateMessages[1]?.role !== 'user') {
const prompt = MessageTools.getSwipe(firstUserMessage)?.content;
templateMessages.splice(1, 0, {
role: 'user',
content: Huggingface.applyTemplate(userPrompt, { prompt, isStart: true }),
});
}
templateMessages[1].content = `${lore}\n\n${templateMessages[1].content}`;
let prompt = Huggingface.applyChatTemplate(connection.instruct, templateMessages);
if (isRegen) {
prompt += lastMessageContent;
}
return {
prompt,
isContinue,
isRegen,
};
},
generate: async function* (prompt, extraSettings = {}): AsyncGenerator<Connection.TextChunk> {
try {
console.log('[LLM.generate]', prompt);
for await (const { text, cost } of Connection.generate(connection, prompt, {
...extraSettings,
banned_tokens: bannedWords.filter(w => w.trim()),
})) {
setSpentKudos(sk => sk + cost);
setTotalSpentKudos(sk => sk + cost);
yield { text, cost };
}
} catch (e) {
if (e instanceof Error && e.name !== 'AbortError') {
alert(e.message);
} else {
console.error('[LLM.generate]', e);
}
}
},
summarize: async (message) => {
try {
const content = Huggingface.applyTemplate(summarizePrompt, { message });
const prompt = Huggingface.applyChatTemplate(connection.instruct, [{ role: 'user', content }]);
console.log('[LLM.summarize]', prompt);
const tokens = await Array.fromAsync(Connection.generate(connection, prompt));
const summary = tokens.reduce((sum, token) => ({
text: sum.text + token.text,
cost: sum.cost + token.cost,
}), { text: '', cost: 0 });
setSpentKudos(sk => sk + summary.cost);
setTotalSpentKudos(sk => sk + summary.cost);
return {
text: MessageTools.trimSentence(summary.text),
cost: summary.cost,
};
} catch (e) {
console.error('Error summarizing:', e);
return { text: '', cost: 0 };
}
},
countTokens: async (prompt) => {
return await Connection.countTokens(connection, prompt);
},
stopGeneration: () => {
Connection.stopGeneration();
},
}), [connection, lore, userPrompt, systemPrompt, bannedWords, summarizePrompt]);
useAsyncEffect(async () => {
if (isOnline && triggerNext && !generating.value) {
setTriggerNext(false);
setContinueLast(false);
let messageId = messages.length - 1;
let text = '';
const { prompt, isRegen } = await actions.compilePrompt(messages, { continueLast });
if (isRegen) {
text = MessageTools.getSwipe(messages.at(-1))?.content ?? '';
} else {
addMessage('', 'assistant');
messageId++;
}
generating.setTrue();
editSummary(messageId, 'Generating...', 0);
for await (const chunk of actions.generate(prompt)) {
text += chunk.text;
setPromptTokens(promptTokens + approximateTokens(text));
editMessage(messageId, text.trim(), chunk.cost);
}
generating.setFalse();
text = MessageTools.trimSentence(text);
editMessage(messageId, text, 0);
editSummary(messageId, '', 0);
MessageTools.playReady();
}
}, [triggerNext, isOnline]);
useAsyncEffect(async () => {
if (isOnline && summaryEnabled && !processing.summarizing) {
try {
processing.summarizing = true;
for (let id = 0; id < messages.length; id++) {
const message = messages[id];
const swipe = MessageTools.getSwipe(message);
if (message.role === 'assistant' && swipe?.content?.includes('\n') && !swipe.summary) {
const { text, cost } = await actions.summarize(swipe.content);
editSummary(id, text, cost);
}
}
} catch (e) {
console.error(`Could not summarize`, e)
} finally {
processing.summarizing = false;
}
}
}, [messages, summaryEnabled, isOnline]);
useEffect(throttle(() => {
Connection.getContextLength(connection).then(setContextLength);
Connection.getModelName(connection).then(normalizeModel).then(setModelName);
}, 1000, true), [connection]);
const calculateTokens = useCallback(throttle(async () => {
if (isOnline && !processing.tokenizing && !generating.value) {
try {
processing.tokenizing = true;
const { prompt } = await actions.compilePrompt(messages);
const tokens = await actions.countTokens(prompt);
setPromptTokens(tokens);
} catch (e) {
console.error(`Could not count tokens`, e)
} finally {
processing.tokenizing = false;
}
}
}, 1000, true), [actions, messages, isOnline]);
useEffect(() => {
calculateTokens();
}, [messages, connection, systemPrompt, lore, userPrompt, isOnline]);
useEffect(() => {
try {
const hasTools = Huggingface.testToolCalls(connection.instruct);
setHasToolCalls(hasTools);
} catch {
setHasToolCalls(false);
}
}, [connection.instruct]);
const rawContext: IContext = {
generating: generating.value,
modelName,
hasToolCalls,
promptTokens,
contextLength,
spentKudos,
};
const context = useMemo(() => rawContext, Object.values(rawContext));
const value = useMemo(() => ({ ...context, ...actions }), [context, actions])
return (
<LLMContext.Provider value={value}>
{children}
</LLMContext.Provider>
);
}

View File

@ -1,362 +0,0 @@
import { createContext } from "preact";
import { useCallback, useEffect, useMemo, useReducer, useState, type Dispatch, type StateUpdater } from "preact/hooks";
import { MessageTools, type IMessage } from "../tools/messages";
import { useInputState } from "@common/hooks/useInputState";
import { type IConnection } from "../tools/connection";
import { loadObject, saveObject } from "../../../common/storage";
import { useInputCallback } from "@common/hooks/useInputCallback";
import { callUpdater, throttle } from "@common/utils";
import { Huggingface } from "../tools/huggingface";
interface IStory {
lore: string;
messages: IMessage[];
}
interface StoriesState {
currentStory: string;
stories: Record<string, IStory>;
lore: string;
messages: IMessage[];
}
interface StoryActionSetCurrent {
currentStory: StateUpdater<string>;
}
interface StoryActionSetMessages {
messages: StateUpdater<IMessage[]>;
}
interface StoryActionSetLore {
lore: StateUpdater<string>;
}
interface StoryActionWithId {
action: 'create' | 'delete';
id: string;
fromId?: string;
}
type StoryAction = StoryActionSetCurrent | StoryActionSetLore | StoryActionSetMessages | StoryActionWithId;
interface IContext {
connection: IConnection;
input: string;
systemPrompt: string;
userPrompt: string;
summarizePrompt: string;
summaryEnabled: boolean;
bannedWords: string[];
totalSpentKudos: number;
stories: Record<string, IStory>;
currentStory: string;
//
triggerNext: boolean;
continueLast: boolean;
}
interface IComputableContext {
lore: string;
messages: IMessage[];
}
interface IActions {
setConnection: (connection: IConnection) => void;
setInput: (url: string | Event) => void;
setInstruct: (template: string | Event) => void;
setLore: (lore: string | Event) => void;
setSystemPrompt: (prompt: string | Event) => void;
setUserPrompt: (prompt: string | Event) => void;
setSummarizePrompt: (prompt: string | Event) => void;
setBannedWords: (words: string[]) => void;
setSummaryEnabled: (summaryEnabled: boolean) => void;
setTotalSpentKudos: Dispatch<StateUpdater<number>>;
setTriggerNext: (triggerNext: boolean) => void;
setContinueLast: (continueLast: boolean) => void;
setMessages: (messages: IMessage[]) => void;
addMessage: (content: string, role: IMessage['role'], triggerNext?: boolean) => void;
editMessage: (index: number, content: string, cost: number) => void;
editSummary: (index: number, summary: string, cost: number) => void;
deleteMessage: (index: number) => void;
setCurrentSwipe: (index: number, swipe: number) => void;
addSwipe: (index: number, content: string) => void;
continueMessage: (continueLast?: boolean) => void;
setCurrentStory: (id: string) => void;
createStory: (id: string, fromId?: string) => void;
deleteStory: (id: string) => void;
}
const SAVE_KEY = 'ai_game_save_state';
export const DEFAULT_STORY = 'default';
const INSTRUCT_MISTRAL = Huggingface.formatTemplate(`{% if messages[0]['role'] == 'system' %}{% set system_message = messages[0]['content'] %}{% set loop_messages = messages[1:] %}{% else %}{% set loop_messages = messages %}{% endif %}{% for message in loop_messages %}{% if message['role'] == 'user' %}{% if loop.first and system_message is defined %}{{ ' [INST] ' + system_message + '\n\n' + message['content'] + ' [/INST]' }}{% else %}{{ ' [INST] ' + message['content'] + ' [/INST]' }}{% endif %}{% elif message['role'] == 'assistant' %}{{ ' ' + message['content'] + '</s>' }}{% endif %}{% endfor %}`);
const INSTRUCT_CHATML = Huggingface.formatTemplate(`{% for message in messages %}{{ '<|im_start|>' + message['role'] + '\n\n' + message['content'] + '<|im_end|>' + '\n' }}{% endfor %}{% if add_generation_prompt %}{{ '<|im_start|>assistant\n\n' }}{% endif %}`);
const INSTRUCT_LLAMA = Huggingface.formatTemplate(`{% for message in messages %}{{ '<|start_header_id|>' + message['role'] + '<|end_header_id|>\n\n' + message['content'] | trim + '<|eot_id|>' }}{% endfor %}{% if add_generation_prompt %}{{ '<|start_header_id|>assistant<|end_header_id|>\n\n' }}{% endif %}`);
const INSTRUCT_ALPACA = Huggingface.formatTemplate(`{% for message in messages %}{% if message['role'] == 'system' and message['content'] %}{{ message['content'] + '\n\n' }}{% elif message['role'] == 'user' %}{{ '### Instruction:\n\n' + message['content'] + '\n\n' }}{% elif message['role'] == 'assistant' %}{{ '### Response:\n\n' + message['content'] + '\n\n' }}{% endif %}{% endfor %}{% if add_generation_prompt %}{{ '### Response:\n\n' }}{% endif %}`);
const INSTRUCT_METHARME = Huggingface.formatTemplate(`{% for message in messages %}{% if message['role'] == 'system' and message['content'] %}{{ '<|system|>' + message['content'] }}{% elif message['role'] == 'user' %}{{ '<|user|>' + message['content'] }}{% elif message['role'] == 'assistant' %}{{'<|model|>' + message['content'] }}{% endif %}{% endfor %}{% if add_generation_prompt %}{{ '<|model|>' }}{% endif %}`);
const INSTRUCT_GEMMA = Huggingface.formatTemplate(`{% for message in messages %}{% if (message['role'] == 'assistant') %}{% set role = 'model' %}{% else %}{% set role = message['role'] %}{% endif %}{{ '<start_of_turn>' + role + '\n' + message['content'] | trim + '<end_of_turn>\n' }}{% endfor %}{% if add_generation_prompt %}{{ '<start_of_turn>model\n' }}{% endif %}`);
export const INSTRUCTS = {
'Mistral': INSTRUCT_MISTRAL,
'ChatML': INSTRUCT_CHATML,
'LLama': INSTRUCT_LLAMA,
'Alpaca': INSTRUCT_ALPACA,
'Metharme': INSTRUCT_METHARME,
'Gemma': INSTRUCT_GEMMA,
}
const DEFAULT_CONTEXT: IContext = {
connection: {
type: 'kobold',
url: 'http://localhost:5001',
instruct: INSTRUCT_MISTRAL,
},
input: '',
systemPrompt: 'You are a creative writer. Write a story based on the world description below. Story should be adult and mature; and could include swearing, violence and unfairness. Portray characters realistically and stay in the lore.',
stories: {},
currentStory: DEFAULT_STORY,
userPrompt: Huggingface.formatTemplate(`{% if isStart -%}
Write a novel using information above as a reference.
{%- else -%}
Continue the story forward.
{%- endif %}
{% if prompt -%}
This is the description of what should happen next in your answer: {{ prompt | trim }}
{% endif %}
Remember that this story should be infinite and go forever.
Make sure to follow the world description and rules exactly. Avoid cliffhangers and pauses, be creative.`),
summarizePrompt: 'Summarize following text in one paragraph:\n\n{{ message }}\n\nAnswer with shortened text only.',
summaryEnabled: true,
bannedWords: [],
totalSpentKudos: 0,
triggerNext: false,
continueLast: false,
};
const EMPTY_STORY: IStory = {
lore: '',
messages: [],
};
const saveContext = throttle(async (context: IContext & IComputableContext) => {
const contextToSave: Partial<IContext & IComputableContext> = { ...context };
delete contextToSave.triggerNext;
delete contextToSave.continueLast;
delete contextToSave.lore;
delete contextToSave.messages;
return saveObject(SAVE_KEY, contextToSave);
}, 1000, true);
export type IStateContext = IContext & IActions & IComputableContext;
export const StateContext = createContext<IStateContext>({} as IStateContext);
const loadedContext = await loadObject(SAVE_KEY, DEFAULT_CONTEXT);
const storyReducer = (state: StoriesState, action: StoryAction): StoriesState => {
let { currentStory, stories } = state;
if ('id' in action) {
switch (action.action) {
case 'create':
stories[action.id] = {
...EMPTY_STORY,
lore: (action.fromId && stories[action.fromId]?.lore) ?? EMPTY_STORY.lore,
};
break;
case 'delete':
if (action.id !== DEFAULT_STORY) {
delete stories[action.id];
if (action.id === currentStory) {
currentStory = DEFAULT_STORY;
}
}
break;
}
} else if ('currentStory' in action) {
currentStory = callUpdater(action.currentStory, currentStory);
} else if ('messages' in action) {
stories[currentStory].messages = callUpdater(action.messages, stories[currentStory]?.messages ?? []);
} else if ('lore' in action) {
stories[currentStory].lore = callUpdater(action.lore, stories[currentStory]?.lore ?? '');
}
return {
currentStory,
stories,
lore: stories[currentStory].lore ?? '',
messages: stories[currentStory].messages ?? [],
};
};
export const StateContextProvider = ({ children }: { children?: any }) => {
const [connection, setConnection] = useState<IConnection>(loadedContext.connection);
const [input, setInput] = useInputState(loadedContext.input);
const [systemPrompt, setSystemPrompt] = useInputState(loadedContext.systemPrompt);
const [userPrompt, setUserPrompt] = useInputState(loadedContext.userPrompt);
const [summarizePrompt, setSummarizePrompt] = useInputState(loadedContext.summarizePrompt);
const [bannedWords, setBannedWords] = useState<string[]>(loadedContext.bannedWords);
const [summaryEnabled, setSummaryEnabled] = useState(loadedContext.summaryEnabled);
const [totalSpentKudos, setTotalSpentKudos] = useState(loadedContext.totalSpentKudos);
const [storiesState, storyDispatch] = useReducer(storyReducer, {
stories: loadedContext.stories,
currentStory: loadedContext.currentStory,
lore: loadedContext.stories[loadedContext.currentStory]?.lore ?? '',
messages: loadedContext.stories[loadedContext.currentStory]?.messages ?? [],
});
const [triggerNext, setTriggerNext] = useState(false);
const [continueLast, setContinueLast] = useState(false);
const [instruct, setInstruct] = useInputState(connection.instruct);
useEffect(() => setConnection({ ...connection, instruct }), [instruct]);
const setLore = useInputCallback((lore) => {
storyDispatch({ lore });
}, []);
const setMessages = useCallback((messages: StateUpdater<IMessage[]>) => {
storyDispatch({ messages });
}, []);
const setCurrentStory = useCallback((currentStory: StateUpdater<string>) => {
storyDispatch({ currentStory });
}, []);
const actions: IActions = useMemo(() => ({
setConnection,
setInput,
setInstruct,
setSystemPrompt,
setUserPrompt,
setSummarizePrompt,
setLore,
setSummaryEnabled,
setTriggerNext,
setContinueLast,
setTotalSpentKudos,
setCurrentStory,
setBannedWords: (words) => setBannedWords(words.slice()),
setMessages: (newMessages) => setMessages(newMessages.slice()),
addMessage: (content, role, triggerNext = false) => {
setMessages(messages => [
...messages,
MessageTools.create(content, role),
]);
setTriggerNext(triggerNext);
},
editMessage: (index, content, cost) => {
setMessages(messages => MessageTools.updateSwipe(messages, index, { content }, cost));
},
editSummary: (index, summary, cost) => {
setMessages(messages => MessageTools.updateSwipe(messages, index, { summary }, cost));
},
deleteMessage: (index) => setMessages(messages =>
messages.filter((_, i) => i !== index)
),
setCurrentSwipe: (index, currentSwipe) => {
let shouldTrigger = false;
setMessages(messages =>
messages.map(
(message, i) => {
if (i === index) {
const swipes = message.swipes.slice();
const latestSwipe = swipes.at(-1);
if (currentSwipe >= swipes.length) {
if (latestSwipe && latestSwipe.content.length > 0) {
currentSwipe = swipes.length;
swipes.push({ content: '', cost: 0 });
} else {
currentSwipe = swipes.length - 1;
}
shouldTrigger = true;
} else while (currentSwipe < 0) {
currentSwipe += swipes.length;
}
return {
...message, swipes, currentSwipe
};
} else {
return message;
}
}
)
);
setTriggerNext(shouldTrigger);
},
addSwipe: (index, content) => setMessages(messages =>
messages.map(
(message, i) => {
if (i === index) {
const swipes = [...message.swipes, { content, cost: 0 }];
return {
...message,
swipes,
currentSwipe: swipes.length - 1,
};
} else {
return message;
}
}
)
),
continueMessage: (c = false) => {
setTriggerNext(true);
setContinueLast(c);
},
createStory: (id: string, fromId?: string) => {
storyDispatch({ id, action: 'create', fromId });
},
deleteStory: (id: string) => {
storyDispatch({ id, action: 'delete' });
}
}), []);
const rawContext: IContext & IComputableContext = {
connection,
input,
systemPrompt,
userPrompt,
summarizePrompt,
summaryEnabled,
bannedWords,
totalSpentKudos,
...storiesState,
//
triggerNext,
continueLast,
};
const context = useMemo(() => rawContext, Object.values(rawContext));
useEffect(() => {
saveContext(context);
}, [context]);
const value = useMemo(() => ({ ...context, ...actions }), [context, actions])
return (
<StateContext.Provider value={value}>
{children}
</StateContext.Provider>
);
}

View File

@ -1,18 +0,0 @@
import { render } from "preact";
import { StateContextProvider } from "./contexts/state";
import { LLMContextProvider } from "./contexts/llm";
import { App } from "./components/app";
import './assets/style.css';
import './assets/emoji.css';
export default function main() {
render(
<StateContextProvider>
<LLMContextProvider>
<App />
</LLMContextProvider>
</StateContextProvider>,
document.body
);
}

View File

@ -1,431 +0,0 @@
import Lock from "@common/lock";
import SSE from "@common/sse";
import { throttle } from "@common/utils";
import delay from "delay";
import { Huggingface } from "./huggingface";
import { approximateTokens, normalizeModel } from "./model";
interface IBaseConnection {
type: 'kobold' | 'horde';
instruct: string;
url?: string;
apiKey?: string;
model?: string;
}
interface IKoboldConnection extends IBaseConnection {
type: 'kobold';
url: string;
}
interface IHordeConnection extends IBaseConnection {
type: 'horde';
apiKey?: string;
model: string;
}
export type IConnection = IKoboldConnection | IHordeConnection;
interface IHordeWorker {
id: string;
models: string[];
flagged: boolean;
online: boolean;
maintenance_mode: boolean;
max_context_length: number;
max_length: number;
performance: string;
}
export interface IHordeModel {
name: string;
hordeNames: string[];
maxLength: number;
maxContext: number;
workers: string[];
}
interface IHordeResult {
faulted: boolean;
done: boolean;
finished: number;
kudos: number;
generations?: {
text: string;
}[];
}
const DEFAULT_GENERATION_SETTINGS = {
temperature: 0.8,
min_p: 0.1,
rep_pen: 1.08,
rep_pen_range: -1,
rep_pen_slope: 0.7,
top_k: 100,
top_p: 0.92,
banned_tokens: [] as string[],
max_length: 300,
trim_stop: true,
stop_sequence: ['[INST]', '[/INST]', '</s>', '<|'],
dry_allowed_length: 5,
dry_multiplier: 0.8,
dry_base: 1,
dry_sequence_breakers: ["\n", ":", "\"", "*"],
dry_penalty_last_n: 0
}
const MIN_PERFORMANCE = 2.0;
const MIN_WORKER_CONTEXT = 8192;
const MAX_HORDE_LENGTH = 512;
const MAX_HORDE_CONTEXT = 32000;
export const HORDE_ANON_KEY = '0000000000';
export type IGenerationSettings = Partial<typeof DEFAULT_GENERATION_SETTINGS>;
export namespace Connection {
const AIHORDE = 'https://aihorde.net';
let abortController = new AbortController();
export interface TextChunk {
text: string;
cost: number;
}
async function* generateKobold(url: string, prompt: string, extraSettings: IGenerationSettings = {}): AsyncGenerator<TextChunk> {
const sse = new SSE(`${url}/api/extra/generate/stream`, {
payload: JSON.stringify({
...DEFAULT_GENERATION_SETTINGS,
...extraSettings,
prompt,
}),
});
const messages: string[] = [];
const messageLock = new Lock();
let end = false;
sse.addEventListener('message', (e) => {
if (e.data) {
{
const { token, finish_reason } = JSON.parse(e.data);
messages.push(token);
if (finish_reason && finish_reason !== 'null') {
end = true;
}
}
}
messageLock.release();
});
const handleEnd = () => {
end = true;
messageLock.release();
};
abortController.signal.addEventListener('abort', handleEnd);
sse.addEventListener('error', handleEnd);
sse.addEventListener('abort', handleEnd);
sse.addEventListener('readystatechange', (e) => {
if (e.readyState === SSE.CLOSED) handleEnd();
});
while (!end || messages.length) {
while (messages.length > 0) {
const text = messages.shift();
if (text != null) {
try {
yield { text, cost: 0 };
} catch { }
}
}
if (!end) {
await messageLock.wait();
}
}
sse.close();
}
async function* generateHorde(connection: IHordeConnection, prompt: string, extraSettings: IGenerationSettings = {}): AsyncGenerator<TextChunk> {
if (!connection.model) {
throw new Error('Horde not connected');
}
const models = await getHordeModels();
const model = models.get(connection.model);
if (model) {
let maxLength = Math.min(model.maxLength, DEFAULT_GENERATION_SETTINGS.max_length);
if (extraSettings.max_length && extraSettings.max_length < maxLength) {
maxLength = extraSettings.max_length;
}
const baseTemperature = extraSettings.temperature ?? DEFAULT_GENERATION_SETTINGS.temperature;
let currentTemperature = baseTemperature;
const MAX_TEMPERATURE = 2.0;
const TEMP_INCREMENT = 0.15;
const RECOVERY_LENGTH = 16;
const requestData = {
prompt,
params: {
...DEFAULT_GENERATION_SETTINGS,
...extraSettings,
n: 1,
max_context_length: model.maxContext,
max_length: maxLength,
rep_pen_range: Math.min(model.maxContext, 4096),
temperature: currentTemperature,
},
models: model.hordeNames,
workers: model.workers,
};
const bannedTokens = requestData.params.banned_tokens ?? [];
let recoveryMode = false;
const { signal } = abortController;
while (true) {
const generateResponse = await fetch(`${AIHORDE}/api/v2/generate/text/async`, {
method: 'POST',
body: JSON.stringify(requestData),
headers: {
'Content-Type': 'application/json',
apikey: connection.apiKey || HORDE_ANON_KEY,
},
signal,
});
if (!generateResponse.ok || generateResponse.status >= 400) {
throw new Error(`Error starting generation: ${generateResponse.statusText}: ${await generateResponse.text()}`);
}
const { id } = await generateResponse.json() as { id: string };
const request = async (method = 'GET'): Promise<TextChunk | null> => {
const response = await fetch(`${AIHORDE}/api/v2/generate/text/status/${id}`, { method });
if (response.ok && response.status < 400) {
const result: IHordeResult = await response.json();
if (result.generations?.length === 1) {
const { text } = result.generations[0];
return { text, cost: result.kudos };
}
} else {
throw new Error(await response.text());
}
return null;
};
const deleteRequest = async () => (await request('DELETE')) ?? { text: '', cost: 0 };
let text: string | null = null;
while (!text) {
try {
await delay(2500, { signal });
const response = await request();
if (response?.text) {
text = response.text;
let minStopIdx = text.length;
for (const sequence of requestData.params.stop_sequence) {
const stopIdx = text.indexOf(sequence);
if (stopIdx >= 0 && stopIdx < minStopIdx) {
minStopIdx = stopIdx;
}
}
if (minStopIdx < text.length) {
text = text.slice(0, minStopIdx);
}
const locaseText = text.toLowerCase();
let unsloppedText = text;
let slopDetected = false;
let minSlopIdx = text.length;
let detectedBan = '';
for (const ban of bannedTokens) {
const slopIdx = locaseText.indexOf(ban.toLowerCase());
if (slopIdx >= 0 && slopIdx < minSlopIdx) {
minSlopIdx = slopIdx;
detectedBan = ban;
slopDetected = true;
}
}
if (slopDetected) {
console.log(`[horde] slop '${detectedBan}' detected at ${minSlopIdx}`);
unsloppedText = unsloppedText.slice(0, minSlopIdx).trimEnd();
}
yield { text: unsloppedText, cost: response.cost };
requestData.prompt += unsloppedText;
if (slopDetected) {
recoveryMode = true;
requestData.params.max_length = RECOVERY_LENGTH;
currentTemperature = Math.min(MAX_TEMPERATURE, currentTemperature + TEMP_INCREMENT);
requestData.params.temperature = currentTemperature;
requestData.params.top_p = Math.min(0.98, 0.92 + (currentTemperature - baseTemperature) * 0.02);
} else if (recoveryMode) {
recoveryMode = false;
requestData.params.max_length = maxLength;
requestData.params.temperature = baseTemperature;
requestData.params.top_p = 0.92;
currentTemperature = baseTemperature;
} else {
return; // we are finished
}
}
} catch (e) {
if (!signal.aborted) {
console.error('Error in horde generation:', e);
}
return yield deleteRequest();
}
}
}
}
throw new Error(`Model ${connection.model} is offline`);
}
export async function* generate(connection: IConnection, prompt: string, extraSettings: IGenerationSettings = {}) {
if (connection.type === 'kobold') {
yield* generateKobold(connection.url, prompt, extraSettings);
} else if (connection.type === 'horde') {
yield* generateHorde(connection, prompt, extraSettings);
}
}
export function stopGeneration() {
abortController.abort();
abortController = new AbortController(); // refresh
}
async function requestHordeModels(): Promise<Map<string, IHordeModel>> {
try {
const response = await fetch(`${AIHORDE}/api/v2/workers?type=text`);
if (response.ok) {
const workers: IHordeWorker[] = await response.json();
const goodWorkers = workers.filter(w =>
w.online
&& !w.maintenance_mode
&& !w.flagged
&& w.max_context_length >= MIN_WORKER_CONTEXT
&& parseFloat(w.performance) >= MIN_PERFORMANCE
);
const models = new Map<string, IHordeModel>();
for (const worker of goodWorkers) {
for (const modelName of worker.models) {
const normName = normalizeModel(modelName);
let model = models.get(normName);
if (!model) {
model = {
hordeNames: [],
maxContext: MAX_HORDE_CONTEXT,
maxLength: MAX_HORDE_LENGTH,
name: normName,
workers: []
}
}
if (!model.hordeNames.includes(modelName)) {
model.hordeNames.push(modelName);
}
if (!model.workers.includes(worker.id)) {
model.workers.push(worker.id);
}
model.maxContext = Math.min(model.maxContext, worker.max_context_length);
model.maxLength = Math.min(model.maxLength, worker.max_length);
models.set(normName, model);
}
}
return models;
}
} catch (e) {
console.error(e);
}
return new Map();
};
export const getHordeModels = throttle(requestHordeModels, 10000);
export async function getModelName(connection: IConnection): Promise<string> {
if (connection.type === 'kobold') {
try {
const response = await fetch(`${connection.url}/api/v1/model`);
if (response.ok) {
const { result } = await response.json();
return result;
}
} catch (e) {
console.error('Error getting max tokens', e);
}
} else if (connection.type === 'horde') {
return connection.model;
}
return '';
}
export async function getContextLength(connection: IConnection): Promise<number> {
if (connection.type === 'kobold') {
try {
const response = await fetch(`${connection.url}/api/extra/true_max_context_length`);
if (response.ok) {
const { value } = await response.json();
return value;
}
} catch (e) {
console.error('Error getting max tokens', e);
}
} else if (connection.type === 'horde' && connection.model) {
const models = await getHordeModels();
const model = models.get(connection.model);
if (model) {
return model.maxContext;
}
}
return 0;
}
export async function countTokens(connection: IConnection, prompt: string) {
if (connection.type === 'kobold') {
try {
const response = await fetch(`${connection.url}/api/extra/tokencount`, {
body: JSON.stringify({ prompt }),
headers: { 'Content-Type': 'applicarion/json' },
method: 'POST',
});
if (response.ok) {
const { value } = await response.json();
return value;
}
} catch (e) {
console.error('Error counting tokens:', e);
}
} else if (connection.type === 'horde') {
const model = await getModelName(connection);
const tokenizer = await Huggingface.findTokenizer(model);
if (tokenizer) {
try {
const { ids } = tokenizer.encode(prompt);
return ids.length;
} catch (e) {
console.error('Error counting tokens with tokenizer:', e);
}
}
}
return approximateTokens(prompt);
}
}

View File

@ -1,142 +0,0 @@
export namespace DOMTools {
export const animate = (e: unknown, animationName: string) => {
if (e instanceof Event) {
e = e.target;
}
if (e instanceof HTMLElement) {
e.style.animationName = '';
e.style.animationName = animationName;
}
}
export const scrollDown = (e: unknown, smooth?: any) => {
if (e instanceof Event) {
e = e.target;
}
if (e instanceof HTMLElement) {
e.scrollTo({
top: e.scrollHeight,
behavior: smooth !== false ? 'smooth' : 'instant',
});
}
}
const HIDDEN_TEXTAREA_STYLE = `
min-height:0 !important;
max-height:none !important;
height:0 !important;
visibility:hidden !important;
overflow:hidden !important;
position:absolute !important;
z-index:-1000 !important;
top:0 !important;
right:0 !important
`;
const SIZING_STYLE = [
'letter-spacing',
'line-height',
'padding-top',
'padding-bottom',
'font-family',
'font-weight',
'font-size',
'text-rendering',
'text-transform',
'width',
'text-indent',
'padding-left',
'padding-right',
'border-width',
'box-sizing'
];
let hiddenTextarea: HTMLTextAreaElement;
export const calculateNodeHeight = (uiTextNode: HTMLTextAreaElement, minRows = null, maxRows = null) => {
if (!hiddenTextarea) {
hiddenTextarea = document.createElement('textarea');
document.body.appendChild(hiddenTextarea);
}
// Copy all CSS properties that have an impact on the height of the content in
// the textbox
let {
paddingSize, borderSize,
boxSizing, sizingStyle
} = calculateNodeStyling(uiTextNode);
// Need to have the overflow attribute to hide the scrollbar otherwise
// text-lines will not calculated properly as the shadow will technically be
// narrower for content
hiddenTextarea.setAttribute('style', sizingStyle + ';' + HIDDEN_TEXTAREA_STYLE);
hiddenTextarea.value = uiTextNode.value || uiTextNode.placeholder || 'x';
let minHeight = -Infinity;
let maxHeight = Infinity;
let height = hiddenTextarea.scrollHeight;
if (boxSizing === 'border-box') {
// border-box: add border, since height = content + padding + border
height = height + borderSize;
} else if (boxSizing === 'content-box') {
// remove padding, since height = content
height = height - paddingSize;
}
if (minRows !== null || maxRows !== null) {
// measure height of a textarea with a single row
hiddenTextarea.value = 'x';
let singleRowHeight = hiddenTextarea.scrollHeight - paddingSize;
if (minRows !== null) {
minHeight = singleRowHeight * minRows;
if (boxSizing === 'border-box') {
minHeight = minHeight + paddingSize + borderSize;
}
height = Math.max(minHeight, height);
}
if (maxRows !== null) {
maxHeight = singleRowHeight * maxRows;
if (boxSizing === 'border-box') {
maxHeight = maxHeight + paddingSize + borderSize;
}
height = Math.min(maxHeight, height);
}
}
return { height, minHeight, maxHeight };
}
function calculateNodeStyling(node: HTMLElement) {
let style = window.getComputedStyle(node);
let boxSizing = (
style.getPropertyValue('box-sizing') ||
style.getPropertyValue('-moz-box-sizing') ||
style.getPropertyValue('-webkit-box-sizing')
);
let paddingSize = (
parseFloat(style.getPropertyValue('padding-bottom')) +
parseFloat(style.getPropertyValue('padding-top'))
);
let borderSize = (
parseFloat(style.getPropertyValue('border-bottom-width')) +
parseFloat(style.getPropertyValue('border-top-width'))
);
let sizingStyle = SIZING_STYLE
.map(name => `${name}:${style.getPropertyValue(name)}`)
.join(';');
let nodeInfo = {
sizingStyle,
paddingSize,
borderSize,
boxSizing
};
return nodeInfo;
}
}

View File

@ -1,448 +0,0 @@
import { gguf } from '@huggingface/gguf';
import * as hub from '@huggingface/hub';
import { Template } from '@huggingface/jinja';
import { Tokenizer } from '@huggingface/tokenizers';
import { loadObject, saveObject } from '@common/storage';
import { normalizeModel } from './model';
export namespace Huggingface {
export interface ITemplateMessage {
role: 'user' | 'assistant' | 'system';
content: string;
}
interface INumberParameter {
type: 'number';
enum?: number[];
description?: string;
}
interface IStringParameter {
type: 'string';
enum?: string[];
description?: string;
}
interface IArrayParameter {
type: 'array';
description?: string;
items: IParameter;
}
interface IObjectParameter {
type: 'object';
description?: string;
properties: Record<string, IParameter>;
required?: string[];
}
type IParameter = INumberParameter | IStringParameter | IArrayParameter | IObjectParameter;
interface ITool {
type: 'function',
function: {
name: string;
description?: string;
parameters?: IObjectParameter;
}
}
export interface IFunction {
name: string;
description?: string;
parameters?: Record<string, IParameter>;
}
interface TokenizerConfig {
chat_template: string;
bos_token?: string;
eos_token?: string;
}
type TokenizerJson = any;
type TokenizerInfo = [TokenizerConfig | null, TokenizerJson | null];
const TEMPLATE_CACHE_KEY = 'ai_game_template_cache';
const TOKENIZER_CACHE_KEY = 'ai_game_tokenizer_cache';
const templateCache: Record<string, string> = {};
const tokenizerCache: Record<string, TokenizerInfo> = {};
const prevLoading: Promise<unknown> = Promise.all([
loadObject(TEMPLATE_CACHE_KEY, {}).then(c => Object.assign(templateCache, c)),
loadObject(TOKENIZER_CACHE_KEY, {}).then(c => Object.assign(tokenizerCache, c)),
]);
const compiledTemplates = new Map<string, Template>();
const compiledTokenizers = new Map<string, Tokenizer | null>();
const hasField = <T extends string>(obj: unknown, field: T): obj is Record<T, unknown> => (
obj != null && typeof obj === 'object' && (field in obj)
);
const isTokenizerConfig = (obj: unknown): obj is TokenizerConfig => (
hasField(obj, 'chat_template') && (typeof obj.chat_template === 'string')
&& (!hasField(obj, 'eos_token') || !obj.eos_token || typeof obj.eos_token === 'string')
&& (!hasField(obj, 'bos_token') || !obj.bos_token || typeof obj.bos_token === 'string')
);
const loadHuggingfaceTokenizer = async (modelName: string, configOnly = false): Promise<TokenizerInfo> => {
await prevLoading;
modelName = normalizeModel(modelName);
console.log(`[huggingface] searching config for '${modelName}'`);
const cachedConfig = tokenizerCache[modelName];
if (cachedConfig && cachedConfig[0] != null && cachedConfig[1] != null) {
console.log(`[huggingface] found cached config for '${modelName}'`);
return cachedConfig;
}
const hubModels = await Array.fromAsync(hub.listModels({ search: { query: modelName }, additionalFields: ['config'] }));
const models = hubModels.filter(m => {
if (m.gated) return false;
if (!normalizeModel(m.name).includes(modelName)) return false;
return true;
}).sort((a, b) => b.downloads - a.downloads);
let tokenizerConfig: TokenizerConfig | null = null;
let tokenizerJson: TokenizerJson | null = null;
for (const model of models) {
const { config, name } = model;
if (name.toLowerCase().includes('gguf')) continue;
if (!tokenizerJson && !configOnly) {
try {
console.log(`[huggingface] searching tokenizer in '${name}/tokenizer.json'`);
const fileResponse = await hub.downloadFile({
repo: name,
path: 'tokenizer.json',
});
if (fileResponse) {
tokenizerJson = JSON.parse(await fileResponse.text());
console.log(`[huggingface] found tokenizer in '${name}/tokenizer.json'`);
}
} catch { }
}
if (!tokenizerConfig) {
if (hasField(config, 'tokenizer_config') && isTokenizerConfig(config.tokenizer_config)) {
tokenizerConfig = config.tokenizer_config;
console.log(`[huggingface] found config for '${modelName}' in '${name}'`);
}
}
if (!tokenizerConfig) {
try {
console.log(`[huggingface] searching config in '${name}/tokenizer_config.json'`);
const fileResponse = await hub.downloadFile({
repo: name,
path: 'tokenizer_config.json',
});
if (fileResponse) {
const maybeConfig = JSON.parse(await fileResponse.text());
if (!hasField(maybeConfig, 'chat_template') || !maybeConfig.chat_template) {
console.log(`[huggingface] searching template in '${name}/chat_template.jinja'`);
const templateResponse = await hub.downloadFile({
repo: name,
path: 'chat_template.jinja',
}).catch(() => null);
if (templateResponse) {
const template = await templateResponse.text().catch(() => null);
if (template) {
maybeConfig.chat_template = template;
}
}
}
if (isTokenizerConfig(maybeConfig)) {
tokenizerConfig = maybeConfig;
console.log(`[huggingface] found config for '${modelName}' in '${name}/tokenizer_config.json'`);
break;
}
}
} catch { }
}
}
if (!tokenizerConfig) {
for (const model of models) {
try {
for await (const file of hub.listFiles({ repo: model.name, recursive: true })) {
if (file.type !== 'file' || !file.path.endsWith('.gguf')) continue;
try {
console.log(`[huggingface] searching config in '${model.name}/${file.path}'`);
const fileInfo = await hub.fileDownloadInfo({ repo: model.name, path: file.path });
if (fileInfo?.url) {
const { metadata } = await gguf(fileInfo.url);
if ('tokenizer.chat_template' in metadata) {
const chat_template = metadata['tokenizer.chat_template'];
const tokens = metadata['tokenizer.ggml.tokens'];
const bos_token = tokens[metadata['tokenizer.ggml.bos_token_id']];
const eos_token = tokens[metadata['tokenizer.ggml.eos_token_id']];
const maybeConfig = {
chat_template,
bos_token,
eos_token,
}
if (isTokenizerConfig(maybeConfig)) {
tokenizerConfig = maybeConfig;
console.log(`[huggingface] found config for '${modelName}' in '${model.name}/${file.path}'`);
break;
}
} else if ('tokenizer.ggml.model' in metadata) {
break; // no reason to touch different quants
}
}
} catch { }
}
} catch { }
if (tokenizerConfig) {
break;
}
}
}
if (tokenizerConfig) {
if (tokenizerConfig.chat_template) {
tokenizerConfig.chat_template = formatTemplate(tokenizerConfig.chat_template, tokenizerConfig);
}
const info: TokenizerInfo = [tokenizerConfig, tokenizerJson];
if (!configOnly) {
tokenizerCache[modelName] = info;
saveObject(TOKENIZER_CACHE_KEY, tokenizerCache);
}
return info;
}
console.log(`[huggingface] not found config for '${modelName}'`);
return [null, null];
};
function updateRequired<T extends IParameter>(param: T): T {
if ('items' in param) {
updateRequired(param.items);
} else if ('properties' in param) {
for (const prop of Object.values(param.properties)) {
updateRequired(prop);
}
param.required = Object.keys(param.properties);
}
return param;
}
const convertFunctionToTool = (fn: IFunction): ITool => ({
type: 'function',
function: {
name: fn.name,
description: fn.description,
parameters: updateRequired({
type: 'object',
properties: fn.parameters ?? {},
})
}
})
export const testToolCalls = (template: string): boolean => {
const history: ITemplateMessage[] = [
{ role: 'system', content: 'You are calculator.' },
{ role: 'user', content: 'Calculate 2 + 2.' },
];
const needle = '___AWOORWA_NEEDLE__';
const tools: IFunction[] = [{
name: 'add',
description: 'Test function',
parameters: {
a: { type: 'number' },
b: { type: 'number' },
c: { type: 'array', items: { type: 'number' } },
d: { type: 'object', properties: { inside: { type: 'number', description: needle } } },
}
}];
const text = applyChatTemplate(template, history, tools);
return text.includes(needle);
}
export const minifyTemplate = (input: string, config?: TokenizerConfig) => {
let minified = input;
do {
input = minified;
minified = input.replace(/raise_exception\(('[^')]+'|"[^")]+")\)/g, `''`)
.replace(/(['"])\s*\+\s*bos_token/gi, `$1`)
.replace(/bos_token\s*\+\s*(['"])/gi, `$1`)
.replace(/(['"])\s*\+\s*eos_token/gi, `${config?.eos_token?.replace('$', '$$') ?? ''}$1`)
.replace(/eos_token\s*\+\s*(['"])/gi, `$1${config?.eos_token?.replace('$', '$$') ?? ''}`)
.replace(/\{#-?[^#]+-?#}/gi, '')
.replace(/\s*(\{[{%])-/gi, '$1')
.replace(/-([}%]\})\s*/gi, '$1')
.replace(/\{\{\s*(''|"")\s*\}\}/g, '')
.replace(/\s*\}\}\{\{\s*/, ' + ')
.replace(/\n+['"]/g, (match) => match.replace(/\n/gi, '\\n'))
.replace(/'\s*\+\s*'/g, '')
.replace(/"\s*\+\s*"/g, '')
.replace(/\{%\s*else\s*%\}\{%\s*endif\s*%\}/gi, '{% endif %}')
.replace(/\{%\s*elif[^}]+%\}\{%\s*endif\s*%\}/gi, '{% endif %}')
.replace(/\{%\s*if[^}]+%\}\{%\s*endif\s*%\}/gi, '')
.replaceAll('bos_token', `''`)
.replaceAll('eos_token', `'${config?.eos_token ?? ''}'`);
} while (minified !== input);
return minified;
}
export const formatTemplate = (input: string, config?: TokenizerConfig) => {
const minified = minifyTemplate(input, config);
type ParserState = 'none' | 'open_brace' | 'block' | 'block_end' | 'quote' | 'escaped';
let state: ParserState = 'none';
let currentBlock = '';
let blockStart = '';
let quoteStart = '';
let escaped = false;
const blocks: string[] = [];
for (const ch of minified) {
currentBlock += ch;
if (state === 'none') {
if (ch === '{') {
state = 'open_brace';
}
} else if (state === 'open_brace') {
if (ch === '{' || ch === '%') {
blockStart = ch;
state = 'block';
currentBlock += '-';
} else {
state = 'none';
}
} else if (state === 'block') {
if (ch === '"' || ch === "'") {
quoteStart = ch;
state = 'quote';
} else if (ch === blockStart || blockStart === '{' && ch === '}') {
currentBlock = currentBlock.slice(0, -1) + '-' + ch;
state = 'block_end';
}
} else if (state === 'block_end') {
if (ch === '}') {
state = 'none';
blocks.push(currentBlock);
currentBlock = '';
} else {
state = 'block';
}
} else if (state === 'quote') {
if (!escaped && ch === quoteStart) {
state = 'block';
} else if (!escaped && ch === '\\') {
escaped = true;
} else {
escaped = false;
}
}
}
if (currentBlock) {
blocks.push(currentBlock);
}
let indent = '';
for (let i = 0; i < blocks.length; i++) {
const line = blocks[i];
const content = line.slice(3).trim();
if (content.startsWith('if ') || content.startsWith('for ')) {
blocks[i] = indent + line;
indent += ' ';
} else if (content.startsWith('else ') || content.startsWith('elif ')) {
indent = indent.slice(2);
blocks[i] = indent + line;
indent += ' ';
} else if (content.startsWith("end")) {
indent = indent.slice(2);
blocks[i] = indent + line;
} else {
blocks[i] = indent + line;
}
}
return blocks.filter(b => b.trim()).join('\n');
}
export const findModelTemplate = async (modelName: string): Promise<string | null> => {
modelName = normalizeModel(modelName);
if (!modelName) return '';
let template = templateCache[modelName] ?? null;
if (template) {
console.log(`[huggingface] found cached template for '${modelName}'`);
} else {
const [config] = await loadHuggingfaceTokenizer(modelName, true);
if (config?.chat_template?.trim()) {
template = config.chat_template;
}
}
templateCache[modelName] = template;
saveObject(TEMPLATE_CACHE_KEY, templateCache);
return template;
}
export const findTokenizer = async (modelName: string): Promise<Tokenizer | null> => {
modelName = normalizeModel(modelName);
if (!modelName) return null;
let tokenizer = compiledTokenizers.get(modelName) ?? null;
if (!tokenizer) {
const [tokenizerConfig, tokenizerJson] = await loadHuggingfaceTokenizer(modelName);
if (tokenizerConfig && tokenizerJson) {
tokenizer = new Tokenizer(tokenizerJson, tokenizerConfig);
compiledTokenizers.set(modelName, tokenizer);
}
}
return tokenizer;
}
export const applyChatTemplate = (templateString: string, messages: ITemplateMessage[], functions?: IFunction[]) => (
applyTemplate(templateString, {
messages,
add_generation_prompt: true,
enable_thinking: false,
tools: functions?.map(convertFunctionToTool),
})
);
export const applyTemplate = (templateString: string, args: Record<string, any>): string => {
try {
let template = compiledTemplates.get(templateString);
if (!template) {
template = new Template(templateString);
compiledTemplates.set(templateString, template);
}
const result = template.render(args);
return result;
} catch (e) {
console.error('[applyTemplate] error:', e);
}
return '';
}
}

View File

@ -1,109 +0,0 @@
import messageSound from '../assets/message.mp3';
export interface ISwipe {
content: string;
summary?: string;
cost: number;
}
export interface IMessage {
role: 'user' | 'assistant' | 'system';
currentSwipe: number;
swipes: ISwipe[];
technical?: boolean;
}
export namespace MessageTools {
export const getSwipe = (message?: IMessage | null) => message?.swipes[message?.currentSwipe];
export const create = (content: string, role: IMessage['role'] = 'user', technical = false, cost = 0): IMessage => (
{ role, currentSwipe: 0, swipes: [{ content, cost }], technical }
);
export const playReady = () => {
messageSound.currentTime = 0;
messageSound.play();
}
export const format = (message: string): string => {
const replaceRegex = /(\*\*?|")/ig;
const splitToken = '___SPLIT_AWOORWA___';
const preparedMessage = message.replace(replaceRegex, `${splitToken}$1${splitToken}`);
const parts = preparedMessage.split(splitToken);
const stack: string[] = [];
let resultHTML = '';
for (const part of parts) {
const isClose = stack.at(-1) === part;
if (isClose) {
stack.pop();
if (part === '*' || part === '**') {
resultHTML += `</span>`;
} else if (part === '"') {
resultHTML += `"</span>`;
}
} else {
if (part === '*') {
stack.push(part);
resultHTML += `<span class="italic">`;
} else if (part === '**') {
stack.push(part);
resultHTML += `<span class="bold">`;
} else if (part === '"') {
stack.push(part);
resultHTML += `<span class="quote">"`;
} else {
resultHTML += part;
}
}
}
while (stack.length) {
const part = stack.pop();
if (part === '*' || part === '**') {
resultHTML += `</span>`;
} else if (part === '"') {
resultHTML += `"</span>`;
}
}
return resultHTML;
}
export const trimSentence = (text: string): string => {
let latestEnd = -1;
let latestPairEnd = text.length;
for (const end of '.!?;…*"`)}]\n') {
latestEnd = Math.max(latestEnd, text.lastIndexOf(end));
}
for (const char of '*"`') {
const idx = text.lastIndexOf(char);
const match = text.match(new RegExp(`[${char}]`, 'g'));
if (match && match.length % 2 !== 0) {
latestPairEnd = Math.min(latestPairEnd, idx - 1);
}
}
latestEnd = Math.min(latestEnd, latestPairEnd);
if (latestEnd > 0) {
text = text.slice(0, latestEnd + 1);
}
return text.trim();
}
export const updateSwipe = (messages: IMessage[], index: number, update: Partial<ISwipe>, cost = 0) => (
messages.map(
(m, i) => ({
...m,
swipes: i === index
? m.swipes.map((s, si) => (si === m.currentSwipe ? { ...s, ...update, cost: (s.cost || 0) + cost } : s))
: m.swipes
})
)
)
}

View File

@ -1,27 +0,0 @@
export const normalizeModel = (model: string) => {
let currentModel = model.split(/[\\\/]/).at(-1);
currentModel = currentModel.split('::').at(0).toLowerCase();
let normalizedModel: string;
do {
normalizedModel = currentModel;
currentModel = currentModel
.replace(/[ ._-]\d+(k$|-context)/i, '') // remove context length, i.e. -32k
.replace(/[ ._-](gptq|awq|exl2?|imat|i\d|h\d)/i, '') // remove quant name
.replace(/([ ._-]?gg(uf|ml)[ ._-]?(v[ ._-]?\d)?)/i, '') // remove gguf-v3/ggml/etc
.replace(/[ ._-]i?q([ ._-]?\d[ ._-]?(k?[ ._-]?x*[ ._-]?[lms]?)?)+/i, '') // remove quant size
.replace(/[ ._-]\d+(\.\d+)?bpw/i, '') // remove bpw
.replace(/[ ._-]f(p|loat)?(8|16|32)/i, '')
.replace(/^(debug-?)+/i, '')
.trim();
} while (normalizedModel !== currentModel);
return normalizedModel
.replace(/[ _-]+/ig, '-')
.replace(/\.{2,}/, '-')
.replace(/[ ._-]+$/ig, '')
.trim();
}
export const approximateTokens = (prompt: string): number => Math.round(prompt.length / 4);

View File

@ -0,0 +1,23 @@
.root {
display: flex;
flex-direction: column;
width: 100dvw;
height: 100dvh;
overflow: hidden;
background: var(--bg);
}
.body {
display: flex;
flex-direction: row;
flex: 1;
gap: 0;
overflow: hidden;
}
@media (max-width: 768px) {
.body {
flex-direction: column;
overflow-y: auto;
}
}

View File

@ -0,0 +1,74 @@
.modal {
width: 480px;
}
.loading, .error {
color: var(--text-muted);
font-size: 14px;
text-align: center;
padding: 20px;
}
.error {
color: var(--accent);
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th, td {
padding: 7px 10px;
text-align: left;
border-bottom: 1px solid var(--border);
}
th {
color: var(--text-muted);
font-weight: normal;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
position: sticky;
top: 0;
background: var(--bg);
}
td {
color: var(--text-dim);
}
tr:hover td {
background: var(--bg-hover);
color: var(--text);
}
}
.selfRow td {
color: var(--accent-alt) !important;
font-weight: bold;
}
.above {
color: var(--accent) !important;
}
.below {
color: var(--accent-alt) !important;
}
.nextPlace {
font-size: 13px;
color: var(--text-muted);
strong {
color: var(--yellow);
}
}
.nextPlaceMeta {
font-size: 12px;
color: var(--text-muted);
opacity: 0.7;
}

View File

@ -0,0 +1,67 @@
.modal {
width: 480px;
}
.loading {
color: var(--text-muted);
font-size: 14px;
text-align: center;
padding: 20px;
}
.workerForm {
display: flex;
flex-direction: column;
gap: 10px;
background: var(--bg-active);
border-radius: var(--radius);
padding: 12px;
}
.workerName {
font-weight: bold;
color: var(--accent-alt);
font-size: 14px;
}
.label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--text-muted);
input {
width: 100%;
}
}
.textarea {
width: 100%;
resize: vertical;
}
.checkboxLabel {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-muted);
cursor: pointer;
}
.formActions {
display: flex;
flex-direction: row;
gap: 8px;
}
.saveBtn {
color: var(--accent-alt) !important;
}
.deleteBtn {
color: var(--accent) !important;
margin-left: auto;
}

View File

@ -0,0 +1,72 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
max-height: 80vh;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.title {
font-size: 16px;
font-weight: bold;
color: var(--text);
}
.closeButton {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
color: var(--text-muted);
background: transparent;
border: none;
border-radius: var(--radius);
cursor: pointer;
&:hover {
color: var(--text);
background: var(--bg-hover);
}
}
.body {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
}
.footer {
padding: 12px 16px;
border-top: 1px solid var(--border);
display: flex;
flex-direction: row;
gap: 8px;
flex-shrink: 0;
align-items: center;
}

View File

@ -0,0 +1,23 @@
.navbar {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.title {
font-size: 18px;
font-weight: bold;
color: var(--accent);
letter-spacing: 0.5px;
}
.actions {
display: flex;
flex-direction: row;
gap: 4px;
}

View File

@ -0,0 +1,19 @@
.modal {
width: 380px;
}
.label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
color: var(--text-muted);
input {
width: 100%;
}
}
.saveBtn {
color: var(--accent-alt) !important;
}

View File

@ -0,0 +1,125 @@
.panel {
width: 420px;
min-width: 320px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0;
background: var(--bg-panel);
border-right: 1px solid var(--border);
overflow-y: auto;
padding: 16px;
gap: 16px;
}
@media (max-width: 768px) {
.panel {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--border);
}
}
.sectionTitle {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
margin-bottom: 8px;
}
.username {
font-size: 16px;
font-weight: bold;
color: var(--accent-alt);
margin-bottom: 10px;
}
.stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.stat {
background: var(--bg-active);
border-radius: var(--radius);
padding: 8px 10px;
dt {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 2px;
}
dd {
font-size: 14px;
color: var(--text);
font-weight: bold;
}
}
.userStats, .networkStats {
display: flex;
flex-direction: column;
}
.modelsSection {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.tableWrapper {
overflow-y: auto;
overflow-x: hidden;
flex: 1;
}
.table {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
font-size: 12px;
th, td {
padding: 5px 6px;
text-align: left;
border-bottom: 1px solid var(--border);
}
th {
color: var(--text-muted);
cursor: pointer;
user-select: none;
white-space: nowrap;
position: sticky;
top: 0;
background: var(--bg-panel);
&:hover {
color: var(--text);
}
}
td {
color: var(--text-dim);
overflow-wrap: break-word;
}
tr:hover td {
color: var(--text);
background: var(--bg-hover);
}
}
.ownModel td {
color: var(--accent-alt) !important;
}
.colNum {
width: 52px;
text-align: right;
white-space: nowrap;
}

View File

@ -0,0 +1,49 @@
@import "@common/assets/global.css";
body {
background: var(--bg);
color: var(--text);
font-family: 'Georgia', serif;
font-size: 14px;
line-height: 1.5;
width: 100dvw;
height: 100dvh;
overflow: hidden;
}
button {
cursor: pointer;
background: none;
border: none;
color: var(--text-muted);
font-family: inherit;
font-size: inherit;
transition: color var(--transition), background var(--transition);
border-radius: var(--radius);
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 4px;
padding: 4px 8px;
&:hover {
color: var(--text);
background: var(--bg-hover);
}
}
input, textarea {
background: var(--bg-active);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: inherit;
font-size: inherit;
padding: 6px 10px;
outline: none;
transition: border-color var(--transition);
&:focus {
border-color: var(--accent);
}
}

View File

@ -0,0 +1,133 @@
.card {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 6px;
transition: border-color var(--transition);
&:hover {
border-color: var(--bg-active);
}
}
.own {
border-color: var(--accent) !important;
}
.offline {
opacity: 0.5;
}
.header {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
gap: 6px;
}
.name {
font-weight: bold;
color: var(--text);
font-size: 13px;
word-break: break-word;
}
.hasInfo {
cursor: pointer;
&:hover {
color: var(--blue);
}
}
.infoToggle {
color: var(--text-muted);
font-size: 10px;
}
.badges {
display: flex;
flex-direction: row;
gap: 4px;
flex-wrap: wrap;
flex-shrink: 0;
}
.badge {
font-size: 10px;
padding: 1px 5px;
border-radius: 2px;
font-weight: bold;
text-transform: uppercase;
}
.online {
background: var(--accent-alt);
color: #1a1a1a;
}
.offlineBadge {
background: var(--bg-active);
color: var(--text-muted);
}
.maintenance {
background: var(--yellow);
color: #1a1a1a;
}
.trusted {
background: var(--blue);
color: #1a1a1a;
}
.info {
font-size: 12px;
color: var(--text-dim);
background: var(--bg-active);
border-radius: var(--radius);
padding: 6px 8px;
white-space: pre-wrap;
word-break: break-word;
}
.models {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 4px;
}
.model {
font-size: 11px;
background: var(--bg-active);
border-radius: 2px;
padding: 1px 5px;
color: var(--text-dim);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
}
.detail {
dt {
font-size: 10px;
color: var(--text-muted);
}
dd {
font-size: 12px;
color: var(--text-dim);
}
}

View File

@ -0,0 +1,47 @@
.panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
flex-wrap: wrap;
gap: 6px;
}
.count {
font-size: 12px;
color: var(--text-muted);
white-space: nowrap;
}
.sortBar {
display: flex;
flex-direction: row;
gap: 2px;
flex-wrap: wrap;
}
.sortActive {
color: var(--accent) !important;
background: var(--bg-active) !important;
}
.list {
flex: 1;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
align-content: start;
gap: 8px;
padding: 10px;
}

View File

@ -0,0 +1,27 @@
import { Title } from "@common/components/Title";
import { useHordeState } from "../contexts/state";
import { Navbar } from "./navbar";
import { StatsPanel } from "./stats-panel";
import { WorkersPanel } from "./workers-panel";
import { LeaderboardModal } from "./modals/leaderboard-modal";
import { OptionsModal } from "./modals/options-modal";
import { ManageWorkersModal } from "./modals/manage-workers-modal";
import styles from "../assets/app.module.css";
export const App = () => {
const { leaderboard, options, manageWorkers } = useHordeState();
return (
<div class={styles.root}>
<Title>Horde Overseer</Title>
<Navbar />
<div class={styles.body}>
<StatsPanel />
<WorkersPanel />
</div>
<LeaderboardModal open={leaderboard.value} onClose={leaderboard.setFalse} />
<OptionsModal open={options.value} onClose={options.setFalse} />
<ManageWorkersModal open={manageWorkers.value} onClose={manageWorkers.setFalse} />
</div>
);
};

View File

@ -0,0 +1,159 @@
import { useEffect, useState } from "preact/hooks";
import { X } from "lucide-preact";
import { useHordeState } from "../../contexts/state";
import { fetchLeaderboard, type LeaderboardEntry } from "../../utils/api";
import { formatNumber, formatTime } from "@common/utils";
import modalStyles from "../../assets/modal.module.css";
import styles from "../../assets/leaderboard-modal.module.css";
interface Props {
open: boolean;
onClose: () => void;
}
const PAGE_SIZE = 25;
export const LeaderboardModal = ({ open, onClose }: Props) => {
const { state } = useHordeState();
const { user, workers } = state;
const ownWorkerIds = new Set(user?.worker_ids ?? []);
const ownWorkers = workers.filter(w => ownWorkerIds.has(w.id));
const totalUptime = ownWorkers.reduce((s, w) => s + w.uptime, 0);
const totalGenerated = ownWorkers.reduce((s, w) => s + (w.kudos_details?.generated ?? 0), 0);
const kudosPerHour = totalUptime > 0 ? (totalGenerated / totalUptime) * 3600 : 0;
const [rows, setRows] = useState<(LeaderboardEntry & { rank: number; diff?: number })[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
let cancelled = false;
const load = async () => {
setLoading(true);
setError(null);
try {
if (!user) {
const page = await fetchLeaderboard(1);
if (!cancelled) {
setRows(page.slice(0, 10).map((e, i) => ({ ...e, rank: i + 1 })));
}
} else {
let userRank = -1;
let page = 1;
outer: while (true) {
const entries = await fetchLeaderboard(page);
if (entries.length === 0) break;
for (let i = 0; i < entries.length; i++) {
if (entries[i].username === user.username) {
userRank = (page - 1) * PAGE_SIZE + i + 1;
break outer;
}
}
page++;
if (page > 20) break;
}
if (cancelled) return;
const startRank = Math.max(1, userRank - 10);
const startPage = Math.ceil(startRank / PAGE_SIZE);
const endPage = Math.ceil(userRank / PAGE_SIZE);
const collected: LeaderboardEntry[] = [];
for (let p = startPage; p <= endPage; p++) {
const entries = await fetchLeaderboard(p);
collected.push(...entries);
}
if (cancelled) return;
const userKudos = user.kudos;
const result = collected
.slice(startRank - (startPage - 1) * PAGE_SIZE - 1)
.slice(0, userRank - startRank + 1)
.map((e, i) => ({
...e,
rank: startRank + i,
diff: e.username === user.username ? undefined : e.kudos - userKudos,
}));
setRows(result);
}
} catch (e) {
if (!cancelled) setError(String(e));
} finally {
if (!cancelled) setLoading(false);
}
};
load();
return () => { cancelled = true; };
}, [open, user?.username]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [open]);
if (!open) return null;
return (
<div class={modalStyles.overlay} onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
<div class={`${modalStyles.modal} ${styles.modal}`}>
<div class={modalStyles.header}>
<span class={modalStyles.title}>Leaderboard</span>
<button class={modalStyles.closeButton} onClick={onClose}><X size={16} /></button>
</div>
<div class={modalStyles.body}>
{loading && <p class={styles.loading}>Loading</p>}
{error && <p class={styles.error}>{error}</p>}
{!loading && !error && rows.length > 0 && (
<table class={styles.table}>
<thead>
<tr>
<th>#</th>
<th>Username</th>
<th>Kudos</th>
{user && <th>Diff</th>}
</tr>
</thead>
<tbody>
{rows.map(r => (
<tr key={r.rank} class={r.username === user?.username ? styles.selfRow : undefined}>
<td>{r.rank}</td>
<td>{r.username}</td>
<td>{formatNumber(r.kudos)}</td>
{user && (
<td class={r.diff !== undefined ? (r.diff > 0 ? styles.above : styles.below) : undefined}>
{r.diff !== undefined ? `+${formatNumber(r.diff)}` : '—'}
</td>
)}
</tr>
))}
</tbody>
</table>
)}
</div>
{(() => {
if (!user || rows.length < 2 || kudosPerHour <= 0) return null;
const nextRow = rows[rows.length - 2];
if (!nextRow.diff || nextRow.diff <= 0) return null;
const secs = Math.ceil((nextRow.diff / kudosPerHour) * 3600);
return (
<div class={modalStyles.footer}>
<span class={styles.nextPlace}>
Time to #{nextRow.rank}: <strong>{formatTime(secs)}</strong>
<span class={styles.nextPlaceMeta}> ({formatNumber(Math.round(kudosPerHour))} kudos/h)</span>
</span>
</div>
);
})()}
</div>
</div>
);
};

View File

@ -0,0 +1,143 @@
import { useEffect, useState } from "preact/hooks";
import { X } from "lucide-preact";
import { useHordeState } from "../../contexts/state";
import { fetchWorker, updateWorker, deleteWorker, type WorkerData } from "../../utils/api";
import modalStyles from "../../assets/modal.module.css";
import styles from "../../assets/manage-workers-modal.module.css";
interface Props {
open: boolean;
onClose: () => void;
}
interface WorkerEdit {
name: string;
info: string;
maintenance_mode: boolean;
}
export const ManageWorkersModal = ({ open, onClose }: Props) => {
const { state, dispatch } = useHordeState();
const { user, apiKey } = state;
const [workerDetails, setWorkerDetails] = useState<WorkerData[]>([]);
const [edits, setEdits] = useState<Record<string, WorkerEdit>>({});
const [saving, setSaving] = useState<Record<string, boolean>>({});
const [deleting, setDeleting] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open || !user || !apiKey) return;
let cancelled = false;
setLoading(true);
Promise.all(user.worker_ids.map(id => fetchWorker(id, apiKey)))
.then(details => {
if (cancelled) return;
setWorkerDetails(details);
const initialEdits: Record<string, WorkerEdit> = {};
for (const w of details) {
initialEdits[w.id] = { name: w.name, info: w.info ?? '', maintenance_mode: w.maintenance_mode };
}
setEdits(initialEdits);
})
.catch(console.error)
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [open, user?.worker_ids.join(',')]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [open]);
if (!open || !user) return null;
const setEdit = (id: string, patch: Partial<WorkerEdit>) => {
setEdits(prev => ({ ...prev, [id]: { ...prev[id], ...patch } }));
};
const save = async (id: string) => {
if (!apiKey) return;
setSaving(prev => ({ ...prev, [id]: true }));
try {
const updated = await updateWorker(id, apiKey, edits[id]);
setWorkerDetails(prev => prev.map(w => w.id === id ? updated : w));
} catch (e) {
console.error('[horde] save worker error:', e);
} finally {
setSaving(prev => ({ ...prev, [id]: false }));
}
};
const confirmDelete = async (id: string) => {
if (!apiKey || !confirm('Delete this worker? This cannot be undone.')) return;
setDeleting(id);
try {
await deleteWorker(id, apiKey);
setWorkerDetails(prev => prev.filter(w => w.id !== id));
dispatch({ type: 'SET_USER', user: { ...user, worker_ids: user.worker_ids.filter(i => i !== id) } });
} catch (e) {
console.error('[horde] delete worker error:', e);
} finally {
setDeleting(null);
}
};
return (
<div class={modalStyles.overlay} onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
<div class={`${modalStyles.modal} ${styles.modal}`}>
<div class={modalStyles.header}>
<span class={modalStyles.title}>Manage Workers</span>
<button class={modalStyles.closeButton} onClick={onClose}><X size={16} /></button>
</div>
<div class={modalStyles.body}>
{loading && <p class={styles.loading}>Loading</p>}
{!loading && workerDetails.map(w => {
const edit = edits[w.id] ?? { name: w.name, info: w.info ?? '', maintenance_mode: w.maintenance_mode };
return (
<div key={w.id} class={styles.workerForm}>
<div class={styles.workerName}>{w.name}</div>
<label class={styles.label}>
<span>Name</span>
<input
value={edit.name}
onInput={e => setEdit(w.id, { name: (e.target as HTMLInputElement).value })}
/>
</label>
<label class={styles.label}>
<span>Info</span>
<textarea
class={styles.textarea}
value={edit.info}
onInput={e => setEdit(w.id, { info: (e.target as HTMLTextAreaElement).value })}
rows={3}
/>
</label>
<label class={styles.checkboxLabel}>
<input
type="checkbox"
checked={edit.maintenance_mode}
onChange={e => setEdit(w.id, { maintenance_mode: (e.target as HTMLInputElement).checked })}
/>
<span>Maintenance mode</span>
</label>
<div class={styles.formActions}>
<button class={styles.saveBtn} onClick={() => save(w.id)} disabled={saving[w.id]}>
{saving[w.id] ? 'Saving…' : 'Save'}
</button>
<button class={styles.deleteBtn} onClick={() => confirmDelete(w.id)} disabled={deleting === w.id}>
{deleting === w.id ? 'Deleting…' : 'Delete'}
</button>
</div>
</div>
);
})}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,67 @@
import { useEffect, useState } from "preact/hooks";
import { X } from "lucide-preact";
import { useHordeState } from "../../contexts/state";
import modalStyles from "../../assets/modal.module.css";
import styles from "../../assets/options-modal.module.css";
interface Props {
open: boolean;
onClose: () => void;
}
export const OptionsModal = ({ open, onClose }: Props) => {
const { state, dispatch } = useHordeState();
const [draft, setDraft] = useState(state.apiKey);
useEffect(() => {
if (open) setDraft(state.apiKey);
}, [open]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [open]);
if (!open) return null;
const save = () => {
dispatch({ type: 'SET_API_KEY', apiKey: draft.trim() });
onClose();
};
const clear = () => {
dispatch({ type: 'SET_API_KEY', apiKey: '' });
setDraft('');
onClose();
};
return (
<div class={modalStyles.overlay} onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
<div class={`${modalStyles.modal} ${styles.modal}`}>
<div class={modalStyles.header}>
<span class={modalStyles.title}>Options</span>
<button class={modalStyles.closeButton} onClick={onClose}><X size={16} /></button>
</div>
<div class={modalStyles.body}>
<label class={styles.label}>
<span>API Key</span>
<input
type="password"
value={draft}
onInput={e => setDraft((e.target as HTMLInputElement).value)}
onKeyDown={e => { if (e.key === 'Enter') save(); }}
placeholder="Enter your AI Horde API key"
autoFocus
/>
</label>
</div>
<div class={modalStyles.footer}>
<button class={styles.saveBtn} onClick={save}>Save</button>
{state.apiKey && <button onClick={clear}>Clear</button>}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,19 @@
import { useHordeState } from "../contexts/state";
import styles from "../assets/navbar.module.css";
export const Navbar = () => {
const { leaderboard, options, manageWorkers, state } = useHordeState();
return (
<nav class={styles.navbar}>
<span class={styles.title}>Horde Overseer</span>
<div class={styles.actions}>
<button onClick={leaderboard.setTrue}>Leaderboard</button>
<button onClick={options.setTrue}>Options</button>
{state.user && (
<button onClick={manageWorkers.setTrue}>Manage Workers</button>
)}
</div>
</nav>
);
};

View File

@ -0,0 +1,131 @@
import { useState } from "preact/hooks";
import { useHordeState } from "../contexts/state";
import { formatNumber, formatTime } from "@common/utils";
import styles from "../assets/stats-panel.module.css";
type ModelSortKey = 'name' | 'count' | 'queued' | 'jobs' | 'performance';
type SortDir = 'asc' | 'desc';
export const StatsPanel = () => {
const { state } = useHordeState();
const { user, workers, performance, models } = state;
const [sortKey, setSortKey] = useState<ModelSortKey>('jobs');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const ownWorkerIds = new Set(user?.worker_ids ?? []);
const ownWorkers = workers.filter(w => ownWorkerIds.has(w.id));
// Kudos/hour: sum generated kudos across own workers / total uptime hours
const totalUptime = ownWorkers.reduce((s, w) => s + w.uptime, 0);
const totalGenerated = ownWorkers.reduce((s, w) => s + (w.kudos_details?.generated ?? 0), 0);
const kudosPerHour = totalUptime > 0 ? (totalGenerated / totalUptime) * 3600 : 0;
// Own model names set
const ownModelNames = new Set(ownWorkers.flatMap(w => w.models));
// Sort models
const sortedModels = [...models].sort((a, b) => {
let cmp = 0;
if (sortKey === 'name') cmp = a.name.localeCompare(b.name);
else if (sortKey === 'count') cmp = a.count - b.count;
else if (sortKey === 'queued') cmp = a.queued - b.queued;
else if (sortKey === 'jobs') cmp = a.jobs - b.jobs;
else cmp = a.performance - b.performance;
return sortDir === 'asc' ? cmp : -cmp;
});
const handleSort = (key: ModelSortKey) => {
if (key === sortKey) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setSortKey(key); setSortDir('desc'); }
};
const sortIndicator = (key: ModelSortKey) =>
sortKey === key ? (sortDir === 'asc' ? ' ↑' : ' ↓') : '';
return (
<aside class={styles.panel}>
{user ? (
<section class={styles.userStats}>
<div class={styles.username}>{user.username}</div>
<dl class={styles.stats}>
<div class={styles.stat}>
<dt>Kudos</dt>
<dd>{formatNumber(user.kudos)}</dd>
</div>
<div class={styles.stat}>
<dt>Kudos/h</dt>
<dd>{formatNumber(Math.round(kudosPerHour))}</dd>
</div>
<div class={styles.stat}>
<dt>Workers</dt>
<dd>{user.worker_count}</dd>
</div>
<div class={styles.stat}>
<dt>Tokens generated</dt>
<dd>{formatNumber(user.records.contribution.tokens)}</dd>
</div>
<div class={styles.stat}>
<dt>Requests fulfilled</dt>
<dd>{formatNumber(user.records.fulfillment.text)}</dd>
</div>
<div class={styles.stat}>
<dt>Total uptime</dt>
<dd>{formatTime(totalUptime)}</dd>
</div>
</dl>
</section>
) : (
<section class={styles.networkStats}>
<div class={styles.sectionTitle}>Network</div>
<dl class={styles.stats}>
<div class={styles.stat}>
<dt>Active workers</dt>
<dd>{performance?.text_worker_count ?? '—'}</dd>
</div>
<div class={styles.stat}>
<dt>Queued requests</dt>
<dd>{performance ? formatNumber(performance.queued_text_requests) : '—'}</dd>
</div>
<div class={styles.stat}>
<dt>Queued tokens</dt>
<dd>{performance ? formatNumber(performance.queued_tokens) : '—'}</dd>
</div>
<div class={styles.stat}>
<dt>Throughput (1m)</dt>
<dd>{performance ? `${formatNumber(performance.past_minute_tokens)} tok` : '—'}</dd>
</div>
</dl>
</section>
)}
<section class={styles.modelsSection}>
<div class={styles.sectionTitle}>Models</div>
<div class={styles.tableWrapper}>
<table class={styles.table}>
<thead>
<tr>
<th onClick={() => handleSort('name')}>Name{sortIndicator('name')}</th>
<th class={styles.colNum} onClick={() => handleSort('count')}>Workers{sortIndicator('count')}</th>
<th class={styles.colNum} onClick={() => handleSort('jobs')}>Jobs{sortIndicator('jobs')}</th>
<th class={styles.colNum} onClick={() => handleSort('queued')}>Queued{sortIndicator('queued')}</th>
<th class={styles.colNum} onClick={() => handleSort('performance')}>Perf{sortIndicator('performance')}</th>
</tr>
</thead>
<tbody>
{sortedModels.map(m => (
<tr key={m.name} class={ownModelNames.has(m.name) ? styles.ownModel : undefined}>
<td>{m.name}</td>
<td class={styles.colNum}>{m.count}</td>
<td class={styles.colNum}>{m.jobs}</td>
<td class={styles.colNum}>{formatNumber(m.queued)}</td>
<td class={styles.colNum}>{m.performance > 0 ? `${m.performance.toFixed(1)}` : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</aside>
);
};

View File

@ -0,0 +1,74 @@
import { useBool } from "@common/hooks/useBool";
import { formatNumber, formatTime } from "@common/utils";
import type { WorkerData } from "../utils/api";
import styles from "../assets/worker-card.module.css";
interface Props {
worker: WorkerData;
isOwn: boolean;
}
export const WorkerCard = ({ worker, isOwn }: Props) => {
const expanded = useBool(false);
return (
<div class={`${styles.card} ${isOwn ? styles.own : ''} ${worker.online ? '' : styles.offline}`}>
<div class={styles.header}>
<span
class={`${styles.name} ${worker.info ? styles.hasInfo : ''}`}
onClick={worker.info ? expanded.toggle : undefined}
title={worker.info ? 'Click to expand' : undefined}
>
{worker.name}
{worker.info ? <span class={styles.infoToggle}>{expanded.value ? ' ▲' : ' ▼'}</span> : null}
</span>
<div class={styles.badges}>
<span class={`${styles.badge} ${worker.online ? styles.online : styles.offlineBadge}`}>
{worker.online ? 'online' : 'offline'}
</span>
{worker.maintenance_mode && <span class={`${styles.badge} ${styles.maintenance}`}>maintenance</span>}
{worker.trusted && <span class={`${styles.badge} ${styles.trusted}`}>trusted</span>}
</div>
</div>
{expanded.value && worker.info && (
<div class={styles.info}>{worker.info}</div>
)}
<div class={styles.models}>
{worker.models.map(m => <span key={m} class={styles.model}>{m}</span>)}
</div>
<dl class={styles.details}>
<div class={styles.detail}>
<dt>Uptime</dt>
<dd>{formatTime(worker.uptime)}</dd>
</div>
<div class={styles.detail}>
<dt>Threads</dt>
<dd>{worker.threads}</dd>
</div>
<div class={styles.detail}>
<dt>Max context</dt>
<dd>{formatNumber(worker.max_context_length)}</dd>
</div>
<div class={styles.detail}>
<dt>Max length</dt>
<dd>{formatNumber(worker.max_length)}</dd>
</div>
<div class={styles.detail}>
<dt>Requests</dt>
<dd>{formatNumber(worker.requests_fulfilled)}</dd>
</div>
<div class={styles.detail}>
<dt>Performance</dt>
<dd>{worker.performance}</dd>
</div>
<div class={styles.detail}>
<dt>Kudos earned</dt>
<dd>{formatNumber(worker.kudos_rewards)}</dd>
</div>
</dl>
</div>
);
};

View File

@ -0,0 +1,73 @@
import { useState } from "preact/hooks";
import { useHordeState } from "../contexts/state";
import { WorkerCard } from "./worker-card";
import type { WorkerData } from "../utils/api";
import styles from "../assets/workers-panel.module.css";
type WorkerSortKey = 'name' | 'uptime' | 'requests' | 'kudos' | 'context' | 'length';
type SortDir = 'asc' | 'desc';
const SORT_LABELS: Record<WorkerSortKey, string> = {
name: 'Name',
uptime: 'Uptime',
requests: 'Requests',
kudos: 'Kudos',
context: 'Max Context',
length: 'Max Length',
};
const sortWorkers = (workers: WorkerData[], key: WorkerSortKey, dir: SortDir): WorkerData[] => {
return [...workers].sort((a, b) => {
let cmp = 0;
switch (key) {
case 'name': cmp = a.name.localeCompare(b.name); break;
case 'uptime': cmp = a.uptime - b.uptime; break;
case 'requests': cmp = a.requests_fulfilled - b.requests_fulfilled; break;
case 'kudos': cmp = a.kudos_rewards - b.kudos_rewards; break;
case 'context': cmp = a.max_context_length - b.max_context_length; break;
case 'length': cmp = a.max_length - b.max_length; break;
}
return dir === 'asc' ? cmp : -cmp;
});
};
export const WorkersPanel = () => {
const { state } = useHordeState();
const { workers, user } = state;
const [sortKey, setSortKey] = useState<WorkerSortKey>('name');
const [sortDir, setSortDir] = useState<SortDir>('asc');
const ownWorkerIds = new Set(user?.worker_ids ?? []);
const sorted = sortWorkers(workers, sortKey, sortDir);
const handleSort = (key: WorkerSortKey) => {
if (key === sortKey) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setSortKey(key); setSortDir('desc'); }
};
return (
<section class={styles.panel}>
<div class={styles.header}>
<span class={styles.count}>{workers.length} workers</span>
<div class={styles.sortBar}>
{(Object.keys(SORT_LABELS) as WorkerSortKey[]).map(key => (
<button
key={key}
class={sortKey === key ? styles.sortActive : undefined}
onClick={() => handleSort(key)}
>
{SORT_LABELS[key]}
{sortKey === key ? (sortDir === 'asc' ? ' ↑' : ' ↓') : ''}
</button>
))}
</div>
</div>
<div class={styles.list}>
{sorted.map(w => (
<WorkerCard key={w.id} worker={w} isOwn={ownWorkerIds.has(w.id)} />
))}
</div>
</section>
);
};

View File

@ -0,0 +1,117 @@
import { createContext } from "preact";
import { useContext, useEffect } from "preact/hooks";
import { useStoredReducer } from "@common/hooks/useStored";
import { useBool } from "@common/hooks/useBool";
import {
fetchUser, fetchWorkers, fetchPerformance, fetchModels,
type WorkerData, type UserData, type PerformanceData, type ModelData,
} from "../utils/api";
// ── State ──────────────────────────────────────────────────────────────────
interface HordeState {
apiKey: string;
user: UserData | null;
workers: WorkerData[];
performance: PerformanceData | null;
models: ModelData[];
}
const DEFAULT_STATE: HordeState = {
apiKey: '',
user: null,
workers: [],
performance: null,
models: [],
};
// ── Actions ────────────────────────────────────────────────────────────────
type Action =
| { type: 'SET_API_KEY'; apiKey: string }
| { type: 'SET_USER'; user: UserData | null }
| { type: 'SET_WORKERS'; workers: WorkerData[] }
| { type: 'SET_PERFORMANCE'; performance: PerformanceData }
| { type: 'SET_MODELS'; models: ModelData[] };
const reducer = (state: HordeState, action: Action): HordeState => {
switch (action.type) {
case 'SET_API_KEY': return { ...state, apiKey: action.apiKey, user: null };
case 'SET_USER': return { ...state, user: action.user };
case 'SET_WORKERS': return { ...state, workers: action.workers };
case 'SET_PERFORMANCE': return { ...state, performance: action.performance };
case 'SET_MODELS': return { ...state, models: action.models };
}
};
// ── Context ────────────────────────────────────────────────────────────────
interface HordeContextValue {
state: HordeState;
dispatch: (action: Action) => void;
leaderboard: ReturnType<typeof useBool>;
options: ReturnType<typeof useBool>;
manageWorkers: ReturnType<typeof useBool>;
}
const HordeContext = createContext<HordeContextValue>(null!);
export const useHordeState = () => useContext(HordeContext);
// ── Provider ───────────────────────────────────────────────────────────────
const POLL_INTERVAL_MS = 30_000;
export const HordeStateProvider = ({ children }: { children: preact.ComponentChildren }) => {
const [state, dispatch] = useStoredReducer('horde.state', reducer, DEFAULT_STATE);
const leaderboard = useBool(false);
const options = useBool(false);
const manageWorkers = useBool(false);
useEffect(() => {
let cancelled = false;
const poll = async () => {
if (cancelled) return;
// Always fetch workers, performance, models
try {
const [workers, performance, models] = await Promise.all([
fetchWorkers(),
fetchPerformance(),
fetchModels(),
]);
if (!cancelled) {
dispatch({ type: 'SET_WORKERS', workers });
dispatch({ type: 'SET_PERFORMANCE', performance });
dispatch({ type: 'SET_MODELS', models });
}
} catch (e) {
console.error('[horde] poll error:', e);
}
// Fetch user if apiKey is set
if (state.apiKey && !cancelled) {
try {
const user = await fetchUser(state.apiKey);
if (!cancelled) dispatch({ type: 'SET_USER', user });
} catch {
if (!cancelled) dispatch({ type: 'SET_USER', user: null });
}
} else if (!state.apiKey && !cancelled) {
dispatch({ type: 'SET_USER', user: null });
}
};
poll();
const id = setInterval(poll, POLL_INTERVAL_MS);
return () => { cancelled = true; clearInterval(id); };
}, [state.apiKey]);
return (
<HordeContext.Provider value={{ state, dispatch, leaderboard, options, manageWorkers }}>
{children}
</HordeContext.Provider>
);
};

14
src/games/horde/index.tsx Normal file
View File

@ -0,0 +1,14 @@
import { render } from "preact";
import { App } from "./components/app";
import { HordeStateProvider } from "./contexts/state";
import './assets/style.css';
export default function main() {
render(
<HordeStateProvider>
<App />
</HordeStateProvider>,
document.body
);
}

View File

@ -0,0 +1,117 @@
const BASE = 'https://aihorde.net/api/v2';
export interface WorkerData {
id: string;
name: string;
models: string[];
uptime: number;
online: boolean;
maintenance_mode: boolean;
max_length: number;
max_context_length: number;
requests_fulfilled: number;
performance: string;
kudos_rewards: number;
kudos_details: {
generated: number;
uptime: number;
};
threads: number;
trusted: boolean;
info: string | null;
}
export interface UserData {
username: string;
kudos: number;
worker_count: number;
worker_ids: string[];
records: {
contribution: { tokens: number };
fulfillment: { text: number };
};
}
export interface PerformanceData {
text_worker_count: number;
queued_text_requests: number;
queued_tokens: number;
past_minute_tokens: number;
}
export interface ModelData {
name: string;
count: number;
queued: number;
jobs: number;
performance: number;
eta: number;
type: string;
}
export interface LeaderboardEntry {
username: string;
kudos: number;
id?: number;
}
const headers = (apiKey?: string): HeadersInit =>
apiKey ? { 'apikey': apiKey, 'Content-Type': 'application/json' } : { 'Content-Type': 'application/json' };
export const fetchUser = async (apiKey: string): Promise<UserData> => {
const res = await fetch(`${BASE}/find_user`, { headers: headers(apiKey) });
if (!res.ok) throw new Error(`Failed to fetch user: ${res.status}`);
return res.json();
};
export const fetchWorkers = async (): Promise<WorkerData[]> => {
const res = await fetch(`${BASE}/workers?type=text`);
if (!res.ok) throw new Error(`Failed to fetch workers: ${res.status}`);
return res.json();
};
export const fetchWorker = async (id: string, apiKey: string): Promise<WorkerData> => {
const res = await fetch(`${BASE}/workers/${id}`, { headers: headers(apiKey) });
if (!res.ok) throw new Error(`Failed to fetch worker: ${res.status}`);
return res.json();
};
export const fetchPerformance = async (): Promise<PerformanceData> => {
const res = await fetch(`${BASE}/status/performance`);
if (!res.ok) throw new Error(`Failed to fetch performance: ${res.status}`);
return res.json();
};
export const fetchModels = async (): Promise<ModelData[]> => {
const res = await fetch(`${BASE}/status/models?type=text&model_state=all`);
if (!res.ok) throw new Error(`Failed to fetch models: ${res.status}`);
return res.json();
};
export const fetchLeaderboard = async (page: number): Promise<LeaderboardEntry[]> => {
const res = await fetch(`${BASE}/users?page=${page}&sort=kudos`);
if (!res.ok) throw new Error(`Failed to fetch leaderboard: ${res.status}`);
return res.json();
};
export const updateWorker = async (
id: string,
apiKey: string,
patch: { name?: string; info?: string; maintenance_mode?: boolean }
): Promise<WorkerData> => {
const res = await fetch(`${BASE}/workers/${id}`, {
method: 'PUT',
headers: headers(apiKey),
body: JSON.stringify(patch),
});
if (!res.ok) throw new Error(`Failed to update worker: ${res.status}`);
return res.json();
};
export const deleteWorker = async (id: string, apiKey: string): Promise<void> => {
const res = await fetch(`${BASE}/workers/${id}`, {
method: 'DELETE',
headers: headers(apiKey),
});
if (!res.ok) throw new Error(`Failed to delete worker: ${res.status}`);
};

View File

@ -1,37 +1,12 @@
@import "@common/assets/global.css";
:root {
/* Monokai-inspired palette */
--bg: #272822;
--bg-panel: #1e1f1a;
--bg-hover: #3e3d32;
--bg-active: #49483e;
--border: #3e3d32;
--accent: #f92672;
--accent-alt: #a6e22e;
--text: #f8f8f2;
--text-muted: #75715e;
--text-dim: #cfcfc2;
--yellow: #e6db74;
--orange: #fd971f;
--blue: #66d9ef;
--purple: #ae81ff;
--radius: 4px;
--transition: 0.15s ease;
--textColor: #DCDCD2;
--italicColor: #AFAFAF;
--quoteColor: #D4E5FF;
--codeBg: #49483e;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
scrollbar-width: thin;
scrollbar-color: var(--bg-active) transparent;
}
body {
background: var(--bg);
color: var(--text);

View File

@ -50,7 +50,7 @@ namespace Pathfinding {
openSet.delete(current);
if (current !== start && !forEnemy && current.items.length > 0) continue;
if (current !== start && !forEnemy && current.hasAnyItems) continue;
for (const neighbor of current.activeConnections) {
// tentative gScore is currents gScore plus cost to move to neighbor