feat(ai): integrate local Ollama AI for in-context sentence translation and caching (Task 13)

This commit is contained in:
Antigravity committed 2026-10-06 15:05:24 +03:00
1 parent 0c5951df8d
commit 37a1f1ab35
12 files changed
+605 -15

No files matched your search

+95 -1
View File
@@ -3858,6 +3858,10 @@ body {
margin-bottom: 8px;
}
.popup-context-box {
margin-bottom: 14px;
}
.popup-context {
font-size: 12px;
color: var(--text-muted);
@@ -3865,11 +3869,101 @@ body {
background: var(--bg-input);
padding: 8px 10px;
border-radius: var(--radius-sm);
margin-bottom: 14px;
margin-bottom: 6px;
line-height: 1.4;
border-left: 3px solid var(--primary);
}
.popup-sentence-ai-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.popup-sentence-btn {
font-size: 11px;
padding: 3px 8px;
cursor: pointer;
border-radius: var(--radius-sm);
display: inline-flex;
align-items: center;
gap: 4px;
}
.popup-ai-source-badge {
font-size: 10px;
font-weight: 600;
color: var(--primary);
background: rgba(99, 102, 241, 0.12);
border: 1px solid rgba(99, 102, 241, 0.25);
padding: 2px 6px;
border-radius: 9999px;
letter-spacing: 0.02em;
}
.popup-sentence-loading {
font-size: 11px;
color: var(--text-muted);
padding: 6px 8px;
display: flex;
align-items: center;
gap: 6px;
background: var(--bg-input);
border-radius: var(--radius-sm);
margin-bottom: 6px;
}
.popup-sentence-translation-wrap {
background: rgba(99, 102, 241, 0.08);
border: 1px solid rgba(99, 102, 241, 0.2);
border-radius: var(--radius-sm);
padding: 8px 10px;
margin-bottom: 6px;
animation: fadeIn 0.15s ease-out;
}
.popup-sentence-ru {
font-size: 13px;
line-height: 1.45;
color: var(--text-main);
margin-bottom: 6px;
}
.popup-context-meaning-wrap {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 11px;
padding-top: 6px;
border-top: 1px dashed rgba(99, 102, 241, 0.2);
}
.context-meaning-label {
color: var(--text-muted);
}
.context-meaning-val {
color: var(--success);
font-weight: 600;
}
.use-meaning-btn {
font-size: 10px;
padding: 1px 6px;
color: var(--primary);
border: 1px solid var(--primary);
border-radius: 4px;
cursor: pointer;
background: transparent;
}
.use-meaning-btn:hover {
background: rgba(99, 102, 241, 0.15);
}
.popup-saved-badge {
text-align: center;
font-size: 13px;
+20 -1
View File
@@ -1062,7 +1062,26 @@
<div id="popup-loading" class="popup-loading hidden">Переводим...</div>
<div id="popup-content" class="popup-content">
<div class="popup-translation" id="popup-translation">перевод</div>
<div class="popup-context" id="popup-context">“Контекст из книги...”</div>
<div class="popup-context-box">
<div class="popup-context" id="popup-context">“Контекст из книги...”</div>
<div class="popup-sentence-ai-bar">
<button type="button" id="popup-translate-sentence-btn" class="btn btn-xs btn-outline popup-sentence-btn" title="Перевести всё предложение через локальный ИИ">
🌐 Перевести предложение
</button>
<span id="popup-ai-source-badge" class="popup-ai-source-badge hidden">⚡ Local AI</span>
</div>
<div id="popup-sentence-loading" class="popup-sentence-loading hidden">
<span class="spinner-sm"></span> Локальный ИИ переводит...
</div>
<div id="popup-sentence-translation-wrap" class="popup-sentence-translation-wrap hidden">
<div class="popup-sentence-ru" id="popup-sentence-ru"></div>
<div id="popup-context-meaning-wrap" class="popup-context-meaning-wrap hidden">
<span class="context-meaning-label">🎯 В контексте:</span>
<strong id="popup-context-meaning-val" class="context-meaning-val"></strong>
<button type="button" id="popup-use-context-meaning-btn" class="btn btn-xs btn-ghost use-meaning-btn" title="Использовать этот перевод для карточки">Вставить в карточку</button>
</div>
</div>
</div>
</div>
</div>
<div class="popup-footer">
+30 -5
View File
@@ -66,11 +66,7 @@ export const api = {
return resp.ok ? await resp.json() : [];
},
async lookup(term) {
const resp = await fetch(`/api/lookup?term=${encodeURIComponent(term)}`);
if (resp.ok) {
return await resp.json();
}
throw new Error(`Lookup failed: ${resp.status}`);
return await api.lookup.search(term);
},
async create(payload) {
return await fetch('/api/cards', {
@@ -98,6 +94,35 @@ export const api = {
}
},
// Dictionary Lookup & AI Sentence Translation
lookup: {
async search(term) {
const resp = await fetch(`/api/lookup?term=${encodeURIComponent(term)}`);
if (resp.ok) {
return await resp.json();
}
throw new Error(`Lookup failed: ${resp.status}`);
},
async translateSentence(sentence, targetWord = '') {
const resp = await fetch('/api/translate-sentence', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sentence: sentence,
target_word: targetWord
})
});
if (resp.ok) {
return await resp.json();
}
throw new Error(`Sentence translation failed: ${resp.status}`);
},
async getAiStatus() {
const resp = await fetch('/api/ai/status');
return resp.ok ? await resp.json() : { online: false, models: [] };
}
},
// Collections & Tags
collections: {
async getAll() {
+93
View File
@@ -1151,6 +1151,27 @@ export async function openWordPopup(term, contextSentence, targetElement) {
if (popupLoading) popupLoading.classList.remove('hidden');
if (popupContent) popupContent.classList.add('hidden');
const popupTranslateSentenceBtn = document.getElementById('popup-translate-sentence-btn');
const popupAiSourceBadge = document.getElementById('popup-ai-source-badge');
const popupSentenceLoading = document.getElementById('popup-sentence-loading');
const popupSentenceTranslationWrap = document.getElementById('popup-sentence-translation-wrap');
const popupSentenceRu = document.getElementById('popup-sentence-ru');
const popupContextMeaningWrap = document.getElementById('popup-context-meaning-wrap');
const popupContextMeaningVal = document.getElementById('popup-context-meaning-val');
const popupSentenceAiBar = document.querySelector('.popup-sentence-ai-bar');
if (popupSentenceLoading) popupSentenceLoading.classList.add('hidden');
if (popupSentenceTranslationWrap) popupSentenceTranslationWrap.classList.add('hidden');
if (popupSentenceRu) popupSentenceRu.textContent = '';
if (popupContextMeaningWrap) popupContextMeaningWrap.classList.add('hidden');
if (popupContextMeaningVal) popupContextMeaningVal.textContent = '';
if (popupAiSourceBadge) popupAiSourceBadge.classList.add('hidden');
if (popupTranslateSentenceBtn) {
popupTranslateSentenceBtn.disabled = false;
popupTranslateSentenceBtn.textContent = '🌐 Перевести предложение';
if (popupSentenceAiBar) popupSentenceAiBar.classList.toggle('hidden', !contextSentence);
}
if (popupInLibrary) {
popupInLibrary.classList.toggle('hidden', !existingCard);
}
@@ -1831,6 +1852,78 @@ export function initReader() {
window.addEventListener('scroll', schedulePopupUpdate, { passive: true, capture: true });
window.addEventListener('resize', schedulePopupUpdate, { passive: true });
const popupTranslateSentenceBtn = document.getElementById('popup-translate-sentence-btn');
const popupUseContextMeaningBtn = document.getElementById('popup-use-context-meaning-btn');
const popupSentenceLoading = document.getElementById('popup-sentence-loading');
const popupSentenceTranslationWrap = document.getElementById('popup-sentence-translation-wrap');
const popupSentenceRu = document.getElementById('popup-sentence-ru');
const popupContextMeaningWrap = document.getElementById('popup-context-meaning-wrap');
const popupContextMeaningVal = document.getElementById('popup-context-meaning-val');
const popupAiSourceBadge = document.getElementById('popup-ai-source-badge');
if (popupTranslateSentenceBtn) {
popupTranslateSentenceBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!popupCurrentData || !popupCurrentData.contextSentence) return;
const ctxSentence = popupCurrentData.contextSentence;
const term = popupCurrentData.term;
popupTranslateSentenceBtn.disabled = true;
if (popupSentenceLoading) popupSentenceLoading.classList.remove('hidden');
if (popupSentenceTranslationWrap) popupSentenceTranslationWrap.classList.add('hidden');
if (popupContextMeaningWrap) popupContextMeaningWrap.classList.add('hidden');
try {
const res = await api.lookup.translateSentence(ctxSentence, term);
if (popupSentenceLoading) popupSentenceLoading.classList.add('hidden');
if (popupSentenceTranslationWrap) popupSentenceTranslationWrap.classList.remove('hidden');
if (popupSentenceRu) {
popupSentenceRu.textContent = res.sentence_ru || 'Перевод не получен';
}
if (res.word_meaning && res.word_meaning.trim()) {
popupCurrentData.contextMeaning = res.word_meaning.trim();
if (popupContextMeaningWrap) popupContextMeaningWrap.classList.remove('hidden');
if (popupContextMeaningVal) popupContextMeaningVal.textContent = res.word_meaning.trim();
} else {
if (popupContextMeaningWrap) popupContextMeaningWrap.classList.add('hidden');
}
if (popupAiSourceBadge) {
popupAiSourceBadge.classList.remove('hidden');
popupAiSourceBadge.textContent = res.cached ? '⚡ Кеш (0.01ms)' : '⚡ Local AI (Qwen)';
popupAiSourceBadge.title = res.source || '';
}
positionWordPopup(popupCurrentData.targetElement);
} catch (err) {
console.error('Sentence translation error:', err);
if (popupSentenceLoading) popupSentenceLoading.classList.add('hidden');
if (popupSentenceTranslationWrap) popupSentenceTranslationWrap.classList.remove('hidden');
if (popupSentenceRu) popupSentenceRu.textContent = 'Ошибка связи с локальным ИИ';
} finally {
popupTranslateSentenceBtn.disabled = false;
}
});
}
if (popupUseContextMeaningBtn) {
popupUseContextMeaningBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (!popupCurrentData || !popupCurrentData.contextMeaning) return;
const meaning = popupCurrentData.contextMeaning;
if (popupTranslation) popupTranslation.textContent = meaning;
if (popupCurrentData.lookup) {
popupCurrentData.lookup.translation = meaning;
}
popupUseContextMeaningBtn.textContent = '✓ Вставлено!';
setTimeout(() => {
if (popupUseContextMeaningBtn) popupUseContextMeaningBtn.textContent = 'Вставить в карточку';
}, 1800);
});
}
if (popupSaveBtn) {
popupSaveBtn.addEventListener('click', async () => {
if (!popupCurrentData) return;