v2: модульный монолит — очередь, персистентность, безопасность, SSE, тесты
Some checks failed
test / test (push) Failing after 24s
Some checks failed
test / test (push) Failing after 24s
Реализация целевого дизайна (docs/architecture/target-design.md): - src/: модульная структура (config, logger, errors, http, core: auth/history/jobs/engines/comfy/codex/images) - Очередь заданий: FIFO, кониурентность codex=1 / comfy-upscale=2, MAX_QUEUED_JOBS=5, JSONL-журнал и восстановление после рестарта (running → interrupted, upscale requeue) - Персистентные сессии (sha256-хеши токенов), scrypt-хеш пароля, rate-limit входа, Origin-проверка, magic-byte валидация аплоадов - Атомарная запись манифеста истории, каскадное удаление, URL с фактическим расширением + 302-алиас /image.jpg, миграция history/ → data/history - Даунскейл sharp до 1024px перед Codex + масштабирование области референса - SSE /api/events с фолбэком на polling, фронтенд переведён на ES-модули (public/js/) - Тесты node:test + supertest (27), CI workflow test.yml, Docker/деплой: том kadr-data - Документация: README, docs/architecture/
This commit is contained in:
187
public/js/suggest.js
Normal file
187
public/js/suggest.js
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* suggest.js — Подсказки промптов из истории и генерация через Codex
|
||||
*/
|
||||
|
||||
import { apiFetch } from "./api.js";
|
||||
import { getState, setSuggestRunning } from "./state.js";
|
||||
|
||||
function applyPrompt(promptText) {
|
||||
const promptInput = document.querySelector("#prompt");
|
||||
const promptCounter = document.querySelector("#prompt-counter");
|
||||
const editButton = document.querySelector("#edit-button");
|
||||
|
||||
if (!promptInput) return;
|
||||
|
||||
promptInput.value = promptText;
|
||||
if (promptCounter) {
|
||||
promptCounter.textContent = `${promptText.length} / 1000`;
|
||||
}
|
||||
|
||||
const state = getState();
|
||||
if (editButton) {
|
||||
editButton.disabled = !state.currentVersionId || !promptText.trim() || state.isBusy;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderSuggestChips(container, items) {
|
||||
if (!container) return;
|
||||
container.innerHTML = "";
|
||||
|
||||
for (const item of items) {
|
||||
const text = String(item || "").trim();
|
||||
if (!text) continue;
|
||||
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "suggest-chip";
|
||||
chip.textContent = text;
|
||||
chip.addEventListener("click", () => applyPrompt(text));
|
||||
container.appendChild(chip);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSuggestHistoryChips() {
|
||||
const container = document.querySelector("#suggest-history-chips");
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
const res = await apiFetch("/api/suggest-prompts");
|
||||
const prompts = Array.isArray(res?.prompts) ? res.prompts : [];
|
||||
if (prompts.length === 0) {
|
||||
container.innerHTML = '<span class="suggest-empty">В истории пока нет промптов</span>';
|
||||
return;
|
||||
}
|
||||
renderSuggestChips(container, prompts);
|
||||
} catch {
|
||||
container.innerHTML = '<span class="suggest-empty">Не удалось загрузить подсказки</span>';
|
||||
}
|
||||
}
|
||||
|
||||
export function updateSuggestCodexButton() {
|
||||
const suggestBtn = document.querySelector("#suggest-button");
|
||||
const codexBtn = document.querySelector("#suggest-codex-button");
|
||||
const state = getState();
|
||||
|
||||
const canSuggest = Boolean(state.currentVersionId) && !state.isBusy;
|
||||
if (suggestBtn) {
|
||||
suggestBtn.disabled = !canSuggest;
|
||||
}
|
||||
if (codexBtn) {
|
||||
codexBtn.disabled = !canSuggest || state.suggestRunning;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSuggest() {
|
||||
const state = getState();
|
||||
if (!state.currentVersionId || state.suggestRunning || state.isBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusEl = document.querySelector("#suggest-codex-status");
|
||||
const chipsContainer = document.querySelector("#suggest-codex-chips");
|
||||
const codexBtn = document.querySelector("#suggest-codex-button");
|
||||
|
||||
setSuggestRunning(true);
|
||||
updateSuggestCodexButton();
|
||||
|
||||
if (statusEl) statusEl.textContent = "Codex анализирует кадр…";
|
||||
if (chipsContainer) chipsContainer.innerHTML = "";
|
||||
|
||||
try {
|
||||
const res = await apiFetch("/api/suggest", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceId: state.currentVersionId }),
|
||||
});
|
||||
|
||||
const jobId = res.jobId;
|
||||
await pollSuggestJob(jobId);
|
||||
} catch (err) {
|
||||
if (statusEl) statusEl.textContent = `Ошибка: ${err.message}`;
|
||||
setSuggestRunning(false);
|
||||
updateSuggestCodexButton();
|
||||
}
|
||||
}
|
||||
|
||||
async function pollSuggestJob(jobId) {
|
||||
const statusEl = document.querySelector("#suggest-codex-status");
|
||||
const chipsContainer = document.querySelector("#suggest-codex-chips");
|
||||
|
||||
let failures = 0;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const data = await apiFetch(`/api/jobs/${jobId}/status`);
|
||||
failures = 0;
|
||||
|
||||
if (data.status === "queued") {
|
||||
if (statusEl) {
|
||||
statusEl.textContent = data.queuePosition
|
||||
? `В очереди (позиция ${data.queuePosition})…`
|
||||
: "В очереди…";
|
||||
}
|
||||
setTimeout(poll, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.status === "running") {
|
||||
if (statusEl) statusEl.textContent = "Codex генерирует варианты…";
|
||||
setTimeout(poll, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.status === "done") {
|
||||
if (statusEl) statusEl.textContent = "";
|
||||
const suggestions = data.result?.suggestions || [];
|
||||
if (suggestions.length > 0) {
|
||||
renderSuggestChips(chipsContainer, suggestions);
|
||||
} else {
|
||||
if (statusEl) statusEl.textContent = "Codex не вернул вариантов.";
|
||||
}
|
||||
setSuggestRunning(false);
|
||||
updateSuggestCodexButton();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.status === "error" || data.status === "interrupted") {
|
||||
if (statusEl) statusEl.textContent = `Ошибка: ${data.error || "Не удалось получить подсказки."}`;
|
||||
setSuggestRunning(false);
|
||||
updateSuggestCodexButton();
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(poll, 1500);
|
||||
} catch (err) {
|
||||
failures++;
|
||||
if (failures >= 5) {
|
||||
if (statusEl) statusEl.textContent = `Ошибка связи с сервером: ${err.message}`;
|
||||
setSuggestRunning(false);
|
||||
updateSuggestCodexButton();
|
||||
return;
|
||||
}
|
||||
setTimeout(poll, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
}
|
||||
|
||||
export function initSuggest() {
|
||||
const suggestBtn = document.querySelector("#suggest-button");
|
||||
const suggestPanel = document.querySelector("#suggest-panel");
|
||||
const codexBtn = document.querySelector("#suggest-codex-button");
|
||||
|
||||
if (suggestBtn && suggestPanel) {
|
||||
suggestBtn.addEventListener("click", () => {
|
||||
const isHidden = suggestPanel.hidden;
|
||||
suggestPanel.hidden = !isHidden;
|
||||
if (isHidden) {
|
||||
loadSuggestHistoryChips();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (codexBtn) {
|
||||
codexBtn.addEventListener("click", runSuggest);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user