v0.3 step 1: migration 0004 + authors module + web form panels
Migration 0004 — authors + author_revisions + stories.author_id + stories.author_revision_id + stories.cross_story_memory + author_corpus. Soul versioning built in from day one per cobb's locked decisions: - authors.id immutable identity (slug + display_name + tagline + model) - author_revisions tracks each soul revision with n monotonic - Partial unique index 'idx_author_revisions_current' enforces exactly one is_current=true per author - stories.author_revision_id pins to the exact soul used at gen time (so 'this was the Orson Black active when chapter 8 was written' is always recoverable) - author_corpus tracks 'authored' + 'read' relationships for the v0.3 cross-story memory toggle skald-core::authors module — CRUD: get_by_slug, get_with_current_revision, get_current_revision, get_revision, create_or_get (idempotent), add_revision (transactional, demotes prior is_current=true), assign_to_story (also touches author_corpus). Web v0.1 forms (the second feedback bucket — 'no way to make new stories', 'no options for sequels'): handlers + form panels + POST routes for /stories/new and /stories/:id/continue. Both create a story stub with status='seed'; actual generation will be fired by 'skald continue' (next commit) walking seed rows. Norse visual revamp + mobile collapse deferred — vetting full gen is the priority per cobb's 'green light for v0.3'. Coming back to the aesthetic after the pipeline works end-to-end against a real Orson Black-authored Chapter 8 of Coast-Down.
This commit is contained in:
parent
7187bf5ace
commit
713ba41977
4 changed files with 529 additions and 1 deletions
100
migrations/0004_authors.sql
Normal file
100
migrations/0004_authors.sql
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
-- Authors as personas with souls (v0.3).
|
||||
--
|
||||
-- Each story has a named author whose soul (SOUL.md-style markdown
|
||||
-- blob) IS the LLM's system prompt. Soul replaces opus's default
|
||||
-- base prompt via clawdforge's system_mode='replace' so the model
|
||||
-- BECOMES the author, not "Claude playing the author."
|
||||
--
|
||||
-- Souls are versioned: authors.id is the immutable identity, while
|
||||
-- author_revisions tracks soul edits over time. Stories pin to a
|
||||
-- specific revision so "this was the version of Orson Black active
|
||||
-- when chapter 8 of Coast-Down was written" is always recoverable.
|
||||
|
||||
CREATE TABLE authors (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug TEXT NOT NULL UNIQUE, -- "orson-black"
|
||||
display_name TEXT NOT NULL, -- "Orson Black"
|
||||
persona_tagline TEXT, -- "Orwell but more rebel + pissed off"
|
||||
-- Model the author writes with. Skald is opinionated: always
|
||||
-- opus max effort. Stored per-author so we can later vary
|
||||
-- (e.g. test sonnet for a faster-iterating author).
|
||||
model TEXT NOT NULL DEFAULT 'opus',
|
||||
-- Flag for the operator: this is a synthetic literary persona,
|
||||
-- not a real human author. Display somewhere obvious.
|
||||
is_synthetic BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- One row per soul revision. `n` is monotonic per author. Exactly
|
||||
-- one revision per author is_current=true at any time (partial
|
||||
-- unique index).
|
||||
CREATE TABLE author_revisions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
author_id UUID NOT NULL REFERENCES authors(id) ON DELETE CASCADE,
|
||||
n INTEGER NOT NULL,
|
||||
-- The soul: SOUL.md-style markdown blob. Replaces claude's base
|
||||
-- system prompt when this revision is used.
|
||||
soul TEXT NOT NULL,
|
||||
-- Optional override of the default scaffold prompt that wraps
|
||||
-- the soul. Default scaffold lives in skald-core::forge.
|
||||
system_template TEXT,
|
||||
-- Tools the author can call during gen. Empty default — fiction
|
||||
-- writing doesn't normally need them. Researcher-bent authors
|
||||
-- could opt in for WebSearch / Read.
|
||||
tools TEXT[] NOT NULL DEFAULT '{}',
|
||||
-- Human note: "first cut", "tightened voice after Chapter 8 read"
|
||||
note TEXT,
|
||||
is_current BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (author_id, n)
|
||||
);
|
||||
|
||||
-- At most one current revision per author.
|
||||
CREATE UNIQUE INDEX idx_author_revisions_current
|
||||
ON author_revisions(author_id) WHERE is_current = true;
|
||||
|
||||
CREATE INDEX idx_author_revisions_author ON author_revisions(author_id);
|
||||
|
||||
-- Auto-touch authors.updated_at on author or revision change.
|
||||
CREATE OR REPLACE FUNCTION touch_author_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
UPDATE authors SET updated_at = now()
|
||||
WHERE id = COALESCE(NEW.author_id, OLD.author_id);
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER author_revisions_touch_author
|
||||
AFTER INSERT OR UPDATE OR DELETE ON author_revisions
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION touch_author_updated_at();
|
||||
|
||||
-- Stories carry author identity + the exact revision used at gen
|
||||
-- time. author_id is also denormalized on the row (instead of
|
||||
-- joining through revision) so common "all stories by Orson"
|
||||
-- queries don't need the join.
|
||||
ALTER TABLE stories
|
||||
ADD COLUMN author_id UUID REFERENCES authors(id) ON DELETE SET NULL,
|
||||
ADD COLUMN author_revision_id UUID REFERENCES author_revisions(id) ON DELETE SET NULL,
|
||||
-- Per-story toggle for cross-story memory. When true,
|
||||
-- ContinuationContext::assemble pulls characters / canon /
|
||||
-- summaries from EVERY story in the author's corpus, not just
|
||||
-- the parent chain.
|
||||
ADD COLUMN cross_story_memory BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE INDEX idx_stories_author ON stories(author_id);
|
||||
|
||||
-- Track which stories an author has access to. Auto-populated via
|
||||
-- app code on every story creation (role='authored'); operator can
|
||||
-- mark other stories as 'read' for cross-corpus memory.
|
||||
CREATE TABLE author_corpus (
|
||||
author_id UUID NOT NULL REFERENCES authors(id) ON DELETE CASCADE,
|
||||
story_id UUID NOT NULL REFERENCES stories(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK (role IN ('authored', 'read')),
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (author_id, story_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_author_corpus_author ON author_corpus(author_id);
|
||||
263
skald-core/src/authors.rs
Normal file
263
skald-core/src/authors.rs
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
//! Authors module. Owns the read/write paths for the persona layer.
|
||||
//!
|
||||
//! An [`Author`] is the immutable identity (slug, display_name, model).
|
||||
//! A [`Revision`] is the versioned soul content — exactly one revision
|
||||
//! per author is marked `is_current = true` at any time.
|
||||
//!
|
||||
//! When skald-the-binary runs a forge pass, it pulls the current
|
||||
//! revision via [`get_with_current_revision`], stuffs the soul into
|
||||
//! the system prompt with `SystemMode::Replace`, and the model
|
||||
//! becomes the author for the duration of the call.
|
||||
|
||||
use anyhow::{Context, bail};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Author {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub display_name: String,
|
||||
pub persona_tagline: Option<String>,
|
||||
pub model: String,
|
||||
pub is_synthetic: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Revision {
|
||||
pub id: Uuid,
|
||||
pub author_id: Uuid,
|
||||
pub n: i32,
|
||||
pub soul: String,
|
||||
pub system_template: Option<String>,
|
||||
pub tools: Vec<String>,
|
||||
pub note: Option<String>,
|
||||
pub is_current: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// An author + their currently-active revision. Most pipeline calls
|
||||
/// want both at once.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthorWithRevision {
|
||||
pub author: Author,
|
||||
pub revision: Revision,
|
||||
}
|
||||
|
||||
/// Fetch an author by slug. Returns None if no such author.
|
||||
pub async fn get_by_slug(pool: &PgPool, slug: &str) -> anyhow::Result<Option<Author>> {
|
||||
let row: Option<(Uuid, String, String, Option<String>, String, bool, DateTime<Utc>, DateTime<Utc>)> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, slug, display_name, persona_tagline, model, is_synthetic,
|
||||
created_at, updated_at
|
||||
FROM authors WHERE slug = $1",
|
||||
)
|
||||
.bind(slug)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(
|
||||
|(id, slug, display_name, persona_tagline, model, is_synthetic, created_at, updated_at)| {
|
||||
Author {
|
||||
id,
|
||||
slug,
|
||||
display_name,
|
||||
persona_tagline,
|
||||
model,
|
||||
is_synthetic,
|
||||
created_at,
|
||||
updated_at,
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// Fetch an author + their current revision in one call.
|
||||
pub async fn get_with_current_revision(
|
||||
pool: &PgPool,
|
||||
slug: &str,
|
||||
) -> anyhow::Result<Option<AuthorWithRevision>> {
|
||||
let Some(author) = get_by_slug(pool, slug).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let revision = get_current_revision(pool, author.id)
|
||||
.await?
|
||||
.with_context(|| format!("author {slug} has no current revision"))?;
|
||||
Ok(Some(AuthorWithRevision { author, revision }))
|
||||
}
|
||||
|
||||
/// Fetch the current revision for an author.
|
||||
pub async fn get_current_revision(
|
||||
pool: &PgPool,
|
||||
author_id: Uuid,
|
||||
) -> anyhow::Result<Option<Revision>> {
|
||||
let row: Option<(Uuid, Uuid, i32, String, Option<String>, Vec<String>, Option<String>, bool, DateTime<Utc>)> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, author_id, n, soul, system_template, tools, note, is_current, created_at
|
||||
FROM author_revisions WHERE author_id = $1 AND is_current = true",
|
||||
)
|
||||
.bind(author_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|(id, author_id, n, soul, system_template, tools, note, is_current, created_at)| {
|
||||
Revision {
|
||||
id,
|
||||
author_id,
|
||||
n,
|
||||
soul,
|
||||
system_template,
|
||||
tools,
|
||||
note,
|
||||
is_current,
|
||||
created_at,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// Fetch a specific revision by id (e.g. the one pinned on a story).
|
||||
pub async fn get_revision(pool: &PgPool, revision_id: Uuid) -> anyhow::Result<Option<Revision>> {
|
||||
let row: Option<(Uuid, Uuid, i32, String, Option<String>, Vec<String>, Option<String>, bool, DateTime<Utc>)> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, author_id, n, soul, system_template, tools, note, is_current, created_at
|
||||
FROM author_revisions WHERE id = $1",
|
||||
)
|
||||
.bind(revision_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|(id, author_id, n, soul, system_template, tools, note, is_current, created_at)| {
|
||||
Revision {
|
||||
id,
|
||||
author_id,
|
||||
n,
|
||||
soul,
|
||||
system_template,
|
||||
tools,
|
||||
note,
|
||||
is_current,
|
||||
created_at,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// Create a new author. Idempotent on slug — if an author with this
|
||||
/// slug exists, returns it instead.
|
||||
pub async fn create_or_get(
|
||||
pool: &PgPool,
|
||||
slug: &str,
|
||||
display_name: &str,
|
||||
persona_tagline: Option<&str>,
|
||||
) -> anyhow::Result<Author> {
|
||||
if let Some(existing) = get_by_slug(pool, slug).await? {
|
||||
return Ok(existing);
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO authors (slug, display_name, persona_tagline)
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(display_name)
|
||||
.bind(persona_tagline)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
get_by_slug(pool, slug)
|
||||
.await?
|
||||
.with_context(|| format!("author {slug} just created but not found"))
|
||||
}
|
||||
|
||||
/// Add a new revision to an author and mark it current. The previous
|
||||
/// current revision (if any) gets flipped to is_current=false.
|
||||
pub async fn add_revision(
|
||||
pool: &PgPool,
|
||||
author_id: Uuid,
|
||||
soul: &str,
|
||||
system_template: Option<&str>,
|
||||
tools: &[String],
|
||||
note: Option<&str>,
|
||||
) -> anyhow::Result<Revision> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// Demote the current revision if there is one.
|
||||
sqlx::query("UPDATE author_revisions SET is_current = false WHERE author_id = $1 AND is_current = true")
|
||||
.bind(author_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Next n.
|
||||
let next_n: i32 = sqlx::query_scalar(
|
||||
"SELECT COALESCE(MAX(n), 0) + 1 FROM author_revisions WHERE author_id = $1",
|
||||
)
|
||||
.bind(author_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let revision_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO author_revisions
|
||||
(author_id, n, soul, system_template, tools, note, is_current)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, true)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(author_id)
|
||||
.bind(next_n)
|
||||
.bind(soul)
|
||||
.bind(system_template)
|
||||
.bind(tools)
|
||||
.bind(note)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
get_revision(pool, revision_id)
|
||||
.await?
|
||||
.with_context(|| format!("revision {revision_id} just inserted but not found"))
|
||||
}
|
||||
|
||||
/// Assign an author + revision to an existing story. Pins the
|
||||
/// revision so future re-reads always know which version wrote
|
||||
/// what. Also adds a row to author_corpus marking the author as
|
||||
/// having authored this story.
|
||||
pub async fn assign_to_story(
|
||||
pool: &PgPool,
|
||||
story_id: Uuid,
|
||||
author_id: Uuid,
|
||||
revision_id: Uuid,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let affected = sqlx::query(
|
||||
"UPDATE stories
|
||||
SET author_id = $1, author_revision_id = $2
|
||||
WHERE id = $3",
|
||||
)
|
||||
.bind(author_id)
|
||||
.bind(revision_id)
|
||||
.bind(story_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if affected.rows_affected() == 0 {
|
||||
bail!("story {story_id} not found");
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO author_corpus (author_id, story_id, role)
|
||||
VALUES ($1, $2, 'authored')
|
||||
ON CONFLICT (author_id, story_id) DO NOTHING",
|
||||
)
|
||||
.bind(author_id)
|
||||
.bind(story_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// DB-touching tests live in skald/tests/integration.rs against a
|
||||
// real ephemeral postgres. Unit tests would mostly check trivial
|
||||
// mappings.
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
//! assembly for LLM calls. The story-independence rule: nothing in
|
||||
//! this crate knows about any specific story. Every story is rows.
|
||||
|
||||
pub mod authors;
|
||||
pub mod config;
|
||||
pub mod context;
|
||||
pub mod db;
|
||||
|
|
|
|||
166
skald/src/web.rs
166
skald/src/web.rs
|
|
@ -13,10 +13,12 @@ use std::sync::Arc;
|
|||
use axum::Router;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Html;
|
||||
use axum::response::{Html, Redirect};
|
||||
use axum::routing::get;
|
||||
use axum::Form;
|
||||
use chrono::{DateTime, Utc};
|
||||
use maud::{DOCTYPE, Markup, html};
|
||||
use serde::Deserialize;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
|
@ -28,7 +30,9 @@ pub struct WebState {
|
|||
pub fn router(state: WebState) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/stories/new", get(new_story_form).post(new_story_create))
|
||||
.route("/stories/{id}", get(story_detail))
|
||||
.route("/stories/{id}/continue", get(continue_form).post(continue_create))
|
||||
.route("/stories/{id}/chapters/{n}", get(chapter_view))
|
||||
.route("/stories/{id}/runs", get(runs_view))
|
||||
.with_state(Arc::new(state))
|
||||
|
|
@ -92,6 +96,122 @@ async fn index(State(state): State<Arc<WebState>>) -> Html<String> {
|
|||
Html(render_shell(&stories, None, welcome_panel()).into_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NewStoryForm {
|
||||
title: String,
|
||||
#[serde(default)]
|
||||
prompt: String,
|
||||
#[serde(default)]
|
||||
word_count_target: 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())
|
||||
}
|
||||
|
||||
async fn new_story_create(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Form(form): Form<NewStoryForm>,
|
||||
) -> Result<Redirect, (StatusCode, String)> {
|
||||
let title = form.title.trim();
|
||||
if title.is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, "title is required".into()));
|
||||
}
|
||||
let target: Option<i32> = form.word_count_target.trim().parse().ok();
|
||||
let prompt = if form.prompt.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(form.prompt.trim().to_string())
|
||||
};
|
||||
|
||||
let id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO stories (title, status, prompt, word_count_target)
|
||||
VALUES ($1, 'seed', $2, $3)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(title)
|
||||
.bind(prompt.as_deref())
|
||||
.bind(target)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
sqlx::query("UPDATE stories SET root_story_id = id WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Redirect::to(&format!("/stories/{}", id)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ContinueForm {
|
||||
title: String,
|
||||
#[serde(default)]
|
||||
direction: String,
|
||||
#[serde(default)]
|
||||
word_count_target: String,
|
||||
}
|
||||
|
||||
async fn continue_form(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Html<String>, StatusCode> {
|
||||
let stories = fetch_stories(&state.pool).await;
|
||||
let parent = stories
|
||||
.iter()
|
||||
.find(|s| s.id == id)
|
||||
.cloned()
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
Ok(Html(
|
||||
render_shell(&stories, Some(id), continue_panel(&parent)).into_string(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn continue_create(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Path(parent_id): Path<Uuid>,
|
||||
Form(form): Form<ContinueForm>,
|
||||
) -> Result<Redirect, (StatusCode, String)> {
|
||||
let title = form.title.trim();
|
||||
if title.is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, "title is required".into()));
|
||||
}
|
||||
let target: Option<i32> = form.word_count_target.trim().parse().ok();
|
||||
let direction = if form.direction.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(form.direction.trim().to_string())
|
||||
};
|
||||
|
||||
let root_id: Uuid = sqlx::query_scalar(
|
||||
"SELECT COALESCE(root_story_id, id) FROM stories WHERE id = $1",
|
||||
)
|
||||
.bind(parent_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "parent story not found".into()))?;
|
||||
|
||||
let id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO stories (title, status, prompt, word_count_target, parent_story_id, root_story_id)
|
||||
VALUES ($1, 'seed', $2, $3, $4, $5)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(title)
|
||||
.bind(direction.as_deref())
|
||||
.bind(target)
|
||||
.bind(parent_id)
|
||||
.bind(root_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Redirect::to(&format!("/stories/{}", id)))
|
||||
}
|
||||
|
||||
async fn story_detail(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
|
|
@ -243,6 +363,50 @@ fn render_shell(stories: &[StoryRow], current: Option<Uuid>, main: Markup) -> Ma
|
|||
}
|
||||
}
|
||||
|
||||
fn new_story_panel(err: Option<&str>) -> Markup {
|
||||
html! {
|
||||
article.form-panel {
|
||||
h1 { "New story" }
|
||||
p.muted { "Create a story stub. Actual generation lands when v0.3 wires the gen pipeline." }
|
||||
@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 { "Target word count (optional)"
|
||||
input type="number" name="word_count_target" min="500" step="500";
|
||||
}
|
||||
button type="submit" { "Create" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn continue_panel(parent: &StoryRow) -> 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." }
|
||||
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 { "Direction (optional)"
|
||||
textarea name="direction" rows="4" placeholder="What should the sequel explore?" {}
|
||||
}
|
||||
label { "Target word count (optional)"
|
||||
input type="number" name="word_count_target" min="500" step="500";
|
||||
}
|
||||
button type="submit" { "Queue sequel" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn welcome_panel() -> Markup {
|
||||
html! {
|
||||
div.welcome {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue