From dfd813e6e46cf6a765b4ae91c155ca898cc260f1 Mon Sep 17 00:00:00 2001 From: Andrei Sverdlov <141342766+Andy-Sverdlov-LucaNet@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:18:03 +0200 Subject: [PATCH] fix(ui): add the AI prompt to admin settings and stop resetting it The assistant's prompt could be stored and read through the API but never edited, and saving AI settings silently discarded it. SaveSiteAI substitutes the built-in defaults whenever a request arrives without prompt_config, and the admin form builds its payload from enabled, chosen_provider and ai_providers only. Every save from that page therefore reset the prompt, so a prompt set through the API survived only until somebody touched the model or the API key, reverting with no warning. Add the field to the form. The backend already accepts, persists and returns prompt_config, and getPromptByLanguage already falls back to the constant when the stored prompt is empty, so this is frontend-only: the form now prefills from the stored prompt and sends it back, which both exposes the prompt and removes the reset. Leaving the field empty restores the default. Validate the template on save. getPromptByLanguage runs the prompt through fmt.Sprintf with the user's question, so it needs exactly one %s and no other verb. That is easy to get wrong once the prompt is editable, and it fails quietly: Go appends %!(EXTRA string=...) and the question never reaches the model at all. Rejecting it in the form turns a silent broken assistant into a validation message. zh_cn is preserved from the loaded config rather than dropped, so editing the English prompt does not clear the Chinese one. Fixes #1608 Co-Authored-By: Claude Opus 5 --- i18n/en_US.yaml | 10 +++++ ui/src/common/interface.ts | 4 ++ ui/src/pages/Admin/AiSettings/index.tsx | 58 ++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index 5d1faa3e0..84d58bf49 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -2360,6 +2360,16 @@ ui: model: label: Model msg: Model is required + prompt: + label: Prompt + text: >- + Instructions sent to the model with every question. Use %s where the + user's question should be inserted. Leave empty to use the built-in + default. + msg: >- + The prompt must contain %s exactly once, marking where the user's + question is inserted, and no other format verb. Write %% for a + literal percent sign. add_success: AI settings updated successfully. conversations: topic: Topic diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index 8ab714230..9f328ac9f 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -835,6 +835,10 @@ export interface AiConfig { api_key: string; model: string; }>; + prompt_config?: { + zh_cn: string; + en_us: string; + }; } export interface AiProviderItem { diff --git a/ui/src/pages/Admin/AiSettings/index.tsx b/ui/src/pages/Admin/AiSettings/index.tsx index 2270aa5c5..0c6da23ba 100644 --- a/ui/src/pages/Admin/AiSettings/index.tsx +++ b/ui/src/pages/Admin/AiSettings/index.tsx @@ -68,6 +68,11 @@ const Index = () => { isInvalid: false, errorMsg: '', }, + prompt: { + value: '', + isInvalid: false, + errorMsg: '', + }, }); const [apiHostPlaceholder, setApiHostPlaceholder] = useState(''); const [modelsData, setModels] = useState<{ id: string }[]>([]); @@ -169,7 +174,7 @@ const Index = () => { const checkValidate = () => { let bol = true; - const { api_host, api_key, model } = formData; + const { api_host, api_key, model, prompt } = formData; if (!api_host.value) { bol = false; @@ -199,6 +204,25 @@ const Index = () => { }; } + // The prompt is a format template: the backend runs it through + // fmt.Sprintf with the user's question, so it needs exactly one %s and no + // other verb. Getting this wrong fails quietly -- Go appends + // %!(EXTRA string=...) and the question never reaches the model -- so + // reject it here rather than at answer time. An empty prompt is valid and + // means the built-in default. + if (prompt.value) { + const verbs = prompt.value.replace(/%%/g, '').match(/%./g) || []; + const questionVerbs = verbs.filter((verb) => verb === '%s'); + if (questionVerbs.length !== 1 || verbs.length !== questionVerbs.length) { + bol = false; + formData.prompt = { + ...formData.prompt, + isInvalid: true, + errorMsg: t('prompt.msg'), + }; + } + } + setFormData({ ...formData, }); @@ -227,6 +251,10 @@ const Index = () => { enabled: formData.enabled.value, chosen_provider: formData.provider.value, ai_providers: newProviders, + prompt_config: { + zh_cn: historyConfigRef.current?.prompt_config?.zh_cn || '', + en_us: formData.prompt.value, + }, }; saveAiConfig(params) .then(() => { @@ -295,6 +323,11 @@ const Index = () => { isInvalid: false, errorMsg: '', }, + prompt: { + value: aiConfig.prompt_config?.en_us || '', + isInvalid: false, + errorMsg: '', + }, }); }; @@ -477,6 +510,29 @@ const Index = () => {
{formData.model.errorMsg}
+ + {t('prompt.label')} + + handleValueChange({ + prompt: { + value: e.target.value, + errorMsg: '', + isInvalid: false, + }, + }) + } + /> + {t('prompt.text')} + + {formData.prompt.errorMsg} + + +