Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions public/css/hanzi-graph.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
62 changes: 62 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,68 @@ <h2>Local AI Settings</h2>
</div>
</section>

<section class="settings-section">
<details class="prompt-settings">
<summary class="prompt-settings-summary">Customize Prompts</summary>
<p class="settings-hint">Override the prompts sent to your local model. Leave a field empty to
use the default. Use placeholders like <code>{word}</code> or <code>{text}</code> where
shown.</p>

<div class="settings-item">
<label for="prompt-system">System Prompt:</label>
<textarea id="prompt-system" class="settings-input prompt-textarea" rows="3"
placeholder="Default will be used if empty"></textarea>
<p class="settings-hint">Used as the system context for all AI features.</p>
</div>

<div class="settings-item">
<label for="prompt-explain-chinese">Explain Chinese Sentence:</label>
<textarea id="prompt-explain-chinese" class="settings-input prompt-textarea" rows="2"
placeholder="Default will be used if empty"></textarea>
<p class="settings-hint">Available: <code>{text}</code></p>
</div>

<div class="settings-item">
<label for="prompt-translate-english">Translate English:</label>
<textarea id="prompt-translate-english" class="settings-input prompt-textarea" rows="2"
placeholder="Default will be used if empty"></textarea>
<p class="settings-hint">Available: <code>{text}</code></p>
</div>

<div class="settings-item">
<label for="prompt-generate-sentences">Generate Sentences:</label>
<textarea id="prompt-generate-sentences" class="settings-input prompt-textarea" rows="4"
placeholder="Default will be used if empty"></textarea>
<p class="settings-hint">Available: <code>{word}</code>, <code>{definitions}</code></p>
</div>

<div class="settings-item">
<label for="prompt-analyze-collocation">Analyze Collocation:</label>
<textarea id="prompt-analyze-collocation" class="settings-input prompt-textarea" rows="4"
placeholder="Default will be used if empty"></textarea>
<p class="settings-hint">Available: <code>{collocation}</code></p>
</div>

<div class="settings-item">
<label for="prompt-analyze-image">Analyze Image:</label>
<textarea id="prompt-analyze-image" class="settings-input prompt-textarea" rows="3"
placeholder="Default will be used if empty"></textarea>
<p class="settings-hint">No placeholders — this is sent as the text alongside the image.</p>
</div>

<div class="settings-item">
<label for="prompt-word-in-context">Word in Context:</label>
<textarea id="prompt-word-in-context" class="settings-input prompt-textarea" rows="5"
placeholder="Default will be used if empty"></textarea>
<p class="settings-hint">Available: <code>{word}</code>, <code>{sentence}</code></p>
</div>

<div class="settings-item">
<button id="reset-prompts-button" class="secondary-button">Reset to Defaults</button>
</div>
</details>
</section>

<section class="settings-section">
<h3>Instructions</h3>
<ol class="settings-instructions">
Expand Down
75 changes: 52 additions & 23 deletions public/js/modules/local-ai.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -245,20 +272,22 @@ 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);
return { data: output };
}

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);
Expand All @@ -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 })
}
];

Expand All @@ -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 })
}
];

Expand All @@ -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',
Expand All @@ -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 })
}
];

Expand All @@ -343,6 +371,7 @@ export {
loadSettings,
saveSettings,
getSettings,
getDefaultPrompts,
isLocalAiEnabled,
testConnection,
fetchModels,
Expand Down
40 changes: 40 additions & 0 deletions public/js/modules/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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();

Expand All @@ -156,6 +189,7 @@ function loadLocalAiSettings() {
}

updateLocalAiStatus();
loadPromptSettings();
}

function updateAnkiStatus() {
Expand Down Expand Up @@ -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);
Expand Down
Loading