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:
parent
c899019b35
commit
1e19305432
1 changed files with 134 additions and 13 deletions
147
skald/src/web.rs
147
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<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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue