diff --git a/CLAUDE.md b/CLAUDE.md
index f33acd42..2dc9df90 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -107,10 +107,10 @@ internal/auth/ users, argon2id, opaque hashed tokens, auth codes; hash.go
internal/relpath/ library-relative path rules shared by catalog and auth: Clean (canonical form) and Within (the segment-boundary rule of share scopes and cast tokens)
internal/catalog/ libraries, access grants, books, FTS search, listening state (the data layer)
internal/library/ filesystem view (fsview.go: SafeJoin, and ContentPath/ContentJoin, the one hidden-segment rule) + background scanner (scanner.go: incl. IndexMoved and the .audiosilo/ walk); attachments.go (a book's Bridge attachments: signatures, RefreshAttachments); ebook.go (BookEbook, the Bridge's own .epub rule, for the estimate; a readable one only: a zip whose directory lists META-INF/container.xml, as the Bridge reads it)
-internal/metadata/ dhowden/tag + ffprobe extraction (a `series-part` tag "1-3" is a range: seriesrange.go, ParseSeriesRange, the one grammar of a range position, and ParseSeriesPosition, a number or a range; incl. ReleaseDate: a date tag as YYYY[-MM[-DD]], stored as books.released, never as published); DeriveFromPath (structural path parsing, the scan's baseline: the folder holding the book is the series and the one above it the author, but a lone folder is the author); layout.go: ReadPathLayout (author/series/book LAYOUT, the match's path facts) and FromPathLayout (a path-first library's values); absfile.go: ParseABSFile/ReadABSFile (an Audiobookshelf metadata.json, see Metadata overrides); attachments.go: AttachmentsDir `.audiosilo`, Read/ParseCharacters, Recaps and Provenance (the Bridge's generated files, checked against audiosilo-meta's formats; see Bridge attachments); titlepos.go: PositionInTitle (a series position read from a title that is the series' name then a number, "He Who Fights with Monsters 4: ..."; narrow: set off by a space or comma, 1-3 digits, then the end or a separator, never a range)
+internal/metadata/ dhowden/tag + ffprobe extraction (a `series-part` tag "1-3" is a range: seriesrange.go, ParseSeriesRange, the one grammar of a range position, and ParseSeriesPosition, a number or a range; incl. ReleaseDate: a date tag as YYYY[-MM[-DD]], stored as books.released, never as published); DeriveFromPath (structural path parsing, the scan's baseline: the folder holding the book is the series and the one above it the author, but a lone folder is the author); layout.go: ReadPathLayout (author/series/book LAYOUT, the match's path facts) and FromPathLayout (a path-first library's values); absfile.go: ParseABSFile/ReadABSFile (an Audiobookshelf metadata.json, see Metadata overrides); attachments.go: AttachmentsDir `.audiosilo`, Read/ParseCharacters, Recaps, Description and Provenance (the Bridge's generated files, checked against audiosilo-meta's formats; see Bridge attachments); titlepos.go: PositionInTitle (a series position read from a title that is the series' name then a number, "He Who Fights with Monsters 4: ..."; narrow: set off by a space or comma, 1-3 digits, then the end or a separator, never a range)
internal/media/ Range streaming, download, embedded cover extraction
internal/names/ reading people in an Author/Narrator credit: Split (the deliberately shy co-credit rule), Reversed ("Surname, Given"), SortKey (surname first; the catalog registers it as the SQL function name_sort for the admin list's surname sort)
-internal/meta/ Phase 1.5 community metadata lookup: HTTP client + Service (asin/isbn -> composed enrichment envelope) with a bounded TTL cache and its persistent SQLite level (store.go); the /meta bundle's previous works and spoiler gating (bundle.go); the admin console's match (match.go: metaserve works/match over tag + path facts, metadata.ReadPathLayout; works/search fallback for an older metaserve); community cover fetches (cover.go: public addresses only); owned books' work ids for the Series cards (workids.go); placing the caller's books on rails and the next rail entry (local.go); which of a work's series a book is in (seriesrules.go: the series rules below); generated content laid beside the community work (generated.go: `Answer`, `Unmatched`, `MetaGenerated`); cache-only reads for the Bridge's estimate (cachedenrich.go: `CachedEnrichment`, `Coverages`)
+internal/meta/ Phase 1.5 community metadata lookup: HTTP client + Service (asin/isbn -> composed enrichment envelope) with a bounded TTL cache and its persistent SQLite level (store.go); the /meta bundle's previous works and spoiler gating (bundle.go); the admin console's match (match.go: metaserve works/match over tag + path facts, metadata.ReadPathLayout; works/search fallback for an older metaserve); community cover fetches (cover.go: public addresses only); owned books' work ids for the Series cards (workids.go); placing the caller's books on rails and the next rail entry (local.go); which of a work's series a book is in (seriesrules.go: the series rules below); generated content laid beside the community work (generated.go: `Answer`, `Unmatched`, `MetaGenerated`, `MetaGeneratedDescription`); cache-only reads for the Bridge's estimate (cachedenrich.go: `CachedEnrichment`; `Coverages`, a `Coverage` per book whose `Description` is the community work's `community_description`)
internal/chapteralign/ fits a community recording's chapter list onto a book's own audio (pure, no I/O): anchors by title + time, places the rest in proportion, snaps each to its pause (an injected Prober); classifies fill/titles/refine/restructure/same or why not (length_mismatch, structure_mismatch, crosses_files). testdata/mythos is a real 34-vs-174-chapter golden case
internal/chaptercheck/ the community chapter check: a background pass (every 10 min, and on Catalog.OnBookChange) and on request (Start), meta.RecordingChapters -> chapteralign (ffmpeg silencedetect via media.DetectSilences) -> catalog.SaveCommunityChapters
internal/covercolors/ the background cover colour pass: Runner reads the colour of every book whose cover may have art and holds none for it (catalog.CoverColorsDue), one at a time, once the start or a burst of OnBookChange kicks has been quiet 30 s (<= 10 min), and hourly; the reading is api.colorCover (a cached thumbnail, else an UNCACHED 160 px one), handed in as its Colorer; art with no colour is recorded as such (cover_color = the version alone; CoverSource.ColorRead, while Colored stays "has a colour", so a thumbnail that decodes still records over it); art files that aren't there (an unmounted share) are covercolors.ErrArtMissing: left due, not a failure
@@ -125,7 +125,7 @@ internal/logring/ the admin console's log viewer: an slog handler teeing rec
internal/updates/ the update check: GitHub Releases' latest release, once a day while on (config update_check)
internal/backup/ database backups: VACUUM INTO the backups folder on a schedule or on request, retention, restore applied at the next start
internal/jsonfile/ the small JSON files beside the database (backup's restore marker/result, the mirror's state.json): Write is owner-only, temp file synced then renamed (all or nothing); Read[T] is nil without a file
-internal/bridge/ the Bridge's side on the server (BRIDGE-PLAN.md): its last heartbeat (status.go: validation, the 2-minute online window, features, the lenient `estimates`, Writable, CanContribute) and the job queue (jobs.go: kinds the server can check (`bridge.verify`, `sidecars.generate`), dedupe, priority claim with a lease, long-poll wake, lease expiry, held updates and parks, cooperative cancel, `OnTransition` / `Job.Notable`); estimate.go (EstimateSidecars: a job's cost from the heartbeat's figures); pairing and the bridge token are auth's (auth/bridge.go)
+internal/bridge/ the Bridge's side on the server (BRIDGE-PLAN.md): its last heartbeat (status.go: validation, the 2-minute online window, features, the lenient `estimates`, Writable, CanContribute) and the job queue (jobs.go: kinds the server can check (`bridge.verify`, `sidecars.generate`, `sidecars.describe`), dedupe, priority claim with a lease, long-poll wake, lease expiry, held updates and parks, cooperative cancel, `OnTransition` / `Job.Notable`); estimate.go (EstimateSidecars: a job's cost from the heartbeat's figures); pairing and the bridge token are auth's (auth/bridge.go)
internal/notify/ the event feed (the console's bell) and its deliveries to webhook / ntfy / Discord destinations (incl. the Bridge's `bridge_job_finished` / `bridge_job_failed`)
internal/api/ HTTP transport: routing (api.go), middleware, rate limiting, handlers_*.go
internal/server/ HTTP(S) server, TLS modes (off/selfsigned/autocert), home HTTP on the self-signed port, graceful shutdown
@@ -256,7 +256,8 @@ values; the lists canonical JSON), `books.genres_checked` (the genre tag's one-t
`genres`|`tags`, `label_key`, the book's spelling: derived, rebuilt by `refreshEffective`), recreates
`books_fts` with `genres, tags` columns (rows copied) and sets `abs_indexed_at` NULL on every book with
a metadata.json (`abs_sig <> ''`), so the next scan reads each again for the new fields; see Labels
-below.
+below. `0050` adds `book_attachments.description` (the Bridge's checked `description.json`, Phase 1c;
+`''` for none; derived like the rest of the row).
Routes `/me/queue`, `/me/collections/**`, `/me/share-targets` (`handlers_queue.go`,
`handlers_collections.go`; a stranger's collection id is 404, a viewer's write 403
@@ -506,9 +507,25 @@ future metadata site can attach enrichment without reshaping the schema. The
transport-only. Degradation: disabled -> 404 (and the `metadata` capability is
false, so clients hide the UI); no asin/isbn or no upstream match -> `200
{"matched": false}` (with a `work` of the book's own when the Bridge generated
- characters or recaps for it: `meta.Answer`); upstream unreachable -> 502, unless
+ characters, recaps or a description for it: `meta.Answer`); upstream unreachable -> 502, unless
the book has generated content (this server's own), answered as unmatched with
- it. Out of scope for now: no cover remote-fallback, no tag-based ASIN extraction.
+ it. **Generated description** (Phase 1c): a book's `description.json` rides in the additive
+ `work.generated_description: {text}` (`MetaGeneratedDescription`), NEVER in `community_description`
+ (a shipped 2.0 app shows that first and credits the community; it ignores the new field), and
+ `work.generated.description` (bool, always sent beside `characters`/`recaps`) marks it. It fills
+ only a work with no `community_description` (the core `description`, the publisher's kind, doesn't
+ block it: the player orders those). `fillable` (generated.go) is the one statement of which parts a
+ work leaves to fill (`metadata.AttachmentParts`): `Fillable` loads by it and `parts` lays in by it.
+ `generatedFor` reads only the parts it names that the book's signature names too
+ (`Book.GeneratedParts`), plus the provenance (`catalog.BookAttachments` with a parts mask: a work with
+ community characters and recaps reads just the description and its provenance, and none for a book
+ without one); `?spoilers=hide` keeps
+ it (spoiler-free by construction: the Bridge writes it from the opening chapters' notes only), and
+ attribution never counts it. `generated.generated_at` is bridge.json's `generated_at` (the
+ characters' and recaps' run), or its `description.generated_at` (`Generated.DescriptionAt`) when the
+ description is all the mark names: a description-only job keeps the run's time. An unmatched book
+ with only a description answers `{matched: false, work: {title, generated_description, generated:
+ {description: true, ...}, ...}}`. Out of scope for now: no cover remote-fallback, no tag-based ASIN extraction.
**Reading-order families** (metaserve schema_version 7): `seriesRails` collapses
each family (key `ordering_of || id`) into ONE rail whose top-level view is the
MAIN view - the ref with no `ordering_of` (the primary), else the first ref - so
@@ -898,12 +915,19 @@ future metadata site can attach enrichment without reshaping the schema. The
left as it was by the backfill and recorded as none by a re-index, so the next scan reads it
again (`readABS`' `read`); both log a warning. Known gap: a position read from an ABS title (`titlePosition`) shows as `tag`.
**Bridge attachments** (`metadata/attachments.go`, `library/attachments.go`, `catalog/attachments.go`,
- migration `0048`; BRIDGE-PLAN.md Phase 1a): the Bridge writes a book's generated `characters.json`,
- `recaps.json` and `bridge.json` (provenance) into `.audiosilo/` in a folder book's own folder, or beside
+ migrations `0048`, `0050`; BRIDGE-PLAN.md Phases 1a and 1c): the Bridge writes a book's generated `characters.json`,
+ `recaps.json`, `description.json` and `bridge.json` (provenance) into `.audiosilo/` in a folder book's own folder, or beside
a single-file book with its stem before each name (`Book.characters.json`). They are NOT a metadata
layer (nothing in the effective row reads them): the scan stores them checked (audiosilo-meta's
- `model.Characters` / `model.Recaps`, bounded, a regular file only, never through a link) in
- `book_attachments`, and `/meta` serves them (below). The discovery walk lists each `.audiosilo` folder
+ `model.Characters` / `model.Recaps` / `model.Description`, bounded, a regular file only, never through a link) in
+ `book_attachments`, and `/meta` serves them (below). A description (`metadata.ParseDescription`) is
+ its text trimmed, 200-1500 code points (meta's `description.schema.json` bounds,
+ `TestDescriptionBoundsMatchTheSchema` reads them from audiosilo-meta's embedded `SchemaFS`, imported
+ as `metaschema`), no control character but a line break; a blank text is none, anything else fails
+ the file. A signature lists only the names a book has, in `AttachmentNames` order, and adding the
+ name kept the other three in order, so a book without one keeps its stored signature byte for byte.
+ bridge.json's `description.generated_at` is read as `AttachmentProvenance.DescriptionAt` (the rest of
+ that object is the Bridge's; an object of another shape costs only that time, never the file). The discovery walk lists each `.audiosilo` folder
ONCE (never walks into it; no audio lives there), and a book's signature (`books.attachments_sig`:
name:size:mtime_ns of its own known names) is compared like `abs_sig`: an unchanged book whose
attachments are new, changed, gone or not read for this index is read without a re-index
@@ -913,7 +937,8 @@ future metadata site can attach enrichment without reshaping the schema. The
never taking a book's generated content away. `Scanner.RefreshAttachments` re-reads one indexed
book's attachments, never its audio (the Bridge's `POST /bridge/rescan` mode `attachments`). The
player's `Book.generated` (list, search, item, recent, the next book) is true when the signature names
- a characters or recaps file (`generatedSig`, the narrow column: no join), so the player asks `/meta`
+ a characters, recaps or description file (`sigParts`, an allow-list: bridge.json and any name a later
+ server adds are none; `Book.GeneratedParts`, off the wire, says which; the narrow column, no join), so the player asks `/meta`
for a book with no ASIN/ISBN too. Derived from the files: rebuildable, never carried by a move.
**Series ranges** (an omnibus of books 1-3): the `series_index` field may hold a range,
canonical `"1-3"` (input `1 - 3` and the en dash too; `metadata.ParseSeriesRange`: 0 < start <
@@ -1617,7 +1642,7 @@ future metadata site can attach enrichment without reshaping the schema. The
`Retry-After` (the receiver reports a load error; the sender should say "busy, try again"); it
marks the SENDER's session transcoded (`StreamMarks.Note` keys on `Credential.DeviceID`, the
parent token, whose progress saves make the session).
-- **The Bridge (BRIDGE-PLAN.md Phases 0 and 1a, capability `bridge`, `auth/bridge.go`, `internal/bridge`,
+- **The Bridge (BRIDGE-PLAN.md Phases 0, 1a and 1c, capability `bridge`, `auth/bridge.go`, `internal/bridge`,
`api/handlers_bridge.go`, `handlers_bridge_estimate.go`; security-critical)**: the optional companion (repo `audiosilo-bridge`,
private) that writes to the libraries and runs the heavy jobs, so this server stays read-only. **The
server never connects to the Bridge**: it stores intents (jobs) and the Bridge's last heartbeat, and
@@ -1667,7 +1692,13 @@ future metadata site can attach enrichment without reshaping the schema. The
Phase 1a's `sidecars.generate` (a book `path` required, params `{source, contribute, work_id}`; it writes,
so it is refused 409 `library_not_writable` for a library the last heartbeat doesn't report mapped and
writable, and `contribute: "issue"` is refused 409 `cannot_contribute` unless the heartbeat's
- `estimates.sidecars.can_contribute` says the Bridge can; the open job is still returned).
+ `estimates.sidecars.can_contribute` says the Bridge can; the open job is still returned), and Phase 1c's
+ `sidecars.describe` (a description from the notes the Bridge kept from generating the book, no new
+ ASR: a book `path` required, params `{}` or none, any field 400 (`describeParams`), stored as `{}`;
+ it writes, so the same `library_not_writable`; refused 409 `generate_open` (`ErrGenerateOpen`, checked
+ inside Enqueue's transaction after the dedupe) while a `sidecars.generate` job is open for the book,
+ which writes the description itself, while a generate job is never refused for an open describe; its
+ own dedupe key; an older Bridge doesn't advertise it, so it is the usual 409 `kind_unavailable`).
States queued -> claimed -> running -> done | failed | needs_attention | cancelled; the Bridge PARKS
a job it keeps running (`needs_attention`, the park's code in `progress.park.code`, its message in
`error`) and unparks it (back to `running`) under the same claim: a held state, so its reports still
@@ -1700,7 +1731,7 @@ future metadata site can attach enrichment without reshaping the schema. The
`full` (or none) indexes it (`IndexPath`; with `from`, the book's path before a move the Bridge made,
`Scanner.IndexMoved` carries its durable state when the old book is gone from disk and the
fingerprints match, else ignores it), mode `attachments` re-reads only the book's `.audiosilo/` files
- (`Scanner.RefreshAttachments`, answering `{path, attachments: {characters, recaps, bridge}}`); 404
+ (`Scanner.RefreshAttachments`, answering `{path, attachments: {characters, recaps, description, bridge}}`); 404
`not_indexable`. Console: `GET /admin/bridge` (pairing, online, heartbeat, kinds, features, each
library with the Bridge's mapping, workers, `ui_url`), `GET`/`POST /admin/bridge/jobs`, `GET
/admin/bridge/jobs/{id}`, `GET /admin/bridge/estimate?library_id=&path=&kind=[&brief=1]` (the
@@ -1709,15 +1740,35 @@ future metadata site can attach enrichment without reshaping the schema. The
`agent_cost_usd` null while there is no history, `subscription`, `agent_ready`, `figures` false when
the Bridge sent none, so a false `agent_ready` means "not known"), `writable`, what the book already
has (`has.community` via `meta.Service.Coverages`, cache first and never a full Enrich per book,
- `community_unknown` when it couldn't be asked; `has.local` from `book_attachments`), up to 10 earlier
+ `community_unknown` when it couldn't be asked; `has.local` from `book_attachments` (`catalog.BookAttachmentParts`: which columns are non-empty, nothing decoded); each with
+ `characters`, `recaps` and, since Phase 1c, `description`: the community work's `community_description`
+ (`Coverage.Description`: its `community_description` or its core description, either shown before a generated one) or a stored generated one), `can_describe`
+ (the Bridge is online and advertises `sidecars.describe`; brief too), up to 10 earlier
books of its series in the library with no recaps (`catalog.EarlierInSeries`, each costed), and any
open job; `brief=1` skips the folder look and the series scan, for a card or a batch); 409
`kind_unavailable`. With a `path`, `GET /bridge/libraries/{id}/book` also answers a `book` block
(title, authors and narrators split by `names.Split`, series, `series_index`, asin, isbn, duration,
and `work_id` from the metadata cache alone, `CachedWorkID`), never on the sample-book answer.
Settings > Bridge in `admin-ui` (`features/bridge/`: the topic shows the heartbeat's estimates and
- each generate job's status; the book page's aside and each series card generate characters and
- recaps, a series card queueing those of its books without recaps in reading order). Jobs are carried by
+ each generate or describe job's status; the book page's aside and each series card generate characters and
+ recaps, a series card queueing those of its books without recaps in reading order). The admin book
+ detail carries `generated_description: {text, generated_at?}` (omitted without one; `generated_at` is
+ bridge.json's description time; read with the description and provenance columns only, and only when
+ the signature names a description), which the book page's Community card shows (`describe-section.tsx`:
+ a Description sub-part with the "Generated on this server" mark, the text clamped behind Show more, and
+ a note when the player's About shows another description instead: the book's own, or the
+ community's; "Write a description" confirms and queues `sidecars.describe` when `describeOffered`:
+ `can_describe` and `writable`, local characters or recaps, no description here or from the community
+ (and `community_unknown` unset), no open generate (the estimate's `open_job` included) or describe
+ job; and the card itself adds no generated description shown, no description of the book's own, and
+ no estimate being fetched again; its dialog says the player shows it only when the book has no other
+ description, and says when the Bridge went away; the describe job's status through `GenerateStatus`
+ (a finished one hidden once a description shows), whose done line for a generate job also says
+ whether its description came (`result.description`, `description_error`), and whose stage for a
+ generate job's description step is `describing`). Notifications word a describe job as the
+ description ("Description ready: "). A generate job queued while a describe job is open
+ cancels it in the same transaction (`cancelOpenTx`: at once when queued or parked, else
+ `cancel_requested`), since the generate run writes the description itself. Jobs are carried by
`bridge.MoveJobs`, which `catalog.moveBookState` calls in its transaction with the catalog's clock (an
open job colliding with an open one of its kind at the new path is cancelled; `DedupeKey` rebuilds the
key), and go with their library. **Hidden folders**: no route reaches content through a hidden
diff --git a/admin-ui/src/api/types.ts b/admin-ui/src/api/types.ts
index 39f77f00..5fe2384a 100644
--- a/admin-ui/src/api/types.ts
+++ b/admin-ui/src/api/types.ts
@@ -1641,6 +1641,17 @@ export interface AdminBookDetail {
* the tags look swapped or junk (meta.SearchPrefill).
*/
match_query: string;
+ /**
+ * The description the Bridge generated for the book (its .audiosilo/description.json):
+ * absent when it has none. `generated_at` is when it was written, when known.
+ */
+ generated_description?: GeneratedDescription;
+}
+
+/** A book's generated description on its page (catalog.GeneratedDescription). */
+export interface GeneratedDescription {
+ text: string;
+ generated_at?: string;
}
/** Where a book's chapters come from: its own files, or a community list fitted onto them. */
@@ -2314,9 +2325,13 @@ export interface SidecarsPark {
retry_at?: string;
}
-/** sidecars.generate's progress. */
+/** sidecars.generate's progress (and sidecars.describe's, step and stage "describing"). */
export interface SidecarsProgress {
- /** An engine stage name, or "waiting" | "writing" | "rescanning". */
+ /**
+ * An engine stage name, or "waiting" | "writing" | "rescanning" | "describing"
+ * (the description step, Phase 1c: a generate job's, whose stage is then the
+ * engine's "done", and a describe job's).
+ */
step: string;
message?: string;
done?: number;
@@ -2345,12 +2360,18 @@ export interface SidecarsResult {
work_id: string;
/** The community issues filed, by what they offer (contribute "issue" only). */
contributed?: Record;
+ /** Whether the run also wrote the book's description.json (Phase 1c); absent from an older Bridge. */
+ description?: boolean;
+ /** Why no description was written, in a few plain words; only when `description` is false. */
+ description_error?: string;
}
-/** Which characters and recaps a book has, from the community and from its own folder. */
+/** Which characters, recaps and description a book has, from the community and from its own folder. */
export interface SidecarsCoverage {
characters: boolean;
recaps: boolean;
+ /** A description: generated here, or the community's community_description (Phase 1c). */
+ description: boolean;
}
/** An earlier book of the series without recaps (the estimate's `earlier`). */
@@ -2388,6 +2409,8 @@ export interface BridgeEstimate {
figures: boolean;
/** The library is mapped and writable on the Bridge. */
writable: boolean;
+ /** The Bridge is online and runs sidecars.describe (a description from the notes it kept). */
+ can_describe: boolean;
has: {
community: SidecarsCoverage;
local: SidecarsCoverage;
diff --git a/admin-ui/src/features/book/book-aside.tsx b/admin-ui/src/features/book/book-aside.tsx
index c041ce0e..1d87823c 100644
--- a/admin-ui/src/features/book/book-aside.tsx
+++ b/admin-ui/src/features/book/book-aside.tsx
@@ -8,12 +8,13 @@ import { Button } from '@/components/ui/button';
import { Card, CardHeader } from '@/components/ui/card';
import { formatNumber, formatPercent, formatRelative, progressFraction } from '@/lib/format';
import { cn } from '@/lib/utils';
+import { DescribeSection } from '@/features/bridge/describe-section';
import { GenerateSection } from '@/features/bridge/generate-section';
import { ProgressMenu } from '@/features/people/progress-actions';
/**
* The right-hand column: who's listening, community metadata (and generating
- * characters and recaps with the Bridge), and who can see the book.
+ * characters, recaps and a description with the Bridge), and who can see the book.
*/
export function BookAside({
detail,
@@ -193,6 +194,11 @@ function CommunityCard({
>
)}
+
);
}
diff --git a/admin-ui/src/features/bridge/bridge-jobs.tsx b/admin-ui/src/features/bridge/bridge-jobs.tsx
index ad0a38bf..735fbc45 100644
--- a/admin-ui/src/features/bridge/bridge-jobs.tsx
+++ b/admin-ui/src/features/bridge/bridge-jobs.tsx
@@ -22,7 +22,7 @@ import {
resultSummary,
stateLook,
} from './bridge-model';
-import { KIND_GENERATE } from './generate-model';
+import { isBookKind } from './generate-model';
import { GenerateStatus } from './generate-status';
import { useBridgeJobs } from './use-bridge';
@@ -134,8 +134,8 @@ function JobRow({
? ''
: (libraryNames.get(job.library_id) ??
t('bridge.jobs.libraryId', { id: formatNumber(job.library_id, lang) }));
- // A generate job words its progress and result its own way (GenerateStatus).
- const generate = job.kind === KIND_GENERATE;
+ // A generate or describe job words its progress and result its own way (GenerateStatus).
+ const generate = isBookKind(job.kind);
const progress: ReturnType = generate ? {} : progressOf(job.progress);
const summary = generate ? null : resultSummary(job.result);
const details = hasContent(job.result) || hasContent(job.params);
diff --git a/admin-ui/src/features/bridge/describe-section.tsx b/admin-ui/src/features/bridge/describe-section.tsx
new file mode 100644
index 00000000..55ed97c6
--- /dev/null
+++ b/admin-ui/src/features/bridge/describe-section.tsx
@@ -0,0 +1,204 @@
+import { useId, useState } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { PenLine, Unplug } from 'lucide-react';
+import { useServerInfo } from '@/api/hooks';
+import type { AdminBook, GeneratedDescription } from '@/api/types';
+import { Notice } from '@/components/notice';
+import { Button } from '@/components/ui/button';
+import { Dialog, DialogBody, DialogContent, DialogFormFooter } from '@/components/ui/dialog';
+import { formatRelative } from '@/lib/format';
+import { queueJob } from './bridge-actions';
+import { isOpen, jobActions } from './bridge-model';
+import {
+ clampsDescription,
+ describeOffered,
+ generateAvailable,
+ generateJobsKnown,
+ KIND_DESCRIBE,
+} from './generate-model';
+import { JobCancelButton, JobSection } from './generate-parts';
+import { GenerateStatus } from './generate-status';
+import { useBookGenerateJob, useBridgeLink, useGenerateEstimate } from './use-generate';
+
+/**
+ * The Community card's description part (Bridge Phase 1c): the description the
+ * Bridge generated for the book (its text, clamped, with Show more, and a note
+ * when the player's About shows another description instead), the describe job
+ * while one is open or how it failed, and "Write a description", which queues
+ * `sidecars.describe` after a confirmation. Offered only when the Bridge can
+ * write one (describeOffered) and nothing would be shown in its place: no
+ * description generated here (as this card shows it) and none of the book's
+ * own. Renders nothing when there is nothing to show.
+ */
+export function DescribeSection({
+ book,
+ description,
+ ownDescription = false,
+}: {
+ book: AdminBook;
+ description?: GeneratedDescription;
+ /** The book has a description of its own, which the player's About shows before a generated one. */
+ ownDescription?: boolean;
+}) {
+ const { t, i18n } = useTranslation();
+ const lang = i18n.resolvedLanguage ?? 'en';
+ const server = useServerInfo().data;
+ const available = generateAvailable(server);
+ const { job, jobs } = useBookGenerateJob(
+ book.library_id,
+ book.path,
+ generateJobsKnown(server),
+ KIND_DESCRIBE,
+ );
+ // The same brief estimate the characters-and-recaps part reads (one query).
+ const estimate = useGenerateEstimate(book.library_id, book.path, available, { brief: true });
+ const parked = job?.state === 'needs_attention';
+ const uiUrl = useBridgeLink(parked);
+ const [open, setOpen] = useState(false);
+ const [expanded, setExpanded] = useState(false);
+ const textId = useId();
+
+ // Never beside a description the card shows, nor from an estimate being
+ // fetched again (a job that just ended changed what the book has).
+ const offer =
+ available &&
+ !description &&
+ !ownDescription &&
+ !estimate.isFetching &&
+ describeOffered(estimate.data, jobs);
+ // A cancelled job says nothing, and once a description shows neither does a
+ // finished one: a done job is the description itself, a failed one was made
+ // good since (a later generate job wrote one).
+ const showJob = job && job.state !== 'cancelled' && !(description && !isOpen(job.state));
+ if (!open && !showJob && !offer && !description) return null;
+
+ const canCancel = job ? jobActions(job).cancel : false;
+ const clamps = description ? clampsDescription(description.text) : false;
+ // The player's About shows another description first: the book's own, or the
+ // community's (which /meta serves instead).
+ const shownElsewhere = ownDescription || Boolean(estimate.data?.has.community.description);
+
+ return (
+
+ {description ? (
+ <>
+
+ ) : null}
+
+
+ );
+}
+
+/**
+ * The confirmation before `sidecars.describe`: what it does, what it reads (the
+ * notes the Bridge kept from generating this book, no new speech recognition),
+ * that it costs a little agent time, and where the result appears. The Bridge
+ * going away while it is open is said, as the generate dialog says it: the job
+ * still queues, and waits.
+ */
+function DescribeDialog({
+ open,
+ onOpenChange,
+ book,
+ offline,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ book: AdminBook;
+ offline: boolean;
+}) {
+ const { t } = useTranslation();
+ const qc = useQueryClient();
+ const [busy, setBusy] = useState(false);
+
+ const submit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (busy) return;
+ setBusy(true);
+ const ok = await queueJob(
+ qc,
+ { kind: KIND_DESCRIBE, library_id: book.library_id, path: book.path, params: {} },
+ { name: book.title || book.path, online: !offline },
+ );
+ setBusy(false);
+ if (ok) onOpenChange(false);
+ };
+
+ return (
+
+ );
+}
diff --git a/admin-ui/src/features/bridge/describe.test.tsx b/admin-ui/src/features/bridge/describe.test.tsx
new file mode 100644
index 00000000..6e991822
--- /dev/null
+++ b/admin-ui/src/features/bridge/describe.test.tsx
@@ -0,0 +1,358 @@
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { setToken } from '@/api/token';
+import type { AdminBookDetail, BridgeEstimate, BridgeJob } from '@/api/types';
+import { mockFetch, type MockRequest, type MockRoute } from '@/test/fetch-mock';
+import { serverInfo } from '@/test/fixtures';
+import { bookDetail } from '@/test/library-fixtures';
+import { renderApp } from '@/test/render-app';
+import { signedInRoutes } from '@/test/routes';
+
+// The book page's description part (Bridge Phase 1c): the generated description
+// with its mark and Show more, "Write a description" and its confirmation, and
+// a describe job's status.
+
+const BOOK_PATH = 'Brandon Sanderson/The Stormlight Archive/01 - The Way of Kings';
+const URL = `/library/book?library=1&path=${encodeURIComponent(BOOK_PATH)}`;
+
+const bridgeOn = {
+ ...serverInfo,
+ capabilities: { ...serverInfo.capabilities, bridge: true },
+ bridge: { version: '0.17.0', features: ['bridge', 'sidecars'] },
+};
+
+const LONG_TEXT =
+ 'A storm-wracked land where soldiers fight over gemhearts on the Shattered Plains. ' +
+ 'A slave once a surgeon, a scholar with a secret debt and a highprince who sees ' +
+ 'visions are drawn toward the same old war, each asking what an oath is worth. ' +
+ 'The opening follows them as the first threads begin to cross.';
+
+/** A brief estimate of a book with generated characters and recaps, and no description. */
+function estimate(over: Partial = {}): BridgeEstimate {
+ return {
+ kind: 'sidecars.generate',
+ library_id: 1,
+ path: BOOK_PATH,
+ source: 'audio',
+ duration_seconds: 163800,
+ asr_hours: 11.4,
+ agent_cost_usd: 18.2,
+ subscription: false,
+ agent_ready: true,
+ can_contribute: false,
+ figures: true,
+ writable: true,
+ can_describe: true,
+ has: {
+ community: { characters: false, recaps: false, description: false },
+ local: { characters: true, recaps: true, description: false },
+ },
+ earlier: [],
+ ...over,
+ };
+}
+
+function job(over: Partial = {}): BridgeJob {
+ return {
+ id: 40,
+ kind: 'sidecars.describe',
+ library_id: 1,
+ path: BOOK_PATH,
+ params: {},
+ state: 'running',
+ progress: {},
+ result: {},
+ error: '',
+ requested_by: { id: 1, username: 'chris' },
+ priority: 0,
+ attempts: 1,
+ lease_until: null,
+ cancel_requested: false,
+ created_at: '2026-10-16T12:00:00Z',
+ updated_at: '2026-10-16T12:01:00Z',
+ claimed_at: '2026-10-16T12:00:05Z',
+ finished_at: null,
+ ...over,
+ };
+}
+
+function routes(over: Record = {}) {
+ return signedInRoutes({
+ 'GET /server': { body: bridgeOn },
+ 'GET /admin/libraries/1/book': { body: bookDetail() },
+ 'GET /admin/bridge/jobs': { body: { jobs: [] } },
+ 'GET /admin/bridge/estimate': { body: estimate() },
+ ...over,
+ });
+}
+
+const queued = (calls: MockRequest[]) =>
+ calls.filter((c) => c.method === 'POST' && c.path === '/admin/bridge/jobs');
+
+beforeEach(() => setToken('stored'));
+afterEach(() => vi.unstubAllGlobals());
+
+describe('the generated description', () => {
+ it('shows the text with its mark, clamped behind Show more', async () => {
+ const user = userEvent.setup();
+ mockFetch(
+ routes({
+ 'GET /admin/libraries/1/book': {
+ body: bookDetail({ generated_description: { text: LONG_TEXT } }),
+ },
+ 'GET /admin/bridge/estimate': {
+ body: estimate({
+ has: {
+ community: { characters: false, recaps: false, description: false },
+ local: { characters: true, recaps: true, description: true },
+ },
+ }),
+ },
+ }),
+ );
+ renderApp(URL);
+ const part = await screen.findByRole('region', { name: 'Description' });
+ expect(within(part).getByText(LONG_TEXT)).toHaveClass('line-clamp-4');
+ // The mark the characters-and-recaps part uses too.
+ expect(within(part).getByText('Generated on this server.')).toBeInTheDocument();
+ const more = within(part).getByRole('button', { name: 'Show more' });
+ expect(more).toHaveAttribute('aria-expanded', 'false');
+ await user.click(more);
+ expect(within(part).getByText(LONG_TEXT)).not.toHaveClass('line-clamp-4');
+ expect(within(part).getByRole('button', { name: 'Show less' })).toBeInTheDocument();
+ // It has one already: nothing to offer.
+ expect(within(part).queryByRole('button', { name: 'Write a description' })).toBeNull();
+ });
+
+ it.each<[string, Partial, Partial]>([
+ // The book's own description comes first in the player's About.
+ ['the book has its own', { description: 'Its own blurb.' }, {}],
+ // The community's description is what /meta serves instead.
+ [
+ 'the community has one',
+ {},
+ {
+ has: {
+ community: { characters: false, recaps: false, description: true },
+ local: { characters: true, recaps: true, description: true },
+ },
+ },
+ ],
+ ])('says the player shows another description when %s', async (_name, detail, over) => {
+ mockFetch(
+ routes({
+ 'GET /admin/libraries/1/book': {
+ body: bookDetail({ generated_description: { text: LONG_TEXT }, ...detail }),
+ },
+ 'GET /admin/bridge/estimate': { body: estimate(over) },
+ }),
+ );
+ renderApp(URL);
+ const part = await screen.findByRole('region', { name: 'Description' });
+ expect(
+ await within(part).findByText('Listeners see another description in the player instead.'),
+ ).toBeInTheDocument();
+ });
+
+ it('drops a failed describe job once a description shows (a later run wrote it)', async () => {
+ const error = "Couldn't write a description that passed the checks.";
+ mockFetch(
+ routes({
+ 'GET /admin/libraries/1/book': {
+ body: bookDetail({ generated_description: { text: LONG_TEXT } }),
+ },
+ 'GET /admin/bridge/jobs': {
+ body: { jobs: [job({ state: 'failed', error, finished_at: '2026-10-16T12:05:00Z' })] },
+ },
+ }),
+ );
+ renderApp(URL);
+ const part = await screen.findByRole('region', { name: 'Description' });
+ await waitFor(() => expect(within(part).getByText(LONG_TEXT)).toBeInTheDocument());
+ await new Promise((r) => setTimeout(r, 50));
+ expect(within(part).queryByText(error)).toBeNull();
+ expect(within(part).queryByText('Failed')).toBeNull();
+ });
+
+ it('shows it even while the Bridge is away', async () => {
+ mockFetch(
+ routes({
+ 'GET /server': { body: serverInfo },
+ 'GET /admin/libraries/1/book': {
+ body: bookDetail({ generated_description: { text: 'Short enough to show whole.' } }),
+ },
+ }),
+ );
+ renderApp(URL);
+ const part = await screen.findByRole('region', { name: 'Description' });
+ expect(within(part).getByText('Short enough to show whole.')).not.toHaveClass('line-clamp-4');
+ expect(within(part).queryByRole('button', { name: 'Show more' })).toBeNull();
+ });
+});
+
+describe('write a description', () => {
+ it('confirms, then queues sidecars.describe with no params', async () => {
+ const user = userEvent.setup();
+ const calls = mockFetch(
+ routes({
+ 'POST /admin/bridge/jobs': { status: 201, body: job({ state: 'queued' }) },
+ }),
+ );
+ renderApp(URL);
+ await user.click(await screen.findByRole('button', { name: 'Write a description' }));
+ const dialog = await screen.findByRole('dialog', { name: 'Write a description' });
+ expect(within(dialog).getByText(/from the notes it kept/)).toBeInTheDocument();
+ expect(within(dialog).getByText(/No new speech recognition runs/)).toBeInTheDocument();
+ expect(
+ within(dialog).getByText(/About shows it when the book has no other description/),
+ ).toBeInTheDocument();
+ expect(queued(calls)).toHaveLength(0);
+ await user.click(within(dialog).getByRole('button', { name: 'Write description' }));
+ await waitFor(() => expect(queued(calls)).toHaveLength(1));
+ expect(queued(calls)[0].body).toEqual({
+ kind: 'sidecars.describe',
+ library_id: 1,
+ path: BOOK_PATH,
+ params: {},
+ });
+ });
+
+ it.each<[string, Partial]>([
+ ['a Bridge that does not offer it (an older one, or away)', { can_describe: false }],
+ // The server would refuse each of these.
+ ['a library the Bridge cannot write to', { writable: false }],
+ [
+ 'a generate job the estimate knows is open',
+ { open_job: job({ id: 39, kind: 'sidecars.generate', params: {} }) },
+ ],
+ [
+ 'a community that could not be asked',
+ {
+ has: {
+ community: { characters: false, recaps: false, description: false },
+ local: { characters: true, recaps: true, description: false },
+ community_unknown: true,
+ },
+ },
+ ],
+ [
+ 'a community description',
+ {
+ has: {
+ community: { characters: false, recaps: false, description: true },
+ local: { characters: true, recaps: true, description: false },
+ },
+ },
+ ],
+ [
+ 'nothing generated to write from',
+ {
+ has: {
+ community: { characters: true, recaps: true, description: false },
+ local: { characters: false, recaps: false, description: false },
+ },
+ },
+ ],
+ ])('is not offered with %s', async (_name, over) => {
+ const calls = mockFetch(routes({ 'GET /admin/bridge/estimate': { body: estimate(over) } }));
+ renderApp(URL);
+ await screen.findByRole('heading', { level: 1, name: 'The Way of Kings' });
+ await waitFor(() => expect(calls.some((c) => c.path === '/admin/bridge/estimate')).toBe(true));
+ await new Promise((r) => setTimeout(r, 50));
+ expect(screen.queryByRole('button', { name: 'Write a description' })).toBeNull();
+ });
+
+ it('is not offered for a book with a description of its own (the player shows that)', async () => {
+ const calls = mockFetch(
+ routes({
+ 'GET /admin/libraries/1/book': { body: bookDetail({ description: 'Its own blurb.' }) },
+ }),
+ );
+ renderApp(URL);
+ await screen.findByRole('heading', { level: 1, name: 'The Way of Kings' });
+ await waitFor(() => expect(calls.some((c) => c.path === '/admin/bridge/estimate')).toBe(true));
+ await new Promise((r) => setTimeout(r, 50));
+ expect(screen.queryByRole('button', { name: 'Write a description' })).toBeNull();
+ });
+
+ it('is not offered while a generate job is open (it writes one)', async () => {
+ mockFetch(
+ routes({
+ 'GET /admin/bridge/jobs': {
+ body: { jobs: [job({ id: 39, kind: 'sidecars.generate', params: {} })] },
+ },
+ }),
+ );
+ renderApp(URL);
+ // The generate job's own status shows; no describe action beside it.
+ expect(await screen.findByRole('button', { name: /cancel generating/i })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Write a description' })).toBeNull();
+ });
+
+ it("follows a describe job's stage, and says when it failed", async () => {
+ mockFetch(
+ routes({
+ 'GET /admin/bridge/jobs': {
+ body: {
+ jobs: [job({ progress: { step: 'describing', stage: 'describing', cost_usd: 0.12 } })],
+ },
+ },
+ }),
+ );
+ renderApp(URL);
+ const part = await screen.findByRole('region', { name: 'Description' });
+ expect(await within(part).findByText('Writing the description')).toBeInTheDocument();
+ expect(within(part).getByText(/\$0\.12 so far/)).toBeInTheDocument();
+ expect(
+ within(part).getByRole('button', { name: 'Cancel writing the description' }),
+ ).toBeInTheDocument();
+ expect(within(part).queryByRole('button', { name: 'Write a description' })).toBeNull();
+ });
+
+ it('shows a failed describe job with its error', async () => {
+ const error = "Couldn't write a description that passed the checks.";
+ mockFetch(
+ routes({
+ 'GET /admin/bridge/jobs': {
+ body: { jobs: [job({ state: 'failed', error, finished_at: '2026-10-16T12:05:00Z' })] },
+ },
+ }),
+ );
+ renderApp(URL);
+ const part = await screen.findByRole('region', { name: 'Description' });
+ expect(await within(part).findByText(error)).toBeInTheDocument();
+ // Not open any more: it can be asked again.
+ expect(within(part).getByRole('button', { name: 'Write a description' })).toBeInTheDocument();
+ });
+});
+
+describe("a generate job's description", () => {
+ it('says the description came with it, or why not', async () => {
+ mockFetch(
+ routes({
+ 'GET /admin/bridge/jobs': {
+ body: {
+ jobs: [
+ job({
+ id: 41,
+ kind: 'sidecars.generate',
+ state: 'done',
+ finished_at: '2026-10-16T14:00:00Z',
+ result: {
+ characters: 3,
+ recaps: 2,
+ description: false,
+ description_error: 'the agent is rate limited',
+ },
+ }),
+ ],
+ },
+ },
+ }),
+ );
+ renderApp(URL);
+ expect(
+ await screen.findByText('No description this time: the agent is rate limited'),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/admin-ui/src/features/bridge/generate-model.test.ts b/admin-ui/src/features/bridge/generate-model.test.ts
index 077aca89..418d280d 100644
--- a/admin-ui/src/features/bridge/generate-model.test.ts
+++ b/admin-ui/src/features/bridge/generate-model.test.ts
@@ -8,6 +8,9 @@ import {
batchCheck,
batchPlan,
bridgeJobLink,
+ clampsDescription,
+ DESCRIPTION_CLAMP_CHARS,
+ describeOffered,
destinationKey,
estimateView,
GENERATE_PARKED_POLL_MS,
@@ -19,10 +22,12 @@ import {
generatePollMs,
generateProgress,
generateResult,
+ isBookKind,
+ KIND_DESCRIBE,
KIND_GENERATE,
KNOWN_PARKS,
KNOWN_STAGES,
- latestGenerateJob,
+ latestJobOf,
parkKey,
planEstimate,
roundEta,
@@ -66,9 +71,10 @@ function estimate(over: Partial = {}): BridgeEstimate {
can_contribute: true,
figures: true,
writable: true,
+ can_describe: false,
has: {
- community: { characters: false, recaps: false },
- local: { characters: false, recaps: false },
+ community: { characters: false, recaps: false, description: false },
+ local: { characters: false, recaps: false, description: false },
},
earlier: [],
...over,
@@ -97,17 +103,23 @@ describe('the action gate', () => {
});
it('hides the action when the community has both, and says Regenerate over local data', () => {
- const none = { characters: false, recaps: false };
- const both = { characters: true, recaps: true };
+ const none = { characters: false, recaps: false, description: false };
+ const both = { characters: true, recaps: true, description: false };
expect(generateAction({ community: none, local: none })).toBe('generate');
expect(generateAction({ community: both, local: none })).toBe('hidden');
expect(generateAction({ community: both, local: both })).toBe('hidden');
- expect(generateAction({ community: { characters: true, recaps: false }, local: none })).toBe(
- 'generate',
- );
- expect(generateAction({ community: none, local: { characters: false, recaps: true } })).toBe(
- 'regenerate',
- );
+ expect(
+ generateAction({
+ community: { characters: true, recaps: false, description: false },
+ local: none,
+ }),
+ ).toBe('generate');
+ expect(
+ generateAction({
+ community: none,
+ local: { characters: false, recaps: true, description: false },
+ }),
+ ).toBe('regenerate');
});
});
@@ -143,8 +155,8 @@ describe('polling a book', () => {
job('done', { id: 11 }),
job('failed', { id: 10 }),
];
- expect(latestGenerateJob(jobs)?.id).toBe(11);
- expect(latestGenerateJob([])).toBeUndefined();
+ expect(latestJobOf(jobs, KIND_GENERATE)?.id).toBe(11);
+ expect(latestJobOf([], KIND_GENERATE)).toBeUndefined();
});
});
@@ -246,6 +258,14 @@ describe('progress and result', () => {
expect(generateProgress({ step: 'rescanning', stage: 'done' })).toEqual({
stage: 'rescanning',
});
+ // So does a generate job's description step (Phase 1c), labelled, so the
+ // Bridge's English restatement of it isn't shown.
+ expect(
+ generateProgress({ step: 'describing', message: 'Writing a description', stage: 'done' }),
+ ).toEqual({ stage: 'describing' });
+ expect(generateProgress({ step: 'describing', stage: 'contributing' })).toEqual({
+ stage: 'describing',
+ });
});
it('drops what it cannot use', () => {
@@ -485,8 +505,8 @@ describe('the series batch', () => {
batchCheck(
estimate({
has: {
- community: { characters: false, recaps: true },
- local: { characters: false, recaps: false },
+ community: { characters: false, recaps: true, description: false },
+ local: { characters: false, recaps: false, description: false },
},
}),
),
@@ -498,8 +518,8 @@ describe('the series batch', () => {
batchCheck(
estimate({
has: {
- community: { characters: true, recaps: false },
- local: { characters: true, recaps: false },
+ community: { characters: true, recaps: false, description: false },
+ local: { characters: true, recaps: false, description: false },
},
}),
),
@@ -527,8 +547,8 @@ describe('the series batch', () => {
estimate({
writable: false,
has: {
- community: { characters: false, recaps: true },
- local: { characters: false, recaps: false },
+ community: { characters: false, recaps: true, description: false },
+ local: { characters: false, recaps: false, description: false },
},
}),
),
@@ -545,14 +565,18 @@ describe('the series batch', () => {
batchCheck(estimate({ open_job: job('running', { cancel_requested: true }) })).open,
).toBe(false);
const unasked = {
- community: { characters: false, recaps: false },
- local: { characters: false, recaps: false },
+ community: { characters: false, recaps: false, description: false },
+ local: { characters: false, recaps: false, description: false },
community_unknown: true,
};
expect(batchCheck(estimate({ has: unasked })).unknown).toBe(true);
// Recaps of its own make it covered, whatever the community says.
expect(
- batchCheck(estimate({ has: { ...unasked, local: { characters: false, recaps: true } } })),
+ batchCheck(
+ estimate({
+ has: { ...unasked, local: { characters: false, recaps: true, description: false } },
+ }),
+ ),
).toMatchObject({ covered: true, unknown: false });
});
@@ -620,3 +644,102 @@ describe('the series batch', () => {
expect(started).toEqual([1, 2]);
});
});
+
+describe('a description (Phase 1c)', () => {
+ const has = (
+ local: Partial>,
+ community: Partial> = {},
+ ) => ({
+ community: { characters: false, recaps: false, description: false, ...community },
+ local: { characters: false, recaps: false, description: false, ...local },
+ });
+ const open = (kind: string, state: BridgeJobState = 'running') => ({ kind, state });
+ // A Bridge that runs sidecars.describe and can write to the library.
+ const can = { can_describe: true, writable: true };
+
+ it('is offered for a book with generated content, no description and nothing open', () => {
+ expect(describeOffered({ ...can, has: has({ characters: true }) }, [])).toBe(true);
+ expect(describeOffered({ ...can, has: has({ recaps: true }) }, undefined)).toBe(true);
+ // A finished or cancelled job is no reason to hold back.
+ expect(
+ describeOffered({ ...can, has: has({ recaps: true }) }, [
+ open(KIND_DESCRIBE, 'failed'),
+ open(KIND_GENERATE, 'done'),
+ ]),
+ ).toBe(true);
+ });
+
+ it('is not offered without a Bridge that can, content to write from, or with one already', () => {
+ const local = has({ characters: true, recaps: true, description: false });
+ expect(describeOffered(undefined, [])).toBe(false);
+ expect(describeOffered({ ...can, can_describe: false, has: local }, [])).toBe(false);
+ expect(describeOffered({ ...can, has: has({}) }, [])).toBe(false);
+ expect(describeOffered({ ...can, has: has({ characters: true, description: true }) }, [])).toBe(
+ false,
+ );
+ expect(
+ describeOffered({ ...can, has: has({ characters: true }, { description: true }) }, []),
+ ).toBe(false);
+ });
+
+ it('is not offered where the server would refuse it, or on a guess', () => {
+ const local = has({ characters: true });
+ // A library the Bridge can't write to: the job would be a 409.
+ expect(describeOffered({ ...can, writable: false, has: local }, [])).toBe(false);
+ // The estimate's open generate job, whatever the job list says (or before it loads).
+ expect(describeOffered({ ...can, has: local, open_job: job('running') }, undefined)).toBe(
+ false,
+ );
+ // A community that couldn't be asked may have a description of its own.
+ expect(describeOffered({ ...can, has: { ...local, community_unknown: true } }, [])).toBe(false);
+ });
+
+ it('is not offered while a generate or describe job is open', () => {
+ const e = { ...can, has: has({ characters: true }) };
+ expect(describeOffered(e, [open(KIND_GENERATE)])).toBe(false);
+ expect(describeOffered(e, [open(KIND_DESCRIBE, 'needs_attention')])).toBe(false);
+ // Another kind (a verify) doesn't count.
+ expect(describeOffered(e, [open('bridge.verify')])).toBe(true);
+ });
+
+ it('polls a describe job like a generate one, and finds each kind', () => {
+ expect(generatePollMs([open(KIND_DESCRIBE)])).toBe(GENERATE_POLL_MS);
+ expect(generatePollMs([open(KIND_DESCRIBE, 'needs_attention')])).toBe(GENERATE_PARKED_POLL_MS);
+ const jobs = [open(KIND_DESCRIBE), open(KIND_GENERATE, 'done')];
+ expect(latestJobOf(jobs, KIND_GENERATE)).toBe(jobs[1]);
+ expect(latestJobOf(jobs, KIND_DESCRIBE)).toBe(jobs[0]);
+ expect(isBookKind(KIND_DESCRIBE)).toBe(true);
+ expect(isBookKind(KIND_GENERATE)).toBe(true);
+ expect(isBookKind('bridge.verify')).toBe(false);
+ });
+
+ it("reads a result's description outcome", () => {
+ expect(generateResult({ description: true, checked_against: 'none' })).toEqual({
+ description: true,
+ contributed: [],
+ });
+ expect(
+ generateResult({ characters: 1, description: false, description_error: ' rate limited ' }),
+ ).toEqual({
+ characters: 1,
+ description: false,
+ descriptionError: 'rate limited',
+ contributed: [],
+ });
+ // An error beside a written description says nothing.
+ expect(generateResult({ description: true, description_error: 'x' })?.descriptionError).toBe(
+ undefined,
+ );
+ expect(generateResult({ description: 'yes' })).toBeNull();
+ });
+
+ it('clamps a long description only', () => {
+ expect(clampsDescription('x'.repeat(DESCRIPTION_CLAMP_CHARS))).toBe(false);
+ expect(clampsDescription('x'.repeat(DESCRIPTION_CLAMP_CHARS + 1))).toBe(true);
+ });
+
+ it('labels the describing stage', () => {
+ expect(KNOWN_STAGES).toContain('describing');
+ expect(strings['bridge.stage.describing']).toBe('Writing the description');
+ });
+});
diff --git a/admin-ui/src/features/bridge/generate-model.ts b/admin-ui/src/features/bridge/generate-model.ts
index 9218aaf8..4feed99f 100644
--- a/admin-ui/src/features/bridge/generate-model.ts
+++ b/admin-ui/src/features/bridge/generate-model.ts
@@ -12,14 +12,35 @@ import { relBaseName } from '@/lib/paths';
import { spanIn } from '@/features/library/series/series-model';
import { isOpen, num, progressOf, safeHttpUrl, text } from './bridge-model';
-// Characters and recaps from the Bridge (BRIDGE-PLAN.md Phase 1a): the rules the
-// book page's Community card, its generate dialog, the series batch and the
-// Bridge page's job rows follow for `sidecars.generate`. Pure: no React, no i18n
+// Characters and recaps from the Bridge (BRIDGE-PLAN.md Phase 1a), and the
+// description written from their notes (Phase 1c): the rules the book page's
+// Community card, its generate dialog, the series batch and the Bridge page's
+// job rows follow for `sidecars.generate` and `sidecars.describe`. Pure: no React, no i18n
// instance; callers pass the keys these return to t().
/** The job kind, and the feature (job family) the Bridge advertises for it. */
export const KIND_GENERATE = 'sidecars.generate';
export const FEATURE_SIDECARS = 'sidecars';
+/** Phase 1c's description-only kind: a description from the notes the Bridge kept. */
+export const KIND_DESCRIBE = 'sidecars.describe';
+
+/** The kinds the book page follows: generate and describe (both in the sidecars family). */
+const BOOK_KINDS: readonly string[] = [KIND_GENERATE, KIND_DESCRIBE];
+
+/**
+ * Whether a kind is one the book page follows; its progress and result are
+ * worded by GenerateStatus, on the book page and the Bridge page's job rows.
+ */
+export function isBookKind(kind: string): boolean {
+ return BOOK_KINDS.includes(kind);
+}
+
+/** The book page's kinds' jobs that are still open. */
+function openBookJobs>(
+ jobs: readonly J[] | undefined,
+): J[] {
+ return jobs?.filter((j) => isBookKind(j.kind) && isOpen(j.state)) ?? [];
+}
/**
* Whether the Generate action is offered at all: community metadata is on (the
@@ -32,9 +53,10 @@ export function generateAvailable(server: ServerInfo | undefined): boolean {
}
/**
- * Whether the book page should ask for its generate jobs at all: a server that
- * knows the Bridge (it reports the capability, on or off) with metadata on. A
- * job queued while the Bridge was online stays worth showing while it's away.
+ * Whether the book page should ask for its generate and describe jobs at all: a
+ * server that knows the Bridge (it reports the capability, on or off) with
+ * metadata on. A job queued while the Bridge was online stays worth showing
+ * while it's away.
*/
export function generateJobsKnown(server: ServerInfo | undefined): boolean {
return Boolean(server?.capabilities.metadata && server.capabilities.bridge !== undefined);
@@ -52,9 +74,9 @@ export function generateAction(has: {
}
/**
- * How often a book's generate jobs are asked again: about every 10 s while one
- * is queued or working, every minute while one is parked (it waits on a person
- * or a reset), else not at all.
+ * How often a book's generate and describe jobs are asked again: about every
+ * 10 s while one is queued or working, every minute while one is parked (it
+ * waits on a person or a reset), else not at all.
*/
export const GENERATE_POLL_MS = 10_000;
export const GENERATE_PARKED_POLL_MS = 60_000;
@@ -62,16 +84,51 @@ export const GENERATE_PARKED_POLL_MS = 60_000;
export function generatePollMs(
jobs: readonly Pick[] | undefined,
): number | false {
- const open = jobs?.filter((j) => j.kind === KIND_GENERATE && isOpen(j.state)) ?? [];
+ const open = openBookJobs(jobs);
if (open.some((j) => j.state !== 'needs_attention')) return GENERATE_POLL_MS;
return open.length ? GENERATE_PARKED_POLL_MS : false;
}
-/** The book's newest generate job (the list is newest first), if any. */
-export function latestGenerateJob>(
+/** The book's newest job of a kind (the list is newest first), if any. */
+export function latestJobOf>(
jobs: readonly J[] | undefined,
+ kind: string,
): J | undefined {
- return jobs?.find((j) => j.kind === KIND_GENERATE);
+ return jobs?.find((j) => j.kind === kind);
+}
+
+/**
+ * Whether the card offers "Write a description": the Bridge can (online, runs
+ * sidecars.describe) and can write to the library (the server refuses a job
+ * otherwise), the book has characters or recaps generated here (their notes are
+ * what the description is written from), it has no description yet, here or
+ * from the community (which must have answered: unknown is never "none"), and
+ * no generate or describe job is open for it (a generate job writes one when it
+ * finishes; the estimate's own open generate job counts too, so a job list not
+ * loaded yet can't offer what the server would refuse).
+ */
+export function describeOffered(
+ e: Pick | undefined,
+ jobs: readonly Pick[] | undefined,
+): boolean {
+ if (!e?.can_describe || !e.writable || e.open_job) return false;
+ const { local, community, community_unknown } = e.has;
+ if (
+ !(local.characters || local.recaps) ||
+ local.description ||
+ community.description ||
+ community_unknown
+ ) {
+ return false;
+ }
+ return openBookJobs(jobs).length === 0;
+}
+
+/** A generated description long enough to clamp, with a Show more toggle (about four lines). */
+export const DESCRIPTION_CLAMP_CHARS = 240;
+
+export function clampsDescription(text: string): boolean {
+ return text.trim().length > DESCRIPTION_CLAMP_CHARS;
}
// The engine's stage names (state.State) and the Bridge's own steps, each with a
@@ -100,6 +157,7 @@ export const KNOWN_STAGES = [
'waiting',
'writing',
'rescanning',
+ 'describing',
] as const;
/** "qa_sweep" -> "Qa sweep": a name this console has no label for, made readable. */
@@ -184,10 +242,13 @@ export function generateProgress(progress: unknown): GenerateProgress {
const { text: said, done, total } = progressOf(p);
const step = text(p.step);
const out: GenerateProgress = {};
- // The Bridge's own last steps (saving the files, the rescan) say what is
- // happening; the engine's stage by then is only "done".
+ // The Bridge's own last steps (the description, saving the files, the
+ // rescan) say what is happening; the engine's stage by then is only where it
+ // stopped ("done", or "contributing").
const stage =
- (step === 'writing' || step === 'rescanning' ? step : undefined) ?? text(p.stage) ?? step;
+ (step === 'describing' || step === 'writing' || step === 'rescanning' ? step : undefined) ??
+ text(p.stage) ??
+ step;
if (stage) out.stage = stage;
// The step alone is no message: it is the stage when there is none. Nor is the
// Bridge's wording of a stage this console labels (an English restatement of
@@ -216,11 +277,14 @@ export function generateProgress(progress: unknown): GenerateProgress {
return out;
}
-/** A finished generate job's result as the card words it. */
+/** A finished generate (or describe) job's result as the card words it. */
export interface GenerateResult {
characters?: number;
recaps?: number;
costUsd?: number;
+ /** The description was written (true) or not (false, with why); absent from an older Bridge. */
+ description?: boolean;
+ descriptionError?: string;
/** The community issues filed (http(s) links only), by what each offers. */
contributed: { what: string; url: string }[];
}
@@ -236,6 +300,9 @@ export function generateResult(result: unknown): GenerateResult | null {
if (recaps !== undefined) out.recaps = recaps;
const cost = amount(r.cost_usd);
if (cost !== undefined) out.costUsd = cost;
+ if (typeof r.description === 'boolean') out.description = r.description;
+ const why = text(r.description_error);
+ if (out.description === false && why) out.descriptionError = why;
if (r.contributed && typeof r.contributed === 'object') {
for (const [what, url] of Object.entries(r.contributed as Record)) {
const safe = typeof url === 'string' ? safeHttpUrl(url) : undefined;
@@ -246,6 +313,7 @@ export function generateResult(result: unknown): GenerateResult | null {
out.characters !== undefined ||
out.recaps !== undefined ||
out.costUsd !== undefined ||
+ out.description !== undefined ||
out.contributed.length > 0;
return any ? out : null;
}
diff --git a/admin-ui/src/features/bridge/generate-parts.tsx b/admin-ui/src/features/bridge/generate-parts.tsx
index b8ee0932..66dcac43 100644
--- a/admin-ui/src/features/bridge/generate-parts.tsx
+++ b/admin-ui/src/features/bridge/generate-parts.tsx
@@ -1,13 +1,72 @@
-import { useId } from 'react';
+import { useId, useState, type ReactNode } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
-import { LockKeyhole } from 'lucide-react';
+import { LockKeyhole, X } from 'lucide-react';
+import type { BridgeJob } from '@/api/types';
import { Notice } from '@/components/notice';
import { SettingRow } from '@/components/setting-row';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
+import { cancelJob } from './bridge-actions';
+import { stateLook } from './bridge-model';
import type { Contribute } from './generate-model';
-// The pieces the book's generate dialog and the series batch share.
+// The pieces the book's generate dialog and the series batch share, and the
+// Community card's generate and describe sections.
+
+/**
+ * A Community card section that follows a job: its heading, with the job's
+ * state badge when a job is passed (the caller passes one only while it shows).
+ */
+export function JobSection({
+ id,
+ title,
+ job,
+ children,
+}: {
+ id: string;
+ title: string;
+ job?: BridgeJob;
+ children: ReactNode;
+}) {
+ const { t } = useTranslation();
+ const look = job ? stateLook(job) : undefined;
+ return (
+
+
+
+ {title}
+
+ {look ? {t(look.key)} : null}
+
+ {children}
+
+ );
+}
+
+/** Cancels an open job, disabled while the request is out. */
+export function JobCancelButton({ job, ariaLabel }: { job: BridgeJob; ariaLabel: string }) {
+ const { t } = useTranslation();
+ const qc = useQueryClient();
+ const [cancelling, setCancelling] = useState(false);
+ return (
+
+ );
+}
/** The contribute choice: this server only (off, the default) or offered to the community too. */
export function ContributeSwitch({
diff --git a/admin-ui/src/features/bridge/generate-section.tsx b/admin-ui/src/features/bridge/generate-section.tsx
index 180c67b5..a37c9dd7 100644
--- a/admin-ui/src/features/bridge/generate-section.tsx
+++ b/admin-ui/src/features/bridge/generate-section.tsx
@@ -1,15 +1,13 @@
import { useState } from 'react';
-import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
-import { RotateCw, Sparkles, X } from 'lucide-react';
+import { RotateCw, Sparkles } from 'lucide-react';
import { useServerInfo } from '@/api/hooks';
import type { AdminBook } from '@/api/types';
-import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
-import { cancelJob } from './bridge-actions';
-import { isOpen, jobActions, stateLook } from './bridge-model';
+import { isOpen, jobActions } from './bridge-model';
import { GenerateDialog } from './generate-dialog';
import { generateAction, generateAvailable, generateJobsKnown } from './generate-model';
+import { JobCancelButton, JobSection } from './generate-parts';
import { GenerateStatus } from './generate-status';
import { useBookGenerateJob, useBridgeLink, useGenerateEstimate } from './use-generate';
@@ -23,7 +21,6 @@ import { useBookGenerateJob, useBridgeLink, useGenerateEstimate } from './use-ge
*/
export function GenerateSection({ book }: { book: AdminBook }) {
const { t } = useTranslation();
- const qc = useQueryClient();
const server = useServerInfo().data;
const available = generateAvailable(server);
const { job } = useBookGenerateJob(book.library_id, book.path, generateJobsKnown(server));
@@ -32,7 +29,6 @@ export function GenerateSection({ book }: { book: AdminBook }) {
const parked = job?.state === 'needs_attention';
const uiUrl = useBridgeLink(parked);
const [open, setOpen] = useState(false);
- const [cancelling, setCancelling] = useState(false);
const has = estimate.data?.has;
const action = has ? generateAction(has) : undefined;
@@ -46,17 +42,14 @@ export function GenerateSection({ book }: { book: AdminBook }) {
// An open dialog stays (it says when the Bridge went away), whatever else changed.
if (!open && !showJob && !offer && !generated) return null;
- const look = job ? stateLook(job) : undefined;
const canCancel = job ? jobActions(job).cancel : false;
return (
-
-
-
- {t('bridge.generate.title')}
-
- {showJob && look ? {t(look.key)} : null}
-
+
{generated && !jobOpen ? (
{t('bridge.generate.localNote')}
) : null}
@@ -76,19 +69,7 @@ export function GenerateSection({ book }: { book: AdminBook }) {
) : null}
{job && canCancel ? (
-
+
) : null}
) : null}
@@ -99,6 +80,6 @@ export function GenerateSection({ book }: { book: AdminBook }) {
regenerate={action === 'regenerate'}
offline={!available}
/>
-
+
);
}
diff --git a/admin-ui/src/features/bridge/generate-status.tsx b/admin-ui/src/features/bridge/generate-status.tsx
index 88cef639..9fa8268a 100644
--- a/admin-ui/src/features/bridge/generate-status.tsx
+++ b/admin-ui/src/features/bridge/generate-status.tsx
@@ -7,6 +7,7 @@ import {
bridgeJobLink,
generateProgress,
generateResult,
+ KIND_DESCRIBE,
parkKey,
roundEta,
stageLabel,
@@ -14,11 +15,12 @@ import {
import { isOpen } from './bridge-model';
/**
- * Where a `sidecars.generate` job stands, in a few lines: while it works, the
- * stage, the count, the ETA and the cost so far (with a bar when there is a
- * count); parked, why and the hint, with a link into the Bridge's own page;
- * done, what it made; failed, the error. The book page's Community card and the
- * Bridge page's job rows both show a job this way.
+ * Where a `sidecars.generate` (or `sidecars.describe`) job stands, in a few
+ * lines: while it works, the stage, the count, the ETA and the cost so far (with
+ * a bar when there is a count); parked, why and the hint, with a link into the
+ * Bridge's own page; done, what it made (a generate job's description outcome
+ * too); failed, the error. The book page's Community card and the Bridge page's
+ * job rows both show a job this way.
*/
export function GenerateStatus({ job, uiUrl }: { job: BridgeJob; uiUrl?: string }) {
const { t, i18n } = useTranslation();
@@ -27,17 +29,21 @@ export function GenerateStatus({ job, uiUrl }: { job: BridgeJob; uiUrl?: string
if (job.state === 'done') {
const r = generateResult(job.result);
- const made = [
- r?.characters !== undefined
- ? t('bridge.generate.characters', {
- count: r.characters,
- n: formatNumber(r.characters, lang),
- })
- : undefined,
- r?.recaps !== undefined
- ? t('bridge.generate.recaps', { count: r.recaps, n: formatNumber(r.recaps, lang) })
- : undefined,
- ].filter((s): s is string => Boolean(s));
+ const describe = job.kind === KIND_DESCRIBE;
+ const made = describe
+ ? [t('bridge.describe.done')]
+ : [
+ r?.characters !== undefined
+ ? t('bridge.generate.characters', {
+ count: r.characters,
+ n: formatNumber(r.characters, lang),
+ })
+ : undefined,
+ r?.recaps !== undefined
+ ? t('bridge.generate.recaps', { count: r.recaps, n: formatNumber(r.recaps, lang) })
+ : undefined,
+ r?.description ? t('bridge.generate.descriptionMade') : undefined,
+ ].filter((s): s is string => Boolean(s));
const facts = [
made.length ? formatList(made, lang) : t('bridge.generate.doneNoCounts'),
r?.costUsd !== undefined
@@ -53,6 +59,13 @@ export function GenerateStatus({ job, uiUrl }: { job: BridgeJob; uiUrl?: string
{facts.join(' · ')}