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)

8d2b9d2dab3ad2d459cd68c271e27e1706cecc2a

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
16 files changed, +1600 -219Showing whitespace changes
public/css/secrets.css+102 -0
@@ -0,0 +1,102 @@
1.secretKeyManager {
2 display: flex;
3 flex-direction: column;
4 gap: 5px;
5 height: 100%;
6}
7
8.secretKeyManagerHeader {
9 display: flex;
10 flex-direction: column;
11 justify-content: center;
12 align-items: center;
13 gap: 5px;
14}
15
16.secretKeyManagerSubtitle {
17 display: flex;
18 flex-direction: row;
19 justify-content: space-between;
20 align-items: center;
21 width: 100%;
22}
23
24.secretKeyManagerInfo {
25 display: flex;
26 flex-direction: column;
27 align-items: baseline;
28 flex: 1;
29 font-size: 0.95em;
30}
31
32.secretKeyManagerList {
33 display: flex;
34 flex-direction: column;
35 text-align: left;
36 gap: 5px;
37 flex: 1;
38 overflow-y: auto;
39 height: 100%;
40}
41
42.secretKeyManagerItem {
43 border: 1px solid var(--SmartThemeBorderColor);
44 padding: 5px 10px;
45 border-radius: 10px;
46 background-color: var(--black30a);
47 display: flex;
48 flex-direction: row;
49 align-items: center;
50}
51
52.secretKeyManagerItem.active {
53 background-color: var(--cobalt30a);
54}
55
56.secretKeyManagerItemInfo {
57 display: flex;
58 flex-direction: column;
59 flex: 1;
60 gap: 5px;
61}
62
63.secretKeyManagerItemSubtitle,
64.secretKeyManagerItemHeader {
65 word-break: break-all;
66}
67
68.secretKeyManagerItemId {
69 text-decoration: underline;
70 text-decoration-style: dotted;
71 cursor: pointer;
72}
73
74.secretKeyManagerItemActions {
75 display: flex;
76 flex-direction: column;
77 gap: 5px;
78}
79
80.secretKeyManagerItemActionsRow {
81 display: flex;
82 flex-direction: row;
83 gap: 5px;
84}
85
86.secretKeyManagerItemActionsRow>button {
87 margin: 0;
88}
89
90.secretKeyManagerList:empty {
91 display: none;
92}
93
94.secretKeyManagerListEmpty {
95 display: flex;
96 justify-content: center;
97 align-items: center;
98 width: 100%;
99 opacity: 0.8;
100 font-weight: bold;
101 font-size: 1.05em;
102}
public/index.html+111 -113
@@ -2242,10 +2242,11 @@
2242 </small>2242 </small>
2243 <div class="flex-container">2243 <div class="flex-container">
2244 <input id="horde_api_key" name="horde_api_key" class="text_pole flex1" type="text" placeholder="0000000000" autocomplete="off">2244 <input id="horde_api_key" name="horde_api_key" class="text_pole flex1" type="text" placeholder="0000000000" autocomplete="off">
2245 <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>2245 <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>
2246 <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>
2246 </div>2247 </div>
2247 <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.">2248 <div data-for="horde_api_key" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
2248 For privacy reasons, your API key will be hidden after you reload the page.2249 For privacy reasons, your API key will be hidden after you click 'Connect'.
2249 </div>2250 </div>
2250 <h4 class="horde_model_title">2251 <h4 class="horde_model_title">
2251 <span data-i18n="Models">Models </span>2252 <span data-i18n="Models">Models </span>
@@ -2297,11 +2298,11 @@
2297 </span>2298 </span>
2298 <div class="flex-container">2299 <div class="flex-container">
2299 <input id="api_key_novel" name="api_key_novel" class="text_pole flex1 wide100p" type="text" autocomplete="off">2300 <input id="api_key_novel" name="api_key_novel" class="text_pole flex1 wide100p" type="text" autocomplete="off">
2300 <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">2301 <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">
2301 </div>2302 </div>
2302 </div>2303 </div>
2303 <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.">2304 <div data-for="api_key_novel" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
2304 For privacy reasons, your API key will be hidden after you reload the page.2305 For privacy reasons, your API key will be hidden after you click 'Connect'.
2305 </div>2306 </div>
2306 <h4><span data-i18n="Novel AI Model">Novel AI Model</span>2307 <h4><span data-i18n="Novel AI Model">Novel AI Model</span>
2307 <a href="https://docs.sillytavern.app/usage/api-connections/novelai/#models" class="notes-link" target="_blank">2308 <a href="https://docs.sillytavern.app/usage/api-connections/novelai/#models" class="notes-link" target="_blank">
@@ -2349,10 +2350,10 @@
2349 <h4 data-i18n="TogetherAI API Key">TogetherAI API Key</h4>2350 <h4 data-i18n="TogetherAI API Key">TogetherAI API Key</h4>
2350 <div class="flex-container">2351 <div class="flex-container">
2351 <input id="api_key_togetherai" name="api_key_togetherai" class="text_pole flex1" value="" type="text" autocomplete="off">2352 <input id="api_key_togetherai" name="api_key_togetherai" class="text_pole flex1" value="" type="text" autocomplete="off">
2352 <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>2353 <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>
2353 </div>2354 </div>
2354 <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.">2355 <div data-for="api_key_togetherai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
2355 For privacy reasons, your API key will be hidden after you reload the page.2356 For privacy reasons, your API key will be hidden after you click 'Connect'.
2356 </div>2357 </div>
2357 <div>2358 <div>
2358 <h4 data-i18n="TogetherAI Model">TogetherAI Model</h4>2359 <h4 data-i18n="TogetherAI Model">TogetherAI Model</h4>
@@ -2373,10 +2374,10 @@
2373 </div>2374 </div>
2374 <div class="flex-container">2375 <div class="flex-container">
2375 <input id="api_key_openrouter-tg" name="api_key_openrouter" class="text_pole flex1 api_key_openrouter" value="" type="text" autocomplete="off">2376 <input id="api_key_openrouter-tg" name="api_key_openrouter" class="text_pole flex1 api_key_openrouter" value="" type="text" autocomplete="off">
2376 <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>2377 <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>
2377 </div>2378 </div>
2378 <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.">2379 <div data-for="api_key_openrouter" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
2379 For privacy reasons, your API key will be hidden after you reload the page.2380 For privacy reasons, your API key will be hidden after you click 'Connect'.
2380 </div>2381 </div>
2381 <div>2382 <div>
2382 <h4 data-i18n="OpenRouter Model">OpenRouter Model</h4>2383 <h4 data-i18n="OpenRouter Model">OpenRouter Model</h4>
@@ -2400,10 +2401,10 @@
2400 <h4 data-i18n="InfermaticAI API Key">InfermaticAI API Key</h4>2401 <h4 data-i18n="InfermaticAI API Key">InfermaticAI API Key</h4>
2401 <div class="flex-container">2402 <div class="flex-container">
2402 <input id="api_key_infermaticai" name="api_key_infermaticai" class="text_pole flex1" value="" type="text" autocomplete="off">2403 <input id="api_key_infermaticai" name="api_key_infermaticai" class="text_pole flex1" value="" type="text" autocomplete="off">
2403 <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>2404 <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>
2404 </div>2405 </div>
2405 <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.">2406 <div data-for="api_key_infermaticai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
2406 For privacy reasons, your API key will be hidden after you reload the page.2407 For privacy reasons, your API key will be hidden after you click 'Connect'.
2407 </div>2408 </div>
2408 <div>2409 <div>
2409 <h4 data-i18n="InfermaticAI Model">InfermaticAI Model</h4>2410 <h4 data-i18n="InfermaticAI Model">InfermaticAI Model</h4>
@@ -2423,10 +2424,10 @@
2423 </h4>2424 </h4>
2424 <div class="flex-container">2425 <div class="flex-container">
2425 <input id="api_key_dreamgen" name="api_key_dreamgen" class="text_pole flex1" value="" type="text" autocomplete="off">2426 <input id="api_key_dreamgen" name="api_key_dreamgen" class="text_pole flex1" value="" type="text" autocomplete="off">
2426 <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>2427 <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>
2427 </div>2428 </div>
2428 <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.">2429 <div data-for="api_key_dreamgen" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
2429 For privacy reasons, your API key will be hidden after you reload the page.2430 For privacy reasons, your API key will be hidden after you click 'Connect'.
2430 </div>2431 </div>
2431 <div>2432 <div>
2432 <h4 data-i18n="DreamGen Model">DreamGen Model</h4>2433 <h4 data-i18n="DreamGen Model">DreamGen Model</h4>
@@ -2448,11 +2449,11 @@
2448 </h4>2449 </h4>
2449 <div class="flex-container">2450 <div class="flex-container">
2450 <input id="api_key_mancer" name="api_key_mancer" class="text_pole flex1 wide100p" type="text" autocomplete="off">2451 <input id="api_key_mancer" name="api_key_mancer" class="text_pole flex1 wide100p" type="text" autocomplete="off">
2451 <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">2452 <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">
2452 </div>2453 </div>
2453 </div>2454 </div>
2454 <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.">2455 <div data-for="api_key_mancer" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
2455 For privacy reasons, your API key will be hidden after you reload the page.2456 For privacy reasons, your API key will be hidden after you click 'Connect'.
2456 </div>2457 </div>
2457 <div class="flex1">2458 <div class="flex1">
2458 <h4 data-i18n="Mancer Model">Mancer Model</h4>2459 <h4 data-i18n="Mancer Model">Mancer Model</h4>
@@ -2467,11 +2468,11 @@
2467 <h4 data-i18n="API key (optional)">API key (optional)</h4>2468 <h4 data-i18n="API key (optional)">API key (optional)</h4>
2468 <div class="flex-container">2469 <div class="flex-container">
2469 <input id="api_key_generic" name="api_key_generic" class="text_pole flex1 wide100p" type="text" autocomplete="off">2470 <input id="api_key_generic" name="api_key_generic" class="text_pole flex1 wide100p" type="text" autocomplete="off">
2470 <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">2471 <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">
2471 </div>2472 </div>
2472 </div>2473 </div>
2473 <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.">2474 <div data-for="api_key_generic" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
2474 For privacy reasons, your API key will be hidden after you reload the page.2475 For privacy reasons, your API key will be hidden after you click 'Connect'.
2475 </div>2476 </div>
2476 <div class="flex1">2477 <div class="flex1">
2477 <h4 data-i18n="Server url">Server URL</h4>2478 <h4 data-i18n="Server url">Server URL</h4>
@@ -2496,11 +2497,11 @@
2496 <h4 data-i18n="API key (optional)">API key (optional)</h4>2497 <h4 data-i18n="API key (optional)">API key (optional)</h4>
2497 <div class="flex-container">2498 <div class="flex-container">
2498 <input id="api_key_ooba" name="api_key_ooba" class="text_pole flex1 wide100p" type="text" autocomplete="off">2499 <input id="api_key_ooba" name="api_key_ooba" class="text_pole flex1 wide100p" type="text" autocomplete="off">
2499 <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">2500 <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">
2500 </div>2501 </div>
2501 </div>2502 </div>
2502 <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.">2503 <div data-for="api_key_ooba" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
2503 For privacy reasons, your API key will be hidden after you reload the page.2504 For privacy reasons, your API key will be hidden after you click 'Connect'.
2504 </div>2505 </div>
2505 <div class="flex1">2506 <div class="flex1">
2506 <h4 data-i18n="Server url">Server URL</h4>2507 <h4 data-i18n="Server url">Server URL</h4>
@@ -2518,11 +2519,11 @@
2518 <h4 data-i18n="API key (optional)">API key</h4>2519 <h4 data-i18n="API key (optional)">API key</h4>
2519 <div class="flex-container">2520 <div class="flex-container">
2520 <input id="api_key_featherless" name="api_key_featherless" class="text_pole flex1 wide100p" type="text" autocomplete="off">2521 <input id="api_key_featherless" name="api_key_featherless" class="text_pole flex1 wide100p" type="text" autocomplete="off">
2521 <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">2522 <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">
2522 </div>2523 </div>
2523 </div>2524 </div>
2524 <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.">2525 <div data-for="api_key_featherless" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
2525 For privacy reasons, your API key will be hidden after you reload the page.2526 For privacy reasons, your API key will be hidden after you click 'Connect'.
2526 </div>2527 </div>
2527 <hr>2528 <hr>
2528 <h4 data-i18n="Featherless Model Selection">Featherless Model Selection</h4>2529 <h4 data-i18n="Featherless Model Selection">Featherless Model Selection</h4>
@@ -2569,11 +2570,11 @@
2569 <h4 data-i18n="vLLM API key">vLLM API key</h4>2570 <h4 data-i18n="vLLM API key">vLLM API key</h4>
2570 <div class="flex-container">2571 <div class="flex-container">
2571 <input id="api_key_vllm" name="api_key_vllm" class="text_pole flex1 wide100p" type="text" autocomplete="off">2572 <input id="api_key_vllm" name="api_key_vllm" class="text_pole flex1 wide100p" type="text" autocomplete="off">
2572 <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">2573 <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">
2573 </div>2574 </div>
2574 </div>2575 </div>
2575 <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.">2576 <div data-for="api_key_vllm" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
2576 For privacy reasons, your API key will be hidden after you reload the page.2577 For privacy reasons, your API key will be hidden after you click 'Connect'.
2577 </div>2578 </div>
2578 <div class="flex1">2579 <div class="flex1">
2579 <h4 data-i18n="API url">API URL</h4>2580 <h4 data-i18n="API url">API URL</h4>
@@ -2593,11 +2594,11 @@
2593 <h4 data-i18n="HuggingFace Token">HuggingFace Token</h4>2594 <h4 data-i18n="HuggingFace Token">HuggingFace Token</h4>
2594 <div class="flex-container">2595 <div class="flex-container">
2595 <input id="api_key_huggingface" name="api_key_huggingface" class="text_pole flex1 wide100p" type="text" autocomplete="off">2596 <input id="api_key_huggingface" name="api_key_huggingface" class="text_pole flex1 wide100p" type="text" autocomplete="off">
2596 <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">2597 <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">
2597 </div>2598 </div>
2598 </div>2599 </div>
2599 <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.">2600 <div data-for="api_key_huggingface" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
2600 For privacy reasons, your API key will be hidden after you reload the page.2601 For privacy reasons, your API key will be hidden after you click 'Connect'.
2601 </div>2602 </div>
2602 <div class="flex1">2603 <div class="flex1">
2603 <h4 data-i18n="Endpoint URL">Endpoint URL</h4>2604 <h4 data-i18n="Endpoint URL">Endpoint URL</h4>
@@ -2615,11 +2616,11 @@
2615 <h4 data-i18n="Aphrodite API key">Aphrodite API key</h4>2616 <h4 data-i18n="Aphrodite API key">Aphrodite API key</h4>
2616 <div class="flex-container">2617 <div class="flex-container">
2617 <input id="api_key_aphrodite" name="api_key_aphrodite" class="text_pole flex1 wide100p" type="text" autocomplete="off">2618 <input id="api_key_aphrodite" name="api_key_aphrodite" class="text_pole flex1 wide100p" type="text" autocomplete="off">
2618 <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">2619 <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">
2619 </div>2620 </div>
2620 </div>2621 </div>
2621 <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.">2622 <div data-for="api_key_aphrodite" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
2622 For privacy reasons, your API key will be hidden after you reload the page.2623 For privacy reasons, your API key will be hidden after you click 'Connect'.
2623 </div>2624 </div>
2624 <div class="flex1">2625 <div class="flex1">
2625 <h4 data-i18n="API url">API URL</h4>2626 <h4 data-i18n="API url">API URL</h4>
@@ -2644,11 +2645,11 @@
2644 <h4 data-i18n="API key (optional)">API key (optional)</h4>2645 <h4 data-i18n="API key (optional)">API key (optional)</h4>
2645 <div class="flex-container">2646 <div class="flex-container">
2646 <input id="api_key_llamacpp" name="api_key_llamacpp" class="text_pole flex1 wide100p" type="text" autocomplete="off">2647 <input id="api_key_llamacpp" name="api_key_llamacpp" class="text_pole flex1 wide100p" type="text" autocomplete="off">
2647 <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">2648 <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">
2648 </div>2649 </div>
2649 </div>2650 </div>
2650 <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.">2651 <div data-for="api_key_llamacpp" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
2651 For privacy reasons, your API key will be hidden after you reload the page.2652 For privacy reasons, your API key will be hidden after you click 'Connect'.
2652 </div>2653 </div>
2653 <div class="flex1">2654 <div class="flex1">
2654 <h4 data-i18n="API url">API URL</h4>2655 <h4 data-i18n="API url">API URL</h4>
@@ -2691,11 +2692,11 @@
2691 <h4 data-i18n="Tabby API key">Tabby API key</h4>2692 <h4 data-i18n="Tabby API key">Tabby API key</h4>
2692 <div class="flex-container">2693 <div class="flex-container">
2693 <input id="api_key_tabby" name="api_key_tabby" class="text_pole flex1 wide100p" type="text" autocomplete="off">2694 <input id="api_key_tabby" name="api_key_tabby" class="text_pole flex1 wide100p" type="text" autocomplete="off">
2694 <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">2695 <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">
2695 </div>2696 </div>
2696 </div>2697 </div>
2697 <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.">2698 <div data-for="api_key_tabby" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
2698 For privacy reasons, your API key will be hidden after you reload the page.2699 For privacy reasons, your API key will be hidden after you click 'Connect'.
2699 </div>2700 </div>
2700 <div class="flex1">2701 <div class="flex1">
2701 <h4 data-i18n="API url">API URL</h4>2702 <h4 data-i18n="API url">API URL</h4>
@@ -2743,11 +2744,11 @@
2743 <h4 data-i18n="koboldcpp API key (optional)">koboldcpp API key (optional)</h4>2744 <h4 data-i18n="koboldcpp API key (optional)">koboldcpp API key (optional)</h4>
2744 <div class="flex-container">2745 <div class="flex-container">
2745 <input id="api_key_koboldcpp" name="api_key_koboldcpp" class="text_pole flex1 wide100p" type="text" autocomplete="off">2746 <input id="api_key_koboldcpp" name="api_key_koboldcpp" class="text_pole flex1 wide100p" type="text" autocomplete="off">
2746 <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">2747 <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">
2747 </div>2748 </div>
2748 </div>2749 </div>
2749 <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.">2750 <div data-for="api_key_koboldcpp" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
2750 For privacy reasons, your API key will be hidden after you reload the page.2751 For privacy reasons, your API key will be hidden after you click 'Connect'.
2751 </div>2752 </div>
2752 <div class="flex1">2753 <div class="flex1">
2753 <h4 data-i18n="API url">API URL</h4>2754 <h4 data-i18n="API url">API URL</h4>
@@ -2905,15 +2906,15 @@
2905 </span>2906 </span>
2906 <div class="flex-container">2907 <div class="flex-container">
2907 <input id="api_key_openai" name="api_key_openai" class="text_pole flex1" value="" type="text" autocomplete="off">2908 <input id="api_key_openai" name="api_key_openai" class="text_pole flex1" value="" type="text" autocomplete="off">
2908 <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>2909 <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>
2909 </div>2910 </div>
2910 <div id="ReverseProxyWarningMessage2" class="reverse_proxy_warning">2911 <div id="ReverseProxyWarningMessage2" class="reverse_proxy_warning">
2911 <b data-i18n="Use Proxy password field instead. This input will be ignored.">2912 <b data-i18n="Use Proxy password field instead. This input will be ignored.">
2912 Use "Proxy password" field instead. This input will be ignored.2913 Use "Proxy password" field instead. This input will be ignored.
2913 </b>2914 </b>
2914 </div>2915 </div>
2915 <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.">2916 <div data-for="api_key_openai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
2916 For privacy reasons, your API key will be hidden after you reload the page.2917 For privacy reasons, your API key will be hidden after you click 'Connect'.
2917 </div>2918 </div>
2918 <div>2919 <div>
2919 <h4 data-i18n="OpenAI Model">OpenAI Model</h4>2920 <h4 data-i18n="OpenAI Model">OpenAI Model</h4>
@@ -2999,10 +3000,10 @@
2999 </div>3000 </div>
3000 <div class="flex-container">3001 <div class="flex-container">
3001 <input id="api_key_claude" name="api_key_claude" class="text_pole flex1" value="" type="text" autocomplete="off">3002 <input id="api_key_claude" name="api_key_claude" class="text_pole flex1" value="" type="text" autocomplete="off">
3002 <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>3003 <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>
3003 </div>3004 </div>
3004 <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.">3005 <div data-for="api_key_claude" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
3005 For privacy reasons, your API key will be hidden after you reload the page.3006 For privacy reasons, your API key will be hidden after you click 'Connect'.
3006 </div>3007 </div>
3007 <div>3008 <div>
3008 <h4 data-i18n="Claude Model">Claude Model</h4>3009 <h4 data-i18n="Claude Model">Claude Model</h4>
@@ -3058,10 +3059,10 @@
3058 </div>3059 </div>
3059 <div class="flex-container">3060 <div class="flex-container">
3060 <input id="api_key_openrouter" name="api_key_openrouter" class="text_pole flex1 api_key_openrouter" value="" type="text" autocomplete="off">3061 <input id="api_key_openrouter" name="api_key_openrouter" class="text_pole flex1 api_key_openrouter" value="" type="text" autocomplete="off">
3061 <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>3062 <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>
3062 </div>3063 </div>
3063 <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.">3064 <div data-for="api_key_openrouter" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
3064 For privacy reasons, your API key will be hidden after you reload the page.3065 For privacy reasons, your API key will be hidden after you click 'Connect'.
3065 </div>3066 </div>
3066 <div>3067 <div>
3067 <h4 data-i18n="OpenRouter Model">OpenRouter Model</h4>3068 <h4 data-i18n="OpenRouter Model">OpenRouter Model</h4>
@@ -3122,10 +3123,10 @@
3122 <h4 data-i18n="Scale API Key">Scale API Key</h4>3123 <h4 data-i18n="Scale API Key">Scale API Key</h4>
3123 <div class="flex-container">3124 <div class="flex-container">
3124 <input id="api_key_scale" name="api_key_scale" class="text_pole flex1" value="" autocomplete="off">3125 <input id="api_key_scale" name="api_key_scale" class="text_pole flex1" value="" autocomplete="off">
3125 <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>3126 <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>
3126 </div>3127 </div>
3127 <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.">3128 <div data-for="api_key_scale" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
3128 For privacy reasons, your API key will be hidden after you reload the page.3129 For privacy reasons, your API key will be hidden after you click 'Connect'.
3129 </div>3130 </div>
3130 <h4>Scale API URL</h4>3131 <h4>Scale API URL</h4>
3131 <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">3132 <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 @@
3134 <h4>Scale Cookie (_jwt)</h4>3135 <h4>Scale Cookie (_jwt)</h4>
3135 <div class="flex-container">3136 <div class="flex-container">
3136 <input id="scale_cookie" name="scale_cookie" class="text_pole flex1" value="" autocomplete="off">3137 <input id="scale_cookie" name="scale_cookie" class="text_pole flex1" value="" autocomplete="off">
3137 <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>3138 <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>
3138 </div>3139 </div>
3139 <div data-for="scale_cookie" class="neutral_warning">3140 <div data-for="scale_cookie" class="neutral_warning">
3140 For privacy reasons, your cookie will be hidden after you reload the page.3141 For privacy reasons, your cookie will be hidden after you click 'Connect'.
3141 </div>3142 </div>
3142 </div>3143 </div>
3143 <!-- Its only purpose is to trigger max context size check -->3144 <!-- Its only purpose is to trigger max context size check -->
@@ -3151,10 +3152,10 @@
3151 <h4 data-i18n="AI21 API Key">AI21 API Key</h4>3152 <h4 data-i18n="AI21 API Key">AI21 API Key</h4>
3152 <div class="flex-container">3153 <div class="flex-container">
3153 <input id="api_key_ai21" name="api_key_ai21" class="text_pole flex1" value="" type="text" autocomplete="off">3154 <input id="api_key_ai21" name="api_key_ai21" class="text_pole flex1" value="" type="text" autocomplete="off">
3154 <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>3155 <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>
3155 </div>3156 </div>
3156 <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.">3157 <div data-for="api_key_ai21" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
3157 For privacy reasons, your API key will be hidden after you reload the page.3158 For privacy reasons, your API key will be hidden after you click 'Connect'.
3158 </div>3159 </div>
3159 <div>3160 <div>
3160 <h4 data-i18n="AI21 Model">AI21 Model</h4>3161 <h4 data-i18n="AI21 Model">AI21 Model</h4>
@@ -3181,10 +3182,10 @@
3181 <h4 data-i18n="Google AI Studio API Key">Google AI Studio API Key</h4>3182 <h4 data-i18n="Google AI Studio API Key">Google AI Studio API Key</h4>
3182 <div class="flex-container">3183 <div class="flex-container">
3183 <input id="api_key_makersuite" name="api_key_makersuite" class="text_pole flex1" value="" type="text" autocomplete="off">3184 <input id="api_key_makersuite" name="api_key_makersuite" class="text_pole flex1" value="" type="text" autocomplete="off">
3184 <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>3185 <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>
3185 </div>3186 </div>
3186 <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.">3187 <div data-for="api_key_makersuite" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
3187 For privacy reasons, your API key will be hidden after you reload the page.3188 For privacy reasons, your API key will be hidden after you click 'Connect'.
3188 </div>3189 </div>
3189 <div>3190 <div>
3190 <h4 data-i18n="Google Model">Google Model</h4>3191 <h4 data-i18n="Google Model">Google Model</h4>
@@ -3264,10 +3265,10 @@
3264 </h4>3265 </h4>
3265 <div class="flex-container">3266 <div class="flex-container">
3266 <input id="api_key_vertexai" name="api_key_vertexai" class="text_pole flex1" value="" type="text" autocomplete="off">3267 <input id="api_key_vertexai" name="api_key_vertexai" class="text_pole flex1" value="" type="text" autocomplete="off">
3267 <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>3268 <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>
3268 </div>3269 </div>
3269 <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.">3270 <div data-for="api_key_vertexai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
3270 For privacy reasons, your API key will be hidden after you reload the page.3271 For privacy reasons, your API key will be hidden after you click 'Connect'.
3271 </div>3272 </div>
3272 </div>3273 </div>
32733274
@@ -3301,23 +3302,15 @@
3301 <div id="vertexai_service_account_status" class="info-block marginTopBot5" style="display: none;">3302 <div id="vertexai_service_account_status" class="info-block marginTopBot5" style="display: none;">
3302 <span id="vertexai_service_account_info"></span>3303 <span id="vertexai_service_account_info"></span>
3303 </div>3304 </div>
3304 <textarea id="vertexai_service_account_json" class="text_pole textarea_compact" rows="4" placeholder='Paste your Service Account JSON content here, e.g.:3305 <div class="flex-container">
3305 {3306 <input id="vertexai_service_account_json" name="vertexai_service_account_json" class="text_pole flex1" value="" type="text" autocomplete="off">
3306 "type": "service_account",3307 <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>
3307 "project_id": "your-project-id",3308 </div>
3308 "private_key_id": "...",3309 <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'.">
3309 "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",3310 For privacy reasons, your Service Account JSON content will be hidden after you click 'Validate JSON'.
3310 "client_email": "...",
3311 "client_id": "...",
3312 "auth_uri": "https://accounts.google.com/o/oauth2/auth",
3313 "token_uri": "https://oauth2.googleapis.com/token"
3314 }'></textarea>
3315 <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.">
3316 For privacy reasons, your Service Account JSON content will be hidden after you reload the page.
3317 </div>3311 </div>
3318 <div class="flex-container">3312 <div class="flex-container">
3319 <button type="button" id="vertexai_validate_service_account" class="menu_button menu_button_icon" data-i18n="Validate JSON">Validate JSON</button>3313 <button type="button" id="vertexai_validate_service_account" class="menu_button menu_button_icon" data-i18n="Validate JSON">Validate JSON</button>
3320 <button type="button" id="vertexai_clear_service_account" class="menu_button menu_button_icon" data-i18n="Clear">Clear</button>
3321 </div>3314 </div>
3322 </div>3315 </div>
3323 </div>3316 </div>
@@ -3359,10 +3352,10 @@
3359 <h4 data-i18n="MistralAI API Key">MistralAI API Key</h4>3352 <h4 data-i18n="MistralAI API Key">MistralAI API Key</h4>
3360 <div class="flex-container">3353 <div class="flex-container">
3361 <input id="api_key_mistralai" name="api_key_mistralai" class="text_pole flex1" value="" type="text" autocomplete="off">3354 <input id="api_key_mistralai" name="api_key_mistralai" class="text_pole flex1" value="" type="text" autocomplete="off">
3362 <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>3355 <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>
3363 </div>3356 </div>
3364 <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.">3357 <div data-for="api_key_mistralai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
3365 For privacy reasons, your API key will be hidden after you reload the page.3358 For privacy reasons, your API key will be hidden after you click 'Connect'.
3366 </div>3359 </div>
3367 <div>3360 <div>
3368 <h4 data-i18n="MistralAI Model">MistralAI Model</h4>3361 <h4 data-i18n="MistralAI Model">MistralAI Model</h4>
@@ -3423,10 +3416,10 @@
3423 <h4 data-i18n="Groq API Key">Groq API Key</h4>3416 <h4 data-i18n="Groq API Key">Groq API Key</h4>
3424 <div class="flex-container">3417 <div class="flex-container">
3425 <input id="api_key_groq" name="api_key_groq" class="text_pole flex1" value="" type="text" autocomplete="off">3418 <input id="api_key_groq" name="api_key_groq" class="text_pole flex1" value="" type="text" autocomplete="off">
3426 <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>3419 <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>
3427 </div>3420 </div>
3428 <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.">3421 <div data-for="api_key_groq" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
3429 For privacy reasons, your API key will be hidden after you reload the page.3422 For privacy reasons, your API key will be hidden after you click 'Connect'.
3430 </div>3423 </div>
3431 <h4 data-i18n="Groq Model">Groq Model</h4>3424 <h4 data-i18n="Groq Model">Groq Model</h4>
3432 <select id="model_groq_select">3425 <select id="model_groq_select">
@@ -3465,10 +3458,10 @@
3465 <h4 data-i18n="NanoGPT API Key">NanoGPT API Key</h4>3458 <h4 data-i18n="NanoGPT API Key">NanoGPT API Key</h4>
3466 <div class="flex-container">3459 <div class="flex-container">
3467 <input id="api_key_nanogpt" name="api_key_nanogpt" class="text_pole flex1" value="" type="text" autocomplete="off">3460 <input id="api_key_nanogpt" name="api_key_nanogpt" class="text_pole flex1" value="" type="text" autocomplete="off">
3468 <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>3461 <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>
3469 </div>3462 </div>
3470 <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.">3463 <div data-for="api_key_nanogpt" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
3471 For privacy reasons, your API key will be hidden after you reload the page.3464 For privacy reasons, your API key will be hidden after you click 'Connect'.
3472 </div>3465 </div>
3473 <div>3466 <div>
3474 <h4 data-i18n="NanoGPT Model">NanoGPT Model</h4>3467 <h4 data-i18n="NanoGPT Model">NanoGPT Model</h4>
@@ -3481,10 +3474,10 @@
3481 <h4 data-i18n="DeepSeek API Key">DeepSeek API Key</h4>3474 <h4 data-i18n="DeepSeek API Key">DeepSeek API Key</h4>
3482 <div class="flex-container">3475 <div class="flex-container">
3483 <input id="api_key_deepseek" name="api_key_deepseek" class="text_pole flex1" value="" type="text" autocomplete="off">3476 <input id="api_key_deepseek" name="api_key_deepseek" class="text_pole flex1" value="" type="text" autocomplete="off">
3484 <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>3477 <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>
3485 </div>3478 </div>
3486 <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.">3479 <div data-for="api_key_deepseek" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
3487 For privacy reasons, your API key will be hidden after you reload the page.3480 For privacy reasons, your API key will be hidden after you click 'Connect'.
3488 </div>3481 </div>
3489 <div>3482 <div>
3490 <h4 data-i18n="DeepSeek Model">DeepSeek Model</h4>3483 <h4 data-i18n="DeepSeek Model">DeepSeek Model</h4>
@@ -3499,10 +3492,10 @@
3499 <h4 data-i18n="Perplexity API Key">Perplexity API Key</h4>3492 <h4 data-i18n="Perplexity API Key">Perplexity API Key</h4>
3500 <div class="flex-container">3493 <div class="flex-container">
3501 <input id="api_key_perplexity" name="api_key_perplexity" class="text_pole flex1" value="" type="text" autocomplete="off">3494 <input id="api_key_perplexity" name="api_key_perplexity" class="text_pole flex1" value="" type="text" autocomplete="off">
3502 <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>3495 <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>
3503 </div>3496 </div>
3504 <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.">3497 <div data-for="api_key_perplexity" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
3505 For privacy reasons, your API key will be hidden after you reload the page.3498 For privacy reasons, your API key will be hidden after you click 'Connect'.
3506 </div>3499 </div>
3507 <h4 data-i18n="Perplexity Model">Perplexity Model</h4>3500 <h4 data-i18n="Perplexity Model">Perplexity Model</h4>
3508 <select id="model_perplexity_select">3501 <select id="model_perplexity_select">
@@ -3530,10 +3523,10 @@
3530 <h4 data-i18n="Cohere API Key">Cohere API Key</h4>3523 <h4 data-i18n="Cohere API Key">Cohere API Key</h4>
3531 <div class="flex-container">3524 <div class="flex-container">
3532 <input id="api_key_cohere" name="api_key_cohere" class="text_pole flex1" value="" type="text" autocomplete="off">3525 <input id="api_key_cohere" name="api_key_cohere" class="text_pole flex1" value="" type="text" autocomplete="off">
3533 <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>3526 <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>
3534 </div>3527 </div>
3535 <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.">3528 <div data-for="api_key_cohere" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
3536 For privacy reasons, your API key will be hidden after you reload the page.3529 For privacy reasons, your API key will be hidden after you click 'Connect'.
3537 </div>3530 </div>
3538 <div>3531 <div>
3539 <h4 data-i18n="Cohere Model">Cohere Model</h4>3532 <h4 data-i18n="Cohere Model">Cohere Model</h4>
@@ -3578,10 +3571,10 @@
3578 </h4>3571 </h4>
3579 <div class="flex-container">3572 <div class="flex-container">
3580 <input id="api_key_custom" name="api_key_custom" class="text_pole flex1" value="" type="text" autocomplete="off">3573 <input id="api_key_custom" name="api_key_custom" class="text_pole flex1" value="" type="text" autocomplete="off">
3581 <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>3574 <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>
3582 </div>3575 </div>
3583 <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.">3576 <div data-for="api_key_custom" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
3584 For privacy reasons, your API key will be hidden after you reload the page.3577 For privacy reasons, your API key will be hidden after you click 'Connect'.
3585 </div>3578 </div>
3586 <h4 data-i18n="Enter a Model ID">Enter a Model ID</h4>3579 <h4 data-i18n="Enter a Model ID">Enter a Model ID</h4>
3587 <div class="flex-container">3580 <div class="flex-container">
@@ -3601,10 +3594,10 @@
3601 </h4>3594 </h4>
3602 <div class="flex-container">3595 <div class="flex-container">
3603 <input id="api_key_01ai" name="api_key_01ai" class="text_pole flex1" value="" type="text" autocomplete="off">3596 <input id="api_key_01ai" name="api_key_01ai" class="text_pole flex1" value="" type="text" autocomplete="off">
3604 <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>3597 <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>
3605 </div>3598 </div>
3606 <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.">3599 <div data-for="api_key_01ai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
3607 For privacy reasons, your API key will be hidden after you reload the page.3600 For privacy reasons, your API key will be hidden after you click 'Connect'.
3608 </div>3601 </div>
3609 <h4 data-i18n="01.AI Model">01.AI Model</h4>3602 <h4 data-i18n="01.AI Model">01.AI Model</h4>
3610 <select id="model_01ai_select">3603 <select id="model_01ai_select">
@@ -3618,10 +3611,10 @@
3618 </h4>3611 </h4>
3619 <div class="flex-container">3612 <div class="flex-container">
3620 <input id="api_key_xai" name="api_key_xai" class="text_pole flex1" value="" type="text" autocomplete="off">3613 <input id="api_key_xai" name="api_key_xai" class="text_pole flex1" value="" type="text" autocomplete="off">
3621 <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>3614 <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>
3622 </div>3615 </div>
3623 <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.">3616 <div data-for="api_key_xai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
3624 For privacy reasons, your API key will be hidden after you reload the page.3617 For privacy reasons, your API key will be hidden after you click 'Connect'.
3625 </div>3618 </div>
3626 <h4 data-i18n="xAI Model">xAI Model</h4>3619 <h4 data-i18n="xAI Model">xAI Model</h4>
3627 <select id="model_xai_select">3620 <select id="model_xai_select">
@@ -3687,7 +3680,9 @@
3687 <label for="auto-connect-checkbox" class="checkbox_label"><input id="auto-connect-checkbox" type="checkbox" />3680 <label for="auto-connect-checkbox" class="checkbox_label"><input id="auto-connect-checkbox" type="checkbox" />
3688 <span data-i18n="Auto-connect to Last Server">Auto-connect to Last Server</span>3681 <span data-i18n="Auto-connect to Last Server">Auto-connect to Last Server</span>
3689 </label>3682 </label>
3690 <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>3683 <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">
3684 <span data-i18n="View hidden API keys">View hidden API keys</span>
3685 </a>
3691 </div>3686 </div>
3692 </div>3687 </div>
3693 </div>3688 </div>
@@ -7471,6 +7466,9 @@
7471 </div>7466 </div>
7472 </div>7467 </div>
74737468
7469 <!-- Area for hidden data lists for secrets autosuggests -->
7470 <div id="secrets_datalists" class="displayNone"></div>
7471
7474 <!-- Script includes -->7472 <!-- Script includes -->
7475 <script src="lib/polyfill.js"></script>7473 <script src="lib/polyfill.js"></script>
7476 <script src="lib/jquery-3.5.1.min.js"></script>7474 <script src="lib/jquery-3.5.1.min.js"></script>
public/script.js+2 -0
@@ -208,6 +208,7 @@ import {
208} from './scripts/tags.js';208} from './scripts/tags.js';
209import {209import {
210 SECRET_KEYS,210 SECRET_KEYS,
211 initSecrets,
211 readSecretState,212 readSecretState,
212 secret_state,213 secret_state,
213 writeSecret,214 writeSecret,
@@ -975,6 +976,7 @@ async function firstLoadInit() {
975 reloadMarkdownProcessor();976 reloadMarkdownProcessor();
976 applyBrowserFixes();977 applyBrowserFixes();
977 await getClientVersion();978 await getClientVersion();
979 await initSecrets();
978 await readSecretState();980 await readSecretState();
979 await initLocales();981 await initLocales();
980 initChatUtilities();982 initChatUtilities();
public/scripts/extensions/connection-manager/index.js+13 -0
@@ -13,6 +13,7 @@ import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
13import { SlashCommandScope } from '../../slash-commands/SlashCommandScope.js';13import { SlashCommandScope } from '../../slash-commands/SlashCommandScope.js';
14import { collapseSpaces, getUniqueName, isFalseBoolean, uuidv4 } from '../../utils.js';14import { collapseSpaces, getUniqueName, isFalseBoolean, uuidv4 } from '../../utils.js';
15import { t } from '../../i18n.js';15import { t } from '../../i18n.js';
16import { getSecretLabelById } from '../../secrets.js';
1617
17const MODULE_NAME = 'connection-manager';18const MODULE_NAME = 'connection-manager';
18const NONE = '<None>';19const NONE = '<None>';
@@ -41,6 +42,7 @@ const CC_COMMANDS = [
41 'start-reply-with',42 'start-reply-with',
42 'reasoning-template',43 'reasoning-template',
43 'prompt-post-processing',44 'prompt-post-processing',
45 'secret-id',
44];46];
4547
46const TC_COMMANDS = [48const TC_COMMANDS = [
@@ -57,6 +59,7 @@ const TC_COMMANDS = [
57 'stop-strings',59 'stop-strings',
58 'start-reply-with',60 'start-reply-with',
59 'reasoning-template',61 'reasoning-template',
62 'secret-id',
60];63];
6164
62const FANCY_NAMES = {65const FANCY_NAMES = {
@@ -75,6 +78,7 @@ const FANCY_NAMES = {
75 'start-reply-with': 'Start Reply With',78 'start-reply-with': 'Start Reply With',
76 'reasoning-template': 'Reasoning Template',79 'reasoning-template': 'Reasoning Template',
77 'prompt-post-processing': 'Prompt Post-Processing',80 'prompt-post-processing': 'Prompt Post-Processing',
81 'secret-id': 'Secret',
78};82};
7983
80/**84/**
@@ -344,6 +348,15 @@ function makeFancyProfile(profile) {
344 return acc;348 return acc;
345 }349 }
346350
351 // UUID is not very useful in the UI, so we replace it with a label (if available)
352 if (key === 'secret-id') {
353 const label = getSecretLabelById(profile[key]);
354 if (label) {
355 acc[value] = label;
356 return acc;
357 }
358 }
359
347 acc[value] = profile[key];360 acc[value] = profile[key];
348 return acc;361 return acc;
349 }, {});362 }, {});
public/scripts/horde.js+8 -3
@@ -397,7 +397,8 @@ function getHordeModelTemplate(option) {
397export function initHorde() {397export function initHorde() {
398 $('#horde_model').on('mousedown change', async function (e) {398 $('#horde_model').on('mousedown change', async function (e) {
399 console.log('Horde model change', e);399 console.log('Horde model change', e);
400 horde_settings.models = $('#horde_model').val();400 const modelValue = $('#horde_model').val();
401 horde_settings.models = Array.isArray(modelValue) ? modelValue : [];
401 console.log('Updated Horde models', horde_settings.models);402 console.log('Updated Horde models', horde_settings.models);
402403
403 // Try select instruct preset404 // Try select instruct preset
@@ -429,8 +430,12 @@ export function initHorde () {
429 saveSettingsDebounced();430 saveSettingsDebounced();
430 });431 });
431432
432 $('#horde_api_key').on('input', async function () {433 $('#horde_api_key_button').on('click', async function () {
433 const key = String($(this).val()).trim();434 const key = String($('#horde_api_key').val()).trim();
435 if (!key) {
436 toastr.warning(t`Please enter your Horde API key`);
437 return;
438 }
434 await writeSecret(SECRET_KEYS.HORDE, key);439 await writeSecret(SECRET_KEYS.HORDE, key);
435 });440 });
436441
public/scripts/openai.js+8 -1
@@ -56,6 +56,7 @@ import {
56 getSortableDelay,56 getSortableDelay,
57 getStringHash,57 getStringHash,
58 isDataURL,58 isDataURL,
59 isUuid,
59 isValidUrl,60 isValidUrl,
60 parseJsonFile,61 parseJsonFile,
61 resetScrollHeight,62 resetScrollHeight,
@@ -5670,7 +5671,8 @@ async function onVertexAIValidateServiceAccount() {
5670 }5671 }
56715672
5672 // Save to backend secret storage5673 // Save to backend secret storage
5673 await writeSecret(SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT, jsonContent);5674 const keyLabel = serviceAccount['client_email'] || '';
5675 await writeSecret(SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT, jsonContent, keyLabel);
56745676
5675 // Show success status5677 // Show success status
5676 updateVertexAIServiceAccountStatus(true, `Project: ${serviceAccount.project_id}, Email: ${serviceAccount.client_email}`);5678 updateVertexAIServiceAccountStatus(true, `Project: ${serviceAccount.project_id}, Email: ${serviceAccount.client_email}`);
@@ -5704,6 +5706,11 @@ async function onVertexAIClearServiceAccount() {
5704function onVertexAIServiceAccountJsonChange() {5706function onVertexAIServiceAccountJsonChange() {
5705 const jsonContent = String($(this).val()).trim();5707 const jsonContent = String($(this).val()).trim();
57065708
5709 // Autocomplete has been triggered, don't validate if the input is a UUID
5710 if (isUuid(jsonContent)) {
5711 return;
5712 }
5713
5707 if (jsonContent) {5714 if (jsonContent) {
5708 // Auto-validate when content is pasted5715 // Auto-validate when content is pasted
5709 try {5716 try {
public/scripts/popup.js+9 -1
@@ -53,6 +53,7 @@ export const POPUP_RESULT = {
53 * @property {CustomPopupInput[]?} [customInputs=null] - Custom inputs to add to the popup. The display below the content and the input box, one by one.53 * @property {CustomPopupInput[]?} [customInputs=null] - Custom inputs to add to the popup. The display below the content and the input box, one by one.
54 * @property {(popup: Popup) => Promise<boolean?>|boolean?} [onClosing=null] - Handler called before the popup closes, return `false` to cancel the close54 * @property {(popup: Popup) => Promise<boolean?>|boolean?} [onClosing=null] - Handler called before the popup closes, return `false` to cancel the close
55 * @property {(popup: Popup) => Promise<void?>|void?} [onClose=null] - Handler called after the popup closes, but before the DOM is cleaned up55 * @property {(popup: Popup) => Promise<void?>|void?} [onClose=null] - Handler called after the popup closes, but before the DOM is cleaned up
56 * @property {(popup: Popup) => Promise<void?>|void?} [onOpen=null] - Handler called after the popup opens
56 * @property {number?} [cropAspect=null] - Aspect ratio for the crop popup57 * @property {number?} [cropAspect=null] - Aspect ratio for the crop popup
57 * @property {string?} [cropImage=null] - Image URL to display in the crop popup58 * @property {string?} [cropImage=null] - Image URL to display in the crop popup
58 */59 */
@@ -155,6 +156,7 @@ export class Popup {
155156
156 /** @type {(popup: Popup) => Promise<boolean?>|boolean?} */ onClosing;157 /** @type {(popup: Popup) => Promise<boolean?>|boolean?} */ onClosing;
157 /** @type {(popup: Popup) => Promise<void?>|void?} */ onClose;158 /** @type {(popup: Popup) => Promise<void?>|void?} */ onClose;
159 /** @type {(popup: Popup) => Promise<void?>|void?} */ onOpen;
158160
159 /** @type {POPUP_RESULT|number} */ result;161 /** @type {POPUP_RESULT|number} */ result;
160 /** @type {any} */ value;162 /** @type {any} */ value;
@@ -175,7 +177,7 @@ export class Popup {
175 * @param {string} [inputValue=''] - The initial value of the input field177 * @param {string} [inputValue=''] - The initial value of the input field
176 * @param {PopupOptions} [options={}] - Additional options for the popup178 * @param {PopupOptions} [options={}] - Additional options for the popup
177 */179 */
178 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 } = {}) {180 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 } = {}) {
179 Popup.util.popups.push(this);181 Popup.util.popups.push(this);
180182
181 // Make this popup uniquely identifiable183 // Make this popup uniquely identifiable
@@ -185,6 +187,7 @@ export class Popup {
185 // Utilize event handlers being passed in187 // Utilize event handlers being passed in
186 this.onClosing = onClosing;188 this.onClosing = onClosing;
187 this.onClose = onClose;189 this.onClose = onClose;
190 this.onOpen = onOpen;
188191
189 /**@type {HTMLTemplateElement}*/192 /**@type {HTMLTemplateElement}*/
190 const template = document.querySelector('#popup_template');193 const template = document.querySelector('#popup_template');
@@ -478,6 +481,11 @@ export class Popup {
478481
479 runAfterAnimation(this.dlg, () => {482 runAfterAnimation(this.dlg, () => {
480 this.dlg.removeAttribute('opening');483 this.dlg.removeAttribute('opening');
484
485 // If we have an onOpen handler, we run it now
486 if (this.onOpen) {
487 this.onOpen(this);
488 }
481 });489 });
482490
483 this.#promise = new Promise((resolve) => {491 this.#promise = new Promise((resolve) => {
public/scripts/secrets.js+807 -34
@@ -1,7 +1,18 @@
1import { DOMPurify } from '../lib.js';1import { DOMPurify, moment } from '../lib.js';
2import { getRequestHeaders } from '../script.js';2import { getRequestHeaders } from '../script.js';
3import { t } from './i18n.js';3import { t } from './i18n.js';
4import { chat_completion_sources } from './openai.js';
4import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js';5import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js';
6import { SlashCommand } from './slash-commands/SlashCommand.js';
7import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
8import { enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
9import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
10import { SlashCommandExecutor } from './slash-commands/SlashCommandExecutor.js';
11import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
12import { SlashCommandScope } from './slash-commands/SlashCommandScope.js';
13import { renderTemplateAsync } from './templates.js';
14import { textgen_types } from './textgen-settings.js';
15import { copyText, isTrueBoolean } from './utils.js';
516
6export const SECRET_KEYS = {17export const SECRET_KEYS = {
7 HORDE: 'api_key_horde',18 HORDE: 'api_key_horde',
@@ -48,6 +59,51 @@ export const SECRET_KEYS = {
48 VERTEXAI_SERVICE_ACCOUNT: 'vertexai_service_account_json',59 VERTEXAI_SERVICE_ACCOUNT: 'vertexai_service_account_json',
49};60};
5061
62const FRIENDLY_NAMES = {
63 [SECRET_KEYS.HORDE]: 'AI Horde',
64 [SECRET_KEYS.MANCER]: 'Mancer',
65 [SECRET_KEYS.OPENAI]: 'OpenAI',
66 [SECRET_KEYS.NOVEL]: 'NovelAI',
67 [SECRET_KEYS.CLAUDE]: 'Claude',
68 [SECRET_KEYS.OPENROUTER]: 'OpenRouter',
69 [SECRET_KEYS.SCALE]: 'Scale',
70 [SECRET_KEYS.AI21]: 'AI21',
71 [SECRET_KEYS.SCALE_COOKIE]: 'Scale (Cookie)',
72 [SECRET_KEYS.MAKERSUITE]: 'Google AI Studio',
73 [SECRET_KEYS.VERTEXAI]: 'Google Vertex AI (Express Mode)',
74 [SECRET_KEYS.VLLM]: 'vLLM',
75 [SECRET_KEYS.APHRODITE]: 'Aphrodite',
76 [SECRET_KEYS.TABBY]: 'TabbyAPI',
77 [SECRET_KEYS.MISTRALAI]: 'MistralAI',
78 [SECRET_KEYS.CUSTOM]: 'Custom (OpenAI-compatible)',
79 [SECRET_KEYS.TOGETHERAI]: 'TogetherAI',
80 [SECRET_KEYS.OOBA]: 'Text Generation WebUI',
81 [SECRET_KEYS.INFERMATICAI]: 'InfermaticAI',
82 [SECRET_KEYS.DREAMGEN]: 'DreamGen',
83 [SECRET_KEYS.NOMICAI]: 'NomicAI',
84 [SECRET_KEYS.KOBOLDCPP]: 'KoboldCpp',
85 [SECRET_KEYS.LLAMACPP]: 'llama.cpp',
86 [SECRET_KEYS.COHERE]: 'Cohere',
87 [SECRET_KEYS.PERPLEXITY]: 'Perplexity',
88 [SECRET_KEYS.GROQ]: 'Groq',
89 [SECRET_KEYS.FEATHERLESS]: 'Featherless',
90 [SECRET_KEYS.ZEROONEAI]: '01.AI',
91 [SECRET_KEYS.HUGGINGFACE]: 'HuggingFace',
92 [SECRET_KEYS.NANOGPT]: 'NanoGPT',
93 [SECRET_KEYS.GENERIC]: 'Generic (OpenAI-compatible)',
94 [SECRET_KEYS.DEEPSEEK]: 'DeepSeek',
95 [SECRET_KEYS.XAI]: 'xAI (Grok)',
96 [SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT]: 'Google Vertex AI (Service Account)',
97 [SECRET_KEYS.STABILITY]: 'Stability AI',
98 [SECRET_KEYS.CUSTOM_OPENAI_TTS]: 'Custom OpenAI TTS',
99 [SECRET_KEYS.TAVILY]: 'Tavily',
100 [SECRET_KEYS.BFL]: 'Black Forest Labs',
101 [SECRET_KEYS.SERPAPI]: 'SerpApi',
102 [SECRET_KEYS.SERPER]: 'Serper',
103 [SECRET_KEYS.FALAI]: 'FAL.AI',
104 [SECRET_KEYS.AZURE_TTS]: 'Azure TTS',
105};
106
51const INPUT_MAP = {107const INPUT_MAP = {
52 [SECRET_KEYS.HORDE]: '#horde_api_key',108 [SECRET_KEYS.HORDE]: '#horde_api_key',
53 [SECRET_KEYS.MANCER]: '#api_key_mancer',109 [SECRET_KEYS.MANCER]: '#api_key_mancer',
@@ -85,29 +141,101 @@ const INPUT_MAP = {
85 [SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT]: '#vertexai_service_account_json',141 [SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT]: '#vertexai_service_account_json',
86};142};
87143
88const STATIC_PLACEHOLDER_KEYS = [144const getLabel = () => moment().format('L LT');
89 SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT,
90];
91145
92async function clearSecret() {146/**
93 const key = $(this).data('key');147 * Resolves the secret key based on the selected API, chat completion source, and text completion type.
94 await writeSecret(key, '');148 * @returns {string|null} The secret key corresponding to the selected API, or null if no key is found.
95 secret_state[key] = false;149 */
96 updateSecretDisplay();150function resolveSecretKey() {
97 $(INPUT_MAP[key]).val('').trigger('input');151 const { mainApi, chatCompletionSettings, textCompletionSettings } = SillyTavern.getContext();
98 $('#main_api').trigger('change');152 const chatCompletionSource = chatCompletionSettings.chat_completion_source;
153 const textCompletionType = textCompletionSettings.type;
154
155 if (mainApi === 'koboldhorde') {
156 return SECRET_KEYS.HORDE;
99 }157 }
100158
101export function updateSecretDisplay() {159 if (mainApi === 'novel') {
102 for (const [secret_key, input_selector] of Object.entries(INPUT_MAP)) {160 return SECRET_KEYS.NOVEL;
103 if (STATIC_PLACEHOLDER_KEYS.includes(secret_key)) {161 }
162
163 if (mainApi === 'textgenerationwebui') {
164 const [key] = Object.entries(textgen_types).find(([, value]) => value === textCompletionType) ?? [null];
165 if (key && SECRET_KEYS[key]) {
166 return SECRET_KEYS[key];
167 }
168 }
169
170 if (mainApi === 'openai') {
171 if (chatCompletionSource === chat_completion_sources.VERTEXAI) {
172 switch (chatCompletionSettings.vertexai_auth_mode) {
173 case 'express':
174 return SECRET_KEYS.VERTEXAI;
175 case 'full':
176 return SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT;
177 }
178 }
179
180 if (chatCompletionSource === chat_completion_sources.SCALE) {
181 return chatCompletionSettings.use_alt_scale
182 ? SECRET_KEYS.SCALE_COOKIE
183 : SECRET_KEYS.SCALE;
184 }
185
186 const [key] = Object.entries(chat_completion_sources).find(([, value]) => value === chatCompletionSource) ?? [null];
187 if (key && SECRET_KEYS[key]) {
188 return SECRET_KEYS[key];
189 }
190 }
191
192 return null;
193}
194
195/**
196 * Gets the label of a secret by its ID.
197 * @param {string} id The ID of the secret to find.
198 * @returns {string} The label of the secret with the given ID, or an empty string if not found.
199 */
200export function getSecretLabelById(id) {
201 for (const key of Object.values(SECRET_KEYS)) {
202 const secrets = secret_state[key];
203 if (!Array.isArray(secrets)) {
104 continue;204 continue;
105 }205 }
106 const validSecret = !!secret_state[secret_key];206 const secret = secrets.find(s => s.id === id);
207 if (secret) {
208 return `${secret.label} (${secret.value})`;
209 }
210 }
211 return '';
212}
107213
214export function updateSecretDisplay() {
215 for (const [secret_key, input_selector] of Object.entries(INPUT_MAP)) {
216 const validSecret = !!secret_state[secret_key];
108 const placeholder = $('#viewSecrets').attr(validSecret ? 'key_saved_text' : 'missing_key_text');217 const placeholder = $('#viewSecrets').attr(validSecret ? 'key_saved_text' : 'missing_key_text');
109 $(input_selector).attr('placeholder', placeholder);218 const label = getActiveSecretLabel(secret_key);
219 const placeholderWithLabel = label ? `${placeholder} (${label})` : placeholder;
220 $(input_selector).attr('placeholder', placeholderWithLabel);
221 }
222}
223
224/**
225 * Gets the active secret label for a given key.
226 * @param {string} key Gets the active secret label for a given key.
227 * @returns {string} The label of the active secret, or '[No label]' if none is active.
228 */
229function getActiveSecretLabel(key) {
230 const selectedSecret = secret_state[key];
231 if (Array.isArray(selectedSecret)) {
232 const activeSecret = selectedSecret.find(x => x.active);
233 if (!activeSecret) {
234 return '';
235 }
236 return activeSecret.label || activeSecret.value || t`[No label]`;
110 }237 }
238 return '';
111}239}
112240
113async function viewSecrets() {241async function viewSecrets() {
@@ -125,7 +253,6 @@ async function viewSecrets() {
125 return;253 return;
126 }254 }
127255
128 $('#dialogue_popup').addClass('wide_dialogue_popup');
129 const data = await response.json();256 const data = await response.json();
130 const table = document.createElement('table');257 const table = document.createElement('table');
131 table.classList.add('responsiveTable');258 table.classList.add('responsiveTable');
@@ -138,29 +265,78 @@ async function viewSecrets() {
138 await callGenericPopup(table.outerHTML, POPUP_TYPE.TEXT, '', { wide: true, large: true, allowVerticalScrolling: true });265 await callGenericPopup(table.outerHTML, POPUP_TYPE.TEXT, '', { wide: true, large: true, allowVerticalScrolling: true });
139}266}
140267
268/**
269 * @type {import('../../src/endpoints/secrets.js').SecretStateMap}
270 */
141export let secret_state = {};271export let secret_state = {};
142272
143export async function writeSecret(key, value) {273/**
274 * Write a secret value to the server.
275 * @param {string} key Secret key
276 * @param {string} value Secret value to write
277 * @param {string} [label] (Optional) Label for the key. If not provided, generated automatically.
278 * @return {Promise<string?>} The ID of the newly created secret key, or null if no value is provided.
279 */
280export async function writeSecret(key, value, label) {
144 try {281 try {
282 if (!value) {
283 console.warn(`No value provided for ${key} in writeSecret, redirecting to deleteSecret`);
284 await deleteSecret(key);
285 return null;
286 }
287
288 if (!label) {
289 label = getLabel();
290 }
291
145 const response = await fetch('/api/secrets/write', {292 const response = await fetch('/api/secrets/write', {
146 method: 'POST',293 method: 'POST',
147 headers: getRequestHeaders(),294 headers: getRequestHeaders(),
148 body: JSON.stringify({ key, value }),295 body: JSON.stringify({ key, value, label }),
149 });296 });
150297
151 if (response.ok) {298 if (!response.ok) {
152 const text = await response.text();299 return null;
300 }
153301
154 if (text == 'ok') {302 const { id } = await response.json();
155 secret_state[key] = !!value;303 // Clear the input field
156 updateSecretDisplay();304 $(INPUT_MAP[key]).val('').trigger('input');
305 await readSecretState();
306 return id;
307 } catch (error) {
308 console.error(`Could not write secret value: ${key}`, error);
309 return null;
157 }310 }
158}311}
159 } catch {312
160 console.error('Could not write secret value: ', key);313/**
314 * Deletes a secret value from the server.
315 * @param {string} key Secret key
316 * @param {string} [id] (Optional) ID of the secret key to delete. If not provided, deletes an active key.
317 */
318export async function deleteSecret(key, id) {
319 try {
320 const response = await fetch('/api/secrets/delete', {
321 method: 'POST',
322 headers: getRequestHeaders(),
323 body: JSON.stringify({ key, id }),
324 });
325
326 if (response.ok) {
327 await readSecretState();
328 // Force reconnection to the API with the new key
329 $('#main_api').trigger('change');
330 }
331 } catch (error) {
332 console.error(`Could not delete secret value: ${key}`, error);
161 }333 }
162}334}
163335
336/**
337 * Reads the current state of secrets from the server.
338 * @returns {Promise<void>}
339 */
164export async function readSecretState() {340export async function readSecretState() {
165 try {341 try {
166 const response = await fetch('/api/secrets/read', {342 const response = await fetch('/api/secrets/read', {
@@ -171,6 +347,7 @@ export async function readSecretState() {
171 if (response.ok) {347 if (response.ok) {
172 secret_state = await response.json();348 secret_state = await response.json();
173 updateSecretDisplay();349 updateSecretDisplay();
350 updateInputDataLists();
174 await checkOpenRouterAuth();351 await checkOpenRouterAuth();
175 }352 }
176 } catch {353 } catch {
@@ -181,31 +358,87 @@ export async function readSecretState() {
181/**358/**
182 * Finds a secret value by key.359 * Finds a secret value by key.
183 * @param {string} key Secret key360 * @param {string} key Secret key
184 * @returns {Promise<string | undefined>} Secret value, or undefined if keys are not exposed361 * @param {string} [id] ID of the secret to find. If not provided, will return the active secret.
362 * @returns {Promise<string?>} Secret value, or null if keys are not exposed
185 */363 */
186export async function findSecret(key) {364export async function findSecret(key, id) {
187 try {365 try {
188 const response = await fetch('/api/secrets/find', {366 const response = await fetch('/api/secrets/find', {
189 method: 'POST',367 method: 'POST',
190 headers: getRequestHeaders(),368 headers: getRequestHeaders(),
191 body: JSON.stringify({ key }),369 body: JSON.stringify({ key, id }),
192 });370 });
193371
194 if (response.ok) {372 if (!response.ok) {
373 return null;
374 }
375
195 const data = await response.json();376 const data = await response.json();
196 return data.value;377 return data.value;
197 }
198 } catch {378 } catch {
199 console.error('Could not find secret value: ', key);379 console.error('Could not find secret value: ', key);
380 return null;
381 }
382}
383
384/**
385 * Changes the active value for a given secret key.
386 * @param {string} key Secret key to rotate
387 * @param {string} id ID of the secret to rotate
388 */
389export async function rotateSecret(key, id) {
390 try {
391 const response = await fetch('/api/secrets/rotate', {
392 method: 'POST',
393 headers: getRequestHeaders(),
394 body: JSON.stringify({ key, id }),
395 });
396
397 if (response.ok) {
398 await readSecretState();
399 // Force reconnection to the API with the new key
400 $('#main_api').trigger('change');
401 }
402 } catch (error) {
403 console.error(`Could not rotate secret value: ${key}`, error);
404 }
405}
406
407/**
408 * Renames a secret value on the server.
409 * @param {string} key Secret key to rename
410 * @param {string} id ID of the secret to rename
411 * @param {string} label Label to rename the secret to
412 */
413export async function renameSecret(key, id, label) {
414 try {
415 const response = await fetch('/api/secrets/rename', {
416 method: 'POST',
417 headers: getRequestHeaders(),
418 body: JSON.stringify({ key, id, label }),
419 });
420
421 if (response.ok) {
422 await readSecretState();
423 }
424 } catch (error) {
425 console.error(`Could not rename secret value: ${key}`, error);
200 }426 }
201}427}
202428
429/**
430 * Redirects the user to authorize OpenRouter.
431 */
203function authorizeOpenRouter() {432function authorizeOpenRouter() {
204 const redirectUrl = new URL('/callback/openrouter', window.location.origin);433 const redirectUrl = new URL('/callback/openrouter', window.location.origin);
205 const openRouterUrl = `https://openrouter.ai/auth?callback_url=${encodeURIComponent(redirectUrl.toString())}`;434 const openRouterUrl = `https://openrouter.ai/auth?callback_url=${encodeURIComponent(redirectUrl.toString())}`;
206 location.href = openRouterUrl;435 location.href = openRouterUrl;
207}436}
208437
438/**
439 * Checks if the OpenRouter authorization code is present in the URL, and if so, exchanges it for an API key.
440 * @returns {Promise<void>}
441 */
209async function checkOpenRouterAuth() {442async function checkOpenRouterAuth() {
210 const params = new URLSearchParams(location.search);443 const params = new URLSearchParams(location.search);
211 const source = params.get('source');444 const source = params.get('source');
@@ -245,14 +478,554 @@ async function checkOpenRouterAuth() {
245 }478 }
246}479}
247480
248jQuery(async () => {481/**
482 * Updates the input data lists for secret keys for autocomplete functionality.
483 */
484function updateInputDataLists() {
485 let container = document.getElementById('secrets_datalists');
486 if (!container) {
487 container = document.createElement('div');
488 container.id = 'secrets_datalists';
489 container.style.display = 'none';
490 document.body.appendChild(container);
491 }
492
493 for (const [key, inputSelector] of Object.entries(INPUT_MAP)) {
494 const inputElements = document.querySelectorAll(inputSelector);
495 if (inputElements.length === 0) {
496 console.warn(`No input elements found for key: ${key}`);
497 continue;
498 }
499
500 const dataListId = `${key}_datalist`;
501 let dataList = document.getElementById(dataListId);
502 if (!dataList) {
503 dataList = document.createElement('datalist');
504 dataList.id = dataListId;
505 container.appendChild(dataList);
506 }
507
508 // Clear existing options
509 dataList.innerHTML = '';
510
511 const secrets = secret_state[key];
512 if (!Array.isArray(secrets)) {
513 continue;
514 }
515
516 for (const secret of secrets) {
517 const option = document.createElement('option');
518 option.value = secret.id;
519 option.textContent = `${secret.label} (${secret.value})`;
520 dataList.appendChild(option);
521 }
522
523 // Set the input element to use the datalist
524 inputElements.forEach(element => {
525 element.setAttribute('list', dataListId);
526 });
527 }
528}
529
530/**
531 * Opens the key manager dialog for a specific key.
532 * @param {string} key Key for which to open the key manager dialog.
533 */
534async function openKeyManagerDialog(key) {
535 const name = FRIENDLY_NAMES[key] || key;
536 const template = $(await renderTemplateAsync('secretKeyManager', { name, key }));
537 template.find('button[data-action="add-secret"]').on('click', async function () {
538 let label = '';
539 const value = await Popup.show.input(t`Add Secret`, t`Enter the secret value:`, '', {
540 customInputs: [{
541 id: 'newSecretLabel',
542 type: 'text',
543 label: t`Enter a label for the secret (optional):`,
544 }],
545 onClose: popup => {
546 if (popup.result) {
547 label = popup.inputResults.get('newSecretLabel').toString().trim();
548 }
549 },
550 });
551 if (!value) {
552 return;
553 }
554 await writeSecret(key, value, label);
555 await renderSecretsList();
556 });
557
558 await renderSecretsList();
559 await callGenericPopup(template, POPUP_TYPE.TEXT, '', { wide: true, large: true, onOpen: scrollToActive });
560
561 async function renderSecretsList() {
562 const secrets = secret_state[key] ?? [];
563 const list = template.find('.secretKeyManagerList');
564 const previousScrollTop = list.scrollTop();
565
566 const emptyMessage = template.find('.secretKeyManagerListEmpty');
567 emptyMessage.toggle(secrets.length === 0);
568
569 const itemBlocks = [];
570 for (const secret of secrets) {
571 const itemTemplate = $(await renderTemplateAsync('secretKeyManagerListItem', secret));
572 itemTemplate.find('[data-action="copy-id"]').on('click', async function () {
573 await copyText(secret.id);
574 toastr.info(t`Secret ID copied to clipboard.`);
575 });
576 itemTemplate.find('button[data-action="rotate-secret"]').on('click', async function () {
577 await rotateSecret(key, secret.id);
578 await renderSecretsList();
579 });
580 itemTemplate.find('button[data-action="copy-secret"]').on('click', async function () {
581 const secretValue = await findSecret(key, secret.id);
582 if (secretValue === null) {
583 toastr.error(t`The key exposure might be disabled by the server config.`, t`Failed to copy secret value`);
584 return;
585 }
586 await copyText(secretValue);
587 toastr.info(t`Secret value copied to clipboard.`);
588 });
589 itemTemplate.find('button[data-action="rename-secret"]').on('click', async function () {
590 const label = await Popup.show.input(t`Rename Secret`, t`Enter new label for the secret:`, secret?.label || getLabel());
591 if (!label) {
592 return;
593 }
594 await renameSecret(key, secret.id, label);
595 await renderSecretsList();
596 });
597 itemTemplate.find('button[data-action="delete-secret"]').on('click', async function () {
598 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.`);
599 if (!confirm) {
600 return;
601 }
602 await deleteSecret(key, secret.id);
603 await renderSecretsList();
604 });
605 itemBlocks.push(itemTemplate);
606 }
607
608 list.empty().append(itemBlocks).scrollTop(previousScrollTop);
609 }
610
611 function scrollToActive() {
612 const list = template.find('.secretKeyManagerList');
613 const activeKey = list.find('.active');
614 if (activeKey.length > 0) {
615 const activeKeyScrollTop = activeKey.position().top + list.scrollTop() - list.height() / 2;
616 list.scrollTop(activeKeyScrollTop);
617 }
618 }
619}
620
621function registerSecretSlashCommands() {
622 const secretKeyEnumProvider = () => Object.values(SECRET_KEYS).map(key => new SlashCommandEnumValue(key, FRIENDLY_NAMES[key] || key, enumTypes.name, enumIcons.key));
623 const secretIdEnumProvider = (/** @type {SlashCommandExecutor} */ executor, /** @type {SlashCommandScope} */ _scope) => {
624 const key = executor?.namedArgumentList?.find(x => x.name === 'key')?.value?.toString() || resolveSecretKey();
625 if (!key || !secret_state[key] || !Array.isArray(secret_state[key]) || secret_state[key].length === 0) {
626 return [];
627 }
628
629 return secret_state[key].map(secret => {
630 return new SlashCommandEnumValue(secret.id, `${secret.label} (${secret.value})`, enumTypes.name, enumIcons.key);
631 });
632 };
633
634 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
635 name: 'secret-id',
636 aliases: ['secret-rotate'],
637 helpString: t`Sets the ID of a currently active secret key. Gets the ID of the secret key if no value is provided.`,
638 returns: t`The ID of the secret key that is now active.`,
639 namedArgumentList: [
640 SlashCommandNamedArgument.fromProps({
641 name: 'quiet',
642 description: t`Suppress toast message notifications.`,
643 isRequired: false,
644 defaultValue: String(false),
645 typeList: [ARGUMENT_TYPE.BOOLEAN],
646 }),
647 SlashCommandNamedArgument.fromProps({
648 name: 'key',
649 description: t`The key to get the secret ID for. If not provided, will use the currently active API secrets.`,
650 isRequired: false,
651 typeList: [ARGUMENT_TYPE.STRING],
652 enumProvider: secretKeyEnumProvider,
653 }),
654 ],
655 unnamedArgumentList: [
656 SlashCommandArgument.fromProps({
657 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.`,
658 isRequired: true,
659 typeList: [ARGUMENT_TYPE.STRING],
660 enumProvider: secretIdEnumProvider,
661 }),
662 ],
663 callback: async (args, value) => {
664 const quiet = isTrueBoolean(args?.quiet?.toString());
665 const id = value?.toString()?.trim();
666 const key = args?.key?.toString()?.trim() || resolveSecretKey();
667
668 if (!key) {
669 if (!quiet) {
670 toastr.error(t`No secret key provided, and the key can't be resolved for the currently selected API type.`);
671 }
672 return '';
673 }
674
675 const secrets = secret_state[key];
676 if (!Array.isArray(secrets) || secrets.length === 0) {
677 if (!quiet) {
678 toastr.error(t`No saved secrets found for the key: ${key}`);
679 }
680 return '';
681 }
682
683 if (!id) {
684 const activeSecret = secrets.find(s => s.active);
685 if (!activeSecret) {
686 if (!quiet) {
687 toastr.error(t`No active secret found for the key: ${key}`);
688 }
689 return '';
690 }
691 return activeSecret.id;
692 }
693
694 const savedSecret = secrets.find(s => s.id === id) ?? secrets.find(s => s.label === id);
695 if (!savedSecret) {
696 if (!quiet) {
697 toastr.error(t`No secret found with ID: ${id} for the key: ${key}`);
698 }
699 return '';
700 }
701
702 // Set the secret as active
703 await rotateSecret(key, savedSecret.id);
704 if (!quiet) {
705 toastr.success(t`Secret with ID: ${id} is now active for the key: ${key}`);
706 }
707
708 return savedSecret.id;
709 },
710 }));
711
712 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
713 name: 'secret-delete',
714 helpString: t`Deletes a secret key by ID.`,
715 namedArgumentList: [
716 SlashCommandNamedArgument.fromProps({
717 name: 'quiet',
718 description: t`Suppress toast message notifications.`,
719 isRequired: false,
720 defaultValue: String(false),
721 typeList: [ARGUMENT_TYPE.BOOLEAN],
722 }),
723 SlashCommandNamedArgument.fromProps({
724 name: 'key',
725 description: t`The key to delete the secret from. If not provided, will use the currently active API secrets.`,
726 isRequired: false,
727 typeList: [ARGUMENT_TYPE.STRING],
728 enumProvider: secretKeyEnumProvider,
729 }),
730 ],
731 unnamedArgumentList: [
732 SlashCommandArgument.fromProps({
733 description: t`The ID or a label of the secret key to delete. If not provided, will delete the active secret.`,
734 isRequired: true,
735 typeList: [ARGUMENT_TYPE.STRING],
736 enumProvider: secretIdEnumProvider,
737 }),
738 ],
739 callback: async (args, value) => {
740 const quiet = isTrueBoolean(args?.quiet?.toString());
741 const id = value?.toString()?.trim();
742 const key = args?.key?.toString()?.trim() || resolveSecretKey();
743
744 if (!key) {
745 if (!quiet) {
746 toastr.error(t`No secret key provided, and the key can't be resolved for the currently selected API type.`);
747 }
748 return '';
749 }
750
751 const secrets = secret_state[key];
752 if (!Array.isArray(secrets) || secrets.length === 0) {
753 if (!quiet) {
754 toastr.error(t`No saved secrets found for the key: ${key}`);
755 }
756 return '';
757 }
758
759 const savedSecret = secrets.find(s => s.id === id) ?? secrets.find(s => s.label === id) ?? secrets.find(s => s.active);
760 if (!savedSecret) {
761 if (!quiet) {
762 toastr.error(t`No secret found with ID: ${id} for the key: ${key}`);
763 }
764 return '';
765 }
766
767 // Delete the secret
768 await deleteSecret(key, savedSecret.id);
769 if (!quiet) {
770 toastr.success(t`Secret with ID: ${id} has been deleted for the key: ${key}`);
771 }
772
773 return savedSecret.id;
774 },
775 }));
776
777 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
778 name: 'secret-write',
779 helpString: t`Writes a secret key with a value and an optional label.`,
780 returns: t`The ID of the newly created secret key.`,
781 namedArgumentList: [
782 SlashCommandNamedArgument.fromProps({
783 name: 'quiet',
784 description: t`Suppress toast message notifications.`,
785 isRequired: false,
786 defaultValue: String(false),
787 typeList: [ARGUMENT_TYPE.BOOLEAN],
788 }),
789 SlashCommandNamedArgument.fromProps({
790 name: 'key',
791 description: t`The key to write the secret to. If not provided, will use the currently active API secrets.`,
792 isRequired: false,
793 typeList: [ARGUMENT_TYPE.STRING],
794 enumProvider: secretKeyEnumProvider,
795 }),
796 SlashCommandNamedArgument.fromProps({
797 name: 'label',
798 description: t`The label for the secret key. If not provided, will use the current date and time.`,
799 isRequired: false,
800 typeList: [ARGUMENT_TYPE.STRING],
801 }),
802 ],
803 unnamedArgumentList: [
804 SlashCommandArgument.fromProps({
805 description: t`The value of the secret key to write.`,
806 isRequired: true,
807 typeList: [ARGUMENT_TYPE.STRING],
808 }),
809 ],
810 callback: async (args, value) => {
811 const quiet = isTrueBoolean(args?.quiet?.toString());
812 const key = args?.key?.toString()?.trim() || resolveSecretKey();
813
814 if (!key) {
815 if (!quiet) {
816 toastr.error(t`No secret key provided, and the key can't be resolved for the currently selected API type.`);
817 }
818 return '';
819 }
820
821 const secrets = secret_state[key];
822 if (!Array.isArray(secrets) || secrets.length === 0) {
823 if (!quiet) {
824 toastr.error(t`No saved secrets found for the key: ${key}`);
825 }
826 return '';
827 }
828
829 const valueStr = value?.toString()?.trim();
830 if (!valueStr) {
831 if (!quiet) {
832 toastr.error(t`No value provided for the secret key: ${key}`);
833 }
834 return '';
835 }
836
837 const label = args?.label?.toString()?.trim() || getLabel();
838 const id = await writeSecret(key, valueStr, label);
839
840 if (!quiet) {
841 toastr.success(t`Secret has been written for the key: ${key}`);
842 }
843
844 return id || '';
845 },
846 }));
847
848 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
849 name: 'secret-rename',
850 helpString: t`Renames a secret key by ID.`,
851 namedArgumentList: [
852 SlashCommandNamedArgument.fromProps({
853 name: 'quiet',
854 description: t`Suppress toast message notifications.`,
855 isRequired: false,
856 defaultValue: String(false),
857 typeList: [ARGUMENT_TYPE.BOOLEAN],
858 }),
859 SlashCommandNamedArgument.fromProps({
860 name: 'key',
861 description: t`The key to rename the secret in. If not provided, will use the currently active API secrets.`,
862 isRequired: false,
863 typeList: [ARGUMENT_TYPE.STRING],
864 enumProvider: secretKeyEnumProvider,
865 }),
866 SlashCommandNamedArgument.fromProps({
867 name: 'id',
868 description: t`The ID of the secret to rename. If not provided, will rename the active secret.`,
869 isRequired: true,
870 typeList: [ARGUMENT_TYPE.STRING],
871 }),
872 ],
873 unnamedArgumentList: [
874 SlashCommandArgument.fromProps({
875 description: t`The new label for the secret key.`,
876 isRequired: true,
877 typeList: [ARGUMENT_TYPE.STRING],
878 }),
879 ],
880 callback: async (args, value) => {
881 const quiet = isTrueBoolean(args?.quiet?.toString());
882 const key = args?.key?.toString()?.trim() || resolveSecretKey();
883 const id = args?.id?.toString()?.trim();
884
885 if (!key) {
886 if (!quiet) {
887 toastr.error(t`No secret key provided, and the key can't be resolved for the currently selected API type.`);
888 }
889 return '';
890 }
891
892 const secrets = secret_state[key];
893 if (!Array.isArray(secrets) || secrets.length === 0) {
894 if (!quiet) {
895 toastr.error(t`No saved secrets found for the key: ${key}`);
896 }
897 return '';
898 }
899
900 const newLabel = value?.toString()?.trim();
901 if (!newLabel) {
902 if (!quiet) {
903 toastr.error(t`No new label provided for the secret key: ${key}`);
904 }
905 return '';
906 }
907
908 const savedSecret = secrets.find(s => s.id === id) ?? secrets.find(s => s.label === id) ?? secrets.find(s => s.active);
909 if (!savedSecret) {
910 if (!quiet) {
911 toastr.error(t`No secret found with ID: ${id} for the key: ${key}`);
912 }
913 return '';
914 }
915
916 // Rename the secret
917 await renameSecret(key, savedSecret.id, newLabel);
918 if (!quiet) {
919 toastr.success(t`Secret with ID: ${id} has been renamed to "${newLabel}" for the key: ${key}`);
920 }
921
922 return savedSecret.id;
923 },
924 }));
925
926 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
927 name: 'secret-read',
928 aliases: ['secret-find', 'secret-get'],
929 helpString: t`Reads a secret key by ID. If key exposure is disabled, this command will not work!`,
930 returns: t`The value of the secret key.`,
931 namedArgumentList: [
932 SlashCommandNamedArgument.fromProps({
933 name: 'quiet',
934 description: t`Suppress toast message notifications.`,
935 isRequired: false,
936 defaultValue: String(false),
937 typeList: [ARGUMENT_TYPE.BOOLEAN],
938 }),
939 SlashCommandNamedArgument.fromProps({
940 name: 'key',
941 description: t`The key to read the secret from. If not provided, will use the currently active API secrets.`,
942 isRequired: false,
943 typeList: [ARGUMENT_TYPE.STRING],
944 enumProvider: secretKeyEnumProvider,
945 }),
946 ],
947 unnamedArgumentList: [
948 SlashCommandArgument.fromProps({
949 description: t`The ID or a label of the secret key to read. If not provided, will return the currently active secret value.`,
950 isRequired: true,
951 typeList: [ARGUMENT_TYPE.STRING],
952 enumProvider: secretIdEnumProvider,
953 }),
954 ],
955 callback: async (args, value) => {
956 const quiet = isTrueBoolean(args?.quiet?.toString());
957 const key = args?.key?.toString()?.trim() || resolveSecretKey();
958 const id = value?.toString()?.trim();
959
960 if (!key) {
961 if (!quiet) {
962 toastr.error(t`No secret key provided, and the key can't be resolved for the currently selected API type.`);
963 }
964 return '';
965 }
966
967 const secrets = secret_state[key];
968 if (!Array.isArray(secrets) || secrets.length === 0) {
969 if (!quiet) {
970 toastr.error(t`No saved secrets found for the key: ${key}`);
971 }
972 return '';
973 }
974
975 const savedSecret = secrets.find(s => s.id === id) ?? secrets.find(s => s.label === id) ?? secrets.find(s => s.active);
976 if (!savedSecret) {
977 if (!quiet) {
978 toastr.error(t`No secret found with ID: ${id} for the key: ${key}`);
979 }
980 return '';
981 }
982
983 const secretValue = await findSecret(key, savedSecret.id);
984 if (secretValue === null) {
985 if (!quiet) {
986 toastr.error(t`Could not retrieve the secret value for key: ${key}. Key exposure might be disabled.`);
987 }
988 return '';
989 }
990
991 return secretValue;
992 },
993 }));
994}
995
996export async function initSecrets() {
249 $('#viewSecrets').on('click', viewSecrets);997 $('#viewSecrets').on('click', viewSecrets);
250 $(document).on('click', '.clear-api-key', clearSecret);998 $(document).on('click', '.manage-api-keys', async function () {
999 const key = $(this).data('key');
1000 if (!key || !Object.values(SECRET_KEYS).includes(key)) {
1001 console.error('Invalid key for manage-api-keys:', key);
1002 return;
1003 }
1004 await openKeyManagerDialog(key);
1005 });
251 $(document).on('input', Object.values(INPUT_MAP).join(','), function () {1006 $(document).on('input', Object.values(INPUT_MAP).join(','), function () {
252 const id = $(this).attr('id');1007 const id = $(this).attr('id');
253 const value = $(this).val();1008 const value = $(this).val();
1009
1010 // Find the key based on the entered value
1011 for (const [key, inputSelector] of Object.entries(INPUT_MAP)) {
1012 if (!value || !this.matches(inputSelector)) {
1013 continue;
1014 }
1015 const secrets = secret_state[key];
1016 if (!Array.isArray(secrets)) {
1017 continue;
1018 }
1019 const secretMatch = secrets.find(secret => secret.id === value);
1020 if (secretMatch) {
1021 $(this).val('');
1022 return rotateSecret(key, secretMatch.id);
1023 }
1024 }
1025
254 const warningElement = $(`[data-for="${id}"]`);1026 const warningElement = $(`[data-for="${id}"]`);
255 warningElement.toggle(value.length > 0);1027 warningElement.toggle(value.length > 0);
256 });1028 });
257 $('.openrouter_authorize').on('click', authorizeOpenRouter);1029 $('.openrouter_authorize').on('click', authorizeOpenRouter);
258});1030 registerSecretSlashCommands();
1031}
public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js+1 -0
@@ -40,6 +40,7 @@ export const enumIcons = {
40 server: '🖥️',40 server: '🖥️',
41 popup: '🗔',41 popup: '🗔',
42 image: '🖼️',42 image: '🖼️',
43 key: '🔑',
4344
44 true: '✔️',45 true: '✔️',
45 false: '❌',46 false: '❌',
public/scripts/templates/secretKeyManager.html+27 -0
@@ -0,0 +1,27 @@
1<div class="secretKeyManager">
2 <div class="secretKeyManagerHeader">
3 <div class="secretKeyManagerSubtitle">
4 <div class="secretKeyManagerInfo">
5 <div class="flex-container">
6 <div data-i18n="API:">API:</div>
7 <span>{{name}}</span>
8 </div>
9 <div class="flex-container">
10 <div data-i18n="Key:">Key:</div>
11 <code>{{key}}</code>
12 </div>
13 </div>
14 <div class="secretKeyManagerActions">
15 <button class="menu_button menu_button_icon" data-action="add-secret">
16 <i class="fa-solid fa-plus"></i>
17 <span data-i18n="Add Secret">Add Secret</span>
18 </button>
19 </div>
20 </div>
21 </div>
22 <hr>
23 <div class="secretKeyManagerList"></div>
24 <div class="secretKeyManagerListEmpty">
25 <span data-i18n="No secrets saved.">No secrets saved.</span>
26 </div>
27</div>
public/scripts/templates/secretKeyManagerListItem.html+30 -0
@@ -0,0 +1,30 @@
1<div class="secretKeyManagerItem {{#if active}}active{{/if}}">
2 <div class="secretKeyManagerItemInfo">
3 <div class="secretKeyManagerItemHeader">
4 <strong>{{label}}</strong>
5 <small>{{value}}</small>
6 </div>
7 <div class="secretKeyManagerItemSubtitle">
8 <strong>ID:</strong>
9 <span class="secretKeyManagerItemId" data-action="copy-id" title="Copy ID" data-i18n="[title]Copy ID">{{id}}</span>
10 </div>
11 </div>
12 <div class="secretKeyManagerItemActions">
13 <div class="secretKeyManagerItemActionsRow">
14 <button class="menu_button menu_button_icon {{#if active}}disabled{{/if}}" data-action="rotate-secret" data-id="{{id}}" title="Select" data-i18n="[title]Select">
15 <i class="fa-fw fa-solid fa-check"></i>
16 </button>
17 <button class="menu_button menu_button_icon" data-action="copy-secret" data-id="{{id}}" title="Copy" data-i18n="[title]Copy">
18 <i class="fa-fw fa-solid fa-copy"></i>
19 </button>
20 </div>
21 <div class="secretKeyManagerItemActionsRow">
22 <button class="menu_button menu_button_icon" data-action="rename-secret" data-id="{{id}}" title="Rename" data-i18n="[title]Rename">
23 <i class="fa-fw fa-solid fa-pen-to-square"></i>
24 </button>
25 <button class="menu_button menu_button_icon" data-action="delete-secret" data-id="{{id}}" title="Delete" data-i18n="[title]Delete">
26 <i class="fa-fw fa-solid fa-trash"></i>
27 </button>
28 </div>
29 </div>
30</div>
public/scripts/utils.js+11 -0
@@ -126,6 +126,17 @@ export function isValidUrl(value) {
126}126}
127127
128/**128/**
129 * Checks if a string is a valid UUID (version 1-5).
130 * @param {string} value String to check
131 * @returns {boolean} True if the string is a valid UUID, false otherwise.
132 */
133export function isUuid(value) {
134 // Regular expression to match UUIDs
135 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;
136 return uuidRegex.test(value);
137}
138
139/**
129 * Converts string to a value of a given type. Includes pythonista-friendly aliases.140 * Converts string to a value of a given type. Includes pythonista-friendly aliases.
130 * @param {string|SlashCommandClosure} value String value141 * @param {string|SlashCommandClosure} value String value
131 * @param {string} type Type to convert to142 * @param {string} type Type to convert to
public/style.css+1 -0
@@ -12,6 +12,7 @@
12@import url(css/scrollable-button.css);12@import url(css/scrollable-button.css);
13@import url(css/welcome.css);13@import url(css/welcome.css);
14@import url(css/data-maid.css);14@import url(css/data-maid.css);
15@import url(css/secrets.css);
1516
16:root {17:root {
17 interpolate-size: allow-keywords;18 interpolate-size: allow-keywords;
src/endpoints/secrets.js+461 -67
@@ -3,10 +3,11 @@ import path from 'node:path';
33
4import express from 'express';4import express from 'express';
5import { sync as writeFileAtomicSync } from 'write-file-atomic';5import { sync as writeFileAtomicSync } from 'write-file-atomic';
6import { getConfigValue } from '../util.js';6import { color, getConfigValue, uuidv4 } from '../util.js';
77
8export const SECRETS_FILE = 'secrets.json';8export const SECRETS_FILE = 'secrets.json';
9export const SECRET_KEYS = {9export const SECRET_KEYS = {
10 _MIGRATED: '_migrated',
10 HORDE: 'api_key_horde',11 HORDE: 'api_key_horde',
11 MANCER: 'api_key_mancer',12 MANCER: 'api_key_mancer',
12 VLLM: 'api_key_vllm',13 VLLM: 'api_key_vllm',
@@ -57,6 +58,31 @@ export const SECRET_KEYS = {
57 VERTEXAI_SERVICE_ACCOUNT: 'vertexai_service_account_json',58 VERTEXAI_SERVICE_ACCOUNT: 'vertexai_service_account_json',
58};59};
5960
61/**
62 * @typedef {object} SecretValue
63 * @property {string} id The unique identifier for the secret
64 * @property {string} value The secret value
65 * @property {string} label The label for the secret
66 * @property {boolean} active Whether the secret is currently active
67 */
68
69/**
70 * @typedef {object} SecretState
71 * @property {string} id The unique identifier for the secret
72 * @property {string} value The secret value, masked for security
73 * @property {string} label The label for the secret
74 * @property {boolean} active Whether the secret is currently active
75 */
76
77/**
78 * @typedef {Record<string, SecretState[]|null>} SecretStateMap
79 */
80
81/**
82 * @typedef {{[key: string]: SecretValue[]}} SecretKeys
83 * @typedef {{[key: string]: string}} FlatSecretKeys
84 */
85
60// These are the keys that are safe to expose, even if allowKeysExposure is false86// These are the keys that are safe to expose, even if allowKeysExposure is false
61const EXPORTABLE_KEYS = [87const EXPORTABLE_KEYS = [
62 SECRET_KEYS.LIBRE_URL,88 SECRET_KEYS.LIBRE_URL,
@@ -65,133 +91,443 @@ const EXPORTABLE_KEYS = [
65 SECRET_KEYS.DEEPLX_URL,91 SECRET_KEYS.DEEPLX_URL,
66];92];
6793
94const allowKeysExposure = !!getConfigValue('allowKeysExposure', false, 'boolean');
95
96/**
97 * SecretManager class to handle all secret operations
98 */
99export class SecretManager {
100 /**
101 * @param {import('../users.js').UserDirectoryList} directories
102 */
103 constructor(directories) {
104 this.directories = directories;
105 this.filePath = path.join(directories.root, SECRETS_FILE);
106 this.defaultSecrets = {};
107 }
108
109 /**
110 * Ensures the secrets file exists, creating an empty one if necessary
111 * @private
112 */
113 _ensureSecretsFile() {
114 if (!fs.existsSync(this.filePath)) {
115 writeFileAtomicSync(this.filePath, JSON.stringify(this.defaultSecrets), 'utf-8');
116 }
117 }
118
119 /**
120 * Reads and parses the secrets file
121 * @private
122 * @returns {SecretKeys}
123 */
124 _readSecretsFile() {
125 this._ensureSecretsFile();
126 const fileContents = fs.readFileSync(this.filePath, 'utf-8');
127 return /** @type {SecretKeys} */ (JSON.parse(fileContents));
128 }
129
130 /**
131 * Writes secrets to the file atomically
132 * @private
133 * @param {SecretKeys} secrets
134 */
135 _writeSecretsFile(secrets) {
136 writeFileAtomicSync(this.filePath, JSON.stringify(secrets, null, 4), 'utf-8');
137 }
138
139 /**
140 * Deactivates all secrets for a given key
141 * @private
142 * @param {SecretValue[]} secretArray
143 */
144 _deactivateAllSecrets(secretArray) {
145 secretArray.forEach(secret => {
146 secret.active = false;
147 });
148 }
149
150 /**
151 * Validates that the secret key exists and has valid structure
152 * @private
153 * @param {SecretKeys} secrets
154 * @param {string} key
155 * @returns {boolean}
156 */
157 _validateSecretKey(secrets, key) {
158 return Object.hasOwn(secrets, key) && Array.isArray(secrets[key]);
159 }
160
161 /**
162 * Masks a secret value with asterisks in the middle
163 * @param {string} value The secret value to mask
164 * @returns {string} A masked version of the value for peeking
165 */
166 getMaskedValue(value) {
167 // No masking if exposure is allowed
168 if (allowKeysExposure) {
169 return value;
170 }
171 const threshold = 10;
172 const exposedChars = 3;
173 const placeholder = '*';
174 if (value.length <= threshold) {
175 return placeholder.repeat(threshold);
176 }
177 const visibleEnd = value.slice(-exposedChars);
178 const maskedMiddle = placeholder.repeat(threshold - exposedChars);
179 return `${maskedMiddle}${visibleEnd}`;
180 }
181
68 /**182 /**
69 * Writes a secret to the secrets file183 * Writes a secret to the secrets file
70 * @param {import('../users.js').UserDirectoryList} directories User directories
71 * @param {string} key Secret key184 * @param {string} key Secret key
72 * @param {string} value Secret value185 * @param {string} value Secret value
186 * @param {string} label Label for the secret
187 * @returns {string} The ID of the newly created secret
73 */188 */
74export function writeSecret(directories, key, value) {189 writeSecret(key, value, label = 'Unlabeled') {
75 const filePath = path.join(directories.root, SECRETS_FILE);190 const secrets = this._readSecretsFile();
76191
77 if (!fs.existsSync(filePath)) {192 if (!Array.isArray(secrets[key])) {
78 const emptyFile = JSON.stringify({});193 secrets[key] = [];
79 writeFileAtomicSync(filePath, emptyFile, 'utf-8');
80 }194 }
81195
82 const fileContents = fs.readFileSync(filePath, 'utf-8');196 this._deactivateAllSecrets(secrets[key]);
83 const secrets = JSON.parse(fileContents);197
84 secrets[key] = value;198 const secret = {
85 writeFileAtomicSync(filePath, JSON.stringify(secrets, null, 4), 'utf-8');199 id: uuidv4(),
200 value: value,
201 label: label,
202 active: true,
203 };
204 secrets[key].push(secret);
205
206 this._writeSecretsFile(secrets);
207 return secret.id;
86 }208 }
87209
88 /**210 /**
89 * Deletes a secret from the secrets file211 * Deletes a secret from the secrets file by its ID
90 * @param {import('../users.js').UserDirectoryList} directories User directories
91 * @param {string} key Secret key212 * @param {string} key Secret key
92 * @returns213 * @param {string?} id Secret ID to delete
93 */214 */
94export function deleteSecret(directories, key) {215 deleteSecret(key, id) {
95 const filePath = path.join(directories.root, SECRETS_FILE);216 if (!fs.existsSync(this.filePath)) {
217 return;
218 }
219
220 const secrets = this._readSecretsFile();
96221
97 if (!fs.existsSync(filePath)) {222 if (!this._validateSecretKey(secrets, key)) {
98 return;223 return;
99 }224 }
100225
101 const fileContents = fs.readFileSync(filePath, 'utf-8');226 const secretArray = secrets[key];
102 const secrets = JSON.parse(fileContents);227 const targetIndex = secretArray.findIndex(s => id ? s.id === id : s.active);
228
229 // Delete the secret if found
230 if (targetIndex !== -1) {
231 secretArray.splice(targetIndex, 1);
232 }
233
234 // Reactivate the first secret if none are active
235 if (secretArray.length && !secretArray.some(s => s.active)) {
236 secretArray[0].active = true;
237 }
238
239 // Remove the key if no secrets left
240 if (secretArray.length === 0) {
103 delete secrets[key];241 delete secrets[key];
104 writeFileAtomicSync(filePath, JSON.stringify(secrets, null, 4), 'utf-8');242 }
243
244 this._writeSecretsFile(secrets);
105 }245 }
106246
107 /**247 /**
108 * Reads a secret from the secrets file248 * Reads the active secret value for a given key
109 * @param {import('../users.js').UserDirectoryList} directories User directories
110 * @param {string} key Secret key249 * @param {string} key Secret key
111 * @returns {string} Secret value250 * @param {string?} id ID of the secret to read (optional)
251 * @returns {string} Secret value or empty string if not found
112 */252 */
113export function readSecret(directories, key) {253 readSecret(key, id) {
114 const filePath = path.join(directories.root, SECRETS_FILE);254 if (!fs.existsSync(this.filePath)) {
255 return '';
256 }
257
258 const secrets = this._readSecretsFile();
259 const secretArray = secrets[key];
260
261 if (Array.isArray(secretArray) && secretArray.length > 0) {
262 const activeSecret = secretArray.find(s => id ? s.id === id : s.active);
263 return activeSecret?.value || '';
264 }
115265
116 if (!fs.existsSync(filePath)) {
117 return '';266 return '';
118 }267 }
119268
120 const fileContents = fs.readFileSync(filePath, 'utf-8');269 /**
121 const secrets = JSON.parse(fileContents);270 * Activates a specific secret by ID for a given key
122 return secrets[key];271 * @param {string} key Secret key to rotate
272 * @param {string} id ID of the secret to activate
273 */
274 rotateSecret(key, id) {
275 if (!fs.existsSync(this.filePath)) {
276 return;
277 }
278
279 const secrets = this._readSecretsFile();
280
281 if (!this._validateSecretKey(secrets, key)) {
282 return;
283 }
284
285 const secretArray = secrets[key];
286 const targetIndex = secretArray.findIndex(s => s.id === id);
287
288 if (targetIndex === -1) {
289 console.warn(`Secret with ID ${id} not found for key ${key}`);
290 return;
291 }
292
293 this._deactivateAllSecrets(secretArray);
294 secretArray[targetIndex].active = true;
295
296 this._writeSecretsFile(secrets);
123 }297 }
124298
125 /**299 /**
126 * Reads the secret state from the secrets file300 * Renames a secret by its ID
127 * @param {import('../users.js').UserDirectoryList} directories User directories301 * @param {string} key Secret key to rename
128 * @returns {object} Secret state302 * @param {string} id ID of the secret to rename
303 * @param {string} label New label for the secret
129 */304 */
130export function readSecretState(directories) {305 renameSecret(key, id, label) {
131 const filePath = path.join(directories.root, SECRETS_FILE);306 const secrets = this._readSecretsFile();
307
308 if (!this._validateSecretKey(secrets, key)) {
309 return;
310 }
132311
133 if (!fs.existsSync(filePath)) {312 const secretArray = secrets[key];
134 return {};313 const targetIndex = secretArray.findIndex(s => s.id === id);
314
315 if (targetIndex === -1) {
316 console.warn(`Secret with ID ${id} not found for key ${key}`);
317 return;
318 }
319
320 secretArray[targetIndex].label = label;
321 this._writeSecretsFile(secrets);
135 }322 }
136323
137 const fileContents = fs.readFileSync(filePath, 'utf8');324 /**
138 const secrets = JSON.parse(fileContents);325 * Gets the state of all secrets (whether they exist or not)
326 * @returns {SecretStateMap} Secret state
327 */
328 getSecretState() {
329 const secrets = this._readSecretsFile();
330 /** @type {SecretStateMap} */
139 const state = {};331 const state = {};
140332
141 for (const key of Object.values(SECRET_KEYS)) {333 for (const key of Object.values(SECRET_KEYS)) {
142 state[key] = !!secrets[key]; // convert to boolean334 // Skip migration marker
335 if (key === SECRET_KEYS._MIGRATED) {
336 continue;
337 }
338 const value = secrets[key];
339 if (value && Array.isArray(value) && value.length > 0) {
340 state[key] = value.map(secret => ({
341 id: secret.id,
342 value: this.getMaskedValue(secret.value),
343 label: secret.label,
344 active: secret.active,
345 }));
346 } else {
347 // No secrets for this key
348 state[key] = null;
349 }
143 }350 }
144351
145 return state;352 return state;
146 }353 }
147354
148 /**355 /**
356 * Gets all secrets (for admin viewing)
357 * @returns {SecretKeys} All secrets
358 */
359 getAllSecrets() {
360 return this._readSecretsFile();
361 }
362
363 /**
364 * Migrates legacy flat secrets format to new format
365 */
366 migrateFlatSecrets() {
367 if (!fs.existsSync(this.filePath)) {
368 return;
369 }
370
371 const fileContents = fs.readFileSync(this.filePath, 'utf8');
372 const secrets = /** @type {FlatSecretKeys} */ (JSON.parse(fileContents));
373 const values = Object.values(secrets);
374
375 // Check if already migrated
376 if (secrets[SECRET_KEYS._MIGRATED] || values.length === 0 || values.some(v => Array.isArray(v))) {
377 return;
378 }
379
380 /** @type {SecretKeys} */
381 const migratedSecrets = {};
382
383 for (const [key, value] of Object.entries(secrets)) {
384 if (typeof value === 'string' && value.trim()) {
385 migratedSecrets[key] = [{
386 id: uuidv4(),
387 value: value,
388 label: key,
389 active: true,
390 }];
391 }
392 }
393
394 // Mark as migrated
395 migratedSecrets[SECRET_KEYS._MIGRATED] = [];
396
397 // Save backup of the old secrets file
398 const backupFilePath = path.join(this.directories.backups, `secrets_migration_${Date.now()}.json`);
399 fs.cpSync(this.filePath, backupFilePath);
400
401 this._writeSecretsFile(migratedSecrets);
402 console.info(color.green('Secrets migrated successfully, old secrets backed up to:'), backupFilePath);
403 }
404}
405
406//#region Backwards compatibility
407/**
408 * Writes a secret to the secrets file
409 * @param {import('../users.js').UserDirectoryList} directories User directories
410 * @param {string} key Secret key
411 * @param {string} value Secret value
412 */
413export function writeSecret(directories, key, value) {
414 return new SecretManager(directories).writeSecret(key, value);
415}
416
417/**
418 * Deletes a secret from the secrets file
419 * @param {import('../users.js').UserDirectoryList} directories User directories
420 * @param {string} key Secret key
421 */
422export function deleteSecret(directories, key) {
423 return new SecretManager(directories).deleteSecret(key, null);
424}
425
426/**
427 * Reads a secret from the secrets file
428 * @param {import('../users.js').UserDirectoryList} directories User directories
429 * @param {string} key Secret key
430 * @returns {string} Secret value
431 */
432export function readSecret(directories, key) {
433 return new SecretManager(directories).readSecret(key, null);
434}
435
436/**
437 * Reads the secret state from the secrets file
438 * @param {import('../users.js').UserDirectoryList} directories User directories
439 * @returns {Record<string, boolean>} Secret state
440 */
441export function readSecretState(directories) {
442 const state = new SecretManager(directories).getSecretState();
443 const result = /** @type {Record<string, boolean>} */ ({});
444 for (const key of Object.values(SECRET_KEYS)) {
445 // Skip migration marker
446 if (key === SECRET_KEYS._MIGRATED) {
447 continue;
448 }
449 result[key] = Array.isArray(state[key]) && state[key].length > 0;
450 }
451 return result;
452}
453
454/**
149 * Reads all secrets from the secrets file455 * Reads all secrets from the secrets file
150 * @param {import('../users.js').UserDirectoryList} directories User directories456 * @param {import('../users.js').UserDirectoryList} directories User directories
151 * @returns {Record<string, string> | undefined} Secrets457 * @returns {Record<string, string>} Secrets
152 */458 */
153export function getAllSecrets(directories) {459export function getAllSecrets(directories) {
154 const filePath = path.join(directories.root, SECRETS_FILE);460 const secrets = new SecretManager(directories).getAllSecrets();
155461 const result = /** @type {Record<string, string>} */ ({});
156 if (!fs.existsSync(filePath)) {462 for (const [key, values] of Object.entries(secrets)) {
157 console.error('Secrets file does not exist');463 // Skip migration marker
158 return undefined;464 if (key === SECRET_KEYS._MIGRATED) {
465 continue;
466 }
467 if (Array.isArray(values) && values.length > 0) {
468 const activeSecret = values.find(secret => secret.active);
469 if (activeSecret) {
470 result[key] = activeSecret.value;
159 }471 }
472 }
473 }
474 return result;
475}
476//#endregion
160477
161 const fileContents = fs.readFileSync(filePath, 'utf8');478/**
162 const secrets = JSON.parse(fileContents);479 * Migrates legacy flat secrets format to the new format for all user directories
163 return secrets;480 * @param {import('../users.js').UserDirectoryList[]} directoriesList User directories
481 */
482export function migrateFlatSecrets(directoriesList) {
483 for (const directories of directoriesList) {
484 try {
485 const manager = new SecretManager(directories);
486 manager.migrateFlatSecrets();
487 } catch (error) {
488 console.warn(color.red(`Failed to migrate secrets for ${directories.root}:`), error);
489 }
490 }
164}491}
165492
166export const router = express.Router();493export const router = express.Router();
167494
168router.post('/write', (request, response) => {495router.post('/write', (request, response) => {
169 const key = request.body.key;496 try {
170 const value = request.body.value;497 const { key, value, label } = request.body;
171498
172 writeSecret(request.user.directories, key, value);499 if (!key || typeof value !== 'string') {
173 return response.send('ok');500 return response.status(400).send('Invalid key or value');
501 }
502
503 const manager = new SecretManager(request.user.directories);
504 const id = manager.writeSecret(key, value, label);
505
506 return response.send({ id });
507 } catch (error) {
508 console.error('Error writing secret:', error);
509 return response.sendStatus(500);
510 }
174});511});
175512
176router.post('/read', (request, response) => {513router.post('/read', (request, response) => {
177 try {514 try {
178 const state = readSecretState(request.user.directories);515 const manager = new SecretManager(request.user.directories);
516 const state = manager.getSecretState();
179 return response.send(state);517 return response.send(state);
180 } catch (error) {518 } catch (error) {
181 console.error(error);519 console.error('Error reading secret state:', error);
182 return response.send({});520 return response.send({});
183 }521 }
184});522});
185523
186router.post('/view', async (request, response) => {524router.post('/view', (request, response) => {
187 const allowKeysExposure = getConfigValue('allowKeysExposure', false, 'boolean');525 try {
188
189 if (!allowKeysExposure) {526 if (!allowKeysExposure) {
190 console.error('secrets.json could not be viewed unless the value of allowKeysExposure in config.yaml is set to true');527 console.error('secrets.json could not be viewed unless allowKeysExposure in config.yaml is set to true');
191 return response.sendStatus(403);528 return response.sendStatus(403);
192 }529 }
193530
194 try {
195 const secrets = getAllSecrets(request.user.directories);531 const secrets = getAllSecrets(request.user.directories);
196532
197 if (!secrets) {533 if (!secrets) {
@@ -200,30 +536,88 @@ router.post('/view', async (request, response) => {
200536
201 return response.send(secrets);537 return response.send(secrets);
202 } catch (error) {538 } catch (error) {
203 console.error(error);539 console.error('Error viewing secrets:', error);
204 return response.sendStatus(500);540 return response.sendStatus(500);
205 }541 }
206});542});
207543
208router.post('/find', (request, response) => {544router.post('/find', (request, response) => {
209 const allowKeysExposure = getConfigValue('allowKeysExposure', false, 'boolean');545 try {
210 const key = request.body.key;546 const { key, id } = request.body;
547
548 if (!key) {
549 return response.status(400).send('Key is required');
550 }
211551
212 if (!allowKeysExposure && !EXPORTABLE_KEYS.includes(key)) {552 if (!allowKeysExposure && !EXPORTABLE_KEYS.includes(key)) {
213 console.error('Cannot fetch secrets unless allowKeysExposure in config.yaml is set to true');553 console.error('Cannot fetch secrets unless allowKeysExposure in config.yaml is set to true');
214 return response.sendStatus(403);554 return response.sendStatus(403);
215 }555 }
216556
217 try {557 const manager = new SecretManager(request.user.directories);
218 const secret = readSecret(request.user.directories, key);558 const secretValue = manager.readSecret(key, id);
219559
220 if (!secret) {560 if (!secretValue) {
221 return response.sendStatus(404);561 return response.sendStatus(404);
222 }562 }
223563
224 return response.send({ value: secret });564 return response.send({ value: secretValue });
565 } catch (error) {
566 console.error('Error finding secret:', error);
567 return response.sendStatus(500);
568 }
569});
570
571router.post('/delete', (request, response) => {
572 try {
573 const { key, id } = request.body;
574
575 if (!key) {
576 return response.status(400).send('Key and ID are required');
577 }
578
579 const manager = new SecretManager(request.user.directories);
580 manager.deleteSecret(key, id);
581
582 return response.sendStatus(204);
583 } catch (error) {
584 console.error('Error deleting secret:', error);
585 return response.sendStatus(500);
586 }
587});
588
589router.post('/rotate', (request, response) => {
590 try {
591 const { key, id } = request.body;
592
593 if (!key || !id) {
594 return response.status(400).send('Key and ID are required');
595 }
596
597 const manager = new SecretManager(request.user.directories);
598 manager.rotateSecret(key, id);
599
600 return response.sendStatus(204);
601 } catch (error) {
602 console.error('Error rotating secret:', error);
603 return response.sendStatus(500);
604 }
605});
606
607router.post('/rename', (request, response) => {
608 try {
609 const { key, id, label } = request.body;
610
611 if (!key || !id || !label) {
612 return response.status(400).send('Key, ID, and label are required');
613 }
614
615 const manager = new SecretManager(request.user.directories);
616 manager.renameSecret(key, id, label);
617
618 return response.sendStatus(204);
225 } catch (error) {619 } catch (error) {
226 console.error(error);620 console.error('Error renaming secret:', error);
227 return response.sendStatus(500);621 return response.sendStatus(500);
228 }622 }
229});623});
src/server-main.js+2 -0
@@ -76,6 +76,7 @@ import { checkForNewContent } from './endpoints/content-manager.js';
76import { init as settingsInit } from './endpoints/settings.js';76import { init as settingsInit } from './endpoints/settings.js';
77import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './server-startup.js';77import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './server-startup.js';
78import { diskCache } from './endpoints/characters.js';78import { diskCache } from './endpoints/characters.js';
79import { migrateFlatSecrets } from './endpoints/secrets.js';
7980
80// Unrestrict console logs display limit81// Unrestrict console logs display limit
81util.inspect.defaultOptions.maxArrayLength = null;82util.inspect.defaultOptions.maxArrayLength = null;
@@ -275,6 +276,7 @@ async function preSetupTasks() {
275 await checkForNewContent(directories);276 await checkForNewContent(directories);
276 await ensureThumbnailCache(directories);277 await ensureThumbnailCache(directories);
277 await diskCache.verify(directories);278 await diskCache.verify(directories);
279 migrateFlatSecrets(directories);
278 cleanUploads();280 cleanUploads();
279 migrateAccessLog();281 migrateAccessLog();
280282
src/util.js+7 -0
@@ -7,6 +7,7 @@ import { createRequire } from 'node:module';
7import { Buffer } from 'node:buffer';7import { Buffer } from 'node:buffer';
8import { promises as dnsPromise } from 'node:dns';8import { promises as dnsPromise } from 'node:dns';
9import os from 'node:os';9import os from 'node:os';
10import crypto from 'node:crypto';
1011
11import yaml from 'yaml';12import yaml from 'yaml';
12import { sync as commandExistsSync } from 'command-exists';13import { sync as commandExistsSync } from 'command-exists';
@@ -371,9 +372,15 @@ export const color = chalk;
371 * @returns {string} A UUIDv4 string372 * @returns {string} A UUIDv4 string
372 */373 */
373export function uuidv4() {374export function uuidv4() {
375 // Node v16.7.0+
374 if ('crypto' in globalThis && 'randomUUID' in globalThis.crypto) {376 if ('crypto' in globalThis && 'randomUUID' in globalThis.crypto) {
375 return globalThis.crypto.randomUUID();377 return globalThis.crypto.randomUUID();
376 }378 }
379 // Node v14.17.0+
380 if ('randomUUID' in crypto) {
381 return crypto.randomUUID();
382 }
383 // Very insecure UUID generator, but it's better than nothing.
377 return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {384 return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
378 const r = Math.random() * 16 | 0;385 const r = Math.random() * 16 | 0;
379 const v = c === 'x' ? r : (r & 0x3 | 0x8);386 const v = c === 'x' ? r : (r & 0x3 | 0x8);