diff --git a/skald/src/web.rs b/skald/src/web.rs index 1aacd71..1bb4bf2 100644 --- a/skald/src/web.rs +++ b/skald/src/web.rs @@ -53,6 +53,29 @@ struct StoryRow { canon_fact_count: i64, } +#[derive(Debug, Clone)] +struct AuthorOpt { + slug: String, + display_name: String, + tagline: Option, +} + +async fn fetch_authors(pool: &PgPool) -> Vec { + let rows: Vec<(String, String, Option)> = sqlx::query_as( + "SELECT slug, display_name, persona_tagline FROM authors ORDER BY display_name", + ) + .fetch_all(pool) + .await + .unwrap_or_default(); + rows.into_iter() + .map(|(slug, display_name, tagline)| AuthorOpt { + slug, + display_name, + tagline, + }) + .collect() +} + async fn fetch_stories(pool: &PgPool) -> Vec { let rows: Vec<(Uuid, String, String, i32, DateTime, Option, Option, Option, Option)> = sqlx::query_as( r#" @@ -103,11 +126,14 @@ pub struct NewStoryForm { prompt: String, #[serde(default)] word_count_target: String, + #[serde(default)] + author_slug: String, } async fn new_story_form(State(state): State>) -> Html { let stories = fetch_stories(&state.pool).await; - Html(render_shell(&stories, None, new_story_panel(None)).into_string()) + let authors = fetch_authors(&state.pool).await; + Html(render_shell(&stories, None, new_story_panel(None, &authors)).into_string()) } async fn new_story_create( @@ -143,6 +169,14 @@ async fn new_story_create( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + // Pin the chosen author (if any) onto the new story. + let author_slug = form.author_slug.trim(); + if !author_slug.is_empty() { + if let Ok(Some(awr)) = skald_core::authors::get_with_current_revision(&state.pool, author_slug).await { + let _ = skald_core::authors::assign_to_story(&state.pool, id, awr.author.id, awr.revision.id).await; + } + } + Ok(Redirect::to(&format!("/stories/{}", id))) } @@ -153,6 +187,10 @@ pub struct ContinueForm { direction: String, #[serde(default)] word_count_target: String, + #[serde(default)] + author_slug: String, + #[serde(default)] + fire: String, // "now" = spawn background gen; empty = just queue } async fn continue_form( @@ -160,13 +198,23 @@ async fn continue_form( Path(id): Path, ) -> Result, StatusCode> { let stories = fetch_stories(&state.pool).await; + let authors = fetch_authors(&state.pool).await; let parent = stories .iter() .find(|s| s.id == id) .cloned() .ok_or(StatusCode::NOT_FOUND)?; + // Pre-select the parent's author if it has one. + let parent_author_slug = sqlx::query_scalar::<_, String>( + "SELECT a.slug FROM stories s JOIN authors a ON a.id = s.author_id WHERE s.id = $1", + ) + .bind(id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); Ok(Html( - render_shell(&stories, Some(id), continue_panel(&parent)).into_string(), + render_shell(&stories, Some(id), continue_panel(&parent, &authors, parent_author_slug.as_deref())).into_string(), )) } @@ -209,6 +257,39 @@ async fn continue_create( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + // Pin author (if chosen). + let author_slug = form.author_slug.trim(); + if !author_slug.is_empty() { + if let Ok(Some(awr)) = skald_core::authors::get_with_current_revision(&state.pool, author_slug).await { + let _ = skald_core::authors::assign_to_story(&state.pool, id, awr.author.id, awr.revision.id).await; + } + } + + // If user clicked "fire now," spawn a background gen task. + // Otherwise the sequel sits in seed state until CLI fires it. + if form.fire == "now" { + let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| "postgresql://skald:skald@localhost:5432/skald".into()); + let author_owned = if author_slug.is_empty() { None } else { Some(author_slug.to_string()) }; + let direction_owned = direction.clone(); + tokio::spawn(async move { + if let Err(e) = crate::continue_story::run( + &database_url, + id, + author_owned.as_deref(), + direction_owned.as_deref(), + target, + 2, // recent_n + false, // skip_audit + ) + .await + { + tracing::error!(story_id = %id, error = %e, "background continue task failed"); + } else { + tracing::info!(story_id = %id, "background continue task succeeded"); + } + }); + } + Ok(Redirect::to(&format!("/stories/{}", id))) } @@ -371,21 +452,33 @@ fn render_shell(stories: &[StoryRow], current: Option, main: Markup) -> Ma } } -fn new_story_panel(err: Option<&str>) -> Markup { +fn new_story_panel(err: Option<&str>, authors: &[AuthorOpt]) -> Markup { html! { article.form-panel { - h1 { "New story" } - p.muted { "Create a story stub. Actual generation lands when v0.3 wires the gen pipeline." } + h1 { "Begin a new saga" } + (ornament()) + p.muted { "Create a story row + pick its author. The gen pipeline fires on " code { "skald continue --story " } " or via the continue form once you've drafted the first chapter." } @if let Some(e) = err { p.error { (e) } } form method="post" action="/stories/new" { label { "Title" input type="text" name="title" required; } - label { "Seed prompt (optional)" - textarea name="prompt" rows="4" placeholder="What is this story about?" {} + label { "Author (the soul that will write)" + select name="author_slug" { + option value="" selected { "— house voice (no author bound) —" } + @for a in authors { + option value=(a.slug) { + (a.display_name) + @if let Some(t) = &a.tagline { " — " (t) } + } + } + } } - label { "Target word count (optional)" - input type="number" name="word_count_target" min="500" step="500"; + label { "Seed prompt (optional)" + textarea name="prompt" rows="4" placeholder="What is this saga about? Setting, frame, conflict." {} + } + label { "Target word count per chapter (optional)" + input type="number" name="word_count_target" min="500" step="500" placeholder="3000"; } button type="submit" { "Create" } } @@ -393,21 +486,42 @@ fn new_story_panel(err: Option<&str>) -> Markup { } } -fn continue_panel(parent: &StoryRow) -> Markup { +fn continue_panel(parent: &StoryRow, authors: &[AuthorOpt], parent_author_slug: Option<&str>) -> Markup { html! { article.form-panel { a.back href=(format!("/stories/{}", parent.id)) { "← back to " (parent.title) } h1 { "Continue: " (parent.title) } - p.muted { "Creates a child story stub with parent_story_id = " code { (parent.id) } ". Generation queued for v0.3." } + (ornament()) + p.muted { + "Creates a child saga (parent_story_id = " code { (parent.id) } "). " + "Tick " strong { "fire now" } " to spawn the gen → cleanup → audit pipeline as a " + "background task — takes about 9 minutes of opus wall-clock. Otherwise the seed " + "row waits for " code { "skald continue" } "." + } form method="post" action=(format!("/stories/{}/continue", parent.id)) { label { "Title for the sequel" input type="text" name="title" required value=(format!("{} — continued", parent.title)); } + label { "Author (voice for this chapter)" + select name="author_slug" { + option value="" selected[parent_author_slug.is_none()] { "— house voice (no author bound) —" } + @for a in authors { + option value=(a.slug) selected[parent_author_slug == Some(a.slug.as_str())] { + (a.display_name) + @if let Some(t) = &a.tagline { " — " (t) } + } + } + } + } label { "Direction (optional)" - textarea name="direction" rows="4" placeholder="What should the sequel explore?" {} + textarea name="direction" rows="5" placeholder="What should the sequel explore? Specific scenes, characters, beats." {} } label { "Target word count (optional)" - input type="number" name="word_count_target" min="500" step="500"; + input type="number" name="word_count_target" min="500" step="500" placeholder="3000"; + } + label.checkbox-label { + input type="checkbox" name="fire" value="now"; + span { " fire generation now (background task, ~9 min)" } } button type="submit" { "Queue sequel" } } @@ -971,6 +1085,13 @@ code { font-family: var(--mono); font-size: 0.9em; background: var(--surface-2); } .form-panel button:hover { border-color: var(--accent); background: var(--surface-2); } .form-panel .error { color: var(--crit); } +.form-panel .checkbox-label { + flex-direction: row; align-items: center; gap: 10px; + color: var(--ink-muted); text-transform: none; letter-spacing: 0; font-family: var(--sans); font-size: 14px; font-weight: normal; +} +.form-panel .checkbox-label input[type=checkbox] { + width: 16px; height: 16px; accent-color: var(--accent); +} /* ─── mobile: collapse sidebar inline above main, allow toggle ── */ @media (max-width: 800px) {