Compare commits
2 Commits
cd1177f1d0
...
23bc808cca
| Author | SHA1 | Date |
|---|---|---|
|
|
23bc808cca | |
|
|
6be67d71f8 |
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -80,7 +80,7 @@ export const ContentEditable = ({ value, placeholder, autoLines, onInput, class:
|
||||||
range.deleteContents();
|
range.deleteContents();
|
||||||
|
|
||||||
const endsWithNewline = ref.current.textContent?.endsWith('\n');
|
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));
|
const newline = document.createTextNode('\n'.repeat((endsWithNewline || !caretAtEnd) ? 1 : 2));
|
||||||
range.insertNode(newline);
|
range.insertNode(newline);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ export function loadImageData(dataView: DataView, pointer: number) {
|
||||||
const height = dataView.getUint16(pointer + 2, true);
|
const height = dataView.getUint16(pointer + 2, true);
|
||||||
|
|
||||||
const dataPtr = dataView.getUint32(pointer + 4, 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);
|
const imageData = new ImageData(imageBuffer, width, height);
|
||||||
|
|
||||||
return imageData;
|
return imageData;
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
export const callUpdater = <T>(f: StateUpdater<T>, prev: T) =>
|
||||||
typeof f === 'function' ? (f as Function)(prev) : f;
|
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 |
|
|
@ -1 +0,0 @@
|
||||||
isApp: true
|
|
||||||
|
|
@ -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.
Binary file not shown.
|
Before Width: | Height: | Size: 53 KiB |
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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} />
|
|
||||||
};
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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} />
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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}`}
|
|
||||||
/>;
|
|
||||||
};
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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}>💲 {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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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}`);
|
||||||
|
};
|
||||||
|
|
@ -1,37 +1,12 @@
|
||||||
|
@import "@common/assets/global.css";
|
||||||
|
|
||||||
:root {
|
: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;
|
--textColor: #DCDCD2;
|
||||||
--italicColor: #AFAFAF;
|
--italicColor: #AFAFAF;
|
||||||
--quoteColor: #D4E5FF;
|
--quoteColor: #D4E5FF;
|
||||||
--codeBg: #49483e;
|
--codeBg: #49483e;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--bg-active) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ namespace Pathfinding {
|
||||||
|
|
||||||
openSet.delete(current);
|
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) {
|
for (const neighbor of current.activeConnections) {
|
||||||
// tentative gScore is current’s gScore plus cost to move to neighbor
|
// tentative gScore is current’s gScore plus cost to move to neighbor
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue