diff --git a/skald/src/main.rs b/skald/src/main.rs index 6cfc7e3..fcb638d 100644 --- a/skald/src/main.rs +++ b/skald/src/main.rs @@ -8,6 +8,7 @@ mod import; mod serve; mod show_context; mod summarize; +mod web; use std::path::PathBuf; use std::process::ExitCode; diff --git a/skald/src/serve.rs b/skald/src/serve.rs index 0af07cd..88e9da7 100644 --- a/skald/src/serve.rs +++ b/skald/src/serve.rs @@ -1,8 +1,9 @@ -//! HTTP server (v0.1). +//! HTTP server. //! -//! Today this is intentionally tiny: connect to postgres, run any -//! pending migrations, expose `/health`, and stay alive. The web -//! GUI + clawdforge wiring lands in v0.2. +//! Mounts the web inspector (maud + axum) at `/` plus a `/health` +//! probe. The clawdforge-touching CLI subcommands (summarize, +//! continue) talk to the DB directly — they don't go through this +//! HTTP layer. use std::time::Duration; @@ -16,6 +17,8 @@ use skald_core::db; use sqlx::PgPool; use tokio::signal::unix::{SignalKind, signal}; +use crate::web::{self, WebState}; + #[derive(Clone)] struct AppState { pool: PgPool, @@ -28,13 +31,19 @@ pub async fn run(database_url: &str, listen: &str) -> anyhow::Result<()> { let pool = db::connect_and_migrate(database_url).await?; tracing::info!("database connected, migrations applied"); - let state = AppState { - pool, + let app_state = AppState { + pool: pool.clone(), started_at: Utc::now(), }; - let router = Router::new() + let health_router = Router::new() .route("/health", get(health)) - .with_state(state); + .with_state(app_state); + + let web_router = web::router(WebState { pool }); + + let router = Router::new() + .merge(health_router) + .merge(web_router); let listener = tokio::net::TcpListener::bind(listen).await?; tracing::info!(listen, "api listening"); diff --git a/skald/src/web.rs b/skald/src/web.rs new file mode 100644 index 0000000..a87070d --- /dev/null +++ b/skald/src/web.rs @@ -0,0 +1,593 @@ +//! Web inspector. v0.1 = read-only. Story list in the sidebar, +//! drill into each story to see its chapters / bible / characters / +//! generation log. +//! +//! Stack: axum + maud + (eventually htmx). v0.1 uses full-page +//! reloads on nav; htmx swaps for tab switching come in v0.2. +//! +//! Tone: dark serif inspector. Looks like a writer's tool, not a +//! developer's CRUD app. Single inline stylesheet, no JS yet. + +use std::sync::Arc; + +use axum::Router; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::Html; +use axum::routing::get; +use chrono::{DateTime, Utc}; +use maud::{DOCTYPE, Markup, html}; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Clone)] +pub struct WebState { + pub pool: PgPool, +} + +pub fn router(state: WebState) -> Router { + Router::new() + .route("/", get(index)) + .route("/stories/{id}", get(story_detail)) + .route("/stories/{id}/chapters/{n}", get(chapter_view)) + .route("/stories/{id}/runs", get(runs_view)) + .with_state(Arc::new(state)) +} + +// ─── data fetches ──────────────────────────────────────────────── + +#[derive(Debug, Clone)] +struct StoryRow { + id: Uuid, + title: String, + status: String, + word_count_actual: i32, + updated_at: DateTime, + chapter_count: i64, + character_count: i64, + summary_count: i64, + canon_fact_count: i64, +} + +async fn fetch_stories(pool: &PgPool) -> Vec { + let rows: Vec<(Uuid, String, String, i32, DateTime, Option, Option, Option, Option)> = sqlx::query_as( + r#" + SELECT + s.id, + s.title, + s.status, + s.word_count_actual, + s.updated_at, + (SELECT count(*) FROM chapters c WHERE c.story_id = s.id), + (SELECT count(*) FROM characters ch WHERE ch.story_id = s.id), + (SELECT count(*) FROM chapter_summaries cs JOIN chapters c2 ON c2.id = cs.chapter_id WHERE c2.story_id = s.id), + (SELECT count(*) FROM canon_facts cf WHERE cf.story_id = s.id) + FROM stories s + ORDER BY s.updated_at DESC + "# + ) + .fetch_all(pool) + .await + .unwrap_or_default(); + + rows.into_iter() + .map(|(id, title, status, words, updated_at, ch, chars, sums, facts)| StoryRow { + id, + title, + status, + word_count_actual: words, + updated_at, + chapter_count: ch.unwrap_or(0), + character_count: chars.unwrap_or(0), + summary_count: sums.unwrap_or(0), + canon_fact_count: facts.unwrap_or(0), + }) + .collect() +} + +// ─── handlers ──────────────────────────────────────────────────── + +async fn index(State(state): State>) -> Html { + let stories = fetch_stories(&state.pool).await; + Html(render_shell(&stories, None, welcome_panel()).into_string()) +} + +async fn story_detail( + State(state): State>, + Path(id): Path, +) -> Result, StatusCode> { + let stories = fetch_stories(&state.pool).await; + let Some(story) = stories.iter().find(|s| s.id == id).cloned() else { + return Err(StatusCode::NOT_FOUND); + }; + + let chapters: Vec<(i32, Option, i32, bool)> = sqlx::query_as( + r#" + SELECT c.n, c.title, c.word_count, cs.chapter_id IS NOT NULL + FROM chapters c + LEFT JOIN chapter_summaries cs ON cs.chapter_id = c.id + WHERE c.story_id = $1 + ORDER BY c.n + "# + ) + .bind(id) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + let characters: Vec<(String, String, String)> = sqlx::query_as( + "SELECT name, kind, key_facts FROM characters WHERE story_id = $1 ORDER BY kind, name", + ) + .bind(id) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + let canon_facts: Vec<(String, String, String)> = sqlx::query_as( + "SELECT category, title, body FROM canon_facts WHERE story_id = $1 ORDER BY category, title", + ) + .bind(id) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + let panel = story_panel(&story, &chapters, &characters, &canon_facts); + Ok(Html(render_shell(&stories, Some(id), panel).into_string())) +} + +async fn chapter_view( + State(state): State>, + Path((id, n)): Path<(Uuid, i32)>, +) -> Result, StatusCode> { + let stories = fetch_stories(&state.pool).await; + + let row: Option<(Option, String, i32, Option)> = sqlx::query_as( + r#" + SELECT c.title, c.body_md, c.word_count, cs.body + FROM chapters c + LEFT JOIN chapter_summaries cs ON cs.chapter_id = c.id + WHERE c.story_id = $1 AND c.n = $2 + "# + ) + .bind(id) + .bind(n) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + + let Some((title, body_md, word_count, summary)) = row else { + return Err(StatusCode::NOT_FOUND); + }; + + let panel = chapter_panel(id, n, title.as_deref(), &body_md, word_count, summary.as_deref()); + Ok(Html(render_shell(&stories, Some(id), panel).into_string())) +} + +async fn runs_view( + State(state): State>, + Path(id): Path, +) -> Result, StatusCode> { + let stories = fetch_stories(&state.pool).await; + if !stories.iter().any(|s| s.id == id) { + return Err(StatusCode::NOT_FOUND); + } + + let runs: Vec<(Uuid, String, String, DateTime, Option>, Option)> = + sqlx::query_as( + "SELECT id, kind, status, started_at, ended_at, error + FROM generation_runs + WHERE story_id = $1 + ORDER BY started_at DESC + LIMIT 200", + ) + .bind(id) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + let panel = runs_panel(id, &runs); + Ok(Html(render_shell(&stories, Some(id), panel).into_string())) +} + +// ─── templates ─────────────────────────────────────────────────── + +fn render_shell(stories: &[StoryRow], current: Option, main: Markup) -> Markup { + html! { + (DOCTYPE) + html lang="en" { + head { + meta charset="utf-8"; + meta name="viewport" content="width=device-width, initial-scale=1"; + title { "skald" } + style { (STYLESHEET) } + } + body { + header.topbar { + a.brand href="/" { "skald" } + span.tagline { "an old-norse poet's inspector" } + } + main.layout { + aside.sidebar { + h2 { "Stories" } + @if stories.is_empty() { + p.empty { "No stories imported yet. Run " + code { "skald import-markdown" } " to add one." } + } @else { + ul.story-list { + @for s in stories { + li.story-row.(if Some(s.id) == current { "active" } else { "" }) { + a href=(format!("/stories/{}", s.id)) { + span.title { (s.title) } + span.meta { + (s.chapter_count) " chap · " + (kfmt(s.word_count_actual)) " words · " + (s.summary_count) "/" (s.chapter_count) " summ" + } + span.status."status-".(s.status) { (s.status) } + } + } + } + } + } + } + section.panel { (main) } + } + footer.footbar { + span { "skald · v0.1 inspector · " + a href="http://192.168.0.5:3001/cobb/skald" { "cobb/skald" } + } + } + } + } + } +} + +fn welcome_panel() -> Markup { + html! { + div.welcome { + h1 { "Welcome." } + p.lead { "Pick a story from the sidebar to inspect it." } + p { + "Skald is the database; the binary is the tooling. Every story is rows — " + "chapters, characters, canon, generation runs. This inspector shows them as they are." + } + p.muted { + "Authors, the continue button, and the cleanup audit panel land in v0.3 once " + "the persona layer is wired." + } + } + } +} + +fn story_panel( + s: &StoryRow, + chapters: &[(i32, Option, i32, bool)], + characters: &[(String, String, String)], + canon_facts: &[(String, String, String)], +) -> Markup { + let real_chars: Vec<_> = characters.iter().filter(|c| c.1 == "real").collect(); + let fictional_chars: Vec<_> = characters.iter().filter(|c| c.1 == "fictional").collect(); + + html! { + article.story { + h1 { (s.title) } + div.metabar { + span.status."status-".(s.status) { (s.status) } + span.meta-item { (s.chapter_count) " chapters" } + span.meta-item { (kfmt(s.word_count_actual)) " words" } + span.meta-item { (s.character_count) " characters" } + span.meta-item { (s.canon_fact_count) " canon facts" } + span.meta-item.muted { "updated " (rel_time(s.updated_at)) } + a.runs-link href=(format!("/stories/{}/runs", s.id)) { "→ generation log" } + } + + section.chapters { + h2 { "Chapters" } + ol.chapter-list { + @for (n, title, wc, has_summary) in chapters { + li { + a href=(format!("/stories/{}/chapters/{}", s.id, n)) { + span.n { "Chapter " (n) } + @if let Some(t) = title { + span.ch-title { (strip_chapter_prefix(t, *n)) } + } + span.wc { (kfmt(*wc)) " w" } + @if *has_summary { + span.summary-flag title="summary present" { "✓ summary" } + } @else { + span.summary-flag.missing title="no summary yet" { "○ no summary" } + } + } + } + } + } + } + + @if !real_chars.is_empty() || !fictional_chars.is_empty() { + section.bible-section { + h2 { "Bible — characters" } + @if !real_chars.is_empty() { + h3 { "Real" } + ul.char-list { + @for c in &real_chars { + li { + span.cname { (c.0) } + span.cfacts { (truncate(&c.2, 220)) } + } + } + } + } + @if !fictional_chars.is_empty() { + h3 { "Fictional" } + ul.char-list { + @for c in &fictional_chars { + li { + span.cname { (c.0) } + span.cfacts { (truncate(&c.2, 220)) } + } + } + } + } + } + } + + @if !canon_facts.is_empty() { + section.bible-section { + h2 { "Bible — canon" } + @for (category, title, body) in canon_facts { + details { + summary { + span.category { (category) } + span.ctitle { (title) } + } + div.cbody { (body) } + } + } + } + } + } + } +} + +fn chapter_panel( + story_id: Uuid, + n: i32, + title: Option<&str>, + body_md: &str, + word_count: i32, + summary: Option<&str>, +) -> Markup { + let display_title = title.map(|t| strip_chapter_prefix(t, n).to_string()); + html! { + article.chapter { + a.back href=(format!("/stories/{}", story_id)) { "← back to story" } + h1.ch-title { + span.n { "Chapter " (n) } + @if let Some(t) = display_title { + " — " (t) + } + } + div.metabar { + span.meta-item { (kfmt(word_count)) " words" } + } + @if let Some(s) = summary { + aside.summary-box { + h3 { "Summary" } + p { (s) } + } + } @else { + aside.summary-box.empty { + p { + "No summary yet. Run " + code { "skald summarize --story " (story_id) } + " to generate one." + } + } + } + div.prose { + @for para in body_md.split("\n\n") { + @let trimmed = para.trim(); + @if !trimmed.is_empty() && trimmed != "---" { + p { (trimmed) } + } + } + } + } + } +} + +fn runs_panel(story_id: Uuid, runs: &[(Uuid, String, String, DateTime, Option>, Option)]) -> Markup { + html! { + article.runs { + a.back href=(format!("/stories/{}", story_id)) { "← back to story" } + h1 { "Generation log" } + p.muted { "Every LLM call made for this story, oldest to newest." } + table.runs-table { + thead { + tr { + th { "kind" } + th { "status" } + th { "started" } + th { "duration" } + th { "error" } + } + } + tbody { + @for (_id, kind, status, started, ended, error) in runs { + tr.(format!("run-{}", status)) { + td.kind { (kind) } + td.(format!("status-{}", status)) { (status) } + td.muted { (rel_time(*started)) } + td.muted { + @if let Some(e) = ended { + ((e.signed_duration_since(*started).num_seconds()) ) "s" + } @else { + "—" + } + } + td.error { + @if let Some(e) = error { (truncate(e, 80)) } + } + } + } + } + } + } + } +} + +// ─── helpers ───────────────────────────────────────────────────── + +fn kfmt(n: i32) -> String { + if n >= 1000 { + format!("{:.1}k", n as f64 / 1000.0) + } else { + n.to_string() + } +} + +fn truncate(s: &str, max_chars: usize) -> String { + if s.chars().count() <= max_chars { + return s.to_string(); + } + let mut out: String = s.chars().take(max_chars).collect(); + out.push('…'); + out +} + +/// "Chapter One — Saturday, April 19, 1986" → "Saturday, April 19, 1986" +fn strip_chapter_prefix(title: &str, _n: i32) -> &str { + if let Some(idx) = title.find(" — ") { + let after = &title[idx + " — ".len()..]; + if !after.is_empty() { + return after; + } + } + title +} + +fn rel_time(t: DateTime) -> String { + let now = Utc::now(); + let delta = now.signed_duration_since(t); + let secs = delta.num_seconds(); + if secs < 60 { + format!("{}s ago", secs.max(0)) + } else if secs < 3600 { + format!("{}m ago", secs / 60) + } else if secs < 86400 { + format!("{}h ago", secs / 3600) + } else { + format!("{}d ago", secs / 86400) + } +} + +// ─── stylesheet (inline) ───────────────────────────────────────── + +const STYLESHEET: &str = r#" +:root { + --bg: #0d0d0c; + --surface: #16151a; + --surface-2: #1d1c20; + --border: #2a2830; + --ink: #e8dfc8; + --ink-muted: #8b8579; + --ink-faint: #5d584f; + --accent: #b08d50; + --accent-dim: #76624f; + --crit: #c97474; + --ok: #87a87a; + --warn: #c4a06c; + --serif: "Iowan Old Style", "Constantia", Georgia, "Times New Roman", serif; + --sans: -apple-system, "Segoe UI", Roboto, system-ui, sans-serif; + --mono: "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace; +} +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; background: var(--bg); color: var(--ink); font-family: var(--sans); font-size: 15px; line-height: 1.55; } +a { color: var(--accent); text-decoration: none; } +a:hover { color: var(--ink); } +code { font-family: var(--mono); font-size: 0.9em; background: var(--surface-2); padding: 1px 5px; border-radius: 2px; color: var(--ink-muted); } + +.topbar { display: flex; align-items: baseline; gap: 16px; padding: 14px 24px; border-bottom: 1px solid var(--border); background: var(--surface); } +.brand { font-family: var(--serif); font-size: 22px; color: var(--accent); letter-spacing: 0.5px; } +.brand:hover { color: var(--ink); } +.tagline { color: var(--ink-faint); font-size: 13px; font-style: italic; } + +.footbar { padding: 10px 24px; border-top: 1px solid var(--border); background: var(--surface); color: var(--ink-faint); font-size: 12px; } +.footbar a { color: var(--ink-faint); } + +.layout { display: grid; grid-template-columns: 320px 1fr; min-height: calc(100vh - 90px); } + +.sidebar { padding: 16px 20px; border-right: 1px solid var(--border); background: var(--surface); overflow-y: auto; } +.sidebar h2 { font-family: var(--serif); font-size: 14px; text-transform: uppercase; letter-spacing: 1.5px; color: var(--ink-muted); margin: 0 0 14px 0; font-weight: 400; } +.story-list { list-style: none; margin: 0; padding: 0; } +.story-row { margin-bottom: 4px; } +.story-row a { display: block; padding: 10px 12px; border-radius: 3px; color: var(--ink); } +.story-row a:hover, .story-row.active a { background: var(--surface-2); } +.story-row.active a { border-left: 2px solid var(--accent); padding-left: 10px; } +.story-row .title { display: block; font-family: var(--serif); font-size: 16px; color: var(--ink); margin-bottom: 2px; } +.story-row .meta { display: block; font-size: 11px; color: var(--ink-faint); } +.story-row .status { display: inline-block; font-size: 10px; padding: 1px 6px; border-radius: 2px; margin-left: 6px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--ink-faint); } + +.panel { padding: 36px 48px; overflow-y: auto; max-width: 1100px; } + +.welcome h1 { font-family: var(--serif); font-size: 36px; color: var(--accent); margin: 40px 0 16px 0; font-weight: 400; } +.welcome .lead { font-size: 17px; color: var(--ink); } +.welcome p { max-width: 65ch; } +.welcome .muted { color: var(--ink-faint); font-size: 14px; } + +.story h1 { font-family: var(--serif); font-size: 32px; color: var(--ink); margin: 0 0 14px 0; font-weight: 400; } +.metabar { display: flex; flex-wrap: wrap; gap: 16px; font-size: 13px; color: var(--ink-muted); margin-bottom: 30px; align-items: center; padding-bottom: 16px; border-bottom: 1px solid var(--border); } +.metabar .status { font-size: 10px; padding: 2px 8px; border-radius: 2px; background: var(--surface-2); text-transform: uppercase; letter-spacing: 0.5px; } +.metabar .runs-link { margin-left: auto; font-size: 12px; color: var(--ink-faint); } +.metabar .runs-link:hover { color: var(--accent); } +.muted { color: var(--ink-faint); } +.story h2, .chapter h2, .runs h2 { font-family: var(--serif); font-size: 14px; text-transform: uppercase; letter-spacing: 1.5px; color: var(--ink-muted); margin: 36px 0 14px 0; font-weight: 400; } +.story h3, .bible-section h3 { font-family: var(--serif); font-size: 15px; color: var(--accent-dim); margin: 18px 0 10px 0; font-weight: 400; letter-spacing: 0.5px; } + +.chapter-list { list-style: none; padding: 0; margin: 0; } +.chapter-list li { margin-bottom: 2px; } +.chapter-list a { display: grid; grid-template-columns: 100px 1fr auto 110px; gap: 18px; align-items: baseline; padding: 8px 12px; border-radius: 3px; color: var(--ink); } +.chapter-list a:hover { background: var(--surface-2); } +.chapter-list .n { color: var(--ink-faint); font-size: 13px; font-family: var(--mono); } +.chapter-list .ch-title { font-family: var(--serif); font-size: 15px; } +.chapter-list .wc { color: var(--ink-faint); font-size: 12px; font-family: var(--mono); text-align: right; } +.chapter-list .summary-flag { font-size: 11px; color: var(--ok); } +.chapter-list .summary-flag.missing { color: var(--ink-faint); } + +.char-list { list-style: none; padding: 0; margin: 0 0 14px 0; } +.char-list li { display: grid; grid-template-columns: 240px 1fr; gap: 18px; padding: 8px 0; border-bottom: 1px solid var(--surface-2); align-items: baseline; } +.cname { font-family: var(--serif); color: var(--ink); font-size: 14px; } +.cfacts { color: var(--ink-muted); font-size: 13px; line-height: 1.5; } + +.bible-section details { border-bottom: 1px solid var(--surface-2); padding: 10px 0; } +.bible-section summary { cursor: pointer; list-style: none; display: flex; gap: 14px; align-items: baseline; padding: 4px 0; } +.bible-section summary::-webkit-details-marker { display: none; } +.bible-section summary::before { content: "▸"; color: var(--ink-faint); font-size: 11px; margin-right: 4px; } +.bible-section details[open] summary::before { content: "▾"; } +.bible-section .category { font-size: 10px; text-transform: uppercase; letter-spacing: 1px; color: var(--accent-dim); min-width: 140px; } +.bible-section .ctitle { font-family: var(--serif); color: var(--ink); } +.bible-section .cbody { padding: 10px 0 6px 18px; color: var(--ink-muted); white-space: pre-wrap; line-height: 1.65; max-width: 75ch; font-size: 14px; } + +.chapter .back, .runs .back { display: inline-block; color: var(--ink-faint); font-size: 12px; margin-bottom: 18px; } +.chapter h1.ch-title { font-family: var(--serif); font-size: 28px; font-weight: 400; color: var(--ink); margin: 0 0 8px 0; } +.chapter h1.ch-title .n { color: var(--accent-dim); font-size: 22px; } +.summary-box { background: var(--surface); border-left: 2px solid var(--accent-dim); padding: 14px 20px; margin: 24px 0 32px 0; max-width: 75ch; } +.summary-box.empty { border-left-color: var(--surface-2); } +.summary-box h3 { font-family: var(--serif); font-size: 12px; text-transform: uppercase; letter-spacing: 1.5px; color: var(--ink-muted); margin: 0 0 8px 0; font-weight: 400; } +.summary-box p { margin: 0; color: var(--ink-muted); font-size: 14px; line-height: 1.65; } +.prose { font-family: var(--serif); font-size: 17px; line-height: 1.75; max-width: 68ch; color: var(--ink); } +.prose p { margin: 0 0 1.1em 0; } +.prose p:first-letter { /* nothing fancy yet */ } + +.runs-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.runs-table th, .runs-table td { padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--surface-2); } +.runs-table th { color: var(--ink-faint); font-weight: 400; text-transform: uppercase; font-size: 11px; letter-spacing: 1px; } +.runs-table td.kind { font-family: var(--mono); font-size: 12px; color: var(--ink-muted); } +.runs-table td.status-succeeded { color: var(--ok); } +.runs-table td.status-failed { color: var(--crit); } +.runs-table td.status-running { color: var(--warn); } +.runs-table td.error { color: var(--crit); font-size: 12px; } + +.empty { color: var(--ink-faint); font-style: italic; } +"#;