From b5f2263e5b0b9bd057210797f15f89aceb5d7ac2 Mon Sep 17 00:00:00 2001 From: Matthew Reichhoff Date: Fri, 8 May 2026 20:40:39 -0400 Subject: [PATCH] Allow local AI users to modify prompts They can tweak them to work best with whatever model. No such privilege for the main app's prompts though. --- public/css/hanzi-graph.css | 27 +++++++++++++ public/index.html | 62 +++++++++++++++++++++++++++++ public/js/modules/local-ai.js | 75 ++++++++++++++++++++++++----------- public/js/modules/settings.js | 40 +++++++++++++++++++ 4 files changed, 181 insertions(+), 23 deletions(-) diff --git a/public/css/hanzi-graph.css b/public/css/hanzi-graph.css index b8a18c7f..5cbc72fa 100644 --- a/public/css/hanzi-graph.css +++ b/public/css/hanzi-graph.css @@ -1913,6 +1913,33 @@ using settings names for now box-sizing: border-box; } +textarea.settings-input { + resize: vertical; + font-family: inherit; + line-height: 1.5; +} + +.prompt-settings { + margin-top: 4px; +} + +.prompt-settings-summary { + cursor: pointer; + font-size: 16px; + font-weight: 600; + padding: 8px 0; + color: var(--primary-font-color); + user-select: none; +} + +.prompt-settings-summary::-webkit-details-marker { + color: var(--primary-font-color); +} + +.prompt-settings .settings-item { + margin-top: 16px; +} + .settings-select { padding: 10px; font-size: 16px; diff --git a/public/index.html b/public/index.html index 59d694a4..3c7b3650 100644 --- a/public/index.html +++ b/public/index.html @@ -422,6 +422,68 @@

Local AI Settings

+
+
+ Customize Prompts +

Override the prompts sent to your local model. Leave a field empty to + use the default. Use placeholders like {word} or {text} where + shown.

+ +
+ + +

Used as the system context for all AI features.

+
+ +
+ + +

Available: {text}

+
+ +
+ + +

Available: {text}

+
+ +
+ + +

Available: {word}, {definitions}

+
+ +
+ + +

Available: {collocation}

+
+ +
+ + +

No placeholders — this is sent as the text alongside the image.

+
+ +
+ + +

Available: {word}, {sentence}

+
+ +
+ +
+
+
+

Instructions

    diff --git a/public/js/modules/local-ai.js b/public/js/modules/local-ai.js index b1a87ff8..29381582 100644 --- a/public/js/modules/local-ai.js +++ b/public/js/modules/local-ai.js @@ -2,12 +2,24 @@ // This module provides the same interface as the Firebase functions but calls a local server instead. const SETTINGS_KEY = 'localAiSettings'; +// Default prompt templates. Placeholders use {varName} syntax. +const defaultPrompts = { + systemPrompt: 'You are a helpful Chinese teacher for speakers of English who want to learn Chinese. You speak naturally, and you provide helpful sentences that illustrate how to use Chinese vocabulary.', + explainChinese: 'Explain the Chinese text "{text}".', + translateEnglish: 'Translate the English text "{text}" into Chinese, and explain the translation.', + generateSentences: 'Please generate two example Chinese sentences, each with a separate English translation and pinyin, for each of the following definitions of the Chinese word "{word}":\n{definitions}\n\nEach sentence must include "{word}".', + analyzeCollocation: 'Please generate three example Chinese sentences, each with a separate English translation and pinyin, that uses the phrase "{collocation}".\nEach sentence must include "{collocation}".\n\nPlease also translate "{collocation}" to English and provide a plain-text explanation of how such a phrase would be used.', + analyzeImage: 'Read the Chinese text in this image, split it into sentences, and then explain it, including an English translation for each sentence and any relevant grammar rules. If the image contains good English translations of the Chinese text, use those verbatim.', + wordInContext: 'In the sentence "{sentence}", explain how the word "{word}" is used.\n\nProvide:\n1. The meaning of "{word}" as used in this specific sentence (in English).\n2. A plain-text explanation of why "{word}" is used here, including any nuances, grammatical role, or idiomatic usage that would help a learner understand its function in this context.\n\nKeep your explanation focused and practical for a language learner.' +}; + // Default settings const defaultSettings = { enabled: false, endpoint: 'http://localhost:1234/v1', model: '', - availableModels: [] + availableModels: [], + customPrompts: {} }; let settings = loadSettings(); @@ -38,6 +50,26 @@ function isLocalAiEnabled() { return settings.enabled && settings.endpoint && settings.model; } +function getDefaultPrompts() { + return { ...defaultPrompts }; +} + +// Returns the active prompts: custom values override defaults, empty strings fall back to defaults. +function getActivePrompts() { + const custom = settings.customPrompts || {}; + const result = {}; + for (const key of Object.keys(defaultPrompts)) { + result[key] = (custom[key] !== undefined && custom[key] !== '') ? custom[key] : defaultPrompts[key]; + } + return result; +} + +// threat model here is users calling a local API with prompts and input that they control, so little need to worry about escaping or injection here. +// The user can already do whatever they want with the prompts and input. +function applyTemplate(template, vars) { + return template.replace(/\{(\w+)\}/g, (_, key) => (vars[key] !== undefined ? vars[key] : `{${key}}`)); +} + // JSON Schema definitions matching the Firebase function schemas. // See `functions/src/schemas.ts` for the backend schema definitions. const schemas = { @@ -159,11 +191,6 @@ const schemas = { } }; -// System prompts -const systemPrompts = { - chineseTeacher: 'You are a helpful Chinese teacher for speakers of English who want to learn Chinese. You speak naturally, and you provide helpful sentences that illustrate how to use Chinese vocabulary.' -}; - async function callLocalAi(messages, schema) { const response = await fetch(`${settings.endpoint}/chat/completions`, { method: 'POST', @@ -245,11 +272,11 @@ async function fetchModels() { // AI function implementations that mirror the Firebase GenKit functions // See `functions/src/index.ts` for the GenKit entry point. -// TODO: it's unclear these are good prompts on the backend, and they probably are worse for -// less-capable local models. Both sides likely need tuning. async function explainChineseSentence(text) { + const prompts = getActivePrompts(); const messages = [ - { role: 'user', content: `Explain the Chinese text "${text}".` } + { role: 'system', content: prompts.systemPrompt }, + { role: 'user', content: applyTemplate(prompts.explainChinese, { text }) } ]; const output = await callLocalAi(messages, schemas.explanation); @@ -257,8 +284,10 @@ async function explainChineseSentence(text) { } async function translateEnglish(text) { + const prompts = getActivePrompts(); const messages = [ - { role: 'user', content: `Translate the English text "${text}" into Chinese, and explain the translation.` } + { role: 'system', content: prompts.systemPrompt }, + { role: 'user', content: applyTemplate(prompts.translateEnglish, { text }) } ]; const output = await callLocalAi(messages, schemas.englishExplanation); @@ -267,12 +296,13 @@ async function translateEnglish(text) { } async function generateChineseSentences(word, definitions) { + const prompts = getActivePrompts(); const definitionsList = definitions.map(d => `* ${d}`).join('\n'); const messages = [ - { role: 'system', content: systemPrompts.chineseTeacher }, + { role: 'system', content: prompts.systemPrompt }, { role: 'user', - content: `Please generate two example Chinese sentences, each with a separate English translation and pinyin, for each of the following definitions of the Chinese word "${word}":\n${definitionsList}\n\nEach sentence must include "${word}".` + content: applyTemplate(prompts.generateSentences, { word, definitions: definitionsList }) } ]; @@ -281,11 +311,12 @@ async function generateChineseSentences(word, definitions) { } async function analyzeCollocation(collocation) { + const prompts = getActivePrompts(); const messages = [ - { role: 'system', content: systemPrompts.chineseTeacher }, + { role: 'system', content: prompts.systemPrompt }, { role: 'user', - content: `Please generate three example Chinese sentences, each with a separate English translation and pinyin, that uses the phrase "${collocation}".\nEach sentence must include "${collocation}".\n\nPlease also translate "${collocation}" to English and provide a plain-text explanation of how such a phrase would be used.` + content: applyTemplate(prompts.analyzeCollocation, { collocation }) } ]; @@ -296,15 +327,17 @@ async function analyzeCollocation(collocation) { // TODO: how common are multi-modal models (which this assumes) in local AI setups? // we might need to let the user pick a separate model for images? Not sure yet. async function analyzeImage(base64ImageContents) { + const prompts = getActivePrompts(); // Note: Image analysis requires a vision-capable model // The base64 content should be in format: data:image/jpeg;base64,xxxxx const messages = [ + { role: 'system', content: prompts.systemPrompt }, { role: 'user', content: [ { type: 'text', - text: 'Read the Chinese text in this image, split it into sentences, and then explain it, including an English translation for each sentence and any relevant grammar rules. If the image contains good English translations of the Chinese text, use those verbatim.' + text: prompts.analyzeImage }, { type: 'image_url', @@ -321,17 +354,12 @@ async function analyzeImage(base64ImageContents) { } async function explainWordInContext(word, sentence) { + const prompts = getActivePrompts(); const messages = [ - { role: 'system', content: systemPrompts.chineseTeacher }, + { role: 'system', content: prompts.systemPrompt }, { role: 'user', - content: `In the sentence "${sentence}", explain how the word "${word}" is used. - -Provide: -1. The meaning of "${word}" as used in this specific sentence (in English). -2. A plain-text explanation of why "${word}" is used here, including any nuances, grammatical role, or idiomatic usage that would help a learner understand its function in this context. - -Keep your explanation focused and practical for a language learner.` + content: applyTemplate(prompts.wordInContext, { word, sentence }) } ]; @@ -343,6 +371,7 @@ export { loadSettings, saveSettings, getSettings, + getDefaultPrompts, isLocalAiEnabled, testConnection, fetchModels, diff --git a/public/js/modules/settings.js b/public/js/modules/settings.js index 8193480c..fb9f1f39 100644 --- a/public/js/modules/settings.js +++ b/public/js/modules/settings.js @@ -18,6 +18,18 @@ const connectionStatus = document.getElementById('connection-status'); const localAiStatusContainer = document.getElementById('local-ai-status-container'); const localAiStatus = document.getElementById('local-ai-status'); +// Prompt customization DOM elements +const promptTextareas = { + systemPrompt: document.getElementById('prompt-system'), + explainChinese: document.getElementById('prompt-explain-chinese'), + translateEnglish: document.getElementById('prompt-translate-english'), + generateSentences: document.getElementById('prompt-generate-sentences'), + analyzeCollocation: document.getElementById('prompt-analyze-collocation'), + analyzeImage: document.getElementById('prompt-analyze-image'), + wordInContext: document.getElementById('prompt-word-in-context') +}; +const resetPromptsButton = document.getElementById('reset-prompts-button'); + // Anki DOM elements const ankiEnabledCheckbox = document.getElementById('anki-enabled'); const ankiEndpointInput = document.getElementById('anki-endpoint'); @@ -145,6 +157,27 @@ function handleLocalAiEndpointChange() { connectionStatus.className = 'connection-status'; } +function handlePromptChange(key) { + const customPrompts = { ...(localAi.getSettings().customPrompts || {}) }; + customPrompts[key] = promptTextareas[key].value; + localAi.saveSettings({ customPrompts }); +} + +function handleResetPrompts() { + localAi.saveSettings({ customPrompts: {} }); + loadPromptSettings(); +} + +function loadPromptSettings() { + const settings = localAi.getSettings(); + const defaults = localAi.getDefaultPrompts(); + const custom = settings.customPrompts || {}; + for (const key of Object.keys(promptTextareas)) { + promptTextareas[key].value = custom[key] || ''; + promptTextareas[key].placeholder = defaults[key] || 'Default will be used if empty'; + } +} + function loadLocalAiSettings() { const settings = localAi.getSettings(); @@ -156,6 +189,7 @@ function loadLocalAiSettings() { } updateLocalAiStatus(); + loadPromptSettings(); } function updateAnkiStatus() { @@ -340,6 +374,12 @@ function initialize() { testConnectionButton.addEventListener('click', handleTestConnection); refreshModelsButton.addEventListener('click', handleRefreshModels); + // Prompt customization event listeners + for (const key of Object.keys(promptTextareas)) { + promptTextareas[key].addEventListener('change', () => handlePromptChange(key)); + } + resetPromptsButton.addEventListener('click', handleResetPrompts); + // Anki event listeners ankiEnabledCheckbox.addEventListener('change', handleAnkiEnabledChange); ankiEndpointInput.addEventListener('change', handleAnkiEndpointChange);