Secrets manager (#4131)

* Secret manager (now for real)

* Refactor secret manager dialog

* Add error handling to secrets migration

* Adjust default value

* Add secret-id slash command

* Add secret management slash commands

* Improve type definitions

* Improve compatibility of UUID generator

* Add copy buttons to manager view

* Improve compatibility with Vertex AI service account
- Changed to input since textarea can't be used with datalist
- Unblock regular key placeholder
- Save email as a key label
- Interrupt validation if the input is a UUID (autocompleted)

* Add optional label input for secret values in key manager dialog

* Update masking rules

* /secret-id: make the arg "required" (it's not)
This commit is contained in:
Cohee
2025-06-11 21:26:19 +03:00
committed by GitHub
parent 8b5a8914b1
commit 8d2b9d2dab
16 changed files with 1619 additions and 238 deletions
+102
View File
@@ -0,0 +1,102 @@
.secretKeyManager {
display: flex;
flex-direction: column;
gap: 5px;
height: 100%;
}
.secretKeyManagerHeader {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
}
.secretKeyManagerSubtitle {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
width: 100%;
}
.secretKeyManagerInfo {
display: flex;
flex-direction: column;
align-items: baseline;
flex: 1;
font-size: 0.95em;
}
.secretKeyManagerList {
display: flex;
flex-direction: column;
text-align: left;
gap: 5px;
flex: 1;
overflow-y: auto;
height: 100%;
}
.secretKeyManagerItem {
border: 1px solid var(--SmartThemeBorderColor);
padding: 5px 10px;
border-radius: 10px;
background-color: var(--black30a);
display: flex;
flex-direction: row;
align-items: center;
}
.secretKeyManagerItem.active {
background-color: var(--cobalt30a);
}
.secretKeyManagerItemInfo {
display: flex;
flex-direction: column;
flex: 1;
gap: 5px;
}
.secretKeyManagerItemSubtitle,
.secretKeyManagerItemHeader {
word-break: break-all;
}
.secretKeyManagerItemId {
text-decoration: underline;
text-decoration-style: dotted;
cursor: pointer;
}
.secretKeyManagerItemActions {
display: flex;
flex-direction: column;
gap: 5px;
}
.secretKeyManagerItemActionsRow {
display: flex;
flex-direction: row;
gap: 5px;
}
.secretKeyManagerItemActionsRow>button {
margin: 0;
}
.secretKeyManagerList:empty {
display: none;
}
.secretKeyManagerListEmpty {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
opacity: 0.8;
font-weight: bold;
font-size: 1.05em;
}
+111 -113
View File
@@ -2242,10 +2242,11 @@
</small>
<div class="flex-container">
<input id="horde_api_key" name="horde_api_key" class="text_pole flex1" type="text" placeholder="0000000000" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_horde"></div>
<div title="Save and connect" data-i18n="[title]Save and connect" class="menu_button fa-solid fa-plug fa-fw" id="horde_api_key_button"></div>
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_horde"></div>
</div>
<div data-for="horde_api_key" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="horde_api_key" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<h4 class="horde_model_title">
<span data-i18n="Models">Models </span>
@@ -2297,11 +2298,11 @@
</span>
<div class="flex-container">
<input id="api_key_novel" name="api_key_novel" class="text_pole flex1 wide100p" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_novel">
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_novel">
</div>
</div>
<div data-for="api_key_novel" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_novel" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<h4><span data-i18n="Novel AI Model">Novel AI Model</span>
<a href="https://docs.sillytavern.app/usage/api-connections/novelai/#models" class="notes-link" target="_blank">
@@ -2349,10 +2350,10 @@
<h4 data-i18n="TogetherAI API Key">TogetherAI API Key</h4>
<div class="flex-container">
<input id="api_key_togetherai" name="api_key_togetherai" class="text_pole flex1" value="" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_togetherai"></div>
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_togetherai"></div>
</div>
<div data-for="api_key_togetherai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_togetherai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<div>
<h4 data-i18n="TogetherAI Model">TogetherAI Model</h4>
@@ -2373,10 +2374,10 @@
</div>
<div class="flex-container">
<input id="api_key_openrouter-tg" name="api_key_openrouter" class="text_pole flex1 api_key_openrouter" value="" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_openrouter"></div>
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_openrouter"></div>
</div>
<div data-for="api_key_openrouter" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_openrouter" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<div>
<h4 data-i18n="OpenRouter Model">OpenRouter Model</h4>
@@ -2400,10 +2401,10 @@
<h4 data-i18n="InfermaticAI API Key">InfermaticAI API Key</h4>
<div class="flex-container">
<input id="api_key_infermaticai" name="api_key_infermaticai" class="text_pole flex1" value="" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_infermaticai"></div>
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_infermaticai"></div>
</div>
<div data-for="api_key_infermaticai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_infermaticai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<div>
<h4 data-i18n="InfermaticAI Model">InfermaticAI Model</h4>
@@ -2423,10 +2424,10 @@
</h4>
<div class="flex-container">
<input id="api_key_dreamgen" name="api_key_dreamgen" class="text_pole flex1" value="" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_dreamgen"></div>
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_dreamgen"></div>
</div>
<div data-for="api_key_dreamgen" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_dreamgen" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<div>
<h4 data-i18n="DreamGen Model">DreamGen Model</h4>
@@ -2448,11 +2449,11 @@
</h4>
<div class="flex-container">
<input id="api_key_mancer" name="api_key_mancer" class="text_pole flex1 wide100p" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_mancer">
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_mancer">
</div>
</div>
<div data-for="api_key_mancer" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_mancer" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<div class="flex1">
<h4 data-i18n="Mancer Model">Mancer Model</h4>
@@ -2467,11 +2468,11 @@
<h4 data-i18n="API key (optional)">API key (optional)</h4>
<div class="flex-container">
<input id="api_key_generic" name="api_key_generic" class="text_pole flex1 wide100p" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_generic">
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_generic">
</div>
</div>
<div data-for="api_key_generic" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_generic" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<div class="flex1">
<h4 data-i18n="Server url">Server URL</h4>
@@ -2496,11 +2497,11 @@
<h4 data-i18n="API key (optional)">API key (optional)</h4>
<div class="flex-container">
<input id="api_key_ooba" name="api_key_ooba" class="text_pole flex1 wide100p" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_ooba">
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_ooba">
</div>
</div>
<div data-for="api_key_ooba" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_ooba" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<div class="flex1">
<h4 data-i18n="Server url">Server URL</h4>
@@ -2518,11 +2519,11 @@
<h4 data-i18n="API key (optional)">API key</h4>
<div class="flex-container">
<input id="api_key_featherless" name="api_key_featherless" class="text_pole flex1 wide100p" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_featherless">
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_featherless">
</div>
</div>
<div data-for="api_key_featherless" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_featherless" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<hr>
<h4 data-i18n="Featherless Model Selection">Featherless Model Selection</h4>
@@ -2569,11 +2570,11 @@
<h4 data-i18n="vLLM API key">vLLM API key</h4>
<div class="flex-container">
<input id="api_key_vllm" name="api_key_vllm" class="text_pole flex1 wide100p" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_vllm">
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_vllm">
</div>
</div>
<div data-for="api_key_vllm" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_vllm" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<div class="flex1">
<h4 data-i18n="API url">API URL</h4>
@@ -2593,11 +2594,11 @@
<h4 data-i18n="HuggingFace Token">HuggingFace Token</h4>
<div class="flex-container">
<input id="api_key_huggingface" name="api_key_huggingface" class="text_pole flex1 wide100p" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_huggingface">
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_huggingface">
</div>
</div>
<div data-for="api_key_huggingface" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_huggingface" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<div class="flex1">
<h4 data-i18n="Endpoint URL">Endpoint URL</h4>
@@ -2615,11 +2616,11 @@
<h4 data-i18n="Aphrodite API key">Aphrodite API key</h4>
<div class="flex-container">
<input id="api_key_aphrodite" name="api_key_aphrodite" class="text_pole flex1 wide100p" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_aphrodite">
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_aphrodite">
</div>
</div>
<div data-for="api_key_aphrodite" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_aphrodite" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<div class="flex1">
<h4 data-i18n="API url">API URL</h4>
@@ -2644,11 +2645,11 @@
<h4 data-i18n="API key (optional)">API key (optional)</h4>
<div class="flex-container">
<input id="api_key_llamacpp" name="api_key_llamacpp" class="text_pole flex1 wide100p" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_llamacpp">
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_llamacpp">
</div>
</div>
<div data-for="api_key_llamacpp" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_llamacpp" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<div class="flex1">
<h4 data-i18n="API url">API URL</h4>
@@ -2691,11 +2692,11 @@
<h4 data-i18n="Tabby API key">Tabby API key</h4>
<div class="flex-container">
<input id="api_key_tabby" name="api_key_tabby" class="text_pole flex1 wide100p" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_tabby">
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_tabby">
</div>
</div>
<div data-for="api_key_tabby" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_tabby" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<div class="flex1">
<h4 data-i18n="API url">API URL</h4>
@@ -2743,11 +2744,11 @@
<h4 data-i18n="koboldcpp API key (optional)">koboldcpp API key (optional)</h4>
<div class="flex-container">
<input id="api_key_koboldcpp" name="api_key_koboldcpp" class="text_pole flex1 wide100p" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_koboldcpp">
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_koboldcpp">
</div>
</div>
<div data-for="api_key_koboldcpp" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_koboldcpp" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<div class="flex1">
<h4 data-i18n="API url">API URL</h4>
@@ -2905,15 +2906,15 @@
</span>
<div class="flex-container">
<input id="api_key_openai" name="api_key_openai" class="text_pole flex1" value="" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_openai"></div>
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_openai"></div>
</div>
<div id="ReverseProxyWarningMessage2" class="reverse_proxy_warning">
<b data-i18n="Use Proxy password field instead. This input will be ignored.">
Use "Proxy password" field instead. This input will be ignored.
</b>
</div>
<div data-for="api_key_openai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_openai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<div>
<h4 data-i18n="OpenAI Model">OpenAI Model</h4>
@@ -2999,10 +3000,10 @@
</div>
<div class="flex-container">
<input id="api_key_claude" name="api_key_claude" class="text_pole flex1" value="" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_claude"></div>
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_claude"></div>
</div>
<div data-for="api_key_claude" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_claude" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<div>
<h4 data-i18n="Claude Model">Claude Model</h4>
@@ -3058,10 +3059,10 @@
</div>
<div class="flex-container">
<input id="api_key_openrouter" name="api_key_openrouter" class="text_pole flex1 api_key_openrouter" value="" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_openrouter"></div>
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_openrouter"></div>
</div>
<div data-for="api_key_openrouter" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_openrouter" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<div>
<h4 data-i18n="OpenRouter Model">OpenRouter Model</h4>
@@ -3122,10 +3123,10 @@
<h4 data-i18n="Scale API Key">Scale API Key</h4>
<div class="flex-container">
<input id="api_key_scale" name="api_key_scale" class="text_pole flex1" value="" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_scale"></div>
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_scale"></div>
</div>
<div data-for="api_key_scale" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_scale" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<h4>Scale API URL</h4>
<input id="api_url_scale" name="api_url_scale" class="text_pole" value="" autocomplete="off" placeholder="https://dashboard.scale.com/spellbook/api/v2/deploy/xxxxxxx">
@@ -3134,10 +3135,10 @@
<h4>Scale Cookie (_jwt)</h4>
<div class="flex-container">
<input id="scale_cookie" name="scale_cookie" class="text_pole flex1" value="" autocomplete="off">
<div title="Clear your cookie" data-i18n="[title]Clear your cookie" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="scale_cookie"></div>
<div title="Clear your cookie" data-i18n="[title]Clear your cookie" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="scale_cookie"></div>
</div>
<div data-for="scale_cookie" class="neutral_warning">
For privacy reasons, your cookie will be hidden after you reload the page.
For privacy reasons, your cookie will be hidden after you click 'Connect'.
</div>
</div>
<!-- Its only purpose is to trigger max context size check -->
@@ -3151,10 +3152,10 @@
<h4 data-i18n="AI21 API Key">AI21 API Key</h4>
<div class="flex-container">
<input id="api_key_ai21" name="api_key_ai21" class="text_pole flex1" value="" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_ai21"></div>
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_ai21"></div>
</div>
<div data-for="api_key_ai21" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_ai21" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<div>
<h4 data-i18n="AI21 Model">AI21 Model</h4>
@@ -3181,10 +3182,10 @@
<h4 data-i18n="Google AI Studio API Key">Google AI Studio API Key</h4>
<div class="flex-container">
<input id="api_key_makersuite" name="api_key_makersuite" class="text_pole flex1" value="" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_makersuite"></div>
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_makersuite"></div>
</div>
<div data-for="api_key_makersuite" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_makersuite" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<div>
<h4 data-i18n="Google Model">Google Model</h4>
@@ -3264,10 +3265,10 @@
</h4>
<div class="flex-container">
<input id="api_key_vertexai" name="api_key_vertexai" class="text_pole flex1" value="" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_vertexai"></div>
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_vertexai"></div>
</div>
<div data-for="api_key_vertexai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_vertexai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
</div>
@@ -3301,23 +3302,15 @@
<div id="vertexai_service_account_status" class="info-block marginTopBot5" style="display: none;">
<span id="vertexai_service_account_info"></span>
</div>
<textarea id="vertexai_service_account_json" class="text_pole textarea_compact" rows="4" placeholder='Paste your Service Account JSON content here, e.g.:
{
"type": "service_account",
"project_id": "your-project-id",
"private_key_id": "...",
"private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
"client_email": "...",
"client_id": "...",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token"
}'></textarea>
<div data-for="vertexai_service_account_json" class="neutral_warning" data-i18n="For privacy reasons, your Service Account JSON content will be hidden after you reload the page.">
For privacy reasons, your Service Account JSON content will be hidden after you reload the page.
<div class="flex-container">
<input id="vertexai_service_account_json" name="vertexai_service_account_json" class="text_pole flex1" value="" type="text" autocomplete="off">
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="vertexai_service_account_json"></div>
</div>
<div data-for="vertexai_service_account_json" class="neutral_warning" data-i18n="For privacy reasons, your Service Account JSON content will be hidden after you click 'Validate JSON'.">
For privacy reasons, your Service Account JSON content will be hidden after you click 'Validate JSON'.
</div>
<div class="flex-container">
<button type="button" id="vertexai_validate_service_account" class="menu_button menu_button_icon" data-i18n="Validate JSON">Validate JSON</button>
<button type="button" id="vertexai_clear_service_account" class="menu_button menu_button_icon" data-i18n="Clear">Clear</button>
</div>
</div>
</div>
@@ -3359,10 +3352,10 @@
<h4 data-i18n="MistralAI API Key">MistralAI API Key</h4>
<div class="flex-container">
<input id="api_key_mistralai" name="api_key_mistralai" class="text_pole flex1" value="" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_mistralai"></div>
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_mistralai"></div>
</div>
<div data-for="api_key_mistralai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_mistralai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<div>
<h4 data-i18n="MistralAI Model">MistralAI Model</h4>
@@ -3423,10 +3416,10 @@
<h4 data-i18n="Groq API Key">Groq API Key</h4>
<div class="flex-container">
<input id="api_key_groq" name="api_key_groq" class="text_pole flex1" value="" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_groq"></div>
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_groq"></div>
</div>
<div data-for="api_key_groq" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_groq" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<h4 data-i18n="Groq Model">Groq Model</h4>
<select id="model_groq_select">
@@ -3465,10 +3458,10 @@
<h4 data-i18n="NanoGPT API Key">NanoGPT API Key</h4>
<div class="flex-container">
<input id="api_key_nanogpt" name="api_key_nanogpt" class="text_pole flex1" value="" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_nanogpt"></div>
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_nanogpt"></div>
</div>
<div data-for="api_key_nanogpt" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_nanogpt" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<div>
<h4 data-i18n="NanoGPT Model">NanoGPT Model</h4>
@@ -3481,10 +3474,10 @@
<h4 data-i18n="DeepSeek API Key">DeepSeek API Key</h4>
<div class="flex-container">
<input id="api_key_deepseek" name="api_key_deepseek" class="text_pole flex1" value="" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_deepseek"></div>
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_deepseek"></div>
</div>
<div data-for="api_key_deepseek" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_deepseek" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<div>
<h4 data-i18n="DeepSeek Model">DeepSeek Model</h4>
@@ -3499,10 +3492,10 @@
<h4 data-i18n="Perplexity API Key">Perplexity API Key</h4>
<div class="flex-container">
<input id="api_key_perplexity" name="api_key_perplexity" class="text_pole flex1" value="" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_perplexity"></div>
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_perplexity"></div>
</div>
<div data-for="api_key_perplexity" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_perplexity" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<h4 data-i18n="Perplexity Model">Perplexity Model</h4>
<select id="model_perplexity_select">
@@ -3530,10 +3523,10 @@
<h4 data-i18n="Cohere API Key">Cohere API Key</h4>
<div class="flex-container">
<input id="api_key_cohere" name="api_key_cohere" class="text_pole flex1" value="" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_cohere"></div>
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_cohere"></div>
</div>
<div data-for="api_key_cohere" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_cohere" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<div>
<h4 data-i18n="Cohere Model">Cohere Model</h4>
@@ -3578,10 +3571,10 @@
</h4>
<div class="flex-container">
<input id="api_key_custom" name="api_key_custom" class="text_pole flex1" value="" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_custom"></div>
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_custom"></div>
</div>
<div data-for="api_key_custom" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_custom" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<h4 data-i18n="Enter a Model ID">Enter a Model ID</h4>
<div class="flex-container">
@@ -3601,10 +3594,10 @@
</h4>
<div class="flex-container">
<input id="api_key_01ai" name="api_key_01ai" class="text_pole flex1" value="" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_01ai"></div>
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_01ai"></div>
</div>
<div data-for="api_key_01ai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_01ai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<h4 data-i18n="01.AI Model">01.AI Model</h4>
<select id="model_01ai_select">
@@ -3618,10 +3611,10 @@
</h4>
<div class="flex-container">
<input id="api_key_xai" name="api_key_xai" class="text_pole flex1" value="" type="text" autocomplete="off">
<div title="Clear your API key" data-i18n="[title]Clear your API key" class="menu_button fa-solid fa-circle-xmark clear-api-key" data-key="api_key_xai"></div>
<div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_xai"></div>
</div>
<div data-for="api_key_xai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload the page.">
For privacy reasons, your API key will be hidden after you reload the page.
<div data-for="api_key_xai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
For privacy reasons, your API key will be hidden after you click 'Connect'.
</div>
<h4 data-i18n="xAI Model">xAI Model</h4>
<select id="model_xai_select">
@@ -3687,7 +3680,9 @@
<label for="auto-connect-checkbox" class="checkbox_label"><input id="auto-connect-checkbox" type="checkbox" />
<span data-i18n="Auto-connect to Last Server">Auto-connect to Last Server</span>
</label>
<a id="viewSecrets" href="javascript:void(0);" data-i18n="[missing_key_text]Missing key;[key_saved_text]Key saved" missing_key_text="❌ Missing key" key_saved_text="✔️ Key saved"><span data-i18n="View hidden API keys">View hidden API keys</span></a>
<a id="viewSecrets" href="javascript:void(0);" data-i18n="[missing_key_text]Missing key;[key_saved_text]Key saved" missing_key_text="❌ Missing key" key_saved_text="✔️ Key saved">
<span data-i18n="View hidden API keys">View hidden API keys</span>
</a>
</div>
</div>
</div>
@@ -7471,6 +7466,9 @@
</div>
</div>
<!-- Area for hidden data lists for secrets autosuggests -->
<div id="secrets_datalists" class="displayNone"></div>
<!-- Script includes -->
<script src="lib/polyfill.js"></script>
<script src="lib/jquery-3.5.1.min.js"></script>
+2
View File
@@ -208,6 +208,7 @@ import {
} from './scripts/tags.js';
import {
SECRET_KEYS,
initSecrets,
readSecretState,
secret_state,
writeSecret,
@@ -975,6 +976,7 @@ async function firstLoadInit() {
reloadMarkdownProcessor();
applyBrowserFixes();
await getClientVersion();
await initSecrets();
await readSecretState();
await initLocales();
initChatUtilities();
@@ -13,6 +13,7 @@ import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
import { SlashCommandScope } from '../../slash-commands/SlashCommandScope.js';
import { collapseSpaces, getUniqueName, isFalseBoolean, uuidv4 } from '../../utils.js';
import { t } from '../../i18n.js';
import { getSecretLabelById } from '../../secrets.js';
const MODULE_NAME = 'connection-manager';
const NONE = '<None>';
@@ -41,6 +42,7 @@ const CC_COMMANDS = [
'start-reply-with',
'reasoning-template',
'prompt-post-processing',
'secret-id',
];
const TC_COMMANDS = [
@@ -57,6 +59,7 @@ const TC_COMMANDS = [
'stop-strings',
'start-reply-with',
'reasoning-template',
'secret-id',
];
const FANCY_NAMES = {
@@ -75,6 +78,7 @@ const FANCY_NAMES = {
'start-reply-with': 'Start Reply With',
'reasoning-template': 'Reasoning Template',
'prompt-post-processing': 'Prompt Post-Processing',
'secret-id': 'Secret',
};
/**
@@ -344,6 +348,15 @@ function makeFancyProfile(profile) {
return acc;
}
// UUID is not very useful in the UI, so we replace it with a label (if available)
if (key === 'secret-id') {
const label = getSecretLabelById(profile[key]);
if (label) {
acc[value] = label;
return acc;
}
}
acc[value] = profile[key];
return acc;
}, {});
+9 -4
View File
@@ -394,10 +394,11 @@ function getHordeModelTemplate(option) {
`));
}
export function initHorde () {
export function initHorde() {
$('#horde_model').on('mousedown change', async function (e) {
console.log('Horde model change', e);
horde_settings.models = $('#horde_model').val();
const modelValue = $('#horde_model').val();
horde_settings.models = Array.isArray(modelValue) ? modelValue : [];
console.log('Updated Horde models', horde_settings.models);
// Try select instruct preset
@@ -429,8 +430,12 @@ export function initHorde () {
saveSettingsDebounced();
});
$('#horde_api_key').on('input', async function () {
const key = String($(this).val()).trim();
$('#horde_api_key_button').on('click', async function () {
const key = String($('#horde_api_key').val()).trim();
if (!key) {
toastr.warning(t`Please enter your Horde API key`);
return;
}
await writeSecret(SECRET_KEYS.HORDE, key);
});
+8 -1
View File
@@ -56,6 +56,7 @@ import {
getSortableDelay,
getStringHash,
isDataURL,
isUuid,
isValidUrl,
parseJsonFile,
resetScrollHeight,
@@ -5670,7 +5671,8 @@ async function onVertexAIValidateServiceAccount() {
}
// Save to backend secret storage
await writeSecret(SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT, jsonContent);
const keyLabel = serviceAccount['client_email'] || '';
await writeSecret(SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT, jsonContent, keyLabel);
// Show success status
updateVertexAIServiceAccountStatus(true, `Project: ${serviceAccount.project_id}, Email: ${serviceAccount.client_email}`);
@@ -5704,6 +5706,11 @@ async function onVertexAIClearServiceAccount() {
function onVertexAIServiceAccountJsonChange() {
const jsonContent = String($(this).val()).trim();
// Autocomplete has been triggered, don't validate if the input is a UUID
if (isUuid(jsonContent)) {
return;
}
if (jsonContent) {
// Auto-validate when content is pasted
try {
+9 -1
View File
@@ -53,6 +53,7 @@ export const POPUP_RESULT = {
* @property {CustomPopupInput[]?} [customInputs=null] - Custom inputs to add to the popup. The display below the content and the input box, one by one.
* @property {(popup: Popup) => Promise<boolean?>|boolean?} [onClosing=null] - Handler called before the popup closes, return `false` to cancel the close
* @property {(popup: Popup) => Promise<void?>|void?} [onClose=null] - Handler called after the popup closes, but before the DOM is cleaned up
* @property {(popup: Popup) => Promise<void?>|void?} [onOpen=null] - Handler called after the popup opens
* @property {number?} [cropAspect=null] - Aspect ratio for the crop popup
* @property {string?} [cropImage=null] - Image URL to display in the crop popup
*/
@@ -155,6 +156,7 @@ export class Popup {
/** @type {(popup: Popup) => Promise<boolean?>|boolean?} */ onClosing;
/** @type {(popup: Popup) => Promise<void?>|void?} */ onClose;
/** @type {(popup: Popup) => Promise<void?>|void?} */ onOpen;
/** @type {POPUP_RESULT|number} */ result;
/** @type {any} */ value;
@@ -175,7 +177,7 @@ export class Popup {
* @param {string} [inputValue=''] - The initial value of the input field
* @param {PopupOptions} [options={}] - Additional options for the popup
*/
constructor(content, type, inputValue = '', { okButton = null, cancelButton = null, rows = 1, wide = false, wider = false, large = false, transparent = false, allowHorizontalScrolling = false, allowVerticalScrolling = false, leftAlign = false, animation = 'fast', defaultResult = POPUP_RESULT.AFFIRMATIVE, customButtons = null, customInputs = null, onClosing = null, onClose = null, cropAspect = null, cropImage = null } = {}) {
constructor(content, type, inputValue = '', { okButton = null, cancelButton = null, rows = 1, wide = false, wider = false, large = false, transparent = false, allowHorizontalScrolling = false, allowVerticalScrolling = false, leftAlign = false, animation = 'fast', defaultResult = POPUP_RESULT.AFFIRMATIVE, customButtons = null, customInputs = null, onClosing = null, onClose = null, onOpen = null, cropAspect = null, cropImage = null } = {}) {
Popup.util.popups.push(this);
// Make this popup uniquely identifiable
@@ -185,6 +187,7 @@ export class Popup {
// Utilize event handlers being passed in
this.onClosing = onClosing;
this.onClose = onClose;
this.onOpen = onOpen;
/**@type {HTMLTemplateElement}*/
const template = document.querySelector('#popup_template');
@@ -478,6 +481,11 @@ export class Popup {
runAfterAnimation(this.dlg, () => {
this.dlg.removeAttribute('opening');
// If we have an onOpen handler, we run it now
if (this.onOpen) {
this.onOpen(this);
}
});
this.#promise = new Promise((resolve) => {
+810 -37
View File
@@ -1,7 +1,18 @@
import { DOMPurify } from '../lib.js';
import { DOMPurify, moment } from '../lib.js';
import { getRequestHeaders } from '../script.js';
import { t } from './i18n.js';
import { chat_completion_sources } from './openai.js';
import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
import { enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
import { SlashCommandExecutor } from './slash-commands/SlashCommandExecutor.js';
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { SlashCommandScope } from './slash-commands/SlashCommandScope.js';
import { renderTemplateAsync } from './templates.js';
import { textgen_types } from './textgen-settings.js';
import { copyText, isTrueBoolean } from './utils.js';
export const SECRET_KEYS = {
HORDE: 'api_key_horde',
@@ -48,6 +59,51 @@ export const SECRET_KEYS = {
VERTEXAI_SERVICE_ACCOUNT: 'vertexai_service_account_json',
};
const FRIENDLY_NAMES = {
[SECRET_KEYS.HORDE]: 'AI Horde',
[SECRET_KEYS.MANCER]: 'Mancer',
[SECRET_KEYS.OPENAI]: 'OpenAI',
[SECRET_KEYS.NOVEL]: 'NovelAI',
[SECRET_KEYS.CLAUDE]: 'Claude',
[SECRET_KEYS.OPENROUTER]: 'OpenRouter',
[SECRET_KEYS.SCALE]: 'Scale',
[SECRET_KEYS.AI21]: 'AI21',
[SECRET_KEYS.SCALE_COOKIE]: 'Scale (Cookie)',
[SECRET_KEYS.MAKERSUITE]: 'Google AI Studio',
[SECRET_KEYS.VERTEXAI]: 'Google Vertex AI (Express Mode)',
[SECRET_KEYS.VLLM]: 'vLLM',
[SECRET_KEYS.APHRODITE]: 'Aphrodite',
[SECRET_KEYS.TABBY]: 'TabbyAPI',
[SECRET_KEYS.MISTRALAI]: 'MistralAI',
[SECRET_KEYS.CUSTOM]: 'Custom (OpenAI-compatible)',
[SECRET_KEYS.TOGETHERAI]: 'TogetherAI',
[SECRET_KEYS.OOBA]: 'Text Generation WebUI',
[SECRET_KEYS.INFERMATICAI]: 'InfermaticAI',
[SECRET_KEYS.DREAMGEN]: 'DreamGen',
[SECRET_KEYS.NOMICAI]: 'NomicAI',
[SECRET_KEYS.KOBOLDCPP]: 'KoboldCpp',
[SECRET_KEYS.LLAMACPP]: 'llama.cpp',
[SECRET_KEYS.COHERE]: 'Cohere',
[SECRET_KEYS.PERPLEXITY]: 'Perplexity',
[SECRET_KEYS.GROQ]: 'Groq',
[SECRET_KEYS.FEATHERLESS]: 'Featherless',
[SECRET_KEYS.ZEROONEAI]: '01.AI',
[SECRET_KEYS.HUGGINGFACE]: 'HuggingFace',
[SECRET_KEYS.NANOGPT]: 'NanoGPT',
[SECRET_KEYS.GENERIC]: 'Generic (OpenAI-compatible)',
[SECRET_KEYS.DEEPSEEK]: 'DeepSeek',
[SECRET_KEYS.XAI]: 'xAI (Grok)',
[SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT]: 'Google Vertex AI (Service Account)',
[SECRET_KEYS.STABILITY]: 'Stability AI',
[SECRET_KEYS.CUSTOM_OPENAI_TTS]: 'Custom OpenAI TTS',
[SECRET_KEYS.TAVILY]: 'Tavily',
[SECRET_KEYS.BFL]: 'Black Forest Labs',
[SECRET_KEYS.SERPAPI]: 'SerpApi',
[SECRET_KEYS.SERPER]: 'Serper',
[SECRET_KEYS.FALAI]: 'FAL.AI',
[SECRET_KEYS.AZURE_TTS]: 'Azure TTS',
};
const INPUT_MAP = {
[SECRET_KEYS.HORDE]: '#horde_api_key',
[SECRET_KEYS.MANCER]: '#api_key_mancer',
@@ -85,31 +141,103 @@ const INPUT_MAP = {
[SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT]: '#vertexai_service_account_json',
};
const STATIC_PLACEHOLDER_KEYS = [
SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT,
];
const getLabel = () => moment().format('L LT');
async function clearSecret() {
const key = $(this).data('key');
await writeSecret(key, '');
secret_state[key] = false;
updateSecretDisplay();
$(INPUT_MAP[key]).val('').trigger('input');
$('#main_api').trigger('change');
/**
* Resolves the secret key based on the selected API, chat completion source, and text completion type.
* @returns {string|null} The secret key corresponding to the selected API, or null if no key is found.
*/
function resolveSecretKey() {
const { mainApi, chatCompletionSettings, textCompletionSettings } = SillyTavern.getContext();
const chatCompletionSource = chatCompletionSettings.chat_completion_source;
const textCompletionType = textCompletionSettings.type;
if (mainApi === 'koboldhorde') {
return SECRET_KEYS.HORDE;
}
if (mainApi === 'novel') {
return SECRET_KEYS.NOVEL;
}
if (mainApi === 'textgenerationwebui') {
const [key] = Object.entries(textgen_types).find(([, value]) => value === textCompletionType) ?? [null];
if (key && SECRET_KEYS[key]) {
return SECRET_KEYS[key];
}
}
if (mainApi === 'openai') {
if (chatCompletionSource === chat_completion_sources.VERTEXAI) {
switch (chatCompletionSettings.vertexai_auth_mode) {
case 'express':
return SECRET_KEYS.VERTEXAI;
case 'full':
return SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT;
}
}
if (chatCompletionSource === chat_completion_sources.SCALE) {
return chatCompletionSettings.use_alt_scale
? SECRET_KEYS.SCALE_COOKIE
: SECRET_KEYS.SCALE;
}
const [key] = Object.entries(chat_completion_sources).find(([, value]) => value === chatCompletionSource) ?? [null];
if (key && SECRET_KEYS[key]) {
return SECRET_KEYS[key];
}
}
return null;
}
/**
* Gets the label of a secret by its ID.
* @param {string} id The ID of the secret to find.
* @returns {string} The label of the secret with the given ID, or an empty string if not found.
*/
export function getSecretLabelById(id) {
for (const key of Object.values(SECRET_KEYS)) {
const secrets = secret_state[key];
if (!Array.isArray(secrets)) {
continue;
}
const secret = secrets.find(s => s.id === id);
if (secret) {
return `${secret.label} (${secret.value})`;
}
}
return '';
}
export function updateSecretDisplay() {
for (const [secret_key, input_selector] of Object.entries(INPUT_MAP)) {
if (STATIC_PLACEHOLDER_KEYS.includes(secret_key)) {
continue;
}
const validSecret = !!secret_state[secret_key];
const placeholder = $('#viewSecrets').attr(validSecret ? 'key_saved_text' : 'missing_key_text');
$(input_selector).attr('placeholder', placeholder);
const label = getActiveSecretLabel(secret_key);
const placeholderWithLabel = label ? `${placeholder} (${label})` : placeholder;
$(input_selector).attr('placeholder', placeholderWithLabel);
}
}
/**
* Gets the active secret label for a given key.
* @param {string} key Gets the active secret label for a given key.
* @returns {string} The label of the active secret, or '[No label]' if none is active.
*/
function getActiveSecretLabel(key) {
const selectedSecret = secret_state[key];
if (Array.isArray(selectedSecret)) {
const activeSecret = selectedSecret.find(x => x.active);
if (!activeSecret) {
return '';
}
return activeSecret.label || activeSecret.value || t`[No label]`;
}
return '';
}
async function viewSecrets() {
const response = await fetch('/api/secrets/view', {
method: 'POST',
@@ -125,7 +253,6 @@ async function viewSecrets() {
return;
}
$('#dialogue_popup').addClass('wide_dialogue_popup');
const data = await response.json();
const table = document.createElement('table');
table.classList.add('responsiveTable');
@@ -138,29 +265,78 @@ async function viewSecrets() {
await callGenericPopup(table.outerHTML, POPUP_TYPE.TEXT, '', { wide: true, large: true, allowVerticalScrolling: true });
}
/**
* @type {import('../../src/endpoints/secrets.js').SecretStateMap}
*/
export let secret_state = {};
export async function writeSecret(key, value) {
/**
* Write a secret value to the server.
* @param {string} key Secret key
* @param {string} value Secret value to write
* @param {string} [label] (Optional) Label for the key. If not provided, generated automatically.
* @return {Promise<string?>} The ID of the newly created secret key, or null if no value is provided.
*/
export async function writeSecret(key, value, label) {
try {
if (!value) {
console.warn(`No value provided for ${key} in writeSecret, redirecting to deleteSecret`);
await deleteSecret(key);
return null;
}
if (!label) {
label = getLabel();
}
const response = await fetch('/api/secrets/write', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ key, value }),
body: JSON.stringify({ key, value, label }),
});
if (response.ok) {
const text = await response.text();
if (text == 'ok') {
secret_state[key] = !!value;
updateSecretDisplay();
}
if (!response.ok) {
return null;
}
} catch {
console.error('Could not write secret value: ', key);
const { id } = await response.json();
// Clear the input field
$(INPUT_MAP[key]).val('').trigger('input');
await readSecretState();
return id;
} catch (error) {
console.error(`Could not write secret value: ${key}`, error);
return null;
}
}
/**
* Deletes a secret value from the server.
* @param {string} key Secret key
* @param {string} [id] (Optional) ID of the secret key to delete. If not provided, deletes an active key.
*/
export async function deleteSecret(key, id) {
try {
const response = await fetch('/api/secrets/delete', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ key, id }),
});
if (response.ok) {
await readSecretState();
// Force reconnection to the API with the new key
$('#main_api').trigger('change');
}
} catch (error) {
console.error(`Could not delete secret value: ${key}`, error);
}
}
/**
* Reads the current state of secrets from the server.
* @returns {Promise<void>}
*/
export async function readSecretState() {
try {
const response = await fetch('/api/secrets/read', {
@@ -171,6 +347,7 @@ export async function readSecretState() {
if (response.ok) {
secret_state = await response.json();
updateSecretDisplay();
updateInputDataLists();
await checkOpenRouterAuth();
}
} catch {
@@ -181,31 +358,87 @@ export async function readSecretState() {
/**
* Finds a secret value by key.
* @param {string} key Secret key
* @returns {Promise<string | undefined>} Secret value, or undefined if keys are not exposed
* @param {string} [id] ID of the secret to find. If not provided, will return the active secret.
* @returns {Promise<string?>} Secret value, or null if keys are not exposed
*/
export async function findSecret(key) {
export async function findSecret(key, id) {
try {
const response = await fetch('/api/secrets/find', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ key }),
body: JSON.stringify({ key, id }),
});
if (response.ok) {
const data = await response.json();
return data.value;
if (!response.ok) {
return null;
}
const data = await response.json();
return data.value;
} catch {
console.error('Could not find secret value: ', key);
return null;
}
}
/**
* Changes the active value for a given secret key.
* @param {string} key Secret key to rotate
* @param {string} id ID of the secret to rotate
*/
export async function rotateSecret(key, id) {
try {
const response = await fetch('/api/secrets/rotate', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ key, id }),
});
if (response.ok) {
await readSecretState();
// Force reconnection to the API with the new key
$('#main_api').trigger('change');
}
} catch (error) {
console.error(`Could not rotate secret value: ${key}`, error);
}
}
/**
* Renames a secret value on the server.
* @param {string} key Secret key to rename
* @param {string} id ID of the secret to rename
* @param {string} label Label to rename the secret to
*/
export async function renameSecret(key, id, label) {
try {
const response = await fetch('/api/secrets/rename', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ key, id, label }),
});
if (response.ok) {
await readSecretState();
}
} catch (error) {
console.error(`Could not rename secret value: ${key}`, error);
}
}
/**
* Redirects the user to authorize OpenRouter.
*/
function authorizeOpenRouter() {
const redirectUrl = new URL('/callback/openrouter', window.location.origin);
const openRouterUrl = `https://openrouter.ai/auth?callback_url=${encodeURIComponent(redirectUrl.toString())}`;
location.href = openRouterUrl;
}
/**
* Checks if the OpenRouter authorization code is present in the URL, and if so, exchanges it for an API key.
* @returns {Promise<void>}
*/
async function checkOpenRouterAuth() {
const params = new URLSearchParams(location.search);
const source = params.get('source');
@@ -245,14 +478,554 @@ async function checkOpenRouterAuth() {
}
}
jQuery(async () => {
/**
* Updates the input data lists for secret keys for autocomplete functionality.
*/
function updateInputDataLists() {
let container = document.getElementById('secrets_datalists');
if (!container) {
container = document.createElement('div');
container.id = 'secrets_datalists';
container.style.display = 'none';
document.body.appendChild(container);
}
for (const [key, inputSelector] of Object.entries(INPUT_MAP)) {
const inputElements = document.querySelectorAll(inputSelector);
if (inputElements.length === 0) {
console.warn(`No input elements found for key: ${key}`);
continue;
}
const dataListId = `${key}_datalist`;
let dataList = document.getElementById(dataListId);
if (!dataList) {
dataList = document.createElement('datalist');
dataList.id = dataListId;
container.appendChild(dataList);
}
// Clear existing options
dataList.innerHTML = '';
const secrets = secret_state[key];
if (!Array.isArray(secrets)) {
continue;
}
for (const secret of secrets) {
const option = document.createElement('option');
option.value = secret.id;
option.textContent = `${secret.label} (${secret.value})`;
dataList.appendChild(option);
}
// Set the input element to use the datalist
inputElements.forEach(element => {
element.setAttribute('list', dataListId);
});
}
}
/**
* Opens the key manager dialog for a specific key.
* @param {string} key Key for which to open the key manager dialog.
*/
async function openKeyManagerDialog(key) {
const name = FRIENDLY_NAMES[key] || key;
const template = $(await renderTemplateAsync('secretKeyManager', { name, key }));
template.find('button[data-action="add-secret"]').on('click', async function () {
let label = '';
const value = await Popup.show.input(t`Add Secret`, t`Enter the secret value:`, '', {
customInputs: [{
id: 'newSecretLabel',
type: 'text',
label: t`Enter a label for the secret (optional):`,
}],
onClose: popup => {
if (popup.result) {
label = popup.inputResults.get('newSecretLabel').toString().trim();
}
},
});
if (!value) {
return;
}
await writeSecret(key, value, label);
await renderSecretsList();
});
await renderSecretsList();
await callGenericPopup(template, POPUP_TYPE.TEXT, '', { wide: true, large: true, onOpen: scrollToActive });
async function renderSecretsList() {
const secrets = secret_state[key] ?? [];
const list = template.find('.secretKeyManagerList');
const previousScrollTop = list.scrollTop();
const emptyMessage = template.find('.secretKeyManagerListEmpty');
emptyMessage.toggle(secrets.length === 0);
const itemBlocks = [];
for (const secret of secrets) {
const itemTemplate = $(await renderTemplateAsync('secretKeyManagerListItem', secret));
itemTemplate.find('[data-action="copy-id"]').on('click', async function () {
await copyText(secret.id);
toastr.info(t`Secret ID copied to clipboard.`);
});
itemTemplate.find('button[data-action="rotate-secret"]').on('click', async function () {
await rotateSecret(key, secret.id);
await renderSecretsList();
});
itemTemplate.find('button[data-action="copy-secret"]').on('click', async function () {
const secretValue = await findSecret(key, secret.id);
if (secretValue === null) {
toastr.error(t`The key exposure might be disabled by the server config.`, t`Failed to copy secret value`);
return;
}
await copyText(secretValue);
toastr.info(t`Secret value copied to clipboard.`);
});
itemTemplate.find('button[data-action="rename-secret"]').on('click', async function () {
const label = await Popup.show.input(t`Rename Secret`, t`Enter new label for the secret:`, secret?.label || getLabel());
if (!label) {
return;
}
await renameSecret(key, secret.id, label);
await renderSecretsList();
});
itemTemplate.find('button[data-action="delete-secret"]').on('click', async function () {
const confirm = await Popup.show.confirm(t`Delete Secret: ${secret?.label}`, t`Are you sure you want to delete this secret? This action cannot be undone.`);
if (!confirm) {
return;
}
await deleteSecret(key, secret.id);
await renderSecretsList();
});
itemBlocks.push(itemTemplate);
}
list.empty().append(itemBlocks).scrollTop(previousScrollTop);
}
function scrollToActive() {
const list = template.find('.secretKeyManagerList');
const activeKey = list.find('.active');
if (activeKey.length > 0) {
const activeKeyScrollTop = activeKey.position().top + list.scrollTop() - list.height() / 2;
list.scrollTop(activeKeyScrollTop);
}
}
}
function registerSecretSlashCommands() {
const secretKeyEnumProvider = () => Object.values(SECRET_KEYS).map(key => new SlashCommandEnumValue(key, FRIENDLY_NAMES[key] || key, enumTypes.name, enumIcons.key));
const secretIdEnumProvider = (/** @type {SlashCommandExecutor} */ executor, /** @type {SlashCommandScope} */ _scope) => {
const key = executor?.namedArgumentList?.find(x => x.name === 'key')?.value?.toString() || resolveSecretKey();
if (!key || !secret_state[key] || !Array.isArray(secret_state[key]) || secret_state[key].length === 0) {
return [];
}
return secret_state[key].map(secret => {
return new SlashCommandEnumValue(secret.id, `${secret.label} (${secret.value})`, enumTypes.name, enumIcons.key);
});
};
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'secret-id',
aliases: ['secret-rotate'],
helpString: t`Sets the ID of a currently active secret key. Gets the ID of the secret key if no value is provided.`,
returns: t`The ID of the secret key that is now active.`,
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'quiet',
description: t`Suppress toast message notifications.`,
isRequired: false,
defaultValue: String(false),
typeList: [ARGUMENT_TYPE.BOOLEAN],
}),
SlashCommandNamedArgument.fromProps({
name: 'key',
description: t`The key to get the secret ID for. If not provided, will use the currently active API secrets.`,
isRequired: false,
typeList: [ARGUMENT_TYPE.STRING],
enumProvider: secretKeyEnumProvider,
}),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: t`The ID or a label of the secret key to set as active. If not provided, will return the currently active secret ID.`,
isRequired: true,
typeList: [ARGUMENT_TYPE.STRING],
enumProvider: secretIdEnumProvider,
}),
],
callback: async (args, value) => {
const quiet = isTrueBoolean(args?.quiet?.toString());
const id = value?.toString()?.trim();
const key = args?.key?.toString()?.trim() || resolveSecretKey();
if (!key) {
if (!quiet) {
toastr.error(t`No secret key provided, and the key can't be resolved for the currently selected API type.`);
}
return '';
}
const secrets = secret_state[key];
if (!Array.isArray(secrets) || secrets.length === 0) {
if (!quiet) {
toastr.error(t`No saved secrets found for the key: ${key}`);
}
return '';
}
if (!id) {
const activeSecret = secrets.find(s => s.active);
if (!activeSecret) {
if (!quiet) {
toastr.error(t`No active secret found for the key: ${key}`);
}
return '';
}
return activeSecret.id;
}
const savedSecret = secrets.find(s => s.id === id) ?? secrets.find(s => s.label === id);
if (!savedSecret) {
if (!quiet) {
toastr.error(t`No secret found with ID: ${id} for the key: ${key}`);
}
return '';
}
// Set the secret as active
await rotateSecret(key, savedSecret.id);
if (!quiet) {
toastr.success(t`Secret with ID: ${id} is now active for the key: ${key}`);
}
return savedSecret.id;
},
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'secret-delete',
helpString: t`Deletes a secret key by ID.`,
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'quiet',
description: t`Suppress toast message notifications.`,
isRequired: false,
defaultValue: String(false),
typeList: [ARGUMENT_TYPE.BOOLEAN],
}),
SlashCommandNamedArgument.fromProps({
name: 'key',
description: t`The key to delete the secret from. If not provided, will use the currently active API secrets.`,
isRequired: false,
typeList: [ARGUMENT_TYPE.STRING],
enumProvider: secretKeyEnumProvider,
}),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: t`The ID or a label of the secret key to delete. If not provided, will delete the active secret.`,
isRequired: true,
typeList: [ARGUMENT_TYPE.STRING],
enumProvider: secretIdEnumProvider,
}),
],
callback: async (args, value) => {
const quiet = isTrueBoolean(args?.quiet?.toString());
const id = value?.toString()?.trim();
const key = args?.key?.toString()?.trim() || resolveSecretKey();
if (!key) {
if (!quiet) {
toastr.error(t`No secret key provided, and the key can't be resolved for the currently selected API type.`);
}
return '';
}
const secrets = secret_state[key];
if (!Array.isArray(secrets) || secrets.length === 0) {
if (!quiet) {
toastr.error(t`No saved secrets found for the key: ${key}`);
}
return '';
}
const savedSecret = secrets.find(s => s.id === id) ?? secrets.find(s => s.label === id) ?? secrets.find(s => s.active);
if (!savedSecret) {
if (!quiet) {
toastr.error(t`No secret found with ID: ${id} for the key: ${key}`);
}
return '';
}
// Delete the secret
await deleteSecret(key, savedSecret.id);
if (!quiet) {
toastr.success(t`Secret with ID: ${id} has been deleted for the key: ${key}`);
}
return savedSecret.id;
},
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'secret-write',
helpString: t`Writes a secret key with a value and an optional label.`,
returns: t`The ID of the newly created secret key.`,
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'quiet',
description: t`Suppress toast message notifications.`,
isRequired: false,
defaultValue: String(false),
typeList: [ARGUMENT_TYPE.BOOLEAN],
}),
SlashCommandNamedArgument.fromProps({
name: 'key',
description: t`The key to write the secret to. If not provided, will use the currently active API secrets.`,
isRequired: false,
typeList: [ARGUMENT_TYPE.STRING],
enumProvider: secretKeyEnumProvider,
}),
SlashCommandNamedArgument.fromProps({
name: 'label',
description: t`The label for the secret key. If not provided, will use the current date and time.`,
isRequired: false,
typeList: [ARGUMENT_TYPE.STRING],
}),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: t`The value of the secret key to write.`,
isRequired: true,
typeList: [ARGUMENT_TYPE.STRING],
}),
],
callback: async (args, value) => {
const quiet = isTrueBoolean(args?.quiet?.toString());
const key = args?.key?.toString()?.trim() || resolveSecretKey();
if (!key) {
if (!quiet) {
toastr.error(t`No secret key provided, and the key can't be resolved for the currently selected API type.`);
}
return '';
}
const secrets = secret_state[key];
if (!Array.isArray(secrets) || secrets.length === 0) {
if (!quiet) {
toastr.error(t`No saved secrets found for the key: ${key}`);
}
return '';
}
const valueStr = value?.toString()?.trim();
if (!valueStr) {
if (!quiet) {
toastr.error(t`No value provided for the secret key: ${key}`);
}
return '';
}
const label = args?.label?.toString()?.trim() || getLabel();
const id = await writeSecret(key, valueStr, label);
if (!quiet) {
toastr.success(t`Secret has been written for the key: ${key}`);
}
return id || '';
},
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'secret-rename',
helpString: t`Renames a secret key by ID.`,
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'quiet',
description: t`Suppress toast message notifications.`,
isRequired: false,
defaultValue: String(false),
typeList: [ARGUMENT_TYPE.BOOLEAN],
}),
SlashCommandNamedArgument.fromProps({
name: 'key',
description: t`The key to rename the secret in. If not provided, will use the currently active API secrets.`,
isRequired: false,
typeList: [ARGUMENT_TYPE.STRING],
enumProvider: secretKeyEnumProvider,
}),
SlashCommandNamedArgument.fromProps({
name: 'id',
description: t`The ID of the secret to rename. If not provided, will rename the active secret.`,
isRequired: true,
typeList: [ARGUMENT_TYPE.STRING],
}),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: t`The new label for the secret key.`,
isRequired: true,
typeList: [ARGUMENT_TYPE.STRING],
}),
],
callback: async (args, value) => {
const quiet = isTrueBoolean(args?.quiet?.toString());
const key = args?.key?.toString()?.trim() || resolveSecretKey();
const id = args?.id?.toString()?.trim();
if (!key) {
if (!quiet) {
toastr.error(t`No secret key provided, and the key can't be resolved for the currently selected API type.`);
}
return '';
}
const secrets = secret_state[key];
if (!Array.isArray(secrets) || secrets.length === 0) {
if (!quiet) {
toastr.error(t`No saved secrets found for the key: ${key}`);
}
return '';
}
const newLabel = value?.toString()?.trim();
if (!newLabel) {
if (!quiet) {
toastr.error(t`No new label provided for the secret key: ${key}`);
}
return '';
}
const savedSecret = secrets.find(s => s.id === id) ?? secrets.find(s => s.label === id) ?? secrets.find(s => s.active);
if (!savedSecret) {
if (!quiet) {
toastr.error(t`No secret found with ID: ${id} for the key: ${key}`);
}
return '';
}
// Rename the secret
await renameSecret(key, savedSecret.id, newLabel);
if (!quiet) {
toastr.success(t`Secret with ID: ${id} has been renamed to "${newLabel}" for the key: ${key}`);
}
return savedSecret.id;
},
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'secret-read',
aliases: ['secret-find', 'secret-get'],
helpString: t`Reads a secret key by ID. If key exposure is disabled, this command will not work!`,
returns: t`The value of the secret key.`,
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'quiet',
description: t`Suppress toast message notifications.`,
isRequired: false,
defaultValue: String(false),
typeList: [ARGUMENT_TYPE.BOOLEAN],
}),
SlashCommandNamedArgument.fromProps({
name: 'key',
description: t`The key to read the secret from. If not provided, will use the currently active API secrets.`,
isRequired: false,
typeList: [ARGUMENT_TYPE.STRING],
enumProvider: secretKeyEnumProvider,
}),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: t`The ID or a label of the secret key to read. If not provided, will return the currently active secret value.`,
isRequired: true,
typeList: [ARGUMENT_TYPE.STRING],
enumProvider: secretIdEnumProvider,
}),
],
callback: async (args, value) => {
const quiet = isTrueBoolean(args?.quiet?.toString());
const key = args?.key?.toString()?.trim() || resolveSecretKey();
const id = value?.toString()?.trim();
if (!key) {
if (!quiet) {
toastr.error(t`No secret key provided, and the key can't be resolved for the currently selected API type.`);
}
return '';
}
const secrets = secret_state[key];
if (!Array.isArray(secrets) || secrets.length === 0) {
if (!quiet) {
toastr.error(t`No saved secrets found for the key: ${key}`);
}
return '';
}
const savedSecret = secrets.find(s => s.id === id) ?? secrets.find(s => s.label === id) ?? secrets.find(s => s.active);
if (!savedSecret) {
if (!quiet) {
toastr.error(t`No secret found with ID: ${id} for the key: ${key}`);
}
return '';
}
const secretValue = await findSecret(key, savedSecret.id);
if (secretValue === null) {
if (!quiet) {
toastr.error(t`Could not retrieve the secret value for key: ${key}. Key exposure might be disabled.`);
}
return '';
}
return secretValue;
},
}));
}
export async function initSecrets() {
$('#viewSecrets').on('click', viewSecrets);
$(document).on('click', '.clear-api-key', clearSecret);
$(document).on('click', '.manage-api-keys', async function () {
const key = $(this).data('key');
if (!key || !Object.values(SECRET_KEYS).includes(key)) {
console.error('Invalid key for manage-api-keys:', key);
return;
}
await openKeyManagerDialog(key);
});
$(document).on('input', Object.values(INPUT_MAP).join(','), function () {
const id = $(this).attr('id');
const value = $(this).val();
// Find the key based on the entered value
for (const [key, inputSelector] of Object.entries(INPUT_MAP)) {
if (!value || !this.matches(inputSelector)) {
continue;
}
const secrets = secret_state[key];
if (!Array.isArray(secrets)) {
continue;
}
const secretMatch = secrets.find(secret => secret.id === value);
if (secretMatch) {
$(this).val('');
return rotateSecret(key, secretMatch.id);
}
}
const warningElement = $(`[data-for="${id}"]`);
warningElement.toggle(value.length > 0);
});
$('.openrouter_authorize').on('click', authorizeOpenRouter);
});
registerSecretSlashCommands();
}
@@ -40,6 +40,7 @@ export const enumIcons = {
server: '🖥️',
popup: '🗔',
image: '🖼️',
key: '🔑',
true: '✔️',
false: '❌',
@@ -0,0 +1,27 @@
<div class="secretKeyManager">
<div class="secretKeyManagerHeader">
<div class="secretKeyManagerSubtitle">
<div class="secretKeyManagerInfo">
<div class="flex-container">
<div data-i18n="API:">API:</div>
<span>{{name}}</span>
</div>
<div class="flex-container">
<div data-i18n="Key:">Key:</div>
<code>{{key}}</code>
</div>
</div>
<div class="secretKeyManagerActions">
<button class="menu_button menu_button_icon" data-action="add-secret">
<i class="fa-solid fa-plus"></i>
<span data-i18n="Add Secret">Add Secret</span>
</button>
</div>
</div>
</div>
<hr>
<div class="secretKeyManagerList"></div>
<div class="secretKeyManagerListEmpty">
<span data-i18n="No secrets saved.">No secrets saved.</span>
</div>
</div>
@@ -0,0 +1,30 @@
<div class="secretKeyManagerItem {{#if active}}active{{/if}}">
<div class="secretKeyManagerItemInfo">
<div class="secretKeyManagerItemHeader">
<strong>{{label}}</strong>
<small>{{value}}</small>
</div>
<div class="secretKeyManagerItemSubtitle">
<strong>ID:</strong>
<span class="secretKeyManagerItemId" data-action="copy-id" title="Copy ID" data-i18n="[title]Copy ID">{{id}}</span>
</div>
</div>
<div class="secretKeyManagerItemActions">
<div class="secretKeyManagerItemActionsRow">
<button class="menu_button menu_button_icon {{#if active}}disabled{{/if}}" data-action="rotate-secret" data-id="{{id}}" title="Select" data-i18n="[title]Select">
<i class="fa-fw fa-solid fa-check"></i>
</button>
<button class="menu_button menu_button_icon" data-action="copy-secret" data-id="{{id}}" title="Copy" data-i18n="[title]Copy">
<i class="fa-fw fa-solid fa-copy"></i>
</button>
</div>
<div class="secretKeyManagerItemActionsRow">
<button class="menu_button menu_button_icon" data-action="rename-secret" data-id="{{id}}" title="Rename" data-i18n="[title]Rename">
<i class="fa-fw fa-solid fa-pen-to-square"></i>
</button>
<button class="menu_button menu_button_icon" data-action="delete-secret" data-id="{{id}}" title="Delete" data-i18n="[title]Delete">
<i class="fa-fw fa-solid fa-trash"></i>
</button>
</div>
</div>
</div>
+11
View File
@@ -125,6 +125,17 @@ export function isValidUrl(value) {
}
}
/**
* Checks if a string is a valid UUID (version 1-5).
* @param {string} value String to check
* @returns {boolean} True if the string is a valid UUID, false otherwise.
*/
export function isUuid(value) {
// Regular expression to match UUIDs
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
return uuidRegex.test(value);
}
/**
* Converts string to a value of a given type. Includes pythonista-friendly aliases.
* @param {string|SlashCommandClosure} value String value
+1
View File
@@ -12,6 +12,7 @@
@import url(css/scrollable-button.css);
@import url(css/welcome.css);
@import url(css/data-maid.css);
@import url(css/secrets.css);
:root {
interpolate-size: allow-keywords;
+476 -82
View File
@@ -3,10 +3,11 @@ import path from 'node:path';
import express from 'express';
import { sync as writeFileAtomicSync } from 'write-file-atomic';
import { getConfigValue } from '../util.js';
import { color, getConfigValue, uuidv4 } from '../util.js';
export const SECRETS_FILE = 'secrets.json';
export const SECRET_KEYS = {
_MIGRATED: '_migrated',
HORDE: 'api_key_horde',
MANCER: 'api_key_mancer',
VLLM: 'api_key_vllm',
@@ -57,6 +58,31 @@ export const SECRET_KEYS = {
VERTEXAI_SERVICE_ACCOUNT: 'vertexai_service_account_json',
};
/**
* @typedef {object} SecretValue
* @property {string} id The unique identifier for the secret
* @property {string} value The secret value
* @property {string} label The label for the secret
* @property {boolean} active Whether the secret is currently active
*/
/**
* @typedef {object} SecretState
* @property {string} id The unique identifier for the secret
* @property {string} value The secret value, masked for security
* @property {string} label The label for the secret
* @property {boolean} active Whether the secret is currently active
*/
/**
* @typedef {Record<string, SecretState[]|null>} SecretStateMap
*/
/**
* @typedef {{[key: string]: SecretValue[]}} SecretKeys
* @typedef {{[key: string]: string}} FlatSecretKeys
*/
// These are the keys that are safe to expose, even if allowKeysExposure is false
const EXPORTABLE_KEYS = [
SECRET_KEYS.LIBRE_URL,
@@ -65,6 +91,319 @@ const EXPORTABLE_KEYS = [
SECRET_KEYS.DEEPLX_URL,
];
const allowKeysExposure = !!getConfigValue('allowKeysExposure', false, 'boolean');
/**
* SecretManager class to handle all secret operations
*/
export class SecretManager {
/**
* @param {import('../users.js').UserDirectoryList} directories
*/
constructor(directories) {
this.directories = directories;
this.filePath = path.join(directories.root, SECRETS_FILE);
this.defaultSecrets = {};
}
/**
* Ensures the secrets file exists, creating an empty one if necessary
* @private
*/
_ensureSecretsFile() {
if (!fs.existsSync(this.filePath)) {
writeFileAtomicSync(this.filePath, JSON.stringify(this.defaultSecrets), 'utf-8');
}
}
/**
* Reads and parses the secrets file
* @private
* @returns {SecretKeys}
*/
_readSecretsFile() {
this._ensureSecretsFile();
const fileContents = fs.readFileSync(this.filePath, 'utf-8');
return /** @type {SecretKeys} */ (JSON.parse(fileContents));
}
/**
* Writes secrets to the file atomically
* @private
* @param {SecretKeys} secrets
*/
_writeSecretsFile(secrets) {
writeFileAtomicSync(this.filePath, JSON.stringify(secrets, null, 4), 'utf-8');
}
/**
* Deactivates all secrets for a given key
* @private
* @param {SecretValue[]} secretArray
*/
_deactivateAllSecrets(secretArray) {
secretArray.forEach(secret => {
secret.active = false;
});
}
/**
* Validates that the secret key exists and has valid structure
* @private
* @param {SecretKeys} secrets
* @param {string} key
* @returns {boolean}
*/
_validateSecretKey(secrets, key) {
return Object.hasOwn(secrets, key) && Array.isArray(secrets[key]);
}
/**
* Masks a secret value with asterisks in the middle
* @param {string} value The secret value to mask
* @returns {string} A masked version of the value for peeking
*/
getMaskedValue(value) {
// No masking if exposure is allowed
if (allowKeysExposure) {
return value;
}
const threshold = 10;
const exposedChars = 3;
const placeholder = '*';
if (value.length <= threshold) {
return placeholder.repeat(threshold);
}
const visibleEnd = value.slice(-exposedChars);
const maskedMiddle = placeholder.repeat(threshold - exposedChars);
return `${maskedMiddle}${visibleEnd}`;
}
/**
* Writes a secret to the secrets file
* @param {string} key Secret key
* @param {string} value Secret value
* @param {string} label Label for the secret
* @returns {string} The ID of the newly created secret
*/
writeSecret(key, value, label = 'Unlabeled') {
const secrets = this._readSecretsFile();
if (!Array.isArray(secrets[key])) {
secrets[key] = [];
}
this._deactivateAllSecrets(secrets[key]);
const secret = {
id: uuidv4(),
value: value,
label: label,
active: true,
};
secrets[key].push(secret);
this._writeSecretsFile(secrets);
return secret.id;
}
/**
* Deletes a secret from the secrets file by its ID
* @param {string} key Secret key
* @param {string?} id Secret ID to delete
*/
deleteSecret(key, id) {
if (!fs.existsSync(this.filePath)) {
return;
}
const secrets = this._readSecretsFile();
if (!this._validateSecretKey(secrets, key)) {
return;
}
const secretArray = secrets[key];
const targetIndex = secretArray.findIndex(s => id ? s.id === id : s.active);
// Delete the secret if found
if (targetIndex !== -1) {
secretArray.splice(targetIndex, 1);
}
// Reactivate the first secret if none are active
if (secretArray.length && !secretArray.some(s => s.active)) {
secretArray[0].active = true;
}
// Remove the key if no secrets left
if (secretArray.length === 0) {
delete secrets[key];
}
this._writeSecretsFile(secrets);
}
/**
* Reads the active secret value for a given key
* @param {string} key Secret key
* @param {string?} id ID of the secret to read (optional)
* @returns {string} Secret value or empty string if not found
*/
readSecret(key, id) {
if (!fs.existsSync(this.filePath)) {
return '';
}
const secrets = this._readSecretsFile();
const secretArray = secrets[key];
if (Array.isArray(secretArray) && secretArray.length > 0) {
const activeSecret = secretArray.find(s => id ? s.id === id : s.active);
return activeSecret?.value || '';
}
return '';
}
/**
* Activates a specific secret by ID for a given key
* @param {string} key Secret key to rotate
* @param {string} id ID of the secret to activate
*/
rotateSecret(key, id) {
if (!fs.existsSync(this.filePath)) {
return;
}
const secrets = this._readSecretsFile();
if (!this._validateSecretKey(secrets, key)) {
return;
}
const secretArray = secrets[key];
const targetIndex = secretArray.findIndex(s => s.id === id);
if (targetIndex === -1) {
console.warn(`Secret with ID ${id} not found for key ${key}`);
return;
}
this._deactivateAllSecrets(secretArray);
secretArray[targetIndex].active = true;
this._writeSecretsFile(secrets);
}
/**
* Renames a secret by its ID
* @param {string} key Secret key to rename
* @param {string} id ID of the secret to rename
* @param {string} label New label for the secret
*/
renameSecret(key, id, label) {
const secrets = this._readSecretsFile();
if (!this._validateSecretKey(secrets, key)) {
return;
}
const secretArray = secrets[key];
const targetIndex = secretArray.findIndex(s => s.id === id);
if (targetIndex === -1) {
console.warn(`Secret with ID ${id} not found for key ${key}`);
return;
}
secretArray[targetIndex].label = label;
this._writeSecretsFile(secrets);
}
/**
* Gets the state of all secrets (whether they exist or not)
* @returns {SecretStateMap} Secret state
*/
getSecretState() {
const secrets = this._readSecretsFile();
/** @type {SecretStateMap} */
const state = {};
for (const key of Object.values(SECRET_KEYS)) {
// Skip migration marker
if (key === SECRET_KEYS._MIGRATED) {
continue;
}
const value = secrets[key];
if (value && Array.isArray(value) && value.length > 0) {
state[key] = value.map(secret => ({
id: secret.id,
value: this.getMaskedValue(secret.value),
label: secret.label,
active: secret.active,
}));
} else {
// No secrets for this key
state[key] = null;
}
}
return state;
}
/**
* Gets all secrets (for admin viewing)
* @returns {SecretKeys} All secrets
*/
getAllSecrets() {
return this._readSecretsFile();
}
/**
* Migrates legacy flat secrets format to new format
*/
migrateFlatSecrets() {
if (!fs.existsSync(this.filePath)) {
return;
}
const fileContents = fs.readFileSync(this.filePath, 'utf8');
const secrets = /** @type {FlatSecretKeys} */ (JSON.parse(fileContents));
const values = Object.values(secrets);
// Check if already migrated
if (secrets[SECRET_KEYS._MIGRATED] || values.length === 0 || values.some(v => Array.isArray(v))) {
return;
}
/** @type {SecretKeys} */
const migratedSecrets = {};
for (const [key, value] of Object.entries(secrets)) {
if (typeof value === 'string' && value.trim()) {
migratedSecrets[key] = [{
id: uuidv4(),
value: value,
label: key,
active: true,
}];
}
}
// Mark as migrated
migratedSecrets[SECRET_KEYS._MIGRATED] = [];
// Save backup of the old secrets file
const backupFilePath = path.join(this.directories.backups, `secrets_migration_${Date.now()}.json`);
fs.cpSync(this.filePath, backupFilePath);
this._writeSecretsFile(migratedSecrets);
console.info(color.green('Secrets migrated successfully, old secrets backed up to:'), backupFilePath);
}
}
//#region Backwards compatibility
/**
* Writes a secret to the secrets file
* @param {import('../users.js').UserDirectoryList} directories User directories
@@ -72,36 +411,16 @@ const EXPORTABLE_KEYS = [
* @param {string} value Secret value
*/
export function writeSecret(directories, key, value) {
const filePath = path.join(directories.root, SECRETS_FILE);
if (!fs.existsSync(filePath)) {
const emptyFile = JSON.stringify({});
writeFileAtomicSync(filePath, emptyFile, 'utf-8');
}
const fileContents = fs.readFileSync(filePath, 'utf-8');
const secrets = JSON.parse(fileContents);
secrets[key] = value;
writeFileAtomicSync(filePath, JSON.stringify(secrets, null, 4), 'utf-8');
return new SecretManager(directories).writeSecret(key, value);
}
/**
* Deletes a secret from the secrets file
* @param {import('../users.js').UserDirectoryList} directories User directories
* @param {string} key Secret key
* @returns
*/
export function deleteSecret(directories, key) {
const filePath = path.join(directories.root, SECRETS_FILE);
if (!fs.existsSync(filePath)) {
return;
}
const fileContents = fs.readFileSync(filePath, 'utf-8');
const secrets = JSON.parse(fileContents);
delete secrets[key];
writeFileAtomicSync(filePath, JSON.stringify(secrets, null, 4), 'utf-8');
return new SecretManager(directories).deleteSecret(key, null);
}
/**
@@ -111,87 +430,104 @@ export function deleteSecret(directories, key) {
* @returns {string} Secret value
*/
export function readSecret(directories, key) {
const filePath = path.join(directories.root, SECRETS_FILE);
if (!fs.existsSync(filePath)) {
return '';
}
const fileContents = fs.readFileSync(filePath, 'utf-8');
const secrets = JSON.parse(fileContents);
return secrets[key];
return new SecretManager(directories).readSecret(key, null);
}
/**
* Reads the secret state from the secrets file
* @param {import('../users.js').UserDirectoryList} directories User directories
* @returns {object} Secret state
* @returns {Record<string, boolean>} Secret state
*/
export function readSecretState(directories) {
const filePath = path.join(directories.root, SECRETS_FILE);
if (!fs.existsSync(filePath)) {
return {};
}
const fileContents = fs.readFileSync(filePath, 'utf8');
const secrets = JSON.parse(fileContents);
const state = {};
const state = new SecretManager(directories).getSecretState();
const result = /** @type {Record<string, boolean>} */ ({});
for (const key of Object.values(SECRET_KEYS)) {
state[key] = !!secrets[key]; // convert to boolean
// Skip migration marker
if (key === SECRET_KEYS._MIGRATED) {
continue;
}
result[key] = Array.isArray(state[key]) && state[key].length > 0;
}
return state;
return result;
}
/**
* Reads all secrets from the secrets file
* @param {import('../users.js').UserDirectoryList} directories User directories
* @returns {Record<string, string> | undefined} Secrets
* @returns {Record<string, string>} Secrets
*/
export function getAllSecrets(directories) {
const filePath = path.join(directories.root, SECRETS_FILE);
if (!fs.existsSync(filePath)) {
console.error('Secrets file does not exist');
return undefined;
const secrets = new SecretManager(directories).getAllSecrets();
const result = /** @type {Record<string, string>} */ ({});
for (const [key, values] of Object.entries(secrets)) {
// Skip migration marker
if (key === SECRET_KEYS._MIGRATED) {
continue;
}
if (Array.isArray(values) && values.length > 0) {
const activeSecret = values.find(secret => secret.active);
if (activeSecret) {
result[key] = activeSecret.value;
}
}
}
return result;
}
//#endregion
const fileContents = fs.readFileSync(filePath, 'utf8');
const secrets = JSON.parse(fileContents);
return secrets;
/**
* Migrates legacy flat secrets format to the new format for all user directories
* @param {import('../users.js').UserDirectoryList[]} directoriesList User directories
*/
export function migrateFlatSecrets(directoriesList) {
for (const directories of directoriesList) {
try {
const manager = new SecretManager(directories);
manager.migrateFlatSecrets();
} catch (error) {
console.warn(color.red(`Failed to migrate secrets for ${directories.root}:`), error);
}
}
}
export const router = express.Router();
router.post('/write', (request, response) => {
const key = request.body.key;
const value = request.body.value;
try {
const { key, value, label } = request.body;
writeSecret(request.user.directories, key, value);
return response.send('ok');
if (!key || typeof value !== 'string') {
return response.status(400).send('Invalid key or value');
}
const manager = new SecretManager(request.user.directories);
const id = manager.writeSecret(key, value, label);
return response.send({ id });
} catch (error) {
console.error('Error writing secret:', error);
return response.sendStatus(500);
}
});
router.post('/read', (request, response) => {
try {
const state = readSecretState(request.user.directories);
const manager = new SecretManager(request.user.directories);
const state = manager.getSecretState();
return response.send(state);
} catch (error) {
console.error(error);
console.error('Error reading secret state:', error);
return response.send({});
}
});
router.post('/view', async (request, response) => {
const allowKeysExposure = getConfigValue('allowKeysExposure', false, 'boolean');
if (!allowKeysExposure) {
console.error('secrets.json could not be viewed unless the value of allowKeysExposure in config.yaml is set to true');
return response.sendStatus(403);
}
router.post('/view', (request, response) => {
try {
if (!allowKeysExposure) {
console.error('secrets.json could not be viewed unless allowKeysExposure in config.yaml is set to true');
return response.sendStatus(403);
}
const secrets = getAllSecrets(request.user.directories);
if (!secrets) {
@@ -200,30 +536,88 @@ router.post('/view', async (request, response) => {
return response.send(secrets);
} catch (error) {
console.error(error);
console.error('Error viewing secrets:', error);
return response.sendStatus(500);
}
});
router.post('/find', (request, response) => {
const allowKeysExposure = getConfigValue('allowKeysExposure', false, 'boolean');
const key = request.body.key;
if (!allowKeysExposure && !EXPORTABLE_KEYS.includes(key)) {
console.error('Cannot fetch secrets unless allowKeysExposure in config.yaml is set to true');
return response.sendStatus(403);
}
try {
const secret = readSecret(request.user.directories, key);
const { key, id } = request.body;
if (!secret) {
if (!key) {
return response.status(400).send('Key is required');
}
if (!allowKeysExposure && !EXPORTABLE_KEYS.includes(key)) {
console.error('Cannot fetch secrets unless allowKeysExposure in config.yaml is set to true');
return response.sendStatus(403);
}
const manager = new SecretManager(request.user.directories);
const secretValue = manager.readSecret(key, id);
if (!secretValue) {
return response.sendStatus(404);
}
return response.send({ value: secret });
return response.send({ value: secretValue });
} catch (error) {
console.error(error);
console.error('Error finding secret:', error);
return response.sendStatus(500);
}
});
router.post('/delete', (request, response) => {
try {
const { key, id } = request.body;
if (!key) {
return response.status(400).send('Key and ID are required');
}
const manager = new SecretManager(request.user.directories);
manager.deleteSecret(key, id);
return response.sendStatus(204);
} catch (error) {
console.error('Error deleting secret:', error);
return response.sendStatus(500);
}
});
router.post('/rotate', (request, response) => {
try {
const { key, id } = request.body;
if (!key || !id) {
return response.status(400).send('Key and ID are required');
}
const manager = new SecretManager(request.user.directories);
manager.rotateSecret(key, id);
return response.sendStatus(204);
} catch (error) {
console.error('Error rotating secret:', error);
return response.sendStatus(500);
}
});
router.post('/rename', (request, response) => {
try {
const { key, id, label } = request.body;
if (!key || !id || !label) {
return response.status(400).send('Key, ID, and label are required');
}
const manager = new SecretManager(request.user.directories);
manager.renameSecret(key, id, label);
return response.sendStatus(204);
} catch (error) {
console.error('Error renaming secret:', error);
return response.sendStatus(500);
}
});
+2
View File
@@ -76,6 +76,7 @@ import { checkForNewContent } from './endpoints/content-manager.js';
import { init as settingsInit } from './endpoints/settings.js';
import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './server-startup.js';
import { diskCache } from './endpoints/characters.js';
import { migrateFlatSecrets } from './endpoints/secrets.js';
// Unrestrict console logs display limit
util.inspect.defaultOptions.maxArrayLength = null;
@@ -275,6 +276,7 @@ async function preSetupTasks() {
await checkForNewContent(directories);
await ensureThumbnailCache(directories);
await diskCache.verify(directories);
migrateFlatSecrets(directories);
cleanUploads();
migrateAccessLog();
+7
View File
@@ -7,6 +7,7 @@ import { createRequire } from 'node:module';
import { Buffer } from 'node:buffer';
import { promises as dnsPromise } from 'node:dns';
import os from 'node:os';
import crypto from 'node:crypto';
import yaml from 'yaml';
import { sync as commandExistsSync } from 'command-exists';
@@ -371,9 +372,15 @@ export const color = chalk;
* @returns {string} A UUIDv4 string
*/
export function uuidv4() {
// Node v16.7.0+
if ('crypto' in globalThis && 'randomUUID' in globalThis.crypto) {
return globalThis.crypto.randomUUID();
}
// Node v14.17.0+
if ('randomUUID' in crypto) {
return crypto.randomUUID();
}
// Very insecure UUID generator, but it's better than nothing.
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);