Compare commits

...

3 Commits

Author SHA1 Message Date
Garret Patti
efaff8ca1b add applied tags as context to description prompt
When generating an item description, any already-applied tags are
appended to the system prompt as a source of truth, so the model
can produce a more accurate description aligned with existing tags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 21:12:58 -04:00
Garret Patti
89ac22e9d1 show applied tags first in tag selector picker
Applied tags are now pinned to the front of each category's tag list,
with unapplied tags continuing in usage order behind them. Both
partitions preserve the existing usage-sort from the API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 20:58:12 -04:00
Garret Patti
b0d146679f scope doom scroll to current directory when no filters active
When no filters are selected, doom scroll now recursively fetches only
items under the current directory instead of the entire library root.
Navigating to a new directory invalidates the cached listing. Filter-
based doom scroll (search or tags) continues to search library-wide.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 20:51:29 -04:00
3 changed files with 53 additions and 11 deletions

View File

@@ -38,6 +38,9 @@ export default function MixedView({ libraryId, initialPath }: Props) {
const [recursiveLoaded, setRecursiveLoaded] = useState(false) const [recursiveLoaded, setRecursiveLoaded] = useState(false)
const [doomScrollActive, setDoomScrollActive] = useState(false) const [doomScrollActive, setDoomScrollActive] = useState(false)
const [doomScrollLoading, setDoomScrollLoading] = useState(false) const [doomScrollLoading, setDoomScrollLoading] = useState(false)
const [doomScrollEntries, setDoomScrollEntries] = useState<FileEntry[]>([])
const [doomScrollEntriesLoading, setDoomScrollEntriesLoading] = useState(false)
const [doomScrollEntriesLoaded, setDoomScrollEntriesLoaded] = useState(false)
const toggleTag = (tagId: string) => const toggleTag = (tagId: string) =>
setSelectedTagIds((prev) => { setSelectedTagIds((prev) => {
@@ -71,6 +74,14 @@ export default function MixedView({ libraryId, initialPath }: Props) {
loadPath(initialPath) loadPath(initialPath)
}, [loadPath, initialPath]) }, [loadPath, initialPath])
// Invalidate doom scroll entry cache when the user navigates to a different directory
useEffect(() => {
setDoomScrollEntries([])
setDoomScrollEntriesLoaded(false)
setDoomScrollEntriesLoading(false)
setDoomScrollLoading(false)
}, [currentPath])
const fetchAssignments = useCallback(() => { const fetchAssignments = useCallback(() => {
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`) fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
.then((r) => r.json()) .then((r) => r.json())
@@ -95,6 +106,21 @@ export default function MixedView({ libraryId, initialPath }: Props) {
.finally(() => setRecursiveLoading(false)) .finally(() => setRecursiveLoading(false))
}, [libraryId, recursiveLoaded, recursiveLoading]) }, [libraryId, recursiveLoaded, recursiveLoading])
const fetchDoomScrollEntries = useCallback(() => {
if (doomScrollEntriesLoaded || doomScrollEntriesLoading) return
setDoomScrollEntriesLoading(true)
fetch(
`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(currentPath)}&recursive=true`
)
.then((r) => r.json())
.then((data: DirectoryListing) => {
setDoomScrollEntries(data.entries)
setDoomScrollEntriesLoaded(true)
})
.catch(() => {})
.finally(() => setDoomScrollEntriesLoading(false))
}, [libraryId, currentPath, doomScrollEntriesLoaded, doomScrollEntriesLoading])
// Fetch the full recursive listing the first time any filter becomes active // Fetch the full recursive listing the first time any filter becomes active
useEffect(() => { useEffect(() => {
if (!filtersActive) return if (!filtersActive) return
@@ -182,25 +208,33 @@ export default function MixedView({ libraryId, initialPath }: Props) {
fetchRecursive() fetchRecursive()
return return
} }
if (recursiveLoaded) { // No filters: scope to current directory
if (doomScrollEntriesLoaded) {
setDoomScrollActive(true) setDoomScrollActive(true)
return return
} }
setDoomScrollLoading(true) setDoomScrollLoading(true)
fetchRecursive() fetchDoomScrollEntries()
} }
// Activate doom scroll once the recursive listing finishes loading (when triggered by button) // Activate doom scroll once the appropriate listing finishes loading (when triggered by button)
useEffect(() => { useEffect(() => {
if (doomScrollLoading && !recursiveLoading && recursiveLoaded) { if (!doomScrollLoading) return
const filtersDone = filtersActive && !recursiveLoading && recursiveLoaded
const noFiltersDone = !filtersActive && !doomScrollEntriesLoading && doomScrollEntriesLoaded
if (filtersDone || noFiltersDone) {
setDoomScrollLoading(false) setDoomScrollLoading(false)
setDoomScrollActive(true) setDoomScrollActive(true)
} }
}, [doomScrollLoading, recursiveLoading, recursiveLoaded]) }, [
doomScrollLoading, filtersActive,
recursiveLoading, recursiveLoaded,
doomScrollEntriesLoading, doomScrollEntriesLoaded,
])
// When filters are active, doom scroll uses filteredEntries (already filtered by search/tags). // When filters are active, doom scroll uses filteredEntries (already filtered by search/tags).
// When no filters, doom scroll uses the full recursiveEntries. // When no filters, doom scroll uses files recursively under the current directory.
const doomScrollItems: DoomScrollItem[] = (filtersActive ? filteredEntries : recursiveEntries) const doomScrollItems: DoomScrollItem[] = (filtersActive ? filteredEntries : doomScrollEntries)
.filter((e) => e.type === 'file' && (e.mediaType === 'video' || e.mediaType === 'image') && e.url && isBrowserPlayable(e.name)) .filter((e) => e.type === 'file' && (e.mediaType === 'video' || e.mediaType === 'image') && e.url && isBrowserPlayable(e.name))
.map((e) => ({ url: e.url!, name: e.name, mediaType: e.mediaType as 'video' | 'image' })) .map((e) => ({ url: e.url!, name: e.name, mediaType: e.mediaType as 'video' | 'image' }))

View File

@@ -298,9 +298,13 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Prop
{all.categories.map((category) => { {all.categories.map((category) => {
const categoryTags = all.tags.filter((t) => t.categoryId === category.id) const categoryTags = all.tags.filter((t) => t.categoryId === category.id)
const search = categorySearches[category.id] ?? '' const search = categorySearches[category.id] ?? ''
const visibleTags = categoryTags const filtered = categoryTags.filter(
.filter((t) => !search || t.name.toLowerCase().includes(search.toLowerCase())) (t) => !search || t.name.toLowerCase().includes(search.toLowerCase())
.slice(0, 25) )
const visibleTags = [
...filtered.filter((t) => isAssigned(t.id)),
...filtered.filter((t) => !isAssigned(t.id)),
].slice(0, 25)
return ( return (
<div key={category.id}> <div key={category.id}>

View File

@@ -533,7 +533,11 @@ export async function generateItemDescription(itemKey: string): Promise<string>
base64Images = [fs.readFileSync(thumbnailPath, 'base64')] base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
} }
const systemPrompt = `You are a media cataloging assistant. Describe the given image briefly and objectively in 1-3 sentences.${config.promptDescribe ? ' ' + config.promptDescribe : ''}` const { tags: currentTags } = getResolvedTagsForItem(itemKey)
const tagContext = currentTags.length > 0
? ` This content has the following tags applied describing it: ${currentTags.map((t) => t.name).join(', ')}. Use these as additional context and treat them as a source of truth, overriding any conflicting assumptions made from the image.`
: ''
const systemPrompt = `You are a media cataloging assistant. Describe the given image briefly and objectively in 1-3 sentences.${config.promptDescribe ? ' ' + config.promptDescribe : ''}${tagContext}`
const description = await callVisionApiText(config.endpoint, describeModel, base64Images, systemPrompt) const description = await callVisionApiText(config.endpoint, describeModel, base64Images, systemPrompt)