From 0e2fdf37a818f287b93bf4e44bed536f5fbe249e Mon Sep 17 00:00:00 2001 From: Karl-Johan Alm Date: Tue, 19 Nov 2024 11:32:44 +0900 Subject: [PATCH 01/20] feature: derived templates This PR adds a simple hash based method for picking context and instruct templates based on the chat template, when provided by the back end. --- public/script.js | 26 ++++++++ public/scripts/chat-cemplates.js | 76 ++++++++++++++++++++++ src/endpoints/backends/text-completions.js | 40 ++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 public/scripts/chat-cemplates.js diff --git a/public/script.js b/public/script.js index f655d6fe1..f7c88cc6a 100644 --- a/public/script.js +++ b/public/script.js @@ -267,6 +267,7 @@ import { applyBrowserFixes } from './scripts/browser-fixes.js'; import { initServerHistory } from './scripts/server-history.js'; import { initSettingsSearch } from './scripts/setting-search.js'; import { initBulkEdit } from './scripts/bulk-edit.js'; +import { deriveTemplatesFromChatTemplate } from './scripts/chat-cemplates.js'; //exporting functions and vars for mods export { @@ -1235,6 +1236,31 @@ async function getStatusTextgen() { const supportsTokenization = response.headers.get('x-supports-tokenization') === 'true'; supportsTokenization ? sessionStorage.setItem(TOKENIZER_SUPPORTED_KEY, 'true') : sessionStorage.removeItem(TOKENIZER_SUPPORTED_KEY); + const supportsChatTemplate = response.headers.get('x-supports-chat-template') === 'true'; + + if (supportsChatTemplate) { + const response = await fetch('/api/backends/text-completions/chat_template', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ + api_server: endpoint, + api_type: textgen_settings.type, + }), + }); + + const data = await response.json(); + if (data) { + const chat_template = data.chat_template; + console.log(`We have chat template ${chat_template.split('\n')[0]}...`); + const templates = await deriveTemplatesFromChatTemplate(chat_template); + if (templates) { + const { context, instruct } = templates; + selectContextPreset(context, { isAuto: true }); + selectInstructPreset(instruct, { isAuto: true }); + } + } + } + // We didn't get a 200 status code, but the endpoint has an explanation. Which means it DID connect, but I digress. if (online_status === 'no_connection' && data.response) { toastr.error(data.response, t`API Error`, { timeOut: 5000, preventDuplicates: true }); diff --git a/public/scripts/chat-cemplates.js b/public/scripts/chat-cemplates.js new file mode 100644 index 000000000..f70f9fc18 --- /dev/null +++ b/public/scripts/chat-cemplates.js @@ -0,0 +1,76 @@ +// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest +async function digestMessage(message) { + const msgUint8 = new TextEncoder().encode(message); // encode as (utf-8) Uint8Array + const hashBuffer = await window.crypto.subtle.digest('SHA-256', msgUint8); // hash the message + const hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array + const hashHex = hashArray + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); // convert bytes to hex string + return hashHex; +} + +// the hash can be obtained from command line e.g. via: MODEL=path_to_model; python -c "import json, hashlib, sys; print(hashlib.sha256(json.load(open('"$MODEL"/tokenizer_config.json'))['chat_template'].strip().encode()).hexdigest())" +// note that chat templates must be trimmed to match the llama.cpp metadata value +const derivations = { + // Meta + '93c0e9aa3629bbd77e68dbc0f5621f6e6b23aa8d74b932595cdb8d64684526d7': { + // Meta-Llama-3.1-8B-Instruct + // Meta-Llama-3.1-70B-Instruct + context: 'Llama 3 Instruct', + instruct: 'Llama 3 Instruct', + }, + 'd82792f95932f1c9cef5c4bd992f171225e3bf8c7b609b4557c9e1ec96be819f': { + // Llama-3.2-1B-Instruct + // Llama-3.2-3B-Instruct + context: 'Llama 3 Instruct', + instruct: 'Llama 3 Instruct', + }, + + // Mistral + // Mistral Reference: https://github.com/mistralai/mistral-common + 'cafb64e0e9e5fd2503054b3479593fae39cbdfd52338ce8af9bb4664a8eb05bd': { + // Mistral-Small-Instruct-2409 + // Mistral-Large-Instruct-2407 + context: 'Mistral V2 & V3', + instruct: 'Mistral V2 & V3', + }, + '3c4ad5fa60dd8c7ccdf82fa4225864c903e107728fcaf859fa6052cb80c92ee9': { + // Mistral-Large-Instruct-2411 + context: 'Mistral V7', // https://huggingface.co/mistralai/Mistral-Large-Instruct-2411 + instruct: 'Mistral V7', + }, + 'e7deee034838db2bfc7487788a3013d8a307ab69f72f3c54a85f06fd76007d4e': { + // Mistral-Nemo-Instruct-2407 + context: 'Mistral V3-Tekken', + instruct: 'Mistral V3-Tekken', + }, + '26a59556925c987317ce5291811ba3b7f32ec4c647c400c6cc7e3a9993007ba7': { + // Mistral-7B-Instruct-v0.3 + context: 'Mistral V2 & V3', + instruct: 'Mistral V2 & V3', + }, + + // Gemma + 'ecd6ae513fe103f0eb62e8ab5bfa8d0fe45c1074fa398b089c93a7e70c15cfd6': { + // gemma-2-9b-it + // gemma-2-27b-it + context: 'Gemma 2', + instruct: 'Gemma 2', + }, + + // Cohere + '3b54f5c219ae1caa5c0bb2cdc7c001863ca6807cf888e4240e8739fa7eb9e02e': { + // command-r-08-2024 + context: 'Command R', + instruct: 'Command R', + }, +}; + +export async function deriveTemplatesFromChatTemplate(chat_template) { + const hash = await digestMessage(chat_template); + if (hash in derivations) { + return derivations[hash]; + } + console.log(`Unknown chat template hash: ${hash}`); + return null; +} diff --git a/src/endpoints/backends/text-completions.js b/src/endpoints/backends/text-completions.js index 9a3dac2ce..1c5bdb75a 100644 --- a/src/endpoints/backends/text-completions.js +++ b/src/endpoints/backends/text-completions.js @@ -218,6 +218,18 @@ router.post('/status', jsonParser, async function (request, response) { } catch (error) { console.error(`Failed to get TabbyAPI model info: ${error}`); } + } else if (apiType == TEXTGEN_TYPES.KOBOLDCPP) { + try { + const chatTemplateUrl = baseUrl + '/api/extra/chat_template'; + const chatTemplateReply = await fetch(chatTemplateUrl); + if (chatTemplateReply.ok) { + response.setHeader('x-supports-chat-template', 'true'); + } else { + console.log(`ct res = ${JSON.stringify(chatTemplateReply)}`); + } + } catch (error) { + console.error(`Failed to fetch chat template info: ${error}`); + } } return response.send({ result, data: data.data }); @@ -227,6 +239,34 @@ router.post('/status', jsonParser, async function (request, response) { } }); +router.post('/chat_template', jsonParser, async function (request, response) { + if (!request.body.api_server) return response.sendStatus(400); + + try { + const baseUrl = trimV1(request.body.api_server); + const args = { + headers: { 'Content-Type': 'application/json' }, + }; + + setAdditionalHeaders(request, args, baseUrl); + + const chatTemplateUrl = baseUrl + '/api/extra/chat_template'; + const chatTemplateReply = await fetch(chatTemplateUrl, args); + + if (!chatTemplateReply.ok) { + console.log('Chat template endpoint is offline.'); + return response.status(400); + } + + /** @type {any} */ + const chatTemplate = await chatTemplateReply.json(); + return response.send(chatTemplate); + } catch (error) { + console.error(error); + return response.status(500); + } +}); + router.post('/generate', jsonParser, async function (request, response) { if (!request.body) return response.sendStatus(400); From f25ea9f6d6a285a5250ff35286d3a629df381f04 Mon Sep 17 00:00:00 2001 From: Karl-Johan Alm Date: Tue, 19 Nov 2024 20:09:29 +0900 Subject: [PATCH 02/20] template derivation: move hash part to backend --- package-lock.json | 7 +++++++ package.json | 1 + public/script.js | 4 ++-- public/scripts/chat-cemplates.js | 14 +------------- src/endpoints/backends/text-completions.js | 2 ++ 5 files changed, 13 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index e3d0eade1..42b187bcf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "ip-matching": "^2.1.2", "ipaddr.js": "^2.0.1", "jimp": "^0.22.10", + "js-sha256": "^0.11.0", "localforage": "^1.10.0", "lodash": "^4.17.21", "mime-types": "^2.1.35", @@ -4882,6 +4883,12 @@ "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", "license": "BSD-3-Clause" }, + "node_modules/js-sha256": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.0.tgz", + "integrity": "sha512-6xNlKayMZvds9h1Y1VWc0fQHQ82BxTXizWPEtEeGvmOUYpBRy4gbWroHLpzowe6xiQhHpelCQiE7HEdznyBL9Q==", + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", diff --git a/package.json b/package.json index 3608d9775..5a2c4f15c 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "ip-matching": "^2.1.2", "ipaddr.js": "^2.0.1", "jimp": "^0.22.10", + "js-sha256": "^0.11.0", "localforage": "^1.10.0", "lodash": "^4.17.21", "mime-types": "^2.1.35", diff --git a/public/script.js b/public/script.js index f7c88cc6a..a3446d258 100644 --- a/public/script.js +++ b/public/script.js @@ -1250,9 +1250,9 @@ async function getStatusTextgen() { const data = await response.json(); if (data) { - const chat_template = data.chat_template; + const { chat_template, chat_template_hash } = data; console.log(`We have chat template ${chat_template.split('\n')[0]}...`); - const templates = await deriveTemplatesFromChatTemplate(chat_template); + const templates = await deriveTemplatesFromChatTemplate(chat_template, chat_template_hash); if (templates) { const { context, instruct } = templates; selectContextPreset(context, { isAuto: true }); diff --git a/public/scripts/chat-cemplates.js b/public/scripts/chat-cemplates.js index f70f9fc18..2dfaebbb6 100644 --- a/public/scripts/chat-cemplates.js +++ b/public/scripts/chat-cemplates.js @@ -1,14 +1,3 @@ -// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest -async function digestMessage(message) { - const msgUint8 = new TextEncoder().encode(message); // encode as (utf-8) Uint8Array - const hashBuffer = await window.crypto.subtle.digest('SHA-256', msgUint8); // hash the message - const hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array - const hashHex = hashArray - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); // convert bytes to hex string - return hashHex; -} - // the hash can be obtained from command line e.g. via: MODEL=path_to_model; python -c "import json, hashlib, sys; print(hashlib.sha256(json.load(open('"$MODEL"/tokenizer_config.json'))['chat_template'].strip().encode()).hexdigest())" // note that chat templates must be trimmed to match the llama.cpp metadata value const derivations = { @@ -66,8 +55,7 @@ const derivations = { }, }; -export async function deriveTemplatesFromChatTemplate(chat_template) { - const hash = await digestMessage(chat_template); +export async function deriveTemplatesFromChatTemplate(chat_template, hash) { if (hash in derivations) { return derivations[hash]; } diff --git a/src/endpoints/backends/text-completions.js b/src/endpoints/backends/text-completions.js index 1c5bdb75a..fec67481f 100644 --- a/src/endpoints/backends/text-completions.js +++ b/src/endpoints/backends/text-completions.js @@ -16,6 +16,7 @@ import { } from '../../constants.js'; import { forwardFetchResponse, trimV1, getConfigValue } from '../../util.js'; import { setAdditionalHeaders } from '../../additional-headers.js'; +import { sha256 } from 'js-sha256'; export const router = express.Router(); @@ -260,6 +261,7 @@ router.post('/chat_template', jsonParser, async function (request, response) { /** @type {any} */ const chatTemplate = await chatTemplateReply.json(); + chatTemplate['chat_template_hash'] = sha256.create().update(chatTemplate['chat_template']).hex(); return response.send(chatTemplate); } catch (error) { console.error(error); From c2eaae3d428ef7700ce15200a3fa734f756c8a6d Mon Sep 17 00:00:00 2001 From: Karl-Johan Alm Date: Tue, 19 Nov 2024 21:39:35 +0900 Subject: [PATCH 03/20] switch to crypto lib --- package-lock.json | 7 ------- package.json | 1 - src/endpoints/backends/text-completions.js | 4 ++-- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 42b187bcf..e3d0eade1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,6 @@ "ip-matching": "^2.1.2", "ipaddr.js": "^2.0.1", "jimp": "^0.22.10", - "js-sha256": "^0.11.0", "localforage": "^1.10.0", "lodash": "^4.17.21", "mime-types": "^2.1.35", @@ -4883,12 +4882,6 @@ "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", "license": "BSD-3-Clause" }, - "node_modules/js-sha256": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.0.tgz", - "integrity": "sha512-6xNlKayMZvds9h1Y1VWc0fQHQ82BxTXizWPEtEeGvmOUYpBRy4gbWroHLpzowe6xiQhHpelCQiE7HEdznyBL9Q==", - "license": "MIT" - }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", diff --git a/package.json b/package.json index 5a2c4f15c..3608d9775 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,6 @@ "ip-matching": "^2.1.2", "ipaddr.js": "^2.0.1", "jimp": "^0.22.10", - "js-sha256": "^0.11.0", "localforage": "^1.10.0", "lodash": "^4.17.21", "mime-types": "^2.1.35", diff --git a/src/endpoints/backends/text-completions.js b/src/endpoints/backends/text-completions.js index fec67481f..8a0f6346c 100644 --- a/src/endpoints/backends/text-completions.js +++ b/src/endpoints/backends/text-completions.js @@ -16,7 +16,7 @@ import { } from '../../constants.js'; import { forwardFetchResponse, trimV1, getConfigValue } from '../../util.js'; import { setAdditionalHeaders } from '../../additional-headers.js'; -import { sha256 } from 'js-sha256'; +import { createHash } from 'crypto'; export const router = express.Router(); @@ -261,7 +261,7 @@ router.post('/chat_template', jsonParser, async function (request, response) { /** @type {any} */ const chatTemplate = await chatTemplateReply.json(); - chatTemplate['chat_template_hash'] = sha256.create().update(chatTemplate['chat_template']).hex(); + chatTemplate['chat_template_hash'] = createHash('sha256').update(chatTemplate['chat_template']).digest('hex'); return response.send(chatTemplate); } catch (error) { console.error(error); From cdc01474906fb8e1b5f58b7d895cb613e9f09393 Mon Sep 17 00:00:00 2001 From: Karl-Johan Alm Date: Tue, 19 Nov 2024 21:41:57 +0900 Subject: [PATCH 04/20] fix error console.log message --- src/endpoints/backends/text-completions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/endpoints/backends/text-completions.js b/src/endpoints/backends/text-completions.js index 8a0f6346c..81ed8ea7e 100644 --- a/src/endpoints/backends/text-completions.js +++ b/src/endpoints/backends/text-completions.js @@ -226,7 +226,7 @@ router.post('/status', jsonParser, async function (request, response) { if (chatTemplateReply.ok) { response.setHeader('x-supports-chat-template', 'true'); } else { - console.log(`ct res = ${JSON.stringify(chatTemplateReply)}`); + console.log(`chat_template error: ${JSON.stringify(chatTemplateReply)}`); } } catch (error) { console.error(`Failed to fetch chat template info: ${error}`); From feb1b91619d000fb9bb5a25f8a051aab485b5f8d Mon Sep 17 00:00:00 2001 From: Karl-Johan Alm Date: Tue, 19 Nov 2024 23:38:38 +0900 Subject: [PATCH 05/20] template derivation: add support for llama.cpp server backend --- public/scripts/chat-cemplates.js | 2 +- src/endpoints/backends/text-completions.js | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/public/scripts/chat-cemplates.js b/public/scripts/chat-cemplates.js index 2dfaebbb6..00b2f9771 100644 --- a/public/scripts/chat-cemplates.js +++ b/public/scripts/chat-cemplates.js @@ -59,6 +59,6 @@ export async function deriveTemplatesFromChatTemplate(chat_template, hash) { if (hash in derivations) { return derivations[hash]; } - console.log(`Unknown chat template hash: ${hash}`); + console.log(`Unknown chat template hash: ${hash} for [${chat_template}]`); return null; } diff --git a/src/endpoints/backends/text-completions.js b/src/endpoints/backends/text-completions.js index 81ed8ea7e..d0b9c17a8 100644 --- a/src/endpoints/backends/text-completions.js +++ b/src/endpoints/backends/text-completions.js @@ -231,6 +231,9 @@ router.post('/status', jsonParser, async function (request, response) { } catch (error) { console.error(`Failed to fetch chat template info: ${error}`); } + } else if (apiType == TEXTGEN_TYPES.LLAMACPP) { + // the /props endpoint includes chat template + response.setHeader('x-supports-chat-template', 'true'); } return response.send({ result, data: data.data }); @@ -240,6 +243,11 @@ router.post('/status', jsonParser, async function (request, response) { } }); +const chat_template_endpoints = { + koboldcpp: '/api/extra/chat_template', + llamacpp: '/props', +} + router.post('/chat_template', jsonParser, async function (request, response) { if (!request.body.api_server) return response.sendStatus(400); @@ -251,7 +259,8 @@ router.post('/chat_template', jsonParser, async function (request, response) { setAdditionalHeaders(request, args, baseUrl); - const chatTemplateUrl = baseUrl + '/api/extra/chat_template'; + const apiType = request.body.api_type; + const chatTemplateUrl = baseUrl + chat_template_endpoints[apiType]; const chatTemplateReply = await fetch(chatTemplateUrl, args); if (!chatTemplateReply.ok) { @@ -261,7 +270,12 @@ router.post('/chat_template', jsonParser, async function (request, response) { /** @type {any} */ const chatTemplate = await chatTemplateReply.json(); + // TEMPORARY: llama.cpp's /props endpoint includes a \u0000 at the end of the chat template, resulting in mismatching hashes + if (apiType === TEXTGEN_TYPES.LLAMACPP && chatTemplate['chat_template'].endsWith('\u0000')) { + chatTemplate['chat_template'] = chatTemplate['chat_template'].slice(0, -1); + } chatTemplate['chat_template_hash'] = createHash('sha256').update(chatTemplate['chat_template']).digest('hex'); + console.log(`We have chat template stuff: ${JSON.stringify(chatTemplate)}`); return response.send(chatTemplate); } catch (error) { console.error(error); From bb062f5ec9ac1bc62345e36e2b4c1d66e357c646 Mon Sep 17 00:00:00 2001 From: Karl-Johan Alm Date: Wed, 20 Nov 2024 13:11:23 +0900 Subject: [PATCH 06/20] update endpoint to reflect koboldcpp update --- src/endpoints/backends/text-completions.js | 39 ++++++---------------- 1 file changed, 11 insertions(+), 28 deletions(-) diff --git a/src/endpoints/backends/text-completions.js b/src/endpoints/backends/text-completions.js index d0b9c17a8..b3bd419e2 100644 --- a/src/endpoints/backends/text-completions.js +++ b/src/endpoints/backends/text-completions.js @@ -219,19 +219,7 @@ router.post('/status', jsonParser, async function (request, response) { } catch (error) { console.error(`Failed to get TabbyAPI model info: ${error}`); } - } else if (apiType == TEXTGEN_TYPES.KOBOLDCPP) { - try { - const chatTemplateUrl = baseUrl + '/api/extra/chat_template'; - const chatTemplateReply = await fetch(chatTemplateUrl); - if (chatTemplateReply.ok) { - response.setHeader('x-supports-chat-template', 'true'); - } else { - console.log(`chat_template error: ${JSON.stringify(chatTemplateReply)}`); - } - } catch (error) { - console.error(`Failed to fetch chat template info: ${error}`); - } - } else if (apiType == TEXTGEN_TYPES.LLAMACPP) { + } else if (apiType == TEXTGEN_TYPES.KOBOLDCPP || apiType == TEXTGEN_TYPES.LLAMACPP) { // the /props endpoint includes chat template response.setHeader('x-supports-chat-template', 'true'); } @@ -243,11 +231,6 @@ router.post('/status', jsonParser, async function (request, response) { } }); -const chat_template_endpoints = { - koboldcpp: '/api/extra/chat_template', - llamacpp: '/props', -} - router.post('/chat_template', jsonParser, async function (request, response) { if (!request.body.api_server) return response.sendStatus(400); @@ -260,23 +243,23 @@ router.post('/chat_template', jsonParser, async function (request, response) { setAdditionalHeaders(request, args, baseUrl); const apiType = request.body.api_type; - const chatTemplateUrl = baseUrl + chat_template_endpoints[apiType]; - const chatTemplateReply = await fetch(chatTemplateUrl, args); + const propsUrl = baseUrl + "/props"; + const propsReply = await fetch(propsUrl, args); - if (!chatTemplateReply.ok) { - console.log('Chat template endpoint is offline.'); + if (!propsReply.ok) { + console.log('Properties endpoint is offline.'); return response.status(400); } /** @type {any} */ - const chatTemplate = await chatTemplateReply.json(); + const props = await propsReply.json(); // TEMPORARY: llama.cpp's /props endpoint includes a \u0000 at the end of the chat template, resulting in mismatching hashes - if (apiType === TEXTGEN_TYPES.LLAMACPP && chatTemplate['chat_template'].endsWith('\u0000')) { - chatTemplate['chat_template'] = chatTemplate['chat_template'].slice(0, -1); + if (apiType === TEXTGEN_TYPES.LLAMACPP && props['chat_template'].endsWith('\u0000')) { + props['chat_template'] = props['chat_template'].slice(0, -1); } - chatTemplate['chat_template_hash'] = createHash('sha256').update(chatTemplate['chat_template']).digest('hex'); - console.log(`We have chat template stuff: ${JSON.stringify(chatTemplate)}`); - return response.send(chatTemplate); + props['chat_template_hash'] = createHash('sha256').update(props['chat_template']).digest('hex'); + console.log(`We have chat template stuff: ${JSON.stringify(props)}`); + return response.send(props); } catch (error) { console.error(error); return response.status(500); From 50ffaeb06a3a996b97973e0fac46c250901d61d9 Mon Sep 17 00:00:00 2001 From: Karl-Johan Alm Date: Wed, 20 Nov 2024 00:23:59 +0900 Subject: [PATCH 07/20] UI: add UI to enable/disable auto-derived templates --- public/index.html | 10 ++++++++++ public/script.js | 15 ++++++++++----- public/scripts/instruct-mode.js | 6 ++++++ public/scripts/power-user.js | 14 ++++++++++++++ 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/public/index.html b/public/index.html index fd19d2878..3337a5ced 100644 --- a/public/index.html +++ b/public/index.html @@ -3295,6 +3295,12 @@ +
+ +
@@ -3395,6 +3401,10 @@
+