feat(web): перевод поста на русский локальной моделью (openspec post-translate)

Кнопка «🌐 Перевести» в карточках кандидатов и отобранных переводит пост
локальной моделью (qwen3:8b-nothink), результат в posts.translated_text
(идемпотентный ALTER при старте). Метка «Оригинальный пост» стала закладкой,
при готовом переводе рядом появляется закладка «Перевод» (активна по умолчанию),
переключение на vanilla JS. Повторный клик не тратит токены: перевод в БД.
call_ollama из classifier не подошла (парсит ответ как JSON) — прямой запрос
к Ollama /v1/chat/completions. POST /posts/{id}/translate, лимит 6000 символов.
This commit is contained in:
kpa39l
2026-09-18 05:40:59 +00:00
parent 745680a3a0
commit 4a166fa9d6
9 changed files with 530 additions and 29 deletions
+34 -2
View File
@@ -165,8 +165,22 @@
</div>
<hr class="flex-shrink-0">
<h6 class="text-muted flex-shrink-0">Оригинальный пост</h6>
<div class="flex-grow-1 overflow-auto" style="min-height:0;">
<div class="d-flex align-items-center justify-content-between flex-shrink-0 mb-1">
<ul class="nav nav-pills small mb-0" style="--bs-nav-link-padding-y:.15rem; --bs-nav-link-padding-x:.6rem;">
<li class="nav-item"><button class="nav-link active" data-bs-toggle="pill" data-bs-target="#tab-orig-c" type="button">Оригинал</button></li>
{% if selected.translated_text %}
<li class="nav-item"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-tr-c" type="button">Перевод</button></li>
{% endif %}
</ul>
{% if not selected.translated_text %}
<form method="post" action="/posts/{{ selected.id }}/translate" class="mb-0">
{{ hid }}
<button class="btn btn-sm btn-outline-primary" type="submit" title="Перевести пост на русский локальной моделью (qwen3:8b)">🌐 Перевести</button>
</form>
{% endif %}
</div>
<div class="tab-content flex-grow-1 overflow-auto" style="min-height:0;">
<div class="tab-pane fade show active" id="tab-orig-c">
{% if selected.text %}
<div class="post-text mt-1">{{ selected.text | safe_html }}</div>
{% endif %}
@@ -185,6 +199,12 @@
{% endif %}
{% if selected.summary %}<div class="mt-2"><span class="badge bg-light text-dark">резюме</span> {{ selected.summary }}</div>{% endif %}
</div>
{% if selected.translated_text %}
<div class="tab-pane fade" id="tab-tr-c">
<div class="post-text mt-1">{{ selected.translated_text | safe_html }}</div>
</div>
{% endif %}
</div>
{% else %}
<div class="alert alert-info py-3">Выберите кандидата слева</div>
@@ -230,6 +250,18 @@
try { sessionStorage.removeItem(KEY); } catch(e) {}
});
upd();
// Переключение закладок «Оригинал» / «Перевод» (без CDN, vanilla JS)
document.querySelectorAll('button[data-bs-toggle="pill"]').forEach(function(btn){
btn.addEventListener('click', function(){
document.querySelectorAll('button[data-bs-toggle="pill"]').forEach(function(b){
b.classList.remove('active');
});
this.classList.add('active');
document.querySelectorAll('.tab-pane').forEach(function(p){ p.classList.remove('show','active'); });
var t = document.querySelector(this.dataset.bsTarget);
if (t) t.classList.add('show','active');
});
});
})();
</script>
{% endblock %}
+53 -16
View File
@@ -177,24 +177,49 @@
</form>
</div>
<div class="col-md-6 d-flex flex-column" style="min-height:0;">
<h6 class="text-muted">Оригинальный пост</h6>
<div class="border rounded p-2 bg-light small overflow-auto flex-grow-1" style="min-height:0;">
{% if selected.text %}
<div class="post-text">{{ selected.text | safe_html }}</div>
{% endif %}
{% if selected.media_path %}
{% set media_src = '/media/' ~ selected.media_path.split('/')[-1] %}
{% set media_ext = selected.media_path.lower().rsplit('.', 1)[-1] if '.' in selected.media_path else '' %}
{% if media_ext in ['jpg','jpeg','png','gif','webp'] %}
<a href="{{ media_src }}" target="_blank" title="Открыть полноразмер">
<img src="{{ media_src }}" class="img-fluid rounded mt-2 d-block" style="max-height:50vh" alt="медиа поста">
</a>
{% elif media_ext in ['mp4','webm','mov'] %}
<video src="{{ media_src }}" controls class="mt-2 d-block" style="max-height:50vh"></video>
{% else %}
<a class="btn btn-sm btn-outline-secondary mt-2" href="{{ media_src }}" target="_blank">📎 Медиафайл</a>
<div class="d-flex align-items-center justify-content-between flex-shrink-0 mb-1">
<h6 class="text-muted mb-0">Оригинальный пост</h6>
{% if not selected.translated_text %}
<form method="post" action="/posts/{{ selected.id }}/translate" class="mb-0">
{{ hid }}
<button class="btn btn-sm btn-outline-primary" type="submit" title="Перевести пост на русский локальной моделью (qwen3:8b)">🌐 Перевести</button>
</form>
{% endif %}
</div>
{% if selected.translated_text %}
<ul class="nav nav-pills small mb-1 flex-shrink-0" style="--bs-nav-link-padding-y:.15rem; --bs-nav-link-padding-x:.6rem;">
<li class="nav-item"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-orig-s" type="button">Оригинал</button></li>
<li class="nav-item"><button class="nav-link active" data-bs-toggle="pill" data-bs-target="#tab-tr-s" type="button">Перевод</button></li>
</ul>
{% endif %}
<div class="tab-content flex-grow-1" style="min-height:0;">
<div class="tab-pane fade {% if not selected.translated_text %}show active{% endif %}" id="tab-orig-s">
<div class="border rounded p-2 bg-light small overflow-auto flex-grow-1" style="min-height:0;">
{% if selected.text %}
<div class="post-text">{{ selected.text | safe_html }}</div>
{% endif %}
{% if selected.media_path %}
{% set media_src = '/media/' ~ selected.media_path.split('/')[-1] %}
{% set media_ext = selected.media_path.lower().rsplit('.', 1)[-1] if '.' in selected.media_path else '' %}
{% if media_ext in ['jpg','jpeg','png','gif','webp'] %}
<a href="{{ media_src }}" target="_blank" title="Открыть полноразмер">
<img src="{{ media_src }}" class="img-fluid rounded mt-2 d-block" style="max-height:50vh" alt="медиа поста">
</a>
{% elif media_ext in ['mp4','webm','mov'] %}
<video src="{{ media_src }}" controls class="mt-2 d-block" style="max-height:50vh"></video>
{% else %}
<a class="btn btn-sm btn-outline-secondary mt-2" href="{{ media_src }}" target="_blank">📎 Медиафайл</a>
{% endif %}
{% endif %}
</div>
</div>
{% if selected.translated_text %}
<div class="tab-pane fade show active" id="tab-tr-s">
<div class="border rounded p-2 bg-light small overflow-auto flex-grow-1" style="min-height:0;">
<div class="post-text">{{ selected.translated_text | safe_html }}</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
@@ -261,6 +286,18 @@
try { sessionStorage.removeItem(KEY); } catch(e) {}
});
upd();
// Переключение закладок «Оригинал» / «Перевод» (без CDN, vanilla JS)
document.querySelectorAll('button[data-bs-toggle="pill"]').forEach(function(btn){
btn.addEventListener('click', function(){
document.querySelectorAll('button[data-bs-toggle="pill"]').forEach(function(b){
b.classList.remove('active');
});
this.classList.add('active');
document.querySelectorAll('.tab-pane').forEach(function(p){ p.classList.remove('show','active'); });
var t = document.querySelector(this.dataset.bsTarget);
if (t) t.classList.add('show','active');
});
});
})();
</script>
{% endblock %}