astrololo: generowanie tekstu jako moduł odłączalny (2/5)
build / build (push) Failing after 2s
Testy / Testy warstwy logicznej (silnik) (push) Failing after 4s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Failing after 3s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Failing after 3s
Testy / Testy astrodemo (push) Failing after 3s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 2s
Testy / Kontrola składni wszystkich warstw (push) Failing after 3s
build / build (push) Failing after 2s
Testy / Testy warstwy logicznej (silnik) (push) Failing after 4s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Failing after 3s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Failing after 3s
Testy / Testy astrodemo (push) Failing after 3s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 2s
Testy / Kontrola składni wszystkich warstw (push) Failing after 3s
Drugi z pięciu kroków budowy trzech produktów. Czysty refaktor — zachowanie
aplikacji się nie zmienia, liczba testów rośnie tylko o nowe.
DLACZEGO WARUNEK W SZABLONIE NIE WYSTARCZA. Dotąd generowanie było chowane przez
`{% if can(request, 'ai') %}`. To sprawia, że funkcji nie WIDAĆ, ale nie że jej
NIE MA: plik szablonu dalej leży w obrazie i dalej zawiera jej nazwy, więc `grep`
po kontenerze pokazuje wszystko, czego warunek nie pokazał na ekranie. Astroklient
ma nie mieć śladu, nie mieć wyłączoną funkcję — więc granica musi być KATALOGIEM.
CO SIĘ PRZENIOSŁO do app/dodatki/: dwa szablony, pięć plików statycznych, trasa
strumienia, cztery metody klienta warstwy logicznej, wpis w katalogu funkcji,
wpisy tras i zasobów, akcje formularza i reguły CSS nazywające funkcję
(`textarea.prompt`).
JAK APLIKACJA O TO PYTA. Neutralny most `app/rozszerzenia.py` odpowiada wyłącznie
na pytanie „czy coś jest podpięte i co wnosi". Sam nie wymienia ani jednej nazwy —
pilnuje tego osobny test, bo most jest w KAŻDYM obrazie. Dlatego też katalog
nazywa się `dodatki`, a nie `ai`: nazwa w instrukcji importu byłaby dokładnie tym
śladem, którego wydzielanie ma się pozbyć.
TRZY MIEJSCA, KTÓRE OKAZAŁY SIĘ TRUDNE:
1. Pola formularza. FastAPI czyta je z SYGNATURY, a wspólny handler nie może
wymieniać `prompt_budget` ani `llm_provider`. Rozwiązane zależnością: moduł
deklaruje własne pola u siebie, handler wie tylko, że dostaje słownik.
2. Klient warstwy logicznej jest wspólny, więc metody `prompt`/`horoscope`/
`llm_models` musiały z niego wyjść. Wspólny klient daje teraz samą drogę
w dół (`wywolaj`, `pobierz`, `strumien`) — z szyfrowaniem łącza i tokenem
międzywarstwowym; co nią pojedzie, jest sprawą modułu.
3. Podtytuł ekranu Skompiluj miał wariant „z AI" i wariant bez. Zamiast warunku
jedno zdanie prawdziwe niezależnie od tego, jakie moduły są w obrazie.
DOCKERFILE. `COPY . .` wnosiło do obrazu także testy — a plik testowy nazywa
funkcje wprost. Teraz wchodzi wyłącznie `app/`.
TEST GRANICY. `test_ai_tylko_w_module.py` przechodzi po wszystkim poza modułem
i szuka siedemnastu słów. Znalazł dwanaście resztek, których nie widziałem:
komentarze w main.py i security.py, przykład `models.js` w komentarzu features.py,
komentarze w compile.js i w trzech arkuszach, oraz regułę `textarea.prompt`,
która przy rozbijaniu CSS trafiła do arkuszy ekranów zamiast do modułu.
Ma też kontrolę pozytywną (te słowa MAJĄ padać w module — inaczej test
przechodziłby, gdyby funkcję wydrążono) i test odłączalności mostu.
SPRAWDZONE NA KOPII BEZ MODUŁU: aplikacja startuje, wszystkie ekrany oddają 200,
katalog ma 13 funkcji zamiast 14, trasy strumienia nie ma, spreparowane
`action=prompt` wraca do akcji domyślnej bez śladu, a `grep` po drzewie nie
znajduje ani jednego ze słów.
Testy: presentation 362, logic 342, data 37, render 41.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit was merged in pull request #80.
This commit is contained in:
@@ -2,8 +2,10 @@
|
||||
//
|
||||
// Raport ma trzy kawałki liczone w różnych miejscach:
|
||||
// 1. horoskop — liczy serwer przy wysłaniu tego formularza,
|
||||
// 2. interpretacja natalna — z zakładki Interpretacje (natal.js),
|
||||
// 3. predykcje okresowe — z zakładki Kalendarz (predictions.js).
|
||||
// 2. interpretacja natalna — z zakładki Interpretacje,
|
||||
// 3. predykcje okresowe — z zakładki Kalendarz.
|
||||
// Kawałki 2 i 3 wnosi moduł opcjonalny. Gdy go nie ma, magazynów nie ma również
|
||||
// i czytanie ich zwraca pusto — raport składa się wtedy z samego horoskopu.
|
||||
//
|
||||
// Punkty 2 i 3 mieszkają w magazynie przeglądarki, więc to tutaj je dokładamy.
|
||||
// Odczytujemy je przez API tamtych modułów, a nie sięgając wprost do
|
||||
@@ -86,7 +88,7 @@
|
||||
}
|
||||
|
||||
// ── PDF (PRE-24) ────────────────────────────────────────────────────
|
||||
// Części od AI mieszkają w przeglądarce, więc raport składamy TU i wysyłamy
|
||||
// Te kawałki mieszkają w przeglądarce, więc raport składamy TU i wysyłamy
|
||||
// do serwera, który dokłada kosmogram i woła usługę render.
|
||||
function formData() {
|
||||
function v(name) {
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
// Podpowiedzi modeli zależne od wybranego dostawcy.
|
||||
//
|
||||
// Pole modelu jest CELOWO tekstowe (input + datalist), a nie zamkniętym <select>:
|
||||
// katalog to tylko wygoda, a konto może mieć dostęp do modeli, o których kod nie
|
||||
// wie. Puste pole = model domyślny dostawcy.
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const providerEl = document.getElementById('llmProvider');
|
||||
const modelEl = document.getElementById('llmModel');
|
||||
const listEl = document.getElementById('llmModelList');
|
||||
const hintEl = document.getElementById('llmModelHint');
|
||||
const dataEl = document.getElementById('llmCatalog');
|
||||
if (!providerEl || !modelEl || !listEl || !dataEl) return;
|
||||
|
||||
let catalog = {};
|
||||
let defaults = {};
|
||||
try {
|
||||
const parsed = JSON.parse(dataEl.textContent || '{}');
|
||||
catalog = parsed.providers || {};
|
||||
defaults = parsed.defaults || {};
|
||||
} catch (e) {
|
||||
return; // brak katalogu — pole nadal działa jako wolny tekst
|
||||
}
|
||||
|
||||
const fmt = n =>
|
||||
n >= 1000000 ? (n / 1000000) + 'M' : (n >= 1000 ? Math.round(n / 1000) + 'k' : String(n));
|
||||
|
||||
function refresh(resetValue) {
|
||||
const provider = providerEl.value;
|
||||
const models = catalog[provider] || [];
|
||||
|
||||
listEl.innerHTML = '';
|
||||
models.forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.id;
|
||||
opt.label = m.label + ' · kontekst ' + fmt(m.context_window);
|
||||
listEl.appendChild(opt);
|
||||
});
|
||||
|
||||
const fallback = defaults[provider] || '';
|
||||
modelEl.placeholder = fallback ? 'domyślny: ' + fallback : 'domyślny dostawcy';
|
||||
|
||||
// po zmianie dostawcy stary model nie ma sensu (np. gpt-4o u Anthropica)
|
||||
if (resetValue) modelEl.value = '';
|
||||
|
||||
if (hintEl) {
|
||||
const chosen = models.find(m => m.id === modelEl.value.trim());
|
||||
hintEl.textContent = chosen
|
||||
? chosen.label + ' — okno kontekstu ' + fmt(chosen.context_window) +
|
||||
' tokenów, maksymalna odpowiedź ' + fmt(chosen.max_output) + '.'
|
||||
: 'Zostaw puste, by użyć modelu domyślnego. Możesz też wpisać dowolny ' +
|
||||
'identyfikator modelu, do którego Twoje konto ma dostęp.';
|
||||
}
|
||||
}
|
||||
|
||||
providerEl.addEventListener('change', () => refresh(true));
|
||||
modelEl.addEventListener('input', () => refresh(false));
|
||||
refresh(false);
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
// Zapamiętana interpretacja natalna (PRE-23).
|
||||
//
|
||||
// Odpowiednik predictions.js, tylko że natalna jest JEDNA — dotyczy momentu
|
||||
// urodzenia, nie wybranego okresu. Ponowne wygenerowanie podmienia poprzednią.
|
||||
//
|
||||
// Po co magazyn: zakładka „Skompiluj" składa raport z trzech kawałków liczonych
|
||||
// w różnych miejscach (horoskop, interpretacja natalna, predykcje okresowe).
|
||||
// Bez zapamiętania trzeba by je generować od nowa na jednej stronie.
|
||||
//
|
||||
// localStorage, tak jak reszta stanu (PRE-21/22) — serwer zostaje bezstanowy.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var KEY = 'astrololo.natal.v1';
|
||||
|
||||
function read() {
|
||||
try {
|
||||
var v = JSON.parse(localStorage.getItem(KEY) || 'null');
|
||||
return (v && typeof v === 'object' && v.text) ? v : null;
|
||||
} catch (e) {
|
||||
return null; // uszkodzony wpis nie może zablokować strony
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
try { localStorage.removeItem(KEY); } catch (e) { /* trudno */ }
|
||||
}
|
||||
|
||||
function val(sel) {
|
||||
var el = document.querySelector(sel);
|
||||
return el ? el.value.trim() : '';
|
||||
}
|
||||
|
||||
function save() {
|
||||
var text = val('#horoscopeText');
|
||||
if (!text) return false; // nie ma czego zapamiętać
|
||||
var entry = {
|
||||
text: text,
|
||||
person: val('input[name=person]'),
|
||||
date: val('input[name=date]'),
|
||||
time: val('input[name=time]'),
|
||||
tz_offset: val('input[name=tz_offset]'),
|
||||
lat: val('input[name=lat]'),
|
||||
lon: val('input[name=lon]'),
|
||||
saved_at: new Date().toISOString()
|
||||
};
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(entry));
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false; // przepełniony magazyn — zgłaszamy wyżej
|
||||
}
|
||||
}
|
||||
|
||||
function note(msg) {
|
||||
var el = document.getElementById('natalNote');
|
||||
if (el) el.textContent = msg;
|
||||
}
|
||||
|
||||
function saveCurrent() {
|
||||
var text = val('#horoscopeText');
|
||||
if (!text) return;
|
||||
if (save()) {
|
||||
note('Interpretacja natalna zapamiętana — trafi do raportu (zakładka Skompiluj).');
|
||||
} else {
|
||||
// Interpretacje bywają długie; cicha strata byłaby najgorsza.
|
||||
note('Nie udało się zapamiętać — magazyn przeglądarki pełny. '
|
||||
+ 'Usuń starsze predykcje i spróbuj ponownie.');
|
||||
}
|
||||
}
|
||||
|
||||
function ready(fn) {
|
||||
if (document.readyState !== 'loading') fn();
|
||||
else document.addEventListener('DOMContentLoaded', fn);
|
||||
}
|
||||
|
||||
ready(function () {
|
||||
if (!document.getElementById('natalNote')) return; // nie ta zakładka
|
||||
|
||||
// 1) interpretacja policzona przez okno postępu (strumień)
|
||||
document.addEventListener('astrololo:horoscope', function (e) {
|
||||
if (!e.detail || e.detail.profile === 'natal') saveCurrent();
|
||||
});
|
||||
|
||||
// 2) wariant bez strumienia: strona wróciła z gotowym wynikiem po zwykłym
|
||||
// POST. Zapis nadpisuje jeden slot, więc odświeżenie niczego nie mnoży.
|
||||
if (document.querySelector('#horoscopeText')) saveCurrent();
|
||||
|
||||
var status = read();
|
||||
if (status) {
|
||||
note('W raporcie jest zapamiętana interpretacja natalna z '
|
||||
+ String(status.saved_at).slice(0, 10) + '.');
|
||||
}
|
||||
});
|
||||
|
||||
window.astrololoNatal = { read: read, clear: clear, key: KEY };
|
||||
})();
|
||||
@@ -1,150 +0,0 @@
|
||||
// Zapamiętane predykcje okresowe (PRE-22).
|
||||
//
|
||||
// Kalendarz pozwala policzyć horoskop na wybrany okres. Partnerzy chcą kilku
|
||||
// okresów naraz — i żeby wszystkie trafiły potem do raportu (zakładka
|
||||
// „Skompiluj", PRE-23/24). Tu jest magazyn i lista.
|
||||
//
|
||||
// Trzymamy w localStorage, tak jak wspólne dane formularza (PRE-21): serwer
|
||||
// zostaje bezstanowy, żadne dane urodzeniowe ani treści z baz nie lądują po
|
||||
// stronie usługi. Zakładka „Skompiluj" odczyta to samo miejsce.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var KEY = 'astrololo.predictions.v1';
|
||||
|
||||
function read() {
|
||||
try {
|
||||
var v = JSON.parse(localStorage.getItem(KEY) || '[]');
|
||||
return Array.isArray(v) ? v : [];
|
||||
} catch (e) {
|
||||
return []; // uszkodzony wpis nie może zablokować strony
|
||||
}
|
||||
}
|
||||
|
||||
function write(list) {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(list));
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false; // najczęściej przepełniony magazyn
|
||||
}
|
||||
}
|
||||
|
||||
function byPeriod(a, b) {
|
||||
return String(a.from_date).localeCompare(String(b.from_date));
|
||||
}
|
||||
|
||||
// Klucz tożsamości to OKRES. Ponowne policzenie tego samego zakresu ma
|
||||
// podmienić wpis, a nie dokładać duplikat — inaczej lista puchłaby przy
|
||||
// każdej próbie z innym modelem.
|
||||
function upsert(entry) {
|
||||
var list = read().filter(function (p) {
|
||||
return !(p.from_date === entry.from_date && p.to_date === entry.to_date);
|
||||
});
|
||||
list.push(entry);
|
||||
list.sort(byPeriod);
|
||||
return write(list) ? list : null;
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
var list = read().filter(function (p) { return p.id !== id; });
|
||||
write(list);
|
||||
return list;
|
||||
}
|
||||
|
||||
function val(sel) {
|
||||
var el = document.querySelector(sel);
|
||||
return el ? el.value.trim() : '';
|
||||
}
|
||||
|
||||
/** Wyciąga świeżo policzony horoskop okresowy ze strony. */
|
||||
function capture() {
|
||||
var text = val('#horoscopeText');
|
||||
var from = val('input[name=from_date]');
|
||||
var to = val('input[name=to_date]');
|
||||
if (!text || !from || !to) return null;
|
||||
return {
|
||||
id: from + '..' + to,
|
||||
from_date: from,
|
||||
to_date: to,
|
||||
person: val('input[name=person]'),
|
||||
text: text,
|
||||
saved_at: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
return String(iso || '').slice(0, 10);
|
||||
}
|
||||
|
||||
function render() {
|
||||
var host = document.getElementById('savedPredictions');
|
||||
if (!host) return;
|
||||
var list = read();
|
||||
if (!list.length) {
|
||||
host.innerHTML = '<p class="muted small">Brak zapamiętanych predykcji. ' +
|
||||
'Policz horoskop na wybrany okres — zapamięta się sam i trafi do raportu.</p>';
|
||||
return;
|
||||
}
|
||||
var rows = list.map(function (p) {
|
||||
var chars = (p.text || '').length;
|
||||
return '<tr>' +
|
||||
'<td class="nowrap">' + fmtDate(p.from_date) + ' – ' + fmtDate(p.to_date) + '</td>' +
|
||||
'<td class="muted small">' + fmtDate(p.saved_at) + '</td>' +
|
||||
'<td class="muted small">' + chars.toLocaleString('pl-PL') + ' znaków</td>' +
|
||||
'<td><button type="button" class="ghost" data-drop="' +
|
||||
String(p.id).replace(/"/g, '"') + '">Usuń</button></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
host.innerHTML =
|
||||
'<div class="meta">Zapamiętane predykcje (' + list.length + ') — wejdą do raportu</div>' +
|
||||
'<table class="angles"><thead><tr><th>Okres</th><th>Zapisano</th>' +
|
||||
'<th>Objętość</th><th></th></tr></thead><tbody>' + rows + '</tbody></table>';
|
||||
|
||||
Array.prototype.forEach.call(host.querySelectorAll('[data-drop]'), function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
remove(btn.getAttribute('data-drop'));
|
||||
render();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function saveCurrent() {
|
||||
var entry = capture();
|
||||
if (!entry) return;
|
||||
var note = document.getElementById('predictionsNote');
|
||||
if (upsert(entry)) {
|
||||
if (note) note.textContent = 'Zapamiętano predykcję ' +
|
||||
entry.from_date + ' – ' + entry.to_date + '.';
|
||||
} else if (note) {
|
||||
// Horoskopy bywają długie; gdy magazyn się przepełni, trzeba powiedzieć
|
||||
// wprost, zamiast po cichu zgubić wynik.
|
||||
note.textContent = 'Nie udało się zapamiętać — magazyn przeglądarki pełny. ' +
|
||||
'Usuń starsze predykcje i spróbuj ponownie.';
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function ready(fn) {
|
||||
if (document.readyState !== 'loading') fn();
|
||||
else document.addEventListener('DOMContentLoaded', fn);
|
||||
}
|
||||
|
||||
ready(function () {
|
||||
if (!document.getElementById('savedPredictions')) return; // nie ta zakładka
|
||||
render();
|
||||
|
||||
// 1) horoskop policzony przez okno postępu (strumień)
|
||||
document.addEventListener('astrololo:horoscope', function (e) {
|
||||
if (!e.detail || e.detail.profile === 'period') saveCurrent();
|
||||
});
|
||||
|
||||
// 2) wariant bez strumienia: strona wróciła z gotowym wynikiem po zwykłym
|
||||
// POST. Zapis jest idempotentny (klucz = okres), więc odświeżenie strony
|
||||
// niczego nie duplikuje.
|
||||
if (document.querySelector('#horoscopeText')) saveCurrent();
|
||||
});
|
||||
|
||||
// Udostępniamy magazyn zakładce „Skompiluj" (PRE-23) — jedno źródło prawdy.
|
||||
window.astrololoPredictions = { read: read, remove: remove, key: KEY };
|
||||
})();
|
||||
@@ -1,138 +0,0 @@
|
||||
// Okno postępu przy pisaniu horoskopu.
|
||||
//
|
||||
// Problem: generowanie trwa minutami, a zwykły POST formularza nie daje żadnego
|
||||
// sygnału — aplikacja wygląda na zawieszoną. Zamiast udawanego paska postępu
|
||||
// czytamy strumień RZECZYWISTYCH zdarzeń z serwera (NDJSON) i wypisujemy je
|
||||
// jako log: budowa promptu, limity modelu, każda tura generowania.
|
||||
//
|
||||
// Degradacja: jeśli przeglądarka nie umie strumieniować `fetch`, nie przechwytujemy
|
||||
// wysyłki — formularz idzie klasycznie i wszystko działa jak wcześniej, tylko bez okna.
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const canStream = typeof fetch === 'function' && typeof ReadableStream === 'function' &&
|
||||
typeof TextDecoder === 'function';
|
||||
const form = document.querySelector('form[action="/interpret"], form[action="/timeline"]');
|
||||
if (!canStream || !form) return;
|
||||
|
||||
const profile = form.getAttribute('action') === '/timeline' ? 'period' : 'natal';
|
||||
const btn = form.querySelector('button[value="horoscope"]');
|
||||
if (!btn) return;
|
||||
|
||||
// --- okno ---------------------------------------------------------------
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'progress-overlay';
|
||||
overlay.hidden = true;
|
||||
overlay.innerHTML =
|
||||
'<div class="progress-box" role="dialog" aria-modal="true" aria-label="Postęp generowania">' +
|
||||
'<div class="progress-head">' +
|
||||
'<span class="progress-spinner" aria-hidden="true"></span>' +
|
||||
'<strong id="progressTitle">Piszę horoskop…</strong>' +
|
||||
'<span class="progress-clock" id="progressClock">0:00</span>' +
|
||||
'</div>' +
|
||||
'<ol class="progress-log" id="progressLog"></ol>' +
|
||||
'<p class="muted small">Nie zamykaj tej karty — generowanie trwa na serwerze.</p>' +
|
||||
'<div class="actions"><button type="button" class="ghost" id="progressClose" hidden>Zamknij</button></div>' +
|
||||
'</div>';
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const logEl = overlay.querySelector('#progressLog');
|
||||
const clockEl = overlay.querySelector('#progressClock');
|
||||
const titleEl = overlay.querySelector('#progressTitle');
|
||||
const closeEl = overlay.querySelector('#progressClose');
|
||||
let timer = null;
|
||||
|
||||
function addLine(text, kind) {
|
||||
const li = document.createElement('li');
|
||||
if (kind) li.className = 'log-' + kind;
|
||||
const now = new Date();
|
||||
li.textContent = String(now.getHours()).padStart(2, '0') + ':' +
|
||||
String(now.getMinutes()).padStart(2, '0') + ':' +
|
||||
String(now.getSeconds()).padStart(2, '0') + ' ' + text;
|
||||
logEl.appendChild(li);
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
function startClock() {
|
||||
const t0 = Date.now();
|
||||
timer = setInterval(function () {
|
||||
const s = Math.floor((Date.now() - t0) / 1000);
|
||||
clockEl.textContent = Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function finish(title, allowClose) {
|
||||
if (timer) { clearInterval(timer); timer = null; }
|
||||
titleEl.textContent = title;
|
||||
overlay.querySelector('.progress-spinner').style.visibility = 'hidden';
|
||||
if (allowClose) closeEl.hidden = false;
|
||||
}
|
||||
|
||||
closeEl.addEventListener('click', function () { overlay.hidden = true; });
|
||||
|
||||
// --- przechwycenie wysyłki ----------------------------------------------
|
||||
btn.addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!form.reportValidity()) return; // te same reguły co przy zwykłej wysyłce
|
||||
|
||||
const data = new FormData(form);
|
||||
data.set('profile', profile);
|
||||
|
||||
logEl.innerHTML = '';
|
||||
closeEl.hidden = true;
|
||||
overlay.querySelector('.progress-spinner').style.visibility = '';
|
||||
titleEl.textContent = 'Piszę horoskop…';
|
||||
overlay.hidden = false;
|
||||
startClock();
|
||||
addLine('Wysyłam żądanie…');
|
||||
|
||||
fetch('/horoscope/stream', { method: 'POST', body: data })
|
||||
.then(function (response) {
|
||||
if (!response.ok || !response.body) throw new Error('HTTP ' + response.status);
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
function pump() {
|
||||
return reader.read().then(function (chunk) {
|
||||
if (chunk.done) return;
|
||||
buffer += decoder.decode(chunk.value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop(); // ostatni może być niepełny
|
||||
lines.forEach(function (raw) {
|
||||
if (!raw.trim()) return;
|
||||
let ev;
|
||||
try { ev = JSON.parse(raw); } catch (e) { return; }
|
||||
if (ev.type === 'ping') return; // sam heartbeat, nie logujemy
|
||||
if (ev.type === 'result') {
|
||||
addLine(ev.message || 'Gotowe.', 'ok');
|
||||
if (ev.html) {
|
||||
const host = document.getElementById('promptResult');
|
||||
if (host) host.innerHTML = ev.html;
|
||||
}
|
||||
finish('Gotowe', true);
|
||||
overlay.hidden = true;
|
||||
const host = document.getElementById('promptResult');
|
||||
if (host) host.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
// Gotowy horoskop ogłaszamy zdarzeniem, zamiast zapisywać go tutaj:
|
||||
// to okno postępu, a nie magazyn. Nasłuchuje predictions.js (PRE-22).
|
||||
document.dispatchEvent(new CustomEvent('astrololo:horoscope', {
|
||||
detail: { profile: profile }
|
||||
}));
|
||||
return;
|
||||
}
|
||||
addLine(ev.message || ev.type, ev.type === 'error' ? 'err'
|
||||
: ev.type === 'warn' ? 'warn' : null);
|
||||
if (ev.type === 'error') finish('Nie udało się', true);
|
||||
});
|
||||
return pump();
|
||||
});
|
||||
}
|
||||
return pump();
|
||||
})
|
||||
.catch(function (e) {
|
||||
addLine('Połączenie przerwane: ' + e.message, 'err');
|
||||
addLine('Możesz spróbować ponownie — nic nie zostało utracone.');
|
||||
finish('Nie udało się', true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -102,7 +102,7 @@ body.wheel-zoom-open { overflow: hidden; }
|
||||
.readiness .rdy-missing { color: #e0c060; }
|
||||
.report-head { margin: 1.5rem 0 .5rem; }
|
||||
.report-head h2 { margin: 0; font-size: 1.4rem; }
|
||||
/* Treść od AI bywa długa — white-space:pre-wrap zachowuje akapity modelu,
|
||||
/* Bloki tekstu bywają długie — white-space:pre-wrap zachowuje akapity
|
||||
bez przepisywania ich na HTML. */
|
||||
.report-text { white-space: pre-wrap; background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 10px; padding: .8rem 1rem; margin-top: .4rem; line-height: 1.6; }
|
||||
|
||||
@@ -34,9 +34,3 @@ td.retro { color: #ff9b6a; font-weight: 600; }
|
||||
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
|
||||
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
|
||||
.leaflet-control-attribution { font-size: .68rem; }
|
||||
/* Generator promptu do LLM (LOG-29/30) */
|
||||
textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizing: border-box;
|
||||
background: #12132a; color: var(--ink); border: 1px solid var(--line);
|
||||
border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
|
||||
textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
|
||||
@@ -31,9 +31,3 @@ td.retro { color: #ff9b6a; font-weight: 600; }
|
||||
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
|
||||
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
|
||||
.leaflet-control-attribution { font-size: .68rem; }
|
||||
/* Generator promptu do LLM (LOG-29/30) */
|
||||
textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizing: border-box;
|
||||
background: #12132a; color: var(--ink); border: 1px solid var(--line);
|
||||
border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
|
||||
textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
/* Generowanie tekstu przez model — dokładany wyłącznie, gdy konto ma
|
||||
uprawnienie „ai”. Selektory nazywają funkcję, więc arkusz nie może
|
||||
wchodzić do arkuszy ekranów, na których ta funkcja bywa wyłączona. */
|
||||
|
||||
/* Okno postępu przy generowaniu horoskopu */
|
||||
/* z-index ponad Leaflet: jego kontrolki (.leaflet-top/.leaflet-bottom — zoom,
|
||||
atrybucja) siedzą na z-index:1000 i — bo .leaflet-container nie tworzy własnego
|
||||
kontekstu stackowania — trafiają wprost do korzenia. Przy z-index:50 mapa
|
||||
wychodziła NA WIERZCH modala. 1200 daje zapas nad 1000. */
|
||||
.progress-overlay { position: fixed; inset: 0; background: rgba(8,9,20,.72);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 1200; padding: 1rem; }
|
||||
.progress-overlay[hidden] { display: none; }
|
||||
.progress-box { background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
|
||||
padding: 1rem 1.1rem; width: min(38rem, 100%); box-shadow: 0 18px 48px rgba(0,0,0,.45); }
|
||||
.progress-head { display: flex; align-items: center; gap: .6rem; margin-bottom: .6rem; }
|
||||
.progress-clock { margin-left: auto; font-family: ui-monospace, Menlo, monospace;
|
||||
color: var(--muted); font-size: .9rem; }
|
||||
.progress-spinner { width: 14px; height: 14px; border-radius: 50%; flex: none;
|
||||
border: 2px solid var(--line); border-top-color: var(--accent);
|
||||
animation: spin .8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .progress-spinner { animation: none; } }
|
||||
.progress-log { list-style: none; margin: 0 0 .6rem; padding: .6rem .7rem;
|
||||
background: #12132a; border: 1px solid var(--line); border-radius: 10px;
|
||||
max-height: 15rem; overflow-y: auto;
|
||||
font-family: ui-monospace, Menlo, monospace; font-size: .78rem; line-height: 1.6; }
|
||||
.progress-log li { color: var(--ink); white-space: pre-wrap; }
|
||||
.progress-log li.log-ok { color: #7fd18b; }
|
||||
.progress-log li.log-warn { color: #e0c060; }
|
||||
.progress-log li.log-err { color: #ef6b6b; }
|
||||
Reference in New Issue
Block a user