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,
|
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> {
|
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(
|
let rows: Vec<(Uuid, String, String, i32, DateTime<Utc>, Option<i64>, Option<i64>, Option<i64>, Option<i64>)> = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
|
|
@ -103,11 +126,14 @@ pub struct NewStoryForm {
|
||||||
prompt: String,
|
prompt: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
word_count_target: String,
|
word_count_target: String,
|
||||||
|
#[serde(default)]
|
||||||
|
author_slug: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn new_story_form(State(state): State<Arc<WebState>>) -> Html<String> {
|
async fn new_story_form(State(state): State<Arc<WebState>>) -> Html<String> {
|
||||||
let stories = fetch_stories(&state.pool).await;
|
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(
|
async fn new_story_create(
|
||||||
|
|
@ -143,6 +169,14 @@ async fn new_story_create(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.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)))
|
Ok(Redirect::to(&format!("/stories/{}", id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,6 +187,10 @@ pub struct ContinueForm {
|
||||||
direction: String,
|
direction: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
word_count_target: String,
|
word_count_target: String,
|
||||||
|
#[serde(default)]
|
||||||
|
author_slug: String,
|
||||||
|
#[serde(default)]
|
||||||
|
fire: String, // "now" = spawn background gen; empty = just queue
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn continue_form(
|
async fn continue_form(
|
||||||
|
|
@ -160,13 +198,23 @@ async fn continue_form(
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Html<String>, StatusCode> {
|
) -> Result<Html<String>, StatusCode> {
|
||||||
let stories = fetch_stories(&state.pool).await;
|
let stories = fetch_stories(&state.pool).await;
|
||||||
|
let authors = fetch_authors(&state.pool).await;
|
||||||
let parent = stories
|
let parent = stories
|
||||||
.iter()
|
.iter()
|
||||||
.find(|s| s.id == id)
|
.find(|s| s.id == id)
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or(StatusCode::NOT_FOUND)?;
|
.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(
|
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
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.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)))
|
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! {
|
html! {
|
||||||
article.form-panel {
|
article.form-panel {
|
||||||
h1 { "New story" }
|
h1 { "Begin a new saga" }
|
||||||
p.muted { "Create a story stub. Actual generation lands when v0.3 wires the gen pipeline." }
|
(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) } }
|
@if let Some(e) = err { p.error { (e) } }
|
||||||
form method="post" action="/stories/new" {
|
form method="post" action="/stories/new" {
|
||||||
label { "Title"
|
label { "Title"
|
||||||
input type="text" name="title" required;
|
input type="text" name="title" required;
|
||||||
}
|
}
|
||||||
label { "Seed prompt (optional)"
|
label { "Author (the soul that will write)"
|
||||||
textarea name="prompt" rows="4" placeholder="What is this story about?" {}
|
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" }
|
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! {
|
html! {
|
||||||
article.form-panel {
|
article.form-panel {
|
||||||
a.back href=(format!("/stories/{}", parent.id)) { "← back to " (parent.title) }
|
a.back href=(format!("/stories/{}", parent.id)) { "← back to " (parent.title) }
|
||||||
h1 { "Continue: " (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)) {
|
form method="post" action=(format!("/stories/{}/continue", parent.id)) {
|
||||||
label { "Title for the sequel"
|
label { "Title for the sequel"
|
||||||
input type="text" name="title" required value=(format!("{} — continued", parent.title));
|
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)"
|
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)"
|
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" }
|
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 button:hover { border-color: var(--accent); background: var(--surface-2); }
|
||||||
.form-panel .error { color: var(--crit); }
|
.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 ── */
|
/* ─── mobile: collapse sidebar inline above main, allow toggle ── */
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue