Skip to content

Commit e146f54

Browse files
authored
Allow local AI users to modify prompts (#138)
They can tweak them to work best with whatever model. No such privilege for the main app's prompts though.
1 parent 9a3c5b2 commit e146f54

4 files changed

Lines changed: 181 additions & 23 deletions

File tree

public/css/hanzi-graph.css

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1913,6 +1913,33 @@ using settings names for now
19131913
box-sizing: border-box;
19141914
}
19151915

1916+
textarea.settings-input {
1917+
resize: vertical;
1918+
font-family: inherit;
1919+
line-height: 1.5;
1920+
}
1921+
1922+
.prompt-settings {
1923+
margin-top: 4px;
1924+
}
1925+
1926+
.prompt-settings-summary {
1927+
cursor: pointer;
1928+
font-size: 16px;
1929+
font-weight: 600;
1930+
padding: 8px 0;
1931+
color: var(--primary-font-color);
1932+
user-select: none;
1933+
}
1934+
1935+
.prompt-settings-summary::-webkit-details-marker {
1936+
color: var(--primary-font-color);
1937+
}
1938+
1939+
.prompt-settings .settings-item {
1940+
margin-top: 16px;
1941+
}
1942+
19161943
.settings-select {
19171944
padding: 10px;
19181945
font-size: 16px;

public/index.html

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,68 @@ <h2>Local AI Settings</h2>
422422
</div>
423423
</section>
424424

425+
<section class="settings-section">
426+
<details class="prompt-settings">
427+
<summary class="prompt-settings-summary">Customize Prompts</summary>
428+
<p class="settings-hint">Override the prompts sent to your local model. Leave a field empty to
429+
use the default. Use placeholders like <code>{word}</code> or <code>{text}</code> where
430+
shown.</p>
431+
432+
<div class="settings-item">
433+
<label for="prompt-system">System Prompt:</label>
434+
<textarea id="prompt-system" class="settings-input prompt-textarea" rows="3"
435+
placeholder="Default will be used if empty"></textarea>
436+
<p class="settings-hint">Used as the system context for all AI features.</p>
437+
</div>
438+
439+
<div class="settings-item">
440+
<label for="prompt-explain-chinese">Explain Chinese Sentence:</label>
441+
<textarea id="prompt-explain-chinese" class="settings-input prompt-textarea" rows="2"
442+
placeholder="Default will be used if empty"></textarea>
443+
<p class="settings-hint">Available: <code>{text}</code></p>
444+
</div>
445+
446+
<div class="settings-item">
447+
<label for="prompt-translate-english">Translate English:</label>
448+
<textarea id="prompt-translate-english" class="settings-input prompt-textarea" rows="2"
449+
placeholder="Default will be used if empty"></textarea>
450+
<p class="settings-hint">Available: <code>{text}</code></p>
451+
</div>
452+
453+
<div class="settings-item">
454+
<label for="prompt-generate-sentences">Generate Sentences:</label>
455+
<textarea id="prompt-generate-sentences" class="settings-input prompt-textarea" rows="4"
456+
placeholder="Default will be used if empty"></textarea>
457+
<p class="settings-hint">Available: <code>{word}</code>, <code>{definitions}</code></p>
458+
</div>
459+
460+
<div class="settings-item">
461+
<label for="prompt-analyze-collocation">Analyze Collocation:</label>
462+
<textarea id="prompt-analyze-collocation" class="settings-input prompt-textarea" rows="4"
463+
placeholder="Default will be used if empty"></textarea>
464+
<p class="settings-hint">Available: <code>{collocation}</code></p>
465+
</div>
466+
467+
<div class="settings-item">
468+
<label for="prompt-analyze-image">Analyze Image:</label>
469+
<textarea id="prompt-analyze-image" class="settings-input prompt-textarea" rows="3"
470+
placeholder="Default will be used if empty"></textarea>
471+
<p class="settings-hint">No placeholders — this is sent as the text alongside the image.</p>
472+
</div>
473+
474+
<div class="settings-item">
475+
<label for="prompt-word-in-context">Word in Context:</label>
476+
<textarea id="prompt-word-in-context" class="settings-input prompt-textarea" rows="5"
477+
placeholder="Default will be used if empty"></textarea>
478+
<p class="settings-hint">Available: <code>{word}</code>, <code>{sentence}</code></p>
479+
</div>
480+
481+
<div class="settings-item">
482+
<button id="reset-prompts-button" class="secondary-button">Reset to Defaults</button>
483+
</div>
484+
</details>
485+
</section>
486+
425487
<section class="settings-section">
426488
<h3>Instructions</h3>
427489
<ol class="settings-instructions">

public/js/modules/local-ai.js

Lines changed: 52 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,24 @@
22
// This module provides the same interface as the Firebase functions but calls a local server instead.
33
const SETTINGS_KEY = 'localAiSettings';
44

5+
// Default prompt templates. Placeholders use {varName} syntax.
6+
const defaultPrompts = {
7+
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.',
8+
explainChinese: 'Explain the Chinese text "{text}".',
9+
translateEnglish: 'Translate the English text "{text}" into Chinese, and explain the translation.',
10+
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}".',
11+
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.',
12+
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.',
13+
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.'
14+
};
15+
516
// Default settings
617
const defaultSettings = {
718
enabled: false,
819
endpoint: 'http://localhost:1234/v1',
920
model: '',
10-
availableModels: []
21+
availableModels: [],
22+
customPrompts: {}
1123
};
1224

1325
let settings = loadSettings();
@@ -38,6 +50,26 @@ function isLocalAiEnabled() {
3850
return settings.enabled && settings.endpoint && settings.model;
3951
}
4052

53+
function getDefaultPrompts() {
54+
return { ...defaultPrompts };
55+
}
56+
57+
// Returns the active prompts: custom values override defaults, empty strings fall back to defaults.
58+
function getActivePrompts() {
59+
const custom = settings.customPrompts || {};
60+
const result = {};
61+
for (const key of Object.keys(defaultPrompts)) {
62+
result[key] = (custom[key] !== undefined && custom[key] !== '') ? custom[key] : defaultPrompts[key];
63+
}
64+
return result;
65+
}
66+
67+
// 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.
68+
// The user can already do whatever they want with the prompts and input.
69+
function applyTemplate(template, vars) {
70+
return template.replace(/\{(\w+)\}/g, (_, key) => (vars[key] !== undefined ? vars[key] : `{${key}}`));
71+
}
72+
4173
// JSON Schema definitions matching the Firebase function schemas.
4274
// See `functions/src/schemas.ts` for the backend schema definitions.
4375
const schemas = {
@@ -159,11 +191,6 @@ const schemas = {
159191
}
160192
};
161193

162-
// System prompts
163-
const systemPrompts = {
164-
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.'
165-
};
166-
167194
async function callLocalAi(messages, schema) {
168195
const response = await fetch(`${settings.endpoint}/chat/completions`, {
169196
method: 'POST',
@@ -245,20 +272,22 @@ async function fetchModels() {
245272

246273
// AI function implementations that mirror the Firebase GenKit functions
247274
// See `functions/src/index.ts` for the GenKit entry point.
248-
// TODO: it's unclear these are good prompts on the backend, and they probably are worse for
249-
// less-capable local models. Both sides likely need tuning.
250275
async function explainChineseSentence(text) {
276+
const prompts = getActivePrompts();
251277
const messages = [
252-
{ role: 'user', content: `Explain the Chinese text "${text}".` }
278+
{ role: 'system', content: prompts.systemPrompt },
279+
{ role: 'user', content: applyTemplate(prompts.explainChinese, { text }) }
253280
];
254281

255282
const output = await callLocalAi(messages, schemas.explanation);
256283
return { data: output };
257284
}
258285

259286
async function translateEnglish(text) {
287+
const prompts = getActivePrompts();
260288
const messages = [
261-
{ role: 'user', content: `Translate the English text "${text}" into Chinese, and explain the translation.` }
289+
{ role: 'system', content: prompts.systemPrompt },
290+
{ role: 'user', content: applyTemplate(prompts.translateEnglish, { text }) }
262291
];
263292

264293
const output = await callLocalAi(messages, schemas.englishExplanation);
@@ -267,12 +296,13 @@ async function translateEnglish(text) {
267296
}
268297

269298
async function generateChineseSentences(word, definitions) {
299+
const prompts = getActivePrompts();
270300
const definitionsList = definitions.map(d => `* ${d}`).join('\n');
271301
const messages = [
272-
{ role: 'system', content: systemPrompts.chineseTeacher },
302+
{ role: 'system', content: prompts.systemPrompt },
273303
{
274304
role: 'user',
275-
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}".`
305+
content: applyTemplate(prompts.generateSentences, { word, definitions: definitionsList })
276306
}
277307
];
278308

@@ -281,11 +311,12 @@ async function generateChineseSentences(word, definitions) {
281311
}
282312

283313
async function analyzeCollocation(collocation) {
314+
const prompts = getActivePrompts();
284315
const messages = [
285-
{ role: 'system', content: systemPrompts.chineseTeacher },
316+
{ role: 'system', content: prompts.systemPrompt },
286317
{
287318
role: 'user',
288-
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.`
319+
content: applyTemplate(prompts.analyzeCollocation, { collocation })
289320
}
290321
];
291322

@@ -296,15 +327,17 @@ async function analyzeCollocation(collocation) {
296327
// TODO: how common are multi-modal models (which this assumes) in local AI setups?
297328
// we might need to let the user pick a separate model for images? Not sure yet.
298329
async function analyzeImage(base64ImageContents) {
330+
const prompts = getActivePrompts();
299331
// Note: Image analysis requires a vision-capable model
300332
// The base64 content should be in format: data:image/jpeg;base64,xxxxx
301333
const messages = [
334+
{ role: 'system', content: prompts.systemPrompt },
302335
{
303336
role: 'user',
304337
content: [
305338
{
306339
type: 'text',
307-
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.'
340+
text: prompts.analyzeImage
308341
},
309342
{
310343
type: 'image_url',
@@ -321,17 +354,12 @@ async function analyzeImage(base64ImageContents) {
321354
}
322355

323356
async function explainWordInContext(word, sentence) {
357+
const prompts = getActivePrompts();
324358
const messages = [
325-
{ role: 'system', content: systemPrompts.chineseTeacher },
359+
{ role: 'system', content: prompts.systemPrompt },
326360
{
327361
role: 'user',
328-
content: `In the sentence "${sentence}", explain how the word "${word}" is used.
329-
330-
Provide:
331-
1. The meaning of "${word}" as used in this specific sentence (in English).
332-
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.
333-
334-
Keep your explanation focused and practical for a language learner.`
362+
content: applyTemplate(prompts.wordInContext, { word, sentence })
335363
}
336364
];
337365

@@ -343,6 +371,7 @@ export {
343371
loadSettings,
344372
saveSettings,
345373
getSettings,
374+
getDefaultPrompts,
346375
isLocalAiEnabled,
347376
testConnection,
348377
fetchModels,

public/js/modules/settings.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@ const connectionStatus = document.getElementById('connection-status');
1818
const localAiStatusContainer = document.getElementById('local-ai-status-container');
1919
const localAiStatus = document.getElementById('local-ai-status');
2020

21+
// Prompt customization DOM elements
22+
const promptTextareas = {
23+
systemPrompt: document.getElementById('prompt-system'),
24+
explainChinese: document.getElementById('prompt-explain-chinese'),
25+
translateEnglish: document.getElementById('prompt-translate-english'),
26+
generateSentences: document.getElementById('prompt-generate-sentences'),
27+
analyzeCollocation: document.getElementById('prompt-analyze-collocation'),
28+
analyzeImage: document.getElementById('prompt-analyze-image'),
29+
wordInContext: document.getElementById('prompt-word-in-context')
30+
};
31+
const resetPromptsButton = document.getElementById('reset-prompts-button');
32+
2133
// Anki DOM elements
2234
const ankiEnabledCheckbox = document.getElementById('anki-enabled');
2335
const ankiEndpointInput = document.getElementById('anki-endpoint');
@@ -145,6 +157,27 @@ function handleLocalAiEndpointChange() {
145157
connectionStatus.className = 'connection-status';
146158
}
147159

160+
function handlePromptChange(key) {
161+
const customPrompts = { ...(localAi.getSettings().customPrompts || {}) };
162+
customPrompts[key] = promptTextareas[key].value;
163+
localAi.saveSettings({ customPrompts });
164+
}
165+
166+
function handleResetPrompts() {
167+
localAi.saveSettings({ customPrompts: {} });
168+
loadPromptSettings();
169+
}
170+
171+
function loadPromptSettings() {
172+
const settings = localAi.getSettings();
173+
const defaults = localAi.getDefaultPrompts();
174+
const custom = settings.customPrompts || {};
175+
for (const key of Object.keys(promptTextareas)) {
176+
promptTextareas[key].value = custom[key] || '';
177+
promptTextareas[key].placeholder = defaults[key] || 'Default will be used if empty';
178+
}
179+
}
180+
148181
function loadLocalAiSettings() {
149182
const settings = localAi.getSettings();
150183

@@ -156,6 +189,7 @@ function loadLocalAiSettings() {
156189
}
157190

158191
updateLocalAiStatus();
192+
loadPromptSettings();
159193
}
160194

161195
function updateAnkiStatus() {
@@ -340,6 +374,12 @@ function initialize() {
340374
testConnectionButton.addEventListener('click', handleTestConnection);
341375
refreshModelsButton.addEventListener('click', handleRefreshModels);
342376

377+
// Prompt customization event listeners
378+
for (const key of Object.keys(promptTextareas)) {
379+
promptTextareas[key].addEventListener('change', () => handlePromptChange(key));
380+
}
381+
resetPromptsButton.addEventListener('click', handleResetPrompts);
382+
343383
// Anki event listeners
344384
ankiEnabledCheckbox.addEventListener('change', handleAnkiEnabledChange);
345385
ankiEndpointInput.addEventListener('change', handleAnkiEndpointChange);

0 commit comments

Comments
 (0)