Nørdespørgsmål, spørgsmålspakker, flere lydvarianter og natlig forvarmning
CI / test (push) Successful in 53s
CI / test (push) Successful in 53s
- 87 nye spørgsmål med kilder: Star Wars (inkl. Legends), Harry Potter, Ringenes Herre, Lægevidenskab, E-faktura, IT-sikkerhed, Computerhistorie - Spørgsmålspakker pr. kategori (familie, nørd, arbejde); værten vælger Alle/Familie/Nørd, og arbejdspakken kun under "Flere valg" - Lydeffekter i flere udgaver, der vælges tilfældigt (aldrig samme to gange i træk); jingles og tænkemusik til de nye kategorier; ekstra tænke- og afsløringsmusik - Musik hentes og afkodes først, når den skal spilles, og slippes igen - tts_warm --budget: forvarmer spørgsmål, forklaringer og kategori-replikker (de næste spørgsmål først) inden for et tegnbudget Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
This commit is contained in:
@@ -14,3 +14,6 @@ ELEVENLABS_API_KEY=
|
||||
# BRAIN_API_KEY=
|
||||
# BRAIN_WORKSPACE= # kun ved Anthropic-brugernøgler (sk-ant-usr…)
|
||||
# BRAIN_EFFORT=low
|
||||
|
||||
# Navn på knappen for arbejdspakken (kategorier med pakken "arbejde") på "nyt spil"-siden
|
||||
# WORK_PACK_LABEL=Arbejde
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ from .models import Category, Question
|
||||
|
||||
@admin.register(Category)
|
||||
class CategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "active", "question_count"]
|
||||
list_display = ["name", "packs", "active", "question_count"]
|
||||
list_editable = ["active"]
|
||||
|
||||
@admin.display(description="spørgsmål")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,9 @@ class Command(BaseCommand):
|
||||
for path in paths:
|
||||
for item in json.loads(path.read_text(encoding="utf-8")):
|
||||
cat, _ = Category.objects.get_or_create(name=item["category"].strip())
|
||||
if item.get("packs") and cat.packs != item["packs"]:
|
||||
cat.packs = item["packs"]
|
||||
cat.save(update_fields=["packs"])
|
||||
_, was_created = Question.objects.update_or_create(
|
||||
prompt=item["prompt"].strip(),
|
||||
defaults={"category": cat, "answer": item["answer"].strip(),
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("fupogfakta", "0002_question_times_all_correct")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="category",
|
||||
name="packs",
|
||||
field=models.JSONField("pakker", blank=True, default=list,
|
||||
help_text='Fx ["familie"], ["nørd"] eller ["nørd", "arbejde"]. Tom = familie.'),
|
||||
),
|
||||
]
|
||||
+13
-1
@@ -1,9 +1,19 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
# Pakker: en kategori kan høre til flere. "arbejde" er aldrig med i "alle" og vælges kun med vilje.
|
||||
PACKS = ["familie", "nørd", "arbejde"]
|
||||
PRESETS = {"alle": ["familie", "nørd"], "familie": ["familie"], "nørd": ["nørd"], "arbejde": ["arbejde"]}
|
||||
|
||||
|
||||
class Category(models.Model):
|
||||
name = models.CharField("navn", max_length=60, unique=True)
|
||||
active = models.BooleanField("aktiv", default=True)
|
||||
packs = models.JSONField("pakker", default=list, blank=True,
|
||||
help_text='Fx ["familie"], ["nørd"] eller ["nørd", "arbejde"]. Tom = familie.')
|
||||
|
||||
def in_packs(self, wanted) -> bool:
|
||||
return bool(set(self.packs or ["familie"]) & set(wanted))
|
||||
|
||||
class Meta:
|
||||
verbose_name = "kategori"
|
||||
@@ -53,6 +63,8 @@ def record_question_result(question_id: int, everyone_found_it: bool):
|
||||
times_all_correct=F("times_all_correct") + (1 if everyone_found_it else 0))
|
||||
|
||||
|
||||
def load_game_questions():
|
||||
def load_game_questions(preset: str = "alle"):
|
||||
wanted = PRESETS.get(preset, PRESETS["alle"])
|
||||
qs = Question.objects.filter(active=True, category__active=True, times_all_correct__lt=TOO_EASY).select_related("category")
|
||||
qs = [q for q in qs if q.category.in_packs(wanted)]
|
||||
return [q.as_game_dict() for q in qs if "_____" in q.prompt]
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from fupogfakta.models import Category, Question, load_game_questions
|
||||
|
||||
|
||||
class PackTests(TestCase):
|
||||
def setUp(self):
|
||||
for name, packs in [("Dyreriget", []), ("Star Wars", ["nørd"]), ("E-faktura", ["nørd", "arbejde"]), ("Firma", ["arbejde"])]:
|
||||
cat = Category.objects.create(name=name, packs=packs)
|
||||
Question.objects.create(category=cat, prompt=f"{name} har _____.", answer="x")
|
||||
|
||||
def cats(self, preset):
|
||||
return sorted({q["category"] for q in load_game_questions(preset)})
|
||||
|
||||
def test_presets(self):
|
||||
self.assertEqual(self.cats("alle"), ["Dyreriget", "E-faktura", "Star Wars"]) # arbejde kun med vilje
|
||||
self.assertEqual(self.cats("familie"), ["Dyreriget"]) # tom = familie
|
||||
self.assertEqual(self.cats("nørd"), ["E-faktura", "Star Wars"])
|
||||
self.assertEqual(self.cats("arbejde"), ["E-faktura", "Firma"])
|
||||
self.assertEqual(self.cats("ukendt"), self.cats("alle"))
|
||||
@@ -2,15 +2,30 @@ import re
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from party import tts
|
||||
from fupogfakta.engine import RULES
|
||||
from party.narrator import LINES, TEASES
|
||||
from fupogfakta.models import Question
|
||||
from party import tts
|
||||
from party.narrator import LINES, TEASES, speakable_prompt
|
||||
|
||||
|
||||
def _expand_categories(lines: list[str], categories: list[str]) -> list[str]:
|
||||
"""Replikker, hvis eneste pladsholder er {kategori}, findes i et endeligt antal udgaver: lav dem alle."""
|
||||
out = []
|
||||
for line in lines:
|
||||
if set(re.findall(r"\{(\w+)\}", line)) == {"kategori"}:
|
||||
out += [line.format(kategori=c) for c in categories]
|
||||
return out
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Genererer speakerens faste replikker (dem uden pladsholdere) på forhånd, så første spil ikke venter."
|
||||
help = ("Genererer speakerens replikker på forhånd. Uden --budget: kun de faste replikker (gratis, når de findes). "
|
||||
"Med --budget N: derefter op til N tegn af spørgsmålenes oplæsning og forklaring (de mindst brugte først, "
|
||||
"dvs. dem der kommer næste gang) og kategori-replikkerne. Køres om natten, så kvoten fordeles.")
|
||||
|
||||
def handle(self, *args, **opts):
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--budget", type=int, default=0, help="højst så mange nye tegn ud over de faste replikker")
|
||||
|
||||
def handle(self, *args, budget, **opts):
|
||||
if not tts.enabled():
|
||||
self.stdout.write("ElevenLabs er ikke sat op (ELEVENLABS_API_KEY/ELEVENLABS_VOICE_ID). Springer over.")
|
||||
return
|
||||
@@ -19,3 +34,29 @@ class Command(BaseCommand):
|
||||
static += [r["say"] for r in RULES]
|
||||
ok = sum(1 for line in static if tts.synth_sync(line))
|
||||
self.stdout.write(self.style.SUCCESS(f"{ok} af {len(static)} faste replikker ligger i cachen."))
|
||||
if budget <= 0:
|
||||
return
|
||||
|
||||
# Spørgsmålene i den rækkefølge, spillet vælger dem (mindst brugte først), skiftevis med
|
||||
# kategori-replikkerne, så begge dele bliver dækket over et par nætter.
|
||||
questions = []
|
||||
for q in Question.objects.filter(active=True).select_related("category").order_by("times_used", "id"):
|
||||
questions += [speakable_prompt(q.prompt)] + ([q.note] if q.note else [])
|
||||
categories = sorted(set(Question.objects.filter(active=True).values_list("category__name", flat=True)))
|
||||
per_category = _expand_categories(LINES.get("lie_start", []), categories)
|
||||
queue = [t for pair in zip(questions, per_category) for t in pair]
|
||||
queue += questions[len(per_category):] + per_category[len(questions):]
|
||||
|
||||
spent = made = cached = 0
|
||||
for text in queue:
|
||||
if tts.is_cached(text):
|
||||
cached += 1
|
||||
continue
|
||||
if spent + len(text) > budget:
|
||||
break
|
||||
if tts.synth_sync(text):
|
||||
spent += len(text)
|
||||
made += 1
|
||||
left = sum(1 for t in queue if not tts.is_cached(t))
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f"{made} nye replikker ({spent} tegn); {cached} lå allerede i cachen; {left} mangler endnu."))
|
||||
|
||||
@@ -462,7 +462,8 @@
|
||||
}
|
||||
|
||||
/* ---------- lydeffekter ---------- */
|
||||
var CATS = ['dyreriget', 'kroppen', 'mad_og_drikke', 'rummet_og_naturen', 'opfindelser', 'danmark', 'sprog_og_ord', 'sport', 'historie', 'geografi'];
|
||||
var CATS = ['dyreriget', 'kroppen', 'mad_og_drikke', 'rummet_og_naturen', 'opfindelser', 'danmark', 'sprog_og_ord', 'sport', 'historie', 'geografi',
|
||||
'star_wars', 'harry_potter', 'e_faktura', 'it_sikkerhed', 'computerhistorie', 'laegevidenskab', 'ringenes_herre'];
|
||||
var SFX = ['whoosh', 'tick', 'timeup', 'submit', 'join', 'drumroll', 'stamp', 'truth', 'house', 'explode', 'coins', 'like', 'fanfare', 'category',
|
||||
'whoosh_long', 'swoosh_up', 'pop_in', 'slide', 'boo'].concat(CATS.map(function (c) { return 'cat_' + c; }));
|
||||
var MUSIC = ['music_lobby', 'music_choose', 'music_think', 'music_reveal', 'music_final'];
|
||||
@@ -551,6 +552,23 @@
|
||||
phase: function (t) { t(523, 0, 0.15); t(784, 0.09, 0.25); }
|
||||
};
|
||||
var lastAt = {};
|
||||
/* Varianter: "stamp", "stamp__2", "stamp__3" … er samme effekt i flere udgaver; der vælges tilfældigt
|
||||
(aldrig samme to gange i træk), så hyppige lyde ikke bliver monotone. Uden asset-kort: kun grundnavnet. */
|
||||
var variantCache = {}, lastVariant = {};
|
||||
function variants(name) {
|
||||
if (variantCache[name]) return variantCache[name];
|
||||
var v = [name];
|
||||
if (ASSETS.sfx) Object.keys(ASSETS.sfx).sort().forEach(function (k) { if (k.indexOf(name + '__') === 0) v.push(k); });
|
||||
return (variantCache[name] = v);
|
||||
}
|
||||
function pickVariant(name) {
|
||||
var ready = variants(name).filter(function (n) { return bufs[n]; });
|
||||
if (ready.length < 2) return ready[0] || name;
|
||||
var pool = ready.filter(function (n) { return n !== lastVariant[name]; });
|
||||
var pick = pool[Math.floor(Math.random() * pool.length)];
|
||||
lastVariant[name] = pick;
|
||||
return pick;
|
||||
}
|
||||
/* WPP.sfx(navn, {volume, rate, delay, fallback, gap}) – afspil en effekt (kan overlappe sig selv).
|
||||
fallback: navn der bruges, hvis filen ikke findes (fx cat_<slug> → category).
|
||||
gap: mindst så mange ms mellem to af samme navn; for tætte kald droppes (mod lyd-spam).
|
||||
@@ -565,7 +583,7 @@
|
||||
if (at < (lastAt[name] || 0) - 0.005) return 0;
|
||||
lastAt[name] = at + opts.gap / 1000;
|
||||
}
|
||||
var b = bufs[name];
|
||||
var b = bufs[pickVariant(name)];
|
||||
if (!b) {
|
||||
var f = FALLBACK[name] || FALLBACK[opts.fallback];
|
||||
if (f) f(function (fr, st, d, ty, v, f2) { sound.tone(fr, (st || 0) + delay, d, ty, v, f2); });
|
||||
@@ -576,12 +594,14 @@
|
||||
};
|
||||
WPP.sfx.names = SFX;
|
||||
WPP.sfx.has = function (name) { return known(name) && !failed[name]; };
|
||||
WPP.sfx.variants = variants;
|
||||
/* Hent filerne. Uden liste: alt (kun værten); telefonerne henter kun det, de bruger (boo). */
|
||||
WPP.sfx.preload = function (list) {
|
||||
if (list) { list.forEach(load); return; }
|
||||
if (WPP.sfx._preloaded) return;
|
||||
WPP.sfx._preloaded = true;
|
||||
(ASSETS.sfx ? Object.keys(ASSETS.sfx) : SFX.concat(MUSIC)).forEach(load);
|
||||
/* musikken hentes først, når den skal spilles (afkodet fylder et nummer ~10 MB) */
|
||||
(ASSETS.sfx ? Object.keys(ASSETS.sfx) : SFX).forEach(function (n) { if (!/^music_/.test(n)) load(n); });
|
||||
};
|
||||
/* Afspil en vilkårlig lydfil (fx serverens TTS) gennem AudioContext; caches pr. URL.
|
||||
opts.at = AudioContext-tid, lyden tidligst må starte (afkodningen kan tage et øjeblik). */
|
||||
@@ -610,7 +630,7 @@
|
||||
if (name === music.name) return;
|
||||
music.name = name;
|
||||
music._fade = opts.fade != null ? opts.fade : FADE;
|
||||
music._gain = opts.gain != null ? opts.gain : (MUSIC_GAIN[name] || 1);
|
||||
music._gain = opts.gain != null ? opts.gain : (MUSIC_GAIN[String(name).replace(/__\d+$/, '')] || 1);
|
||||
if (name) load(name);
|
||||
music._kick();
|
||||
},
|
||||
@@ -662,6 +682,12 @@
|
||||
track.g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||
track.src.stop(t + dur + 0.05);
|
||||
} catch (e) { /* allerede stoppet */ }
|
||||
/* slip det afkodede nummer, når det er tonet ud; næste gang hentes det fra browserens cache */
|
||||
var name = track.name;
|
||||
setTimeout(function () {
|
||||
if (music.name === name || (music.cur && music.cur.name === name)) return;
|
||||
delete bufs[name]; delete loading[name];
|
||||
}, (dur + 0.5) * 1000);
|
||||
}
|
||||
|
||||
['pointerdown', 'keydown', 'touchstart', 'touchend', 'click'].forEach(function (ev) {
|
||||
|
||||
@@ -582,7 +582,7 @@
|
||||
sfx(u, 'fanfare', { delay: from ? 0.3 : 0 });
|
||||
sfx(u, 'swoosh_up', { delay: 0.9, volume: 0.4 }); /* vinderens søjle rejser sig */
|
||||
confetti(root);
|
||||
u.finalT = setTimeout(function () { u.finalMusic = true; if (audible(u.ctx)) WPP.music.set('music_final'); }, 3000);
|
||||
u.finalT = setTimeout(function () { u.finalMusic = true; if (audible(u.ctx)) WPP.music.set(musicFor(u, 'final', state)); }, 3000);
|
||||
}
|
||||
}
|
||||
/* kategorikortet: whoosh når det letter, kategoriens jingle når det lander (finalen: FINALE-skiltet smækker først) */
|
||||
@@ -598,7 +598,17 @@
|
||||
if (u.state && pkey(u.state) === key) sparkle(root, root.querySelector('.screen .in-card'), 22);
|
||||
}, (t0 + INTRO_LAND) * 1000);
|
||||
}
|
||||
function musicFor(u, phase) { return phase === 'final' ? (u.finalMusic ? 'music_final' : null) : (MUSIC[phase] || null); }
|
||||
/* Tænkemusik pr. kategori (music_think_<slug>), ellers den fælles. Findes et nummer i flere udgaver
|
||||
(music_reveal__2 …), vælges én pr. spørgsmål, så lie → present → guess spiller videre uden skift. */
|
||||
function musicFor(u, phase, state) {
|
||||
var base = phase === 'final' ? (u.finalMusic ? 'music_final' : null) : (MUSIC[phase] || null);
|
||||
if (!base) return null;
|
||||
var q = state.question || state.intro || {};
|
||||
if (base === 'music_think' && q.slug && WPP.sfx.has('music_think_' + q.slug)) base = 'music_think_' + q.slug;
|
||||
var v = WPP.sfx.variants(base), r = state.round || {};
|
||||
var seed = (r.number || 0) * 7 + (r.question_number || 0) + String(state.code || '').charCodeAt(0);
|
||||
return v[(seed || 0) % v.length];
|
||||
}
|
||||
|
||||
/* nye noder med data-sfx får deres lyd (bevarede noder har allerede fået den) */
|
||||
function nodeSfx(root, u, state, quiet) {
|
||||
@@ -791,7 +801,7 @@
|
||||
u.lastPhase = phase;
|
||||
if (audible(ctx)) {
|
||||
WPP.sfx.preload();
|
||||
WPP.music.set(musicFor(u, phase), { fade: phase === 'intro' || phase === 'final' ? STING_FADE : null });
|
||||
WPP.music.set(musicFor(u, phase, state), { fade: phase === 'intro' || phase === 'final' ? STING_FADE : null });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -897,3 +897,18 @@ button, input { font-family: inherit; }
|
||||
.rl-row .rl-icon { font-size: 1.8rem; }
|
||||
.rl-row b { display: block; }
|
||||
.rl-row span { font-size: .95rem; color: var(--muted); }
|
||||
|
||||
/* ---------- nyt spil: vælg spørgsmålspakke ---------- */
|
||||
.new-game { display: flex; flex-direction: column; align-items: center; gap: 20px; }
|
||||
.packs { display: flex; gap: 8px; margin: 0; padding: 0; border: 0; flex-wrap: wrap; justify-content: center; }
|
||||
.packs legend { width: 100%; margin-bottom: 8px; color: var(--muted); font-weight: 600; }
|
||||
.packs label { cursor: pointer; }
|
||||
.packs input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.packs span { display: inline-block; padding: 10px 20px; border-radius: 999px; font-weight: 700; background: var(--card); box-shadow: inset 0 0 0 2px var(--card-b); }
|
||||
.packs input:checked + span { background: var(--yellow); color: var(--navy); box-shadow: 0 4px 0 var(--yellow-d); }
|
||||
.packs input:focus-visible + span { outline: 3px solid #fff; outline-offset: 2px; }
|
||||
.more-packs { width: 100%; margin-top: 6px; }
|
||||
.more-packs summary { cursor: pointer; color: var(--muted); font-size: .95rem; list-style: none; }
|
||||
.more-packs summary::-webkit-details-marker { display: none; }
|
||||
.more-packs[open] summary { margin-bottom: 8px; }
|
||||
.pack-hint { margin: 8px 0 0; font-size: .9rem; color: var(--muted); }
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -4,7 +4,18 @@
|
||||
<main class="landing">
|
||||
<h1>Weirsøe Party Protocol</h1>
|
||||
<div class="sub"><span class="b-fup">Fup</span> <span class="b-og">og</span> <span class="b-fakta">Fakta</span></div>
|
||||
<form method="post" action="/host/new">{% csrf_token %}
|
||||
<form method="post" action="/host/new" class="new-game">{% csrf_token %}
|
||||
<fieldset class="packs">
|
||||
<legend>Spørgsmål</legend>
|
||||
<label><input type="radio" name="pack" value="alle" checked><span>Alle</span></label>
|
||||
<label><input type="radio" name="pack" value="familie"><span>Familie</span></label>
|
||||
<label><input type="radio" name="pack" value="nørd"><span>Nørd</span></label>
|
||||
<details class="more-packs">
|
||||
<summary>Flere valg…</summary>
|
||||
<label><input type="radio" name="pack" value="arbejde"><span>{{ work_pack_label }}</span></label>
|
||||
<p class="pack-hint">Arbejdsspørgsmål: e-faktura, IT-sikkerhed og kategorier med pakken "arbejde".</p>
|
||||
</details>
|
||||
</fieldset>
|
||||
<button type="submit" class="btn btn-go">Start et nyt spil</button>
|
||||
</form>
|
||||
{% if username %}<p class="who">Logget ind som {{ username }}</p>{% endif %}
|
||||
|
||||
@@ -64,6 +64,10 @@ def _fetch(text: str, path: Path) -> None:
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def is_cached(text: str) -> bool:
|
||||
return bool(text.strip()) and cache_path(_digest(text)).exists()
|
||||
|
||||
|
||||
def synth_sync(text: str) -> dict | None:
|
||||
"""Til management-kommandoer: generér (eller find i cache) synkront."""
|
||||
if not enabled() or not text.strip():
|
||||
|
||||
+3
-2
@@ -32,7 +32,8 @@ def play(request):
|
||||
def host_new_page(request):
|
||||
if not request.host_identity:
|
||||
return _forbidden()
|
||||
return render(request, "party/host_new.html", {"username": request.host_identity.username})
|
||||
return render(request, "party/host_new.html", {"username": request.host_identity.username,
|
||||
"work_pack_label": settings.WORK_PACK_LABEL})
|
||||
|
||||
|
||||
@require_POST
|
||||
@@ -40,7 +41,7 @@ async def host_new(request):
|
||||
ident = request.host_identity
|
||||
if not ident:
|
||||
return _forbidden()
|
||||
questions = await sync_to_async(load_game_questions)()
|
||||
questions = await sync_to_async(load_game_questions)(request.POST.get("pack", "alle"))
|
||||
if not questions:
|
||||
return HttpResponse("Der er ingen spørgsmål i banken. Kør 'manage.py load_questions'.", status=503)
|
||||
room = create_room(questions, ident.username, settings.PUBLIC_URL)
|
||||
|
||||
@@ -36,6 +36,9 @@ TTS_STYLE = float(env("TTS_STYLE", "0.7")) # højere = mere udtryk
|
||||
TTS_SIMILARITY = float(env("TTS_SIMILARITY", "0.8"))
|
||||
|
||||
# Spilhjernen (valgfri, udbyder-agnostisk, se party/brain/providers.py): anthropic | openai | ollama
|
||||
# Navnet på arbejdspakken på "nyt spil"-siden (pakken "arbejde" er aldrig med i "Alle")
|
||||
WORK_PACK_LABEL = env("WORK_PACK_LABEL", "Arbejde")
|
||||
|
||||
BRAIN_PROVIDER = env("BRAIN_PROVIDER", "")
|
||||
BRAIN_MODEL = env("BRAIN_MODEL", "")
|
||||
BRAIN_API_KEY = env("BRAIN_API_KEY", "")
|
||||
|
||||
Reference in New Issue
Block a user