731 lines
22 KiB
Python
731 lines
22 KiB
Python
import os
|
|
import configparser
|
|
import requests
|
|
from flask import Flask, render_template_string, jsonify, send_from_directory
|
|
|
|
app = Flask(__name__)
|
|
|
|
OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://harbor.freshbrewed.science:11444")
|
|
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "gemma4:e4b")
|
|
PROMPT = os.environ.get("JOKE_PROMPT", "tell me a dad joke about programming")
|
|
PORT = int(os.environ.get("PORT", "80"))
|
|
|
|
def get_app_version():
|
|
config = configparser.ConfigParser()
|
|
version_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "version.ini")
|
|
if os.path.exists(version_file):
|
|
try:
|
|
config.read(version_file)
|
|
return config.get("metadata", "version", fallback="unknown")
|
|
except Exception:
|
|
return "unknown"
|
|
return "unknown"
|
|
|
|
HTML_TEMPLATE = """<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<meta name="description" content="Dry programming dad jokes set on a typographic studio proof sheet, pulled live from your local Ollama model." />
|
|
<title>Programming Dad Joke</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
<link href="https://fonts.googleapis.com/css2?family=Hedvig+Letters+Serif:opsz@12..24&family=Geist:wght@400;500&display=swap" rel="stylesheet" />
|
|
<style>
|
|
/* Hallmark · shape: Workbench (draw 9cbbc9) · genre: editorial · theme: Studio (deal 3/3, seed programming-dad-joke-generator-254f·3aca28a0·2026-10-30·r0) · redesign: full
|
|
* hero: Stage · nav: edge-aligned · footer: letter close · imagery: the product · signature: crop-marked proof sheet with orange metric construction guides
|
|
* axes: light/roman-serif/warm · posture: restrained · contrast: pass · fold: pass · v2.0.0 */
|
|
/* Hallmark · pre-emit critique: P5 H5 E5 S4 R5 V5 · weakest: S, generic joke card could fit any trivia app; framed the joke as a crop-marked Studio proof sheet with live typographic metric guides */
|
|
/* Hallmark · reworded "Tell Me Another Joke! 🚀" to "Pull another joke" · 30a bans emoji in controls */
|
|
/* Hallmark · reworded "Freshly compiled humor from our AI model" to "Dry humor for engineers after hours, pulled live from your local model." · 19 names generic AI filler */
|
|
/* Hallmark · dropped "Ollama • Gemma 4" · 54 and 63 ban middle-dot eyebrow badges */
|
|
@import url("./tokens.css");
|
|
@import url("./base.css");
|
|
|
|
.skip {
|
|
position: absolute;
|
|
left: var(--space-md);
|
|
top: calc(-1 * var(--space-3xl));
|
|
z-index: var(--z-toast);
|
|
padding: var(--space-xs) var(--space-md);
|
|
background: var(--color-ink);
|
|
color: var(--color-paper);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.skip:focus-visible {
|
|
top: var(--space-md);
|
|
}
|
|
|
|
.proof {
|
|
background-image: radial-gradient(circle at 1px 1px, var(--color-dot) 1px, transparent 1.4px);
|
|
background-size: 24px 24px;
|
|
}
|
|
|
|
.crop {
|
|
position: relative;
|
|
--l: 8px;
|
|
--o: 12px;
|
|
}
|
|
|
|
.crop::before {
|
|
content: "";
|
|
position: absolute;
|
|
inset: calc(-1 * var(--o));
|
|
pointer-events: none;
|
|
background:
|
|
linear-gradient(var(--color-mark), var(--color-mark)) 0 var(--o) / var(--l) 1px no-repeat,
|
|
linear-gradient(var(--color-mark), var(--color-mark)) var(--o) 0 / 1px var(--l) no-repeat,
|
|
linear-gradient(var(--color-mark), var(--color-mark)) 100% var(--o) / var(--l) 1px no-repeat,
|
|
linear-gradient(var(--color-mark), var(--color-mark)) calc(100% - var(--o)) 0 / 1px var(--l) no-repeat,
|
|
linear-gradient(var(--color-mark), var(--color-mark)) 0 calc(100% - var(--o)) / var(--l) 1px no-repeat,
|
|
linear-gradient(var(--color-mark), var(--color-mark)) var(--o) 100% / 1px var(--l) no-repeat,
|
|
linear-gradient(var(--color-mark), var(--color-mark)) 100% calc(100% - var(--o)) / var(--l) 1px no-repeat,
|
|
linear-gradient(var(--color-mark), var(--color-mark)) calc(100% - var(--o)) 100% / 1px var(--l) no-repeat;
|
|
}
|
|
|
|
.site-header {
|
|
border-bottom: var(--rule-hair) solid var(--color-rule);
|
|
padding-block: var(--space-md);
|
|
}
|
|
|
|
.site-header__inner {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: var(--space-lg);
|
|
}
|
|
|
|
.wordmark {
|
|
font-family: var(--font-display);
|
|
font-size: var(--text-lg);
|
|
font-weight: var(--display-weight);
|
|
letter-spacing: var(--tracking-display);
|
|
color: var(--color-ink);
|
|
text-decoration: none;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.wordmark:hover {
|
|
text-decoration: underline;
|
|
text-decoration-color: var(--color-accent);
|
|
}
|
|
|
|
.site-nav {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-lg);
|
|
font-size: var(--text-sm);
|
|
color: var(--color-ink-2);
|
|
}
|
|
|
|
.site-nav a {
|
|
text-decoration: none;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.site-nav a:hover {
|
|
text-decoration: underline;
|
|
text-decoration-color: var(--color-accent);
|
|
}
|
|
|
|
.hero {
|
|
padding-block: var(--space-3xl);
|
|
border-bottom: var(--rule-hair) solid var(--color-rule);
|
|
}
|
|
|
|
.hero__grid {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
|
|
gap: var(--gap-columns);
|
|
align-items: center;
|
|
}
|
|
|
|
.hero__lead {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
gap: var(--space-lg);
|
|
}
|
|
|
|
.hero__lead .lead {
|
|
color: var(--color-ink-2);
|
|
}
|
|
|
|
.actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-md);
|
|
margin-top: var(--space-xs);
|
|
}
|
|
|
|
.btn {
|
|
position: relative;
|
|
appearance: none;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: var(--space-xs);
|
|
min-height: 48px;
|
|
padding: 0 var(--space-lg);
|
|
border: var(--rule-hair) solid var(--color-ink);
|
|
border-radius: var(--radius-card);
|
|
background: var(--color-paper-hi);
|
|
color: var(--color-ink);
|
|
font-family: var(--font-body);
|
|
font-size: var(--text-md);
|
|
font-weight: 500;
|
|
line-height: 1;
|
|
white-space: nowrap;
|
|
text-decoration: none;
|
|
cursor: pointer;
|
|
transition: background-color var(--dur-short) var(--ease-out), transform var(--dur-micro) var(--ease-out);
|
|
}
|
|
|
|
.btn::after {
|
|
content: "";
|
|
position: absolute;
|
|
inset: var(--space-3xs);
|
|
border: var(--rule-hair) solid var(--color-mark);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.btn--primary {
|
|
background: var(--color-accent);
|
|
color: var(--color-accent-ink);
|
|
}
|
|
|
|
.btn:hover:not(:disabled) {
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.btn:active:not(:disabled) {
|
|
transform: translateY(1px);
|
|
}
|
|
|
|
.btn:focus-visible {
|
|
outline: 2px solid var(--color-focus);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.btn:disabled,
|
|
.btn[aria-disabled="true"] {
|
|
opacity: 0.55;
|
|
cursor: not-allowed;
|
|
transform: none;
|
|
}
|
|
|
|
.btn[aria-busy="true"] {
|
|
cursor: progress;
|
|
}
|
|
|
|
.spinner {
|
|
display: none;
|
|
width: 16px;
|
|
height: 16px;
|
|
border: 2px solid var(--color-mark);
|
|
border-top-color: var(--color-ink);
|
|
border-radius: 50%;
|
|
animation: spin var(--dur-long) linear infinite;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.sheet {
|
|
background: var(--color-paper-hi);
|
|
border: var(--rule-hair) solid var(--color-rule);
|
|
border-radius: var(--radius-card);
|
|
box-shadow: var(--shadow-sheet);
|
|
padding: var(--space-xl);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-lg);
|
|
}
|
|
|
|
.guide-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-sm);
|
|
}
|
|
|
|
.guide-line {
|
|
flex: 1;
|
|
height: var(--rule-hair);
|
|
background: var(--color-accent);
|
|
}
|
|
|
|
.guide-readout {
|
|
font-size: var(--text-xs);
|
|
font-weight: 500;
|
|
color: var(--color-muted);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.joke-specimen {
|
|
margin: 0;
|
|
padding-block: var(--space-md);
|
|
border-top: var(--rule-hair) solid var(--color-accent);
|
|
border-bottom: var(--rule-hair) solid var(--color-accent);
|
|
}
|
|
|
|
.joke-text {
|
|
font-family: var(--font-display);
|
|
font-size: var(--text-xl);
|
|
font-weight: var(--display-weight);
|
|
line-height: 1.4;
|
|
letter-spacing: var(--tracking-display);
|
|
color: var(--color-ink);
|
|
white-space: pre-wrap;
|
|
overflow-wrap: break-word;
|
|
min-height: var(--space-3xl);
|
|
display: flex;
|
|
align-items: center;
|
|
transition: opacity var(--dur-short) var(--ease-out);
|
|
}
|
|
|
|
.sheet[data-loading="true"] .joke-text {
|
|
opacity: 0.45;
|
|
}
|
|
|
|
.proof-progress {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-xs);
|
|
}
|
|
|
|
.proof-progress__meta {
|
|
display: flex;
|
|
align-items: baseline;
|
|
justify-content: space-between;
|
|
gap: var(--space-sm);
|
|
font-size: var(--text-xs);
|
|
font-weight: 500;
|
|
color: var(--color-muted);
|
|
}
|
|
|
|
.proof-progress__track {
|
|
position: relative;
|
|
height: var(--space-2xs);
|
|
background: var(--color-paper-3);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.proof-progress__fill {
|
|
width: 100%;
|
|
height: 100%;
|
|
background: var(--color-accent);
|
|
transform-origin: left center;
|
|
transform: scaleX(var(--progress, 0));
|
|
transition: transform var(--dur-short) var(--ease-out);
|
|
}
|
|
|
|
.section--notes {
|
|
padding-block: var(--section-gap-s);
|
|
}
|
|
|
|
.section__head {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
|
|
gap: var(--gap-columns);
|
|
align-items: end;
|
|
margin-bottom: var(--space-2xl);
|
|
}
|
|
|
|
.section__head p {
|
|
color: var(--color-ink-2);
|
|
}
|
|
|
|
.set {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
border-top: var(--rule-hair) solid var(--color-ink);
|
|
counter-reset: rule-counter;
|
|
}
|
|
|
|
.set__row {
|
|
counter-increment: rule-counter;
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
|
|
gap: var(--gap-columns);
|
|
align-items: baseline;
|
|
padding-block: var(--space-lg);
|
|
border-bottom: var(--rule-hair) solid var(--color-rule);
|
|
}
|
|
|
|
.set__row h3::before {
|
|
content: "0" counter(rule-counter) " ";
|
|
font-family: var(--font-body);
|
|
font-size: var(--text-sm);
|
|
font-weight: 500;
|
|
color: var(--color-muted);
|
|
margin-right: var(--space-sm);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.set__row p {
|
|
color: var(--color-ink-2);
|
|
}
|
|
|
|
.site-footer {
|
|
background: var(--color-paper-2);
|
|
color: var(--color-ink);
|
|
border-top: var(--rule-hair) solid var(--color-ink);
|
|
padding-block: var(--space-2xl);
|
|
}
|
|
|
|
.footer__head {
|
|
display: flex;
|
|
align-items: baseline;
|
|
justify-content: space-between;
|
|
gap: var(--space-lg);
|
|
margin-bottom: var(--space-xl);
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.footer__head p {
|
|
color: var(--color-ink-2);
|
|
font-size: var(--text-sm);
|
|
}
|
|
|
|
.colophon-grid {
|
|
margin: 0;
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: var(--gap-columns);
|
|
border-top: var(--rule-hair) solid var(--color-rule);
|
|
padding-top: var(--space-lg);
|
|
}
|
|
|
|
.colophon-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-2xs);
|
|
min-width: 0;
|
|
}
|
|
|
|
.colophon-item dt {
|
|
font-size: var(--text-sm);
|
|
font-weight: 500;
|
|
color: var(--color-ink-2);
|
|
}
|
|
|
|
.colophon-item dd {
|
|
margin: 0;
|
|
font-size: var(--text-md);
|
|
color: var(--color-ink);
|
|
overflow-wrap: break-word;
|
|
}
|
|
|
|
@media (max-width: 60rem) {
|
|
.hero__grid,
|
|
.section__head,
|
|
.set__row {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
|
|
.colophon-grid {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
}
|
|
|
|
@media (max-width: 40rem) {
|
|
.colophon-grid {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
|
|
.sheet {
|
|
padding: var(--space-lg);
|
|
}
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.btn {
|
|
transition: background-color var(--dur-short) linear;
|
|
}
|
|
|
|
.joke-text,
|
|
.proof-progress__fill {
|
|
transition: none;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<a class="skip" href="#main">Skip to content</a>
|
|
|
|
<header class="site-header proof">
|
|
<div class="container site-header__inner">
|
|
<a href="#main" class="wordmark" aria-label="Programming Dad Joke home">Proof Sheet</a>
|
|
<nav class="site-nav" aria-label="Page sections">
|
|
<a href="#specimen">Specimen</a>
|
|
<a href="#notes">Bench notes</a>
|
|
<a href="#colophon">Colophon</a>
|
|
<span class="num">v{{ version }}</span>
|
|
</nav>
|
|
</div>
|
|
</header>
|
|
|
|
<main id="main">
|
|
<section class="hero proof">
|
|
<div class="container hero__grid">
|
|
<div class="hero__lead">
|
|
<h1>Programming Dad Joke</h1>
|
|
<p class="lead">Dry humor for engineers after hours, pulled live from your local model.</p>
|
|
<div class="actions">
|
|
<button id="refreshBtn" class="btn btn--primary crop" type="button" onclick="fetchNewJoke()">
|
|
<span class="spinner keep-motion" id="spinner" aria-hidden="true"></span>
|
|
<span id="btnText">Pull another joke</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="specimen" class="hero__stage">
|
|
<article id="jokeSheet" class="sheet crop" aria-live="polite" aria-atomic="true">
|
|
<div class="guide-bar" aria-hidden="true">
|
|
<span class="guide-line"></span>
|
|
<span class="guide-readout num">Hedvig Letters Serif, 25pt / 1.4</span>
|
|
</div>
|
|
<blockquote class="joke-specimen">
|
|
<p id="joke" class="joke-text">{{ joke }}</p>
|
|
</blockquote>
|
|
<div id="proofProgress" class="proof-progress" hidden>
|
|
<div class="proof-progress__meta">
|
|
<span id="progressStatus">Setting type on the press (30 to 50s typical)…</span>
|
|
<span id="progressReadout" class="num">0% (0s)</span>
|
|
</div>
|
|
<div
|
|
id="progressBar"
|
|
class="proof-progress__track"
|
|
role="progressbar"
|
|
aria-label="Joke generation progress"
|
|
aria-valuemin="0"
|
|
aria-valuemax="100"
|
|
aria-valuenow="0"
|
|
>
|
|
<div id="progressFill" class="proof-progress__fill"></div>
|
|
</div>
|
|
</div>
|
|
<div class="guide-bar" aria-hidden="true">
|
|
<span class="guide-readout num">Proofed on {{ model }}</span>
|
|
<span class="guide-line"></span>
|
|
<span class="guide-readout num">Release v{{ version }}</span>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="section--notes" id="notes">
|
|
<div class="container">
|
|
<div class="section__head">
|
|
<h2>Three rules on the press</h2>
|
|
<p class="lead">Every groaner on the sheet clears three checks before it leaves the studio.</p>
|
|
</div>
|
|
<ol class="set">
|
|
<li class="set__row">
|
|
<h3>Pun on the literal</h3>
|
|
<p>Take a noun every developer says ten times before lunch, such as cache, branch, or deadlock, and read it as plain kitchen English.</p>
|
|
</li>
|
|
<li class="set__row">
|
|
<h3>Keep the setup short</h3>
|
|
<p>One question across the counter. If the premise needs a whiteboard diagram, the punchline already missed its train.</p>
|
|
</li>
|
|
<li class="set__row">
|
|
<h3>Aim for the eye roll</h3>
|
|
<p>A proper dad joke never chases an applause break. It settles for a quiet sigh from across the table.</p>
|
|
</li>
|
|
</ol>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<footer class="site-footer" id="colophon">
|
|
<div class="container">
|
|
<div class="footer__head">
|
|
<h2>Press colophon</h2>
|
|
<p>Compiled on demand from your local inference runtime.</p>
|
|
</div>
|
|
<dl class="colophon-grid">
|
|
<div class="colophon-item">
|
|
<dt>Host</dt>
|
|
<dd class="num">{{ host }}</dd>
|
|
</div>
|
|
<div class="colophon-item">
|
|
<dt>Model</dt>
|
|
<dd class="num">{{ model }}</dd>
|
|
</div>
|
|
<div class="colophon-item">
|
|
<dt>Prompt</dt>
|
|
<dd>“{{ prompt }}”</dd>
|
|
</div>
|
|
<div class="colophon-item">
|
|
<dt>App Version</dt>
|
|
<dd class="num">v{{ version }}</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
</footer>
|
|
|
|
<script>
|
|
let progressTimer = null;
|
|
|
|
function computeExpectedProgress(elapsedSeconds) {
|
|
if (elapsedSeconds <= 0) return 0.02;
|
|
if (elapsedSeconds <= 30) {
|
|
return 0.02 + (elapsedSeconds / 30) * 0.66;
|
|
}
|
|
if (elapsedSeconds <= 50) {
|
|
return 0.68 + ((elapsedSeconds - 30) / 20) * 0.24;
|
|
}
|
|
return 0.92 + 0.06 * (1 - Math.exp(-(elapsedSeconds - 50) / 20));
|
|
}
|
|
|
|
function setProgressState(ratio, elapsedSeconds, statusText, isComplete) {
|
|
const progressBar = document.getElementById("progressBar");
|
|
const progressFill = document.getElementById("progressFill");
|
|
const progressReadout = document.getElementById("progressReadout");
|
|
const progressStatus = document.getElementById("progressStatus");
|
|
|
|
const clamped = Math.max(0, Math.min(1, ratio));
|
|
const pct = Math.round(clamped * 100);
|
|
const secs = isComplete
|
|
? Math.max(0.1, elapsedSeconds).toFixed(1)
|
|
: String(Math.max(0, Math.floor(elapsedSeconds)));
|
|
|
|
progressFill.style.setProperty("--progress", clamped.toFixed(4));
|
|
progressBar.setAttribute("aria-valuenow", String(pct));
|
|
progressReadout.textContent = pct + "% (" + secs + "s)";
|
|
if (statusText) {
|
|
progressStatus.textContent = statusText;
|
|
}
|
|
}
|
|
|
|
async function fetchNewJoke() {
|
|
const btn = document.getElementById("refreshBtn");
|
|
const btnText = document.getElementById("btnText");
|
|
const spinner = document.getElementById("spinner");
|
|
const jokeEl = document.getElementById("joke");
|
|
const sheet = document.getElementById("jokeSheet");
|
|
const progressWrap = document.getElementById("proofProgress");
|
|
const progressFill = document.getElementById("progressFill");
|
|
|
|
if (progressTimer) {
|
|
clearInterval(progressTimer);
|
|
progressTimer = null;
|
|
}
|
|
|
|
btn.disabled = true;
|
|
btn.setAttribute("aria-busy", "true");
|
|
spinner.style.display = "inline-block";
|
|
btnText.textContent = "Pulling fresh proof\u2026";
|
|
|
|
sheet.setAttribute("data-loading", "true");
|
|
progressWrap.hidden = false;
|
|
|
|
progressFill.style.transition = "none";
|
|
setProgressState(0, 0, "Setting type on the press (30 to 50s typical)\u2026", false);
|
|
void progressFill.offsetWidth;
|
|
progressFill.style.transition = "";
|
|
|
|
const startTime = performance.now();
|
|
setProgressState(0.02, 0, "Setting type on the press (30 to 50s typical)\u2026", false);
|
|
|
|
progressTimer = setInterval(() => {
|
|
const elapsed = (performance.now() - startTime) / 1000;
|
|
const ratio = computeExpectedProgress(elapsed);
|
|
const status =
|
|
elapsed < 30
|
|
? "Setting type on the press (30 to 50s typical)\u2026"
|
|
: elapsed <= 50
|
|
? "Inking the proof sheet (30 to 50s window)\u2026"
|
|
: "Finishing slow impression\u2026";
|
|
setProgressState(ratio, elapsed, status, false);
|
|
}, 100);
|
|
|
|
let nextJoke = "";
|
|
try {
|
|
const response = await fetch("/api/joke");
|
|
if (!response.ok) {
|
|
throw new Error("HTTP error " + response.status);
|
|
}
|
|
const data = await response.json();
|
|
nextJoke = data.joke || "No joke returned.";
|
|
} catch (err) {
|
|
console.error("Failed to load joke:", err);
|
|
nextJoke = "Could not reach the joke service right now. Check the Ollama host and try again.";
|
|
} finally {
|
|
if (progressTimer) {
|
|
clearInterval(progressTimer);
|
|
progressTimer = null;
|
|
}
|
|
|
|
const totalElapsed = (performance.now() - startTime) / 1000;
|
|
setProgressState(1, totalElapsed, "Proof complete (30 to 50s typical)", true);
|
|
jokeEl.textContent = nextJoke;
|
|
sheet.removeAttribute("data-loading");
|
|
|
|
btn.disabled = false;
|
|
btn.removeAttribute("aria-busy");
|
|
spinner.style.display = "none";
|
|
btnText.textContent = "Pull another joke";
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
def request_joke():
|
|
url = f"{OLLAMA_HOST.rstrip('/')}/api/generate"
|
|
payload = {
|
|
"model": OLLAMA_MODEL,
|
|
"prompt": PROMPT,
|
|
"stream": False
|
|
}
|
|
try:
|
|
resp = requests.post(url, json=payload, timeout=60)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
return data.get("response", "No response received from Ollama.")
|
|
except Exception as e:
|
|
return f"Error contacting Ollama ({OLLAMA_HOST}): {e}"
|
|
|
|
@app.route("/")
|
|
def index():
|
|
joke = request_joke()
|
|
version = get_app_version()
|
|
return render_template_string(
|
|
HTML_TEMPLATE,
|
|
joke=joke,
|
|
host=OLLAMA_HOST,
|
|
model=OLLAMA_MODEL,
|
|
prompt=PROMPT,
|
|
version=version
|
|
)
|
|
|
|
@app.route("/tokens.css")
|
|
def tokens_css():
|
|
return send_from_directory(os.path.dirname(os.path.abspath(__file__)), "tokens.css", mimetype="text/css")
|
|
|
|
@app.route("/base.css")
|
|
def base_css():
|
|
return send_from_directory(os.path.dirname(os.path.abspath(__file__)), "base.css", mimetype="text/css")
|
|
|
|
@app.route("/api/joke")
|
|
def api_joke():
|
|
joke = request_joke()
|
|
version = get_app_version()
|
|
return jsonify({
|
|
"joke": joke,
|
|
"version": version
|
|
})
|
|
|
|
if __name__ == "__main__":
|
|
version = get_app_version()
|
|
print(f"Starting server v{version} on port {PORT} with model '{OLLAMA_MODEL}' at {OLLAMA_HOST}...")
|
|
app.run(host="0.0.0.0", port=PORT)
|