web: author picker on both forms + fire-gen background task

Both /stories/new and /stories/:id/continue now carry an author
<select> populated from the authors table. New-saga + continue
panels pre-select the parent story's author when continuing
(propagates the voice across sequels by default; user can override).

Background fire-generation: continue form has a 'fire generation
now' checkbox. When checked, the POST creates the seed story row
AND spawns a tokio::spawn task that calls continue_story::run()
in the background. The user redirects to the new story's detail
page and can refresh to watch status flow seed → generating →
cleaning → auditing → complete. Failure path logs to the
container's tracing output (and generation_runs rows pick up
'failed' status).

Unchecked behavior: same as before — sequel sits in 'seed' state
until 'skald continue' fires it manually. Useful for queuing
multiple drafts before committing the opus spend.

CSS adds .checkbox-label styling so the checkbox + label flow
horizontally with the rest of the form looking sane.

Compiles clean. Smoke-test: open /stories/.../continue, pick an
author, tick 'fire now,' submit. Should redirect to a 'generating'
status; opening generation log shows the running gen pass.
This commit is contained in:
Kayos 2026-05-13 12:48:16 -07:00
parent c899019b35
commit 1e19305432

View file

@ -53,6 +53,29 @@ struct StoryRow {
canon_fact_count: i64,
}
#[derive(Debug, Clone)]
struct AuthorOpt {
slug: String,
display_name: String,
tagline: Option<String>,
}
async fn fetch_authors(pool: &PgPool) -> Vec<AuthorOpt> {
let rows: Vec<(String, String, Option<String>)> = 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<StoryRow> {
let rows: Vec<(Uuid, String, String, i32, DateTime<Utc>, Option<i64>, Option<i64>, Option<i64>, Option<i64>)> = 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<Arc<WebState>>) -> Html<String> {
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<Uuid>,
) -> Result<Html<String>, 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<Uuid>, 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 <id>" } " 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) {