Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 68 additions & 17 deletions CLAUDE.md

Large diffs are not rendered by default.

29 changes: 26 additions & 3 deletions admin-ui/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -2345,12 +2360,18 @@ export interface SidecarsResult {
work_id: string;
/** The community issues filed, by what they offer (contribute "issue" only). */
contributed?: Record<string, string>;
/** 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`). */
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 7 additions & 1 deletion admin-ui/src/features/book/book-aside.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -193,6 +194,11 @@ function CommunityCard({
</>
)}
<GenerateSection book={detail.book} />
<DescribeSection
book={detail.book}
description={detail.generated_description}
ownDescription={Boolean(detail.description?.trim())}
/>
</Card>
);
}
Expand Down
6 changes: 3 additions & 3 deletions admin-ui/src/features/bridge/bridge-jobs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<typeof progressOf> = generate ? {} : progressOf(job.progress);
const summary = generate ? null : resultSummary(job.result);
const details = hasContent(job.result) || hasContent(job.params);
Expand Down
204 changes: 204 additions & 0 deletions admin-ui/src/features/bridge/describe-section.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<JobSection
id="describe-title"
title={t('bridge.describe.title')}
job={showJob ? job : undefined}
>
{description ? (
<>
<p className="text-[12.5px] text-muted-foreground">
{description.generated_at
? t('bridge.describe.noteAt', {
when: formatRelative(description.generated_at, lang),
})
: t('bridge.generate.localNote')}
</p>
<p
id={textId}
className={
clamps && !expanded
? 'line-clamp-4 text-[13px] whitespace-pre-line'
: 'text-[13px] whitespace-pre-line'
}
>
{description.text}
</p>
{clamps ? (
<div>
<Button
variant="link"
size="sm"
className="h-auto p-0"
aria-expanded={expanded}
aria-controls={textId}
onClick={() => setExpanded((v) => !v)}
>
{expanded ? t('bridge.describe.showLess') : t('bridge.describe.showMore')}
</Button>
</div>
) : null}
{shownElsewhere ? (
<p className="text-[12.5px] text-muted-foreground">{t('bridge.describe.notShown')}</p>
) : null}
</>
) : null}
{showJob && job ? <GenerateStatus job={job} uiUrl={uiUrl} /> : null}
{(showJob && canCancel) || offer ? (
<div className="flex flex-wrap gap-2">
{offer ? (
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<PenLine aria-hidden="true" />
{t('bridge.describe.action')}
</Button>
) : null}
{job && canCancel ? (
<JobCancelButton job={job} ariaLabel={t('bridge.describe.cancelAria')} />
) : null}
</div>
) : null}
<DescribeDialog open={open} onOpenChange={setOpen} book={book} offline={!available} />
</JobSection>
);
}

/**
* 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
title={t('bridge.describe.dialog')}
description={book.title || book.path}
icon={PenLine}
tone="info"
>
<form onSubmit={(e) => void submit(e)} className="contents" noValidate>
<DialogBody className="flex flex-col gap-3 text-[13px]">
{offline ? (
<Notice tone="warn" icon={Unplug} title={t('bridge.generate.offline')}>
{t('bridge.generate.offlineBody')}
</Notice>
) : null}
<p>{t('bridge.describe.dialogBody')}</p>
<p className="text-muted-foreground">{t('bridge.describe.dialogCost')}</p>
<p className="text-muted-foreground">{t('bridge.describe.dialogWhere')}</p>
</DialogBody>
<DialogFormFooter busy={busy} submitLabel={t('bridge.describe.submit')} />
</form>
</DialogContent>
</Dialog>
);
}
Loading
Loading