1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-10-09 12:55:00 +02:00

feat(frontend): render Summary rail section + provider selector

New "Summary" rail section between the URL/Preview controls and .meta-list.
A completed summary renders as bold tl;dr, body paragraph, tag chips, and a
provider · model footer; missing or failed shows Generate; pending/running
shows an inline spinner and polls GET every 1500 ms until terminal.

- api.js: fetchEntrySummary + requestEntrySummary. The POST helper unwraps
  ApiError's { "error": ... } body so the missing-env-var message reaches
  the user verbatim rather than as a bare status code.
- ContextRail.jsx: state seeds from detail.latest_summary so the section
  renders immediately on selection. Polling is anchored on the summary
  status rather than started inside the click handler, so a job still
  running when the user navigates away and back is picked up again. A
  transient poll failure is swallowed — the next tick retries, and a real
  failure arrives as status === 'failed'.
- Regenerate passes force:true only when a completed summary is already
  shown; otherwise the request can take the server's 200 cache-hit path.
- Provider choice persists in sessionStorage under archivr:summary:provider,
  with try/catch around both accessors for private-mode browsers.
- Public sessions never see the selector or the Generate button, and the
  section renders at all only when a completed summary made it through the
  server's visibility gate.
- styles.css: .rail-summary-* only; spacing and the action button reuse
  .rail-section and .rail-rearchive-btn. The spinner honours
  prefers-reduced-motion — the text alone conveys the state.
- AGENTS.md: document the summary env vars alongside the existing
  external-tool convention.

Smoke-tested end to end against a scratch archive with a seeded markdown
entry: claude_cli produced a real summary (pending → running → completed in
~11s); a local mock server exercised the openai_compatible transport and
confirmed the Bearer header, model, and system/user role split on the wire;
unconfigured providers return 400 naming the exact variable; a video entry
returns 400 "v1 unsupported"; a repeat POST returns 200 from cache without
adding a row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
TheGeneralist 2026-08-22 15:47:17 +02:00
parent 19f3ea41c1
commit 77e8cf41a7
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
8 changed files with 340 additions and 51 deletions

View file

@ -72,6 +72,7 @@ No CI is configured; no rustfmt.toml/clippy.toml — default `cargo fmt`/`clippy
- **Auth extraction**: `AuthUser` implements `FromRequestParts` — session cookie (`session`) or `Authorization: Bearer` token (stored SHA3-256-hashed). Passwords are Argon2. - **Auth extraction**: `AuthUser` implements `FromRequestParts` — session cookie (`session`) or `Authorization: Bearer` token (stored SHA3-256-hashed). Passwords are Argon2.
- **Logging**: `eprintln!` with `info:`/`warn:` prefixes. No `tracing`/`log` — don't add structured logging piecemeal. - **Logging**: `eprintln!` with `info:`/`warn:` prefixes. No `tracing`/`log` — don't add structured logging piecemeal.
- **External tools by env var**: `ARCHIVR_YT_DLP`, `ARCHIVR_CHROME`, `ARCHIVR_SINGLE_FILE`, `ARCHIVR_TWEET_PYTHON`, `ARCHIVR_TWEET_SCRAPER`, `ARCHIVR_STATIC_DIR`, `ARCHIVR_BIND`. Downloaders shell out to subprocesses; resolve binaries through these vars. - **External tools by env var**: `ARCHIVR_YT_DLP`, `ARCHIVR_CHROME`, `ARCHIVR_SINGLE_FILE`, `ARCHIVR_TWEET_PYTHON`, `ARCHIVR_TWEET_SCRAPER`, `ARCHIVR_STATIC_DIR`, `ARCHIVR_BIND`. Downloaders shell out to subprocesses; resolve binaries through these vars.
- **LLM summaries by env var**: `ARCHIVR_ANTHROPIC_API_KEY` / `ARCHIVR_ANTHROPIC_URL` / `ARCHIVR_ANTHROPIC_MODEL`, `ARCHIVR_OPENAI_API_KEY` / `ARCHIVR_OPENAI_URL` / `ARCHIVR_OPENAI_MODEL`, `ARCHIVR_CLAUDE_CLI` / `ARCHIVR_CLAUDE_MODEL`, `ARCHIVR_CODEX_CLI` / `ARCHIVR_CODEX_MODEL`, plus `ARCHIVR_SUMMARY_HTTP_TIMEOUT` (default 120s) and `ARCHIVR_SUMMARY_CLI_TIMEOUT` (default 300s). Same convention as above — never TOML, which also keeps API keys out of anything the archive persists. Summaries are manual-only: nothing in `capture.rs` triggers them.
- **Frontend**: JSX (no TypeScript), PascalCase components in `frontend/src/components/`, kebab-case CSS classes, plain CSS with custom properties in `styles.css` (no Tailwind/CSS-in-JS). No router — `App.jsx` parses `window.location.pathname` + `history.pushState`. State = `useState` + one `AuthContext`; `sessionStorage` for refresh-resilient dialog state (see `CaptureDialog.jsx` job polling, 500ms). All API calls through `frontend/src/api.js` with relative `/api/*` URLs — add new endpoints there, not inline `fetch`. - **Frontend**: JSX (no TypeScript), PascalCase components in `frontend/src/components/`, kebab-case CSS classes, plain CSS with custom properties in `styles.css` (no Tailwind/CSS-in-JS). No router — `App.jsx` parses `window.location.pathname` + `history.pushState`. State = `useState` + one `AuthContext`; `sessionStorage` for refresh-resilient dialog state (see `CaptureDialog.jsx` job polling, 500ms). All API calls through `frontend/src/api.js` with relative `/api/*` URLs — add new endpoints there, not inline `fetch`.
- **Naming (Rust)**: standard snake_case/PascalCase; visibility and roles are bitflag `u32`s, not enums. - **Naming (Rust)**: standard snake_case/PascalCase; visibility and roles are bitflag `u32`s, not enums.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -6,8 +6,8 @@
<title>Archivr</title> <title>Archivr</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="icon" type="image/x-icon" href="/favicon.ico"> <link rel="icon" type="image/x-icon" href="/favicon.ico">
<script type="module" crossorigin src="/assets/index-B67momER.js"></script> <script type="module" crossorigin src="/assets/index-DdftptO_.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DicH9MNh.css"> <link rel="stylesheet" crossorigin href="/assets/index-e69aZgiQ.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -29,6 +29,40 @@ export async function fetchEntryDetail(archiveId, entryUid) {
return getJson(`/api/archives/${archiveId}/entries/${entryUid}`); return getJson(`/api/archives/${archiveId}/entries/${entryUid}`);
} }
// ── Entry summaries ────────────────────────────────────────────────────────
// Summaries are generated on demand, never at capture time. GET is safe for
// public sessions (the server applies the same visibility gate as entry detail).
export async function fetchEntrySummary(archiveId, entryUid) {
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/summary`);
}
// Kicks off generation. Resolves to either an existing completed summary (200)
// or a freshly claimed pending row (202) — both carry a summary_uid, so the
// caller polls fetchEntrySummary either way.
// The server returns 400 with the exact missing env var name when a provider is
// unconfigured, so its body is surfaced verbatim rather than replaced.
export async function requestEntrySummary(archiveId, entryUid, { provider, force = false } = {}) {
const resp = await fetch(
`/api/archives/${archiveId}/entries/${entryUid}/summary`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, force }),
}
);
if (!resp.ok) {
// ApiError renders as { "error": "..." }; that message is the useful part
// (e.g. "missing required environment variable: ARCHIVR_ANTHROPIC_API_KEY"),
// so surface it verbatim instead of a generic status string.
const detail = await resp.text();
let message = detail.trim();
try { message = JSON.parse(detail).error || message } catch { /* non-JSON body */ }
throw new Error(message || `Summary request failed (${resp.status})`);
}
return resp.json();
}
export async function fetchEntryChildren(archiveId, entryUid) { export async function fetchEntryChildren(archiveId, entryUid) {
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/children`); return getJson(`/api/archives/${archiveId}/entries/${entryUid}/children`);
} }

View file

@ -1,9 +1,40 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { fetchEntryTags, assignTag, removeTag, listEntryCollections, listCollections, addEntryToCollection, updateEntryTitle, deleteEntry, rearchiveEntry, pollCaptureJob } from '../api' import { fetchEntryTags, assignTag, removeTag, listEntryCollections, listCollections, addEntryToCollection, updateEntryTitle, deleteEntry, rearchiveEntry, pollCaptureJob, fetchEntrySummary, requestEntrySummary } from '../api'
import { formatTimestamp, formatBytes, valueText, sourceIconSvg, displayPath } from '../utils' import { formatTimestamp, formatBytes, valueText, sourceIconSvg, displayPath } from '../utils'
const VIS_LABEL = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' } const VIS_LABEL = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }
// Provider labels are display-only; the values are the provider_kind strings
// the server persists in entry_summaries.provider_kind.
const SUMMARY_PROVIDERS = [
{ value: 'anthropic_http', label: 'Anthropic API' },
{ value: 'openai_compatible', label: 'OpenAI-compatible API' },
{ value: 'claude_cli', label: 'Claude CLI' },
{ value: 'codex_cli', label: 'Codex CLI' },
]
const PROVIDER_LABEL = Object.fromEntries(SUMMARY_PROVIDERS.map(p => [p.value, p.label]))
const SUMMARY_PROVIDER_KEY = 'archivr:summary:provider'
const SUMMARY_POLL_MS = 1500
// Summaries are stored as the raw JSON string the model produced (normalized
// server-side to {tldr, summary, tags}). Parsing can still fail for rows written
// by an older prompt version, so fall back to showing the text as-is rather than
// hiding a summary the user can perfectly well read.
function parseSummaryText(text) {
if (!text) return null
try {
const parsed = JSON.parse(text)
if (parsed && typeof parsed === 'object') {
return {
tldr: typeof parsed.tldr === 'string' ? parsed.tldr : '',
summary: typeof parsed.summary === 'string' ? parsed.summary : '',
tags: Array.isArray(parsed.tags) ? parsed.tags.filter(t => typeof t === 'string') : [],
}
}
} catch { /* not JSON — fall through */ }
return { tldr: '', summary: text, tags: [] }
}
const ExternalIcon = () => ( const ExternalIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@ -26,6 +57,20 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
const [fontsOpen, setFontsOpen] = useState(false) const [fontsOpen, setFontsOpen] = useState(false)
useEffect(() => { setFontsOpen(false) }, [detail?.summary?.entry_uid]) useEffect(() => { setFontsOpen(false) }, [detail?.summary?.entry_uid])
// ── Summary state ───────────────────────────────────────────────────────
// `summary` mirrors the server row. It is seeded from detail.latest_summary so
// the section renders immediately on selection, then kept fresh by polling
// only while a job is non-terminal.
const [summary, setSummary] = useState(null)
const [summaryError, setSummaryError] = useState('')
const [summaryBusy, setSummaryBusy] = useState(false)
const [summaryProvider, setSummaryProvider] = useState(() => {
try {
return sessionStorage.getItem(SUMMARY_PROVIDER_KEY) || SUMMARY_PROVIDERS[0].value
} catch { return SUMMARY_PROVIDERS[0].value }
})
const summaryPollRef = useRef(null)
// ── Bulk-panel state ──────────────────────────────────────────────────── // ── Bulk-panel state ────────────────────────────────────────────────────
const isBulk = selectedUids?.size >= 2 const isBulk = selectedUids?.size >= 2
const [bulkTagInput, setBulkTagInput] = useState('') const [bulkTagInput, setBulkTagInput] = useState('')
@ -76,6 +121,80 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
} }
}, []) }, [])
// Seed the summary from the entry detail payload and stop any poll left over
// from the previously selected entry.
useEffect(() => {
clearInterval(summaryPollRef.current)
summaryPollRef.current = null
setSummary(detail?.latest_summary ?? null)
setSummaryError('')
setSummaryBusy(false)
}, [detail?.summary?.entry_uid])
// Poll only while the latest summary is non-terminal. Anchoring the effect on
// the status (rather than starting a timer inside the click handler) means a
// job still running when the user navigates away and back is picked up again.
const summaryStatus = summary?.status
useEffect(() => {
clearInterval(summaryPollRef.current)
summaryPollRef.current = null
if (summaryStatus !== 'pending' && summaryStatus !== 'running') return
if (!archiveId || !detail?.summary?.entry_uid) return
const entryUid = detail.summary.entry_uid
summaryPollRef.current = setInterval(async () => {
try {
const res = await fetchEntrySummary(archiveId, entryUid)
setSummary(res.summary ?? null)
const st = res.summary?.status
if (st !== 'pending' && st !== 'running') {
clearInterval(summaryPollRef.current)
summaryPollRef.current = null
setSummaryBusy(false)
if (st === 'completed') onDetailRefresh?.()
}
} catch {
// A transient poll failure is not worth tearing the section down; the
// next tick retries, and a real failure lands as status === 'failed'.
}
}, SUMMARY_POLL_MS)
return () => {
clearInterval(summaryPollRef.current)
summaryPollRef.current = null
}
}, [summaryStatus, archiveId, detail?.summary?.entry_uid])
useEffect(() => () => clearInterval(summaryPollRef.current), [])
async function handleGenerateSummary(force = false) {
if (!archiveId || !detail?.summary?.entry_uid || summaryBusy) return
setSummaryBusy(true)
setSummaryError('')
try {
const res = await requestEntrySummary(archiveId, detail.summary.entry_uid, {
provider: summaryProvider,
force,
})
if (res.status === 'completed') {
// 200 cache hit: the response *is* the row, no polling needed.
setSummary(res)
setSummaryBusy(false)
onDetailRefresh?.()
} else {
// 202: seed a local pending row so the poll effect starts immediately
// rather than waiting a tick for the first GET.
setSummary({ ...(res ?? {}), status: 'pending' })
}
} catch (e) {
setSummaryError(e.message || 'Summary request failed')
setSummaryBusy(false)
}
}
function handleProviderChange(value) {
setSummaryProvider(value)
try { sessionStorage.setItem(SUMMARY_PROVIDER_KEY, value) } catch { /* private mode */ }
}
// Fetch available collections whenever archiveId is available // Fetch available collections whenever archiveId is available
useEffect(() => { useEffect(() => {
if (!archiveId) { setCollections([]); return } if (!archiveId) { setCollections([]); return }
@ -435,6 +554,80 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
</button> </button>
)} )}
{(() => {
// Public sessions get read-only treatment: the completed text if the
// server's visibility gate let the detail through at all, and never
// the provider selector or Generate button.
const parsed = summary?.status === 'completed'
? parseSummaryText(summary.summary_text)
: null
const running = summary?.status === 'pending' || summary?.status === 'running'
if (isPublicSession && !parsed) return null
return (
<div className="rail-section rail-summary">
<div className="rail-section-heading">Summary</div>
{parsed && (
<div className="rail-summary-body">
{parsed.tldr && <p className="rail-summary-tldr">{parsed.tldr}</p>}
{parsed.summary && <p className="rail-summary-text">{parsed.summary}</p>}
{parsed.tags.length > 0 && (
<div className="rail-summary-tags">
{parsed.tags.map(t => (
<span key={t} className="rail-summary-tag">{t}</span>
))}
</div>
)}
<p className="rail-summary-provider">
{PROVIDER_LABEL[summary.provider_kind] || summary.provider_kind}
{summary.provider_model ? ` \u00b7 ${summary.provider_model}` : ''}
</p>
</div>
)}
{running && (
<p className="rail-summary-status">
<span className="rail-summary-spinner" aria-hidden="true" />
{'Generating\u2026'}
</p>
)}
{summary?.status === 'failed' && summary.error_text && !isPublicSession && (
<p className="form-msg form-msg--err" style={{ margin: '0 0 8px' }}>
{summary.error_text}
</p>
)}
{summaryError && (
<p className="form-msg form-msg--err" style={{ margin: '0 0 8px' }}>
{summaryError}
</p>
)}
{!isPublicSession && !running && (
<div className="rail-summary-controls">
<select
className="rail-summary-select"
value={summaryProvider}
onChange={e => handleProviderChange(e.target.value)}
aria-label="Summary provider"
>
{SUMMARY_PROVIDERS.map(p => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
<button
className="rail-rearchive-btn"
onClick={() => handleGenerateSummary(!!parsed)}
disabled={summaryBusy}
>
{summaryBusy ? '\u2026' : parsed ? 'Regenerate' : 'Generate'}
</button>
</div>
)}
</div>
)
})()}
<div className="meta-list"> <div className="meta-list">
{metaRows.filter(([, v]) => v != null && v !== '').map(([label, value]) => ( {metaRows.filter(([, v]) => v != null && v !== '').map(([label, value]) => (
<div key={label} className="meta-item"> <div key={label} className="meta-item">

View file

@ -3118,3 +3118,64 @@ body.has-audio-bar { padding-bottom: 56px; }
cursor: pointer; cursor: pointer;
} }
.capture-sync-row input[type=checkbox] { cursor: pointer; } .capture-sync-row input[type=checkbox] { cursor: pointer; }
/* ── Summary rail section ────────────────────────────────────────────────── */
/* Reuses .rail-section spacing and .rail-rearchive-btn for the action button;
only the summary-specific typography and the provider row are new here. */
.rail-summary-body { margin-bottom: 10px; }
.rail-summary-tldr {
margin: 0 0 8px;
font-size: 13.5px;
font-weight: 600;
color: var(--ink);
line-height: 1.45;
}
.rail-summary-text {
margin: 0 0 8px;
font-size: 13px;
color: var(--ink);
line-height: 1.55;
}
.rail-summary-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 8px; }
.rail-summary-tag {
font-size: 11px;
padding: 2px 7px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
}
.rail-summary-provider {
margin: 0;
font-size: 11px;
color: var(--muted-2);
letter-spacing: 0.02em;
}
.rail-summary-status {
display: flex; align-items: center; gap: 7px;
margin: 0 0 8px;
font-size: 12.5px;
color: var(--muted);
}
.rail-summary-spinner {
width: 11px; height: 11px;
border: 1.5px solid var(--line);
border-top-color: var(--muted);
border-radius: 50%;
animation: rail-summary-spin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes rail-summary-spin { to { transform: rotate(360deg); } }
/* Respect a reduced-motion preference: the text alone still conveys the state. */
@media (prefers-reduced-motion: reduce) {
.rail-summary-spinner { animation: none; }
}
.rail-summary-controls { display: flex; flex-direction: column; gap: 6px; }
.rail-summary-select {
width: 100%;
padding: 5px 8px;
font-size: 12.5px;
color: var(--ink);
background: var(--paper);
border: 1px solid var(--line);
border-radius: 4px;
}