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:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}, {});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
Reference in New Issue
Block a user