mirror of
https://github.com/thegeneralist01/archivr
synced 2026-10-09 12:55:00 +02:00
feat: add YouTube subtitles, local transcription, self-updating yt-dlp/Deno, X Article and thread titles (#39)
- Capture YouTube subtitles by default (opt-out in UI, API, CLI --no-subtitles) - Summarize YouTube videos from subtitles; fetch on demand, then local transcription, then error - Local transcription fallback: Whisper, Parakeet, Phonon-2 (English only) - Runtime-resolved, self-updating yt-dlp and Deno JS runtime (fixes YouTube 403s) - Settings > Instance > yt-dlp: status and in-app update without restart - X Article titles from article.title, with idempotent startup backfill - Thread title generation (single and bulk) with per-provider cheap models - Per-provider title model settings in Settings > Instance - Docs, mental model, AGENTS.md and transcription spec updated
This commit is contained in:
parent
4f3b2968b6
commit
094f1b0457
35 changed files with 11377 additions and 583 deletions
|
|
@ -37,18 +37,31 @@ export async function fetchEntrySummary(archiveId, entryUid, { signal } = {}) {
|
|||
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/summary`, { signal });
|
||||
}
|
||||
|
||||
// Local transcription engines that are enabled and configured on this server
|
||||
// ([{ kind, label, english_only, languages }]); an empty list means the
|
||||
// feature is off. Logged-in users only; callers treat errors as [].
|
||||
export async function fetchTranscriptionEngines({ signal } = {}) {
|
||||
return getJson('/api/summary/transcription-engines', { signal });
|
||||
}
|
||||
|
||||
// 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, includeImages = false, signal } = {}) {
|
||||
// `transcribeEngine` (a kind from fetchTranscriptionEngines) is sent only when
|
||||
// non-empty; the server uses it only for YouTube videos without subtitles.
|
||||
export async function requestEntrySummary(archiveId, entryUid, { provider, force = false, includeImages = false, transcribeEngine, signal } = {}) {
|
||||
const payload = { provider, force, include_images: includeImages };
|
||||
if (typeof transcribeEngine === 'string' && transcribeEngine.trim()) {
|
||||
payload.transcribe_engine = transcribeEngine.trim();
|
||||
}
|
||||
const resp = await fetch(
|
||||
`/api/archives/${archiveId}/entries/${entryUid}/summary`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider, force, include_images: includeImages }),
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
}
|
||||
);
|
||||
|
|
@ -130,6 +143,28 @@ export async function updateEntryTitle(archiveId, entryUid, title) {
|
|||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
|
||||
// Names an X thread with a cheap model on the server and saves it as the title.
|
||||
// Resolves to { entry_uid, title }; the server's { error } text is surfaced verbatim
|
||||
// (e.g. a missing API-key variable or the provider's failure).
|
||||
export async function generateThreadTitle(archiveId, entryUid, { provider }) {
|
||||
const res = await fetch(`/api/archives/${archiveId}/entries/${entryUid}/thread-title`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.text();
|
||||
let message = detail.trim();
|
||||
try {
|
||||
message = JSON.parse(detail).error || message;
|
||||
} catch {
|
||||
// non-JSON body
|
||||
}
|
||||
throw new Error(message || `Title generation failed (${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchEntryTags(archiveId, entryUid) {
|
||||
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/tags`);
|
||||
}
|
||||
|
|
@ -214,13 +249,14 @@ export async function fetchTags(archiveId) {
|
|||
export async function submitCapture(archiveId, locator, quality = null, extensions = null) {
|
||||
const payload = { locator }
|
||||
if (quality && quality !== 'best') payload.quality = quality
|
||||
// extensions: { ublock_enabled?: bool, reader_mode?: bool, cookie_ext_enabled?: bool, modal_closer_enabled?: bool, via_freedium?: bool }
|
||||
// extensions: { ublock_enabled?: bool, reader_mode?: bool, cookie_ext_enabled?: bool, modal_closer_enabled?: bool, via_freedium?: bool, download_subtitles?: bool }
|
||||
if (extensions) {
|
||||
if (typeof extensions.ublock_enabled === 'boolean') payload.ublock_enabled = extensions.ublock_enabled
|
||||
if (typeof extensions.reader_mode === 'boolean') payload.reader_mode = extensions.reader_mode
|
||||
if (typeof extensions.cookie_ext_enabled === 'boolean') payload.cookie_ext_enabled = extensions.cookie_ext_enabled
|
||||
if (typeof extensions.modal_closer_enabled === 'boolean') payload.modal_closer_enabled = extensions.modal_closer_enabled
|
||||
if (typeof extensions.via_freedium === 'boolean') payload.via_freedium = extensions.via_freedium
|
||||
if (typeof extensions.download_subtitles === 'boolean') payload.download_subtitles = extensions.download_subtitles
|
||||
if (extensions.per_item_quality && typeof extensions.per_item_quality === 'object' && Object.keys(extensions.per_item_quality).length > 0) payload.per_item_quality = extensions.per_item_quality
|
||||
if (extensions.sync === true) payload.sync = true
|
||||
}
|
||||
|
|
@ -382,6 +418,23 @@ export async function updateInstanceSettings(patch) {
|
|||
if (!res.ok) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); }
|
||||
}
|
||||
|
||||
export async function getYtDlpStatus({ signal } = {}) {
|
||||
return getJson('/api/admin/yt-dlp', { signal });
|
||||
}
|
||||
|
||||
// Runs the yt-dlp + Deno update server-side (same as `archivr yt-dlp update`); can take minutes.
|
||||
// err.status carries the HTTP status (409 = another update is running).
|
||||
export async function updateYtDlp() {
|
||||
const res = await fetch('/api/admin/yt-dlp/update', { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const b = await res.json().catch(() => ({}));
|
||||
const err = new Error(b.error || `HTTP ${res.status}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Admin helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listAdminUsers() {
|
||||
|
|
|
|||
|
|
@ -279,6 +279,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
const [cookieExtEnabled, setCookieExtEnabled] = useState(true)
|
||||
const [modalCloserEnabled, setModalCloserEnabled] = useState(true)
|
||||
const [freediumEnabled, setFreediumEnabled] = useState(true)
|
||||
const [downloadSubtitles, setDownloadSubtitles] = useState(true)
|
||||
|
||||
// Load global settings from server once on mount
|
||||
useEffect(() => {
|
||||
|
|
@ -485,6 +486,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
cookie_ext_enabled: cookieExtEnabled,
|
||||
modal_closer_enabled: modalCloserEnabled,
|
||||
via_freedium: freediumEnabled,
|
||||
download_subtitles: downloadSubtitles,
|
||||
...extraExtensions,
|
||||
}
|
||||
try {
|
||||
|
|
@ -957,6 +959,22 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
<span className="ext-toggle-knob" />
|
||||
</button>
|
||||
</label>
|
||||
<label className="capture-ext-row" style={{ marginTop: 8 }}>
|
||||
<span className="capture-ext-label">
|
||||
<span className="capture-ext-name">Download subtitles</span>
|
||||
<span className="capture-ext-desc">Save YouTube subtitles (manual preferred, auto-generated fallback) so videos can be summarized</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={downloadSubtitles}
|
||||
className={`ext-toggle ext-toggle--sm${downloadSubtitles ? ' ext-toggle--on' : ''}`}
|
||||
onClick={() => setDownloadSubtitles(v => !v)}
|
||||
aria-label="Toggle subtitle download for this capture"
|
||||
>
|
||||
<span className="ext-toggle-knob" />
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect, useLayoutEffect, useRef } from 'react'
|
||||
import { fetchEntryTags, assignTag, removeTag, listEntryCollections, listCollections, addEntryToCollection, updateEntryTitle, deleteEntry, rearchiveEntry, pollCaptureJob, fetchEntrySummary, requestEntrySummary } from '../api'
|
||||
import { fetchEntryTags, assignTag, removeTag, listEntryCollections, listCollections, addEntryToCollection, updateEntryTitle, generateThreadTitle, deleteEntry, rearchiveEntry, pollCaptureJob, fetchEntrySummary, requestEntrySummary, fetchTranscriptionEngines } from '../api'
|
||||
import { formatTimestamp, formatBytes, valueText, sourceIconSvg, displayPath } from '../utils'
|
||||
|
||||
const VIS_LABEL = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }
|
||||
|
|
@ -14,9 +14,10 @@ const SUMMARY_PROVIDERS = [
|
|||
]
|
||||
const PROVIDER_LABEL = Object.fromEntries(SUMMARY_PROVIDERS.map(p => [p.value, p.label]))
|
||||
const SUMMARY_PROVIDER_KEY = 'archivr:summary:provider'
|
||||
const SUMMARY_TRANSCRIBE_ENGINE_KEY = 'archivr:summary:transcribe-engine'
|
||||
const SUMMARY_POLL_MS = 1500
|
||||
const UNSUPPORTED_SUMMARY_CONTENT_HEADING = 'This entry can’t be summarized yet.'
|
||||
const UNSUPPORTED_SUMMARY_CONTENT_DETAIL = 'It doesn’t contain archived text that a summary provider can read. Summaries currently support text notes, web pages, X posts and threads, and X Articles. Video, audio, and image-only entries need a transcript or text source.'
|
||||
const UNSUPPORTED_SUMMARY_CONTENT_DETAIL = 'It doesn’t contain archived text that a summary provider can read. Summaries currently support text notes, web pages, X posts and threads, X Articles, and YouTube videos with subtitles. Other video, audio, and image-only entries need a transcript or text source.'
|
||||
const UNSUPPORTED_SUMMARY_CONTENT_MESSAGE = `${UNSUPPORTED_SUMMARY_CONTENT_HEADING}\n\n${UNSUPPORTED_SUMMARY_CONTENT_DETAIL}`
|
||||
|
||||
// Summaries are stored as the raw JSON string the model produced (normalized
|
||||
|
|
@ -57,6 +58,8 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
|
|||
const [rearchiveState, setRearchiveState] = useState('idle') // 'idle' | 'running' | 'done' | 'error'
|
||||
const [rearchiveError, setRearchiveError] = useState('')
|
||||
const rearchivePollRef = useRef(null)
|
||||
const [titleGenState, setTitleGenState] = useState('idle') // 'idle' | 'running' | 'done' | 'error'
|
||||
const [titleGenError, setTitleGenError] = useState('')
|
||||
const [fontsOpen, setFontsOpen] = useState(false)
|
||||
useEffect(() => { setFontsOpen(false) }, [detail?.summary?.entry_uid])
|
||||
|
||||
|
|
@ -73,6 +76,14 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
|
|||
} catch { return SUMMARY_PROVIDERS[0].value }
|
||||
})
|
||||
const [includeSummaryImages, setIncludeSummaryImages] = useState(false)
|
||||
// Local transcription engines offered by the server ([] = feature off) and
|
||||
// the one picked for YouTube videos without subtitles ('' = none).
|
||||
const [transcriptionEngines, setTranscriptionEngines] = useState([])
|
||||
const [transcribeEngine, setTranscribeEngine] = useState(() => {
|
||||
try {
|
||||
return sessionStorage.getItem(SUMMARY_TRANSCRIBE_ENGINE_KEY) || ''
|
||||
} catch { return '' }
|
||||
})
|
||||
const summaryPollRef = useRef(null)
|
||||
const summaryPollAbortRef = useRef(null)
|
||||
const summaryGenerateAbortRef = useRef(null)
|
||||
|
|
@ -97,6 +108,21 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
|
|||
const [singleCollUid, setSingleCollUid] = useState('')
|
||||
const [singleCollState, setSingleCollState] = useState('idle')
|
||||
const [singleCollError, setSingleCollError] = useState('')
|
||||
// Batch thread-title generation; the seq ref invalidates a run whenever the
|
||||
// selection (as a set of uids) or archive changes.
|
||||
const [bulkTitleState, setBulkTitleState] = useState('idle') // 'idle'|'running'|'done'
|
||||
const [bulkTitleProgress, setBulkTitleProgress] = useState({ done: 0, total: 0 })
|
||||
const [bulkTitleSummary, setBulkTitleSummary] = useState({ updated: 0, failed: 0, firstError: '' })
|
||||
const bulkTitleSeqRef = useRef(0)
|
||||
const bulkThreads = (selectedEntries || []).filter(e => e.entity_kind === 'tweet_thread')
|
||||
const bulkSkipped = (selectedUids?.size || 0) - bulkThreads.length
|
||||
const bulkSelectionKey = selectedUids ? [...selectedUids].sort().join(',') : ''
|
||||
useEffect(() => {
|
||||
bulkTitleSeqRef.current++
|
||||
setBulkTitleState('idle')
|
||||
setBulkTitleProgress({ done: 0, total: 0 })
|
||||
setBulkTitleSummary({ updated: 0, failed: 0, firstError: '' })
|
||||
}, [bulkSelectionKey, archiveId])
|
||||
|
||||
useEffect(() => {
|
||||
const seq = ++selectSeqRef.current
|
||||
|
|
@ -128,6 +154,17 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
|
|||
}).catch(() => {})
|
||||
}, [selectedEntry, archiveId, isPublicSession])
|
||||
|
||||
// F4 state is keyed on entry identity, not the entry object: a successful
|
||||
// title generation replaces `selectedEntry` (new title) and must not reset
|
||||
// its own "Title updated." confirmation.
|
||||
const titleGenSeqRef = useRef(0)
|
||||
const selectedEntryUid = selectedEntry?.entry_uid
|
||||
useEffect(() => {
|
||||
titleGenSeqRef.current++
|
||||
setTitleGenState('idle')
|
||||
setTitleGenError('')
|
||||
}, [selectedEntryUid, archiveId])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearInterval(rearchivePollRef.current)
|
||||
|
|
@ -201,6 +238,33 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
|
|||
summaryGenerateAbortRef.current?.abort()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isPublicSession) {
|
||||
setTranscriptionEngines([])
|
||||
return
|
||||
}
|
||||
const controller = new AbortController()
|
||||
fetchTranscriptionEngines({ signal: controller.signal }).then(list => {
|
||||
if (controller.signal.aborted) return
|
||||
const engines = Array.isArray(list) ? list : []
|
||||
setTranscriptionEngines(engines)
|
||||
setTranscribeEngine(current => {
|
||||
if (!current || engines.some(t => t.kind === current)) return current
|
||||
try { sessionStorage.removeItem(SUMMARY_TRANSCRIBE_ENGINE_KEY) } catch { /* private mode */ }
|
||||
return ''
|
||||
})
|
||||
}).catch(() => {
|
||||
if (!controller.signal.aborted) setTranscriptionEngines([])
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [isPublicSession])
|
||||
|
||||
// The engine is only offered (and sent) for YouTube videos, so a stale
|
||||
// session choice never reaches the server for other entries.
|
||||
const transcribeAvailable = transcriptionEngines.length > 0 &&
|
||||
detail?.summary?.source_kind === 'youtube' &&
|
||||
detail?.summary?.entity_kind === 'video'
|
||||
|
||||
async function handleGenerateSummary(force = false) {
|
||||
if (!archiveId || !detail?.summary?.entry_uid || summaryBusy) return
|
||||
const entryUid = detail.summary.entry_uid
|
||||
|
|
@ -216,6 +280,7 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
|
|||
provider: summaryProvider,
|
||||
force,
|
||||
includeImages: includeSummaryImages,
|
||||
transcribeEngine: transcribeAvailable ? transcribeEngine : '',
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (controller.signal.aborted || summarySelectionRef.current !== selectionKey) return
|
||||
|
|
@ -245,6 +310,11 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
|
|||
try { sessionStorage.setItem(SUMMARY_PROVIDER_KEY, value) } catch { /* private mode */ }
|
||||
}
|
||||
|
||||
function handleTranscribeEngineChange(value) {
|
||||
setTranscribeEngine(value)
|
||||
try { sessionStorage.setItem(SUMMARY_TRANSCRIBE_ENGINE_KEY, value) } catch { /* private mode */ }
|
||||
}
|
||||
|
||||
// Fetch available collections whenever archiveId is available
|
||||
useEffect(() => {
|
||||
if (!archiveId) { setCollections([]); return }
|
||||
|
|
@ -436,6 +506,58 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
|
|||
}
|
||||
}
|
||||
|
||||
async function handleGenerateThreadTitle() {
|
||||
if (!selectedEntry || !archiveId || titleGenState === 'running') return
|
||||
const startSeq = titleGenSeqRef.current
|
||||
const entryUid = selectedEntry.entry_uid
|
||||
setTitleGenState('running')
|
||||
setTitleGenError('')
|
||||
try {
|
||||
const { title } = await generateThreadTitle(archiveId, entryUid, { provider: summaryProvider })
|
||||
// Saved server-side regardless of selection; App's caches are keyed by uid.
|
||||
// Done state first (if still on the same entry), then notify App.
|
||||
if (titleGenSeqRef.current === startSeq) setTitleGenState('done')
|
||||
onEntryTitleChange?.(entryUid, title)
|
||||
} catch (e) {
|
||||
if (titleGenSeqRef.current !== startSeq) return
|
||||
setTitleGenState('error')
|
||||
setTitleGenError(e.message || 'Title generation failed.')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBulkGenerateTitles() {
|
||||
if (!archiveId || bulkTitleState === 'running' || bulkThreads.length === 0) return
|
||||
const startSeq = ++bulkTitleSeqRef.current
|
||||
const uids = bulkThreads.map(e => e.entry_uid)
|
||||
const provider = summaryProvider
|
||||
let done = 0, updated = 0, failed = 0, firstError = ''
|
||||
setBulkTitleState('running')
|
||||
setBulkTitleProgress({ done: 0, total: uids.length })
|
||||
setBulkTitleSummary({ updated: 0, failed: 0, firstError: '' })
|
||||
let next = 0
|
||||
async function worker() {
|
||||
while (next < uids.length) {
|
||||
if (bulkTitleSeqRef.current !== startSeq) return
|
||||
const uid = uids[next++]
|
||||
try {
|
||||
const { title } = await generateThreadTitle(archiveId, uid, { provider })
|
||||
updated++
|
||||
// Saved server-side; App's caches are keyed by uid, so always propagate.
|
||||
onEntryTitleChange?.(uid, title)
|
||||
} catch (e) {
|
||||
failed++
|
||||
if (!firstError) firstError = e.message || 'Title generation failed.'
|
||||
}
|
||||
done++
|
||||
if (bulkTitleSeqRef.current === startSeq) setBulkTitleProgress({ done, total: uids.length })
|
||||
}
|
||||
}
|
||||
await Promise.all([worker(), worker()])
|
||||
if (bulkTitleSeqRef.current !== startSeq) return
|
||||
setBulkTitleState('done')
|
||||
setBulkTitleSummary({ updated, failed, firstError })
|
||||
}
|
||||
|
||||
const metaRows = detail ? [
|
||||
['Added', formatTimestamp(detail.summary.archived_at)],
|
||||
['Source', detail.summary.source_kind],
|
||||
|
|
@ -529,6 +651,32 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
|
|||
</div>
|
||||
)}
|
||||
|
||||
{bulkThreads.length > 0 && (
|
||||
<div className="rail-section">
|
||||
<div className="rail-section-heading">Thread titles</div>
|
||||
<button
|
||||
className="rail-rearchive-btn"
|
||||
onClick={handleBulkGenerateTitles}
|
||||
disabled={bulkTitleState === 'running'}
|
||||
title={`Names each thread with a small model via ${PROVIDER_LABEL[summaryProvider] || summaryProvider} (the Summary provider)`}
|
||||
>
|
||||
{bulkTitleState === 'running'
|
||||
? `Generating titles\u2026 ${bulkTitleProgress.done}/${bulkTitleProgress.total}`
|
||||
: `Generate titles for ${bulkThreads.length} thread${bulkThreads.length === 1 ? '' : 's'}`}
|
||||
</button>
|
||||
{bulkSkipped > 0 && (
|
||||
<p className="bulk-title-note">
|
||||
{`${bulkSkipped} non-thread entr${bulkSkipped === 1 ? 'y' : 'ies'} will be skipped.`}
|
||||
</p>
|
||||
)}
|
||||
{bulkTitleState === 'done' && (
|
||||
<p className={`form-msg ${bulkTitleSummary.failed ? 'form-msg--err' : 'form-msg--ok'} bulk-title-note`} role="status">
|
||||
{`${bulkTitleSummary.updated} updated${bulkTitleSummary.failed ? `, ${bulkTitleSummary.failed} failed: ${bulkTitleSummary.firstError}` : ''}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rail-delete-zone">
|
||||
<button
|
||||
className="rail-delete-btn"
|
||||
|
|
@ -676,6 +824,22 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
|
|||
<option key={p.value} value={p.value}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{transcribeAvailable && (
|
||||
<>
|
||||
<select
|
||||
className="rail-summary-select"
|
||||
value={transcribeEngine}
|
||||
onChange={e => handleTranscribeEngineChange(e.target.value)}
|
||||
aria-label="Local transcription if no subtitles"
|
||||
>
|
||||
<option value="">No local transcription</option>
|
||||
{transcriptionEngines.map(t => (
|
||||
<option key={t.kind} value={t.kind}>{t.label}{t.english_only ? ' (English only)' : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="rail-summary-transcribe-note">Used only if this video has no subtitles. Transcription runs on this server and can take several minutes.</p>
|
||||
</>
|
||||
)}
|
||||
<div className={`rail-summary-image-option${summaryProvider === 'claude_cli' ? ' rail-summary-image-option--disabled' : ''}`}>
|
||||
<label className="rail-summary-image-option__label">
|
||||
<input
|
||||
|
|
@ -865,6 +1029,25 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
|
|||
{rearchiveState === 'error' && (
|
||||
<p className="form-msg form-msg--err" style={{ marginTop: '6px' }}>{rearchiveError}</p>
|
||||
)}
|
||||
{detail.summary.entity_kind === 'tweet_thread' && (
|
||||
<>
|
||||
<button
|
||||
className="rail-rearchive-btn"
|
||||
style={{ marginTop: '8px' }}
|
||||
onClick={handleGenerateThreadTitle}
|
||||
disabled={titleGenState === 'running'}
|
||||
title={`Names this thread with a small model via ${PROVIDER_LABEL[summaryProvider] || summaryProvider} (the Summary provider)`}
|
||||
>
|
||||
{titleGenState === 'running' ? 'Generating title\u2026' : 'Generate title'}
|
||||
</button>
|
||||
{titleGenState === 'done' && (
|
||||
<p className="form-msg form-msg--ok" style={{ marginTop: '6px' }}>Title updated.</p>
|
||||
)}
|
||||
{titleGenState === 'error' && (
|
||||
<p className="form-msg form-msg--err" style={{ marginTop: '6px' }}>{titleGenError}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect, useContext, useCallback } from 'react'
|
||||
import { useState, useEffect, useContext, useCallback, useRef } from 'react'
|
||||
import { AuthContext } from '../App.jsx'
|
||||
import {
|
||||
updateProfile, changePassword, patchMe,
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
scanOrphanBlobs, deleteOrphanBlobs,
|
||||
listCookieRules, createCookieRule, updateCookieRule, deleteCookieRule,
|
||||
listRoles, fetchMe,
|
||||
getYtDlpStatus, updateYtDlp,
|
||||
} from '../api.js'
|
||||
|
||||
const ROLE_ADMIN = 4
|
||||
|
|
@ -35,7 +36,12 @@ export default function SettingsView({ tab, onTabChange, archiveId }) {
|
|||
|
||||
{tab === 'profile' && <ProfileTab currentUser={currentUser} setCurrentUser={setCurrentUser} />}
|
||||
{tab === 'tokens' && <TokensTab />}
|
||||
{tab === 'instance' && isAdmin && <InstanceTab isOwner={isOwner} setCurrentUser={setCurrentUser} />}
|
||||
{tab === 'instance' && isAdmin && (
|
||||
<>
|
||||
<InstanceTab isOwner={isOwner} setCurrentUser={setCurrentUser} />
|
||||
<YtDlpSection />
|
||||
</>
|
||||
)}
|
||||
{tab === 'cookies' && isAdmin && <CookiesTab />}
|
||||
{tab === 'extensions' && isAdmin && <ExtensionsTab />}
|
||||
{tab === 'storage' && isAdmin && <StorageTab archiveId={archiveId} />}
|
||||
|
|
@ -242,6 +248,13 @@ function TokensTab() {
|
|||
)
|
||||
}
|
||||
|
||||
const TITLE_MODEL_PROVIDERS = [
|
||||
['anthropic_http', 'Anthropic API'],
|
||||
['openai_compatible', 'OpenAI-compatible API'],
|
||||
['claude_cli', 'Claude CLI'],
|
||||
['codex_cli', 'Codex CLI'],
|
||||
]
|
||||
|
||||
function InstanceTab({ isOwner, setCurrentUser }) {
|
||||
const [settings, setSettings] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
|
@ -290,8 +303,9 @@ function InstanceTab({ isOwner, setCurrentUser }) {
|
|||
e.preventDefault()
|
||||
setSaving(true); setSaveMsg(null)
|
||||
try {
|
||||
const { reorder_children_role_bits: _mask, ...rest } = settings
|
||||
const { reorder_children_role_bits: _mask, title_models: _models, ...rest } = settings
|
||||
await updateInstanceSettings(rest)
|
||||
setSettings(await getInstanceSettings()) // server-trimmed models + effective sources
|
||||
setSaveMsg({ ok: true, text: 'Saved.' })
|
||||
} catch (err) {
|
||||
setSaveMsg({ ok: false, text: err.message })
|
||||
|
|
@ -329,6 +343,23 @@ function InstanceTab({ isOwner, setCurrentUser }) {
|
|||
<option value={3}>Public</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field" style={{ marginTop: 4 }}>
|
||||
<label className="form-label">Thread title models</label>
|
||||
{TITLE_MODEL_PROVIDERS.map(([kind, label]) => {
|
||||
const key = `title_model_${kind}`
|
||||
const info = settings.title_models?.[kind]
|
||||
const placeholder = info ? `${info.fallback_model} (${info.fallback_source})` : ''
|
||||
return (
|
||||
<div key={kind} className="form-field">
|
||||
<label className="form-label" htmlFor={key}>{label}</label>
|
||||
<input id={key} className="field-input" type="text" maxLength={100}
|
||||
value={settings[key] ?? ''} placeholder={placeholder}
|
||||
onChange={e => setSettings(s => ({ ...s, [key]: e.target.value }))} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<p className="form-hint">Cheap model used for Generate title. Leave blank to use the default.</p>
|
||||
</div>
|
||||
{saveMsg && <div className={`form-msg form-msg--${saveMsg.ok ? 'ok' : 'err'}`}>{saveMsg.text}</div>}
|
||||
<button className="btn-primary" type="submit" disabled={saving}>
|
||||
{saving ? 'Saving\u2026' : 'Save Settings'}
|
||||
|
|
@ -859,4 +890,212 @@ function ExtensionsTab() {
|
|||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const YT_DLP_SOURCE_LABELS = {
|
||||
force: 'Forced (ARCHIVR_YT_DLP_FORCE)',
|
||||
env: 'Pinned (ARCHIVR_YT_DLP)',
|
||||
'state-dir': 'Managed install',
|
||||
path: 'System PATH',
|
||||
}
|
||||
const JS_SOURCE_LABELS = {
|
||||
force: 'Forced (ARCHIVR_JS_RUNTIME)',
|
||||
env: 'Pinned (ARCHIVR_DENO)',
|
||||
'state-dir': 'Managed install',
|
||||
path: 'System PATH',
|
||||
}
|
||||
|
||||
function sourceLabel(labels, row) {
|
||||
return (row?.role && labels[row.role]) || row?.label || 'unknown source'
|
||||
}
|
||||
|
||||
function YtDlpCandidateTable({ caption, rows, labels }) {
|
||||
return (
|
||||
<div className="form-field">
|
||||
<div className="form-label">{caption}</div>
|
||||
<div className="ytdlp-table-wrap">
|
||||
<table className="admin-table ytdlp-table">
|
||||
<thead>
|
||||
<tr><th>Source</th><th>Path</th><th>Version</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(row => (
|
||||
<tr key={row.role} className={row.chosen ? 'ytdlp-row--chosen' : undefined}>
|
||||
<td>{sourceLabel(labels, row)}</td>
|
||||
<td className="ytdlp-path">
|
||||
{row.path ?? <span className="muted">not set</span>}
|
||||
</td>
|
||||
<td>
|
||||
{row.invalid
|
||||
? <span className="form-msg--err">invalid: {row.invalid}</span>
|
||||
: (row.path ? (row.version ?? '\u2014') : '\u2014')}
|
||||
</td>
|
||||
<td>{row.chosen && <span className="ytdlp-badge">in use</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function YtDlpSection() {
|
||||
const [status, setStatus] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const [result, setResult] = useState(null)
|
||||
const [updateError, setUpdateError] = useState(null)
|
||||
|
||||
const loadCtrl = useRef(null)
|
||||
const alive = useRef(true)
|
||||
|
||||
// Probing every candidate takes seconds, so this loads separately from the settings form.
|
||||
// A newer load or unmounting aborts the previous request. Resolves to the status or null.
|
||||
async function load() {
|
||||
loadCtrl.current?.abort()
|
||||
const ctrl = new AbortController()
|
||||
loadCtrl.current = ctrl
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const s = await getYtDlpStatus({ signal: ctrl.signal })
|
||||
if (!ctrl.signal.aborted) setStatus(s)
|
||||
return s
|
||||
} catch (e) {
|
||||
if (!ctrl.signal.aborted) setError(e.message)
|
||||
return null
|
||||
} finally {
|
||||
if (!ctrl.signal.aborted) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true
|
||||
load()
|
||||
return () => {
|
||||
alive.current = false
|
||||
loadCtrl.current?.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function handleUpdate() {
|
||||
setUpdating(true)
|
||||
setResult(null)
|
||||
setUpdateError(null)
|
||||
try {
|
||||
const r = await updateYtDlp()
|
||||
setResult(r)
|
||||
setStatus(r.status)
|
||||
} catch (err) {
|
||||
// A proxy may time out (e.g. 504) while the update keeps running server-side.
|
||||
const s = err.status !== 409 && alive.current ? await load() : null
|
||||
setUpdateError(s?.update_running
|
||||
? 'Update still running on the server — refresh in a minute.'
|
||||
: err.message)
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const ytChosen = status?.yt_dlp_chosen
|
||||
const ytChosenRow = (status?.yt_dlp ?? []).find(r => r.chosen)
|
||||
const jsChosen = status?.js_runtime_chosen
|
||||
const inUse = status?.js_runtime_in_use
|
||||
const invalidRows = (status?.js_runtime ?? []).filter(r => r.invalid)
|
||||
|
||||
return (
|
||||
<div className="form-section ytdlp-section">
|
||||
<h2>yt-dlp</h2>
|
||||
{loading && !status && <div className="muted">Loading{'\u2026'}</div>}
|
||||
{error && <div className="form-msg form-msg--err">{error}</div>}
|
||||
|
||||
{status && (
|
||||
<>
|
||||
<dl className="ytdlp-summary">
|
||||
<dt>yt-dlp</dt>
|
||||
<dd>
|
||||
{ytChosen?.version ?? 'unknown'}
|
||||
<span className="muted"> · {ytChosen?.role ? YT_DLP_SOURCE_LABELS[ytChosen.role] : 'unlisted path'}</span>
|
||||
{ytChosen?.path && <div className="ytdlp-path">{ytChosen.path}</div>}
|
||||
</dd>
|
||||
<dt>JS runtime</dt>
|
||||
<dd>
|
||||
{jsChosen ? (
|
||||
<>
|
||||
{jsChosen.kind} {jsChosen.version ?? ''}
|
||||
<span className="muted"> · {JS_SOURCE_LABELS[jsChosen.role] ?? jsChosen.role}</span>
|
||||
{jsChosen.path && <div className="ytdlp-path">{jsChosen.path}</div>}
|
||||
</>
|
||||
) : <span className="form-msg--err">none</span>}
|
||||
</dd>
|
||||
<dt>In use by this server</dt>
|
||||
<dd>
|
||||
{inUse ? (
|
||||
<>
|
||||
{inUse.kind}{jsChosen && jsChosen.path === inUse.path && jsChosen.version ? ` ${jsChosen.version}` : ''}
|
||||
{inUse.path && <div className="ytdlp-path">{inUse.path}</div>}
|
||||
</>
|
||||
) : <span className="muted">no JS runtime</span>}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
{ytChosenRow?.invalid && (
|
||||
<div className="form-msg form-msg--err">
|
||||
The yt-dlp in use does not run: {ytChosenRow.invalid}
|
||||
</div>
|
||||
)}
|
||||
{!jsChosen && (
|
||||
<div className="form-msg form-msg--err">
|
||||
No JS runtime resolved — YouTube downloads may fail with HTTP 403. Update below to install Deno.
|
||||
</div>
|
||||
)}
|
||||
{invalidRows.map(r => (
|
||||
<div key={r.role} className="form-msg form-msg--err">
|
||||
ARCHIVR_JS_RUNTIME={r.path} is invalid ({r.invalid}) and is ignored.
|
||||
</div>
|
||||
))}
|
||||
|
||||
{status.state_dir && <p className="form-hint">State directory: <span className="ytdlp-path">{status.state_dir}</span></p>}
|
||||
{!status.yt_dlp_installed && status.yt_dlp_target && (
|
||||
<p className="form-hint">No managed yt-dlp yet — the update installs it to {status.yt_dlp_target}.</p>
|
||||
)}
|
||||
{!status.deno_installed && status.deno_target && (
|
||||
<p className="form-hint">No managed Deno yet — the update installs it to {status.deno_target}.</p>
|
||||
)}
|
||||
|
||||
<YtDlpCandidateTable caption="yt-dlp candidates" rows={status.yt_dlp} labels={YT_DLP_SOURCE_LABELS} />
|
||||
<YtDlpCandidateTable caption="JS runtime candidates" rows={status.js_runtime} labels={JS_SOURCE_LABELS} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{(status || !loading) && (
|
||||
<div className="ytdlp-actions">
|
||||
<button className="btn-primary" type="button"
|
||||
disabled={updating || status?.update_running} onClick={handleUpdate}>
|
||||
{updating ? 'Updating\u2026 (can take a few minutes)' : 'Update yt-dlp & Deno'}
|
||||
</button>
|
||||
<button className="btn-ghost" type="button" disabled={updating || loading} onClick={load}>
|
||||
Refresh
|
||||
</button>
|
||||
{status?.update_running && !updating && <span className="form-hint">An update is already running.</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<>
|
||||
{[['yt-dlp', result.yt_dlp], ['Deno', result.deno]].map(([name, o]) => (
|
||||
<div key={name} className={`form-msg form-msg--${o.ok ? 'ok' : 'err'}`}>
|
||||
{name}: {o.ok ? o.message : `failed — ${o.message}`}
|
||||
</div>
|
||||
))}
|
||||
{(result.yt_dlp.ok || result.deno.ok) && (
|
||||
<p className="form-hint">New binaries are used for the next capture — no restart needed.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{updateError && <div className="form-msg form-msg--err">{updateError}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -638,6 +638,9 @@ select {
|
|||
}
|
||||
.bulk-coll-select:focus { border-color: var(--accent); }
|
||||
.tag-add-btn:disabled { opacity: 0.45; cursor: default; }
|
||||
.bulk-title-note { font-size: 12px; color: var(--muted); margin: 6px 0 0; }
|
||||
.bulk-title-note.form-msg--ok { color: var(--link); }
|
||||
.bulk-title-note.form-msg--err { color: var(--accent); }
|
||||
|
||||
/* ── Rail delete zone ───────────────────────────────────────────────────── */
|
||||
.rail-delete-zone {
|
||||
|
|
@ -2097,6 +2100,16 @@ select {
|
|||
}
|
||||
.admin-table td:first-child { padding-left: 16px; }
|
||||
.admin-table tr:hover td { background: var(--paper-2); }
|
||||
/* ── Settings › yt-dlp ── */
|
||||
.ytdlp-section { max-width: 760px; }
|
||||
.ytdlp-summary { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0 0 12px; font-size: 14px; }
|
||||
.ytdlp-summary dt { font-weight: 700; color: var(--muted); }
|
||||
.ytdlp-summary dd { margin: 0; }
|
||||
.ytdlp-path { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; word-break: break-all; }
|
||||
.ytdlp-table-wrap { overflow-x: auto; margin: 6px 0 14px; border: 1px solid var(--line-soft); }
|
||||
.ytdlp-row--chosen td { background: var(--paper-2); }
|
||||
.ytdlp-badge { font-size: 11px; font-weight: 700; color: var(--paper); background: var(--link); border-radius: 3px; padding: 1px 6px; white-space: nowrap; }
|
||||
.ytdlp-actions { display: flex; gap: 8px; align-items: center; margin: 8px 0; }
|
||||
.admin-row-disabled td { opacity: 0.45; }
|
||||
.admin-section { margin-bottom: 36px; max-width: 860px; }
|
||||
.admin-section h2 {
|
||||
|
|
@ -3384,6 +3397,12 @@ body.has-audio-bar { padding-bottom: 56px; }
|
|||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.rail-summary-transcribe-note {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--muted);
|
||||
}
|
||||
.rail-summary-image-option--disabled {
|
||||
color: var(--muted-2);
|
||||
background: var(--paper);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue