Add OpenAI series evaluation

This commit is contained in:
korjik 2026-04-21 19:38:34 -07:00
parent 5b2a788cfc
commit 77206d90cb
11 changed files with 1107 additions and 2 deletions

View file

@ -134,6 +134,13 @@ export default {
}
]
if (this.userCanUpdate && this.openAIConfigured) {
items.push({
text: 'Organize Story Order With AI',
action: 'organize-story-order-with-ai'
})
}
if (this.userIsAdminOrUp || this.selectedSeries.rssFeed) {
items.push({
text: this.$strings.LabelOpenRSSFeed,
@ -221,6 +228,9 @@ export default {
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
openAIConfigured() {
return !!this.$store.getters['getServerSetting']('openAIConfigured')
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
@ -427,6 +437,12 @@ export default {
seriesContextMenuAction({ action }) {
if (action === 'open-rss-feed') {
this.showOpenSeriesRSSFeed()
} else if (action === 'organize-story-order-with-ai') {
if (this.processingSeries) {
console.warn('Already processing series')
return
}
this.organizeStoryOrderWithAI()
} else if (action === 're-add-to-continue-listening') {
if (this.processingSeries) {
console.warn('Already processing series')
@ -453,6 +469,35 @@ export default {
feed: this.selectedSeries.rssFeed
})
},
organizeStoryOrderWithAI() {
const payload = {
message: `Organize "${this.selectedSeries.name}" into AI-detected story order? This will update the series sequence on every book in the series.`,
callback: (confirmed) => {
if (!confirmed) return
this.processingSeries = true
this.$axios
.$post(`/api/series/${this.seriesId}/organize-story-order`)
.then((data) => {
if (!data.updated) {
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
} else {
this.$toast.success(`Updated story order for ${data.updated} books`)
}
this.$eventBus.$emit('series-books-updated', { seriesId: this.seriesId })
})
.catch((error) => {
console.error('Failed to organize story order with AI', error)
this.$toast.error(error.response?.data || this.$strings.ToastFailedToUpdate)
})
.finally(() => {
this.processingSeries = false
})
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
reAddSeriesToContinueListening() {
this.processingSeries = true
this.$axios

View file

@ -602,6 +602,11 @@ export default {
this.libraryItemUpdated(ab)
})
},
seriesBooksUpdated(payload) {
if (this.entityName !== 'series-books') return
if (payload?.seriesId !== this.seriesId) return
this.resetEntities(this.currScrollTop)
},
collectionAdded(collection) {
if (this.entityName !== 'collections') return
console.log(`[LazyBookshelf] collectionAdded ${collection.id}`, collection)
@ -791,6 +796,7 @@ export default {
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
this.$eventBus.$on('user-settings', this.settingsUpdated)
this.$eventBus.$on('series-books-updated', this.seriesBooksUpdated)
if (this.$root.socket) {
this.$root.socket.on('item_updated', this.libraryItemUpdated)
@ -822,6 +828,7 @@ export default {
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
this.$eventBus.$off('user-settings', this.settingsUpdated)
this.$eventBus.$off('series-books-updated', this.seriesBooksUpdated)
if (this.$root.socket) {
this.$root.socket.off('item_updated', this.libraryItemUpdated)

View file

@ -1,5 +1,20 @@
<template>
<div class="w-full h-full px-1 md:px-2 py-1 mb-4">
<div v-if="isBookLibrary" class="w-full border border-black-200 p-4 my-8">
<div class="flex flex-wrap items-center">
<div>
<p class="text-lg">Detect Missing Series With AI</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">Analyze books in this library and add missing series names and sequence values using OpenAI. Use the full re-evaluation option after editing book metadata and you want existing series assignments reconsidered.</p>
</div>
<div class="grow" />
<div>
<ui-btn class="mb-3 block" :disabled="processing || !openAIConfigured" @click.stop="detectSeriesWithAI">Detect Missing Series</ui-btn>
<ui-btn :disabled="processing || !openAIConfigured" @click.stop="reEvaluateSeriesWithAI">Re-evaluate All Series</ui-btn>
</div>
</div>
<p v-if="!openAIConfigured" class="text-sm text-yellow-400 mt-3">Configure OpenAI first in server settings.</p>
</div>
<div class="w-full border border-black-200 p-4 my-8">
<div class="flex flex-wrap items-center">
<div>
@ -38,9 +53,55 @@ export default {
},
isBookLibrary() {
return this.mediaType === 'book'
},
openAIConfigured() {
return !!this.$store.getters['getServerSetting']('openAIConfigured')
}
},
methods: {
detectSeriesWithAI() {
const payload = {
message: 'Detect missing series in this library with AI? This only fills books that currently have no series metadata.',
callback: (confirmed) => {
if (confirmed) {
this.runSeriesDetection(true)
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
reEvaluateSeriesWithAI() {
const payload = {
message: 'Re-evaluate all books in this library with AI? This can update sequence values for books that already have series metadata.',
callback: (confirmed) => {
if (confirmed) {
this.runSeriesDetection(false)
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
runSeriesDetection(onlyMissing = true) {
this.$emit('update:processing', true)
this.$axios
.$post(`/api/libraries/${this.libraryId}/detect-series-with-ai?onlyMissing=${onlyMissing ? 1 : 0}`)
.then((data) => {
if (!data.updated) {
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
} else {
this.$toast.success(onlyMissing ? `AI added series data to ${data.updated} books` : `AI re-evaluated series data for ${data.updated} books`)
}
})
.catch((error) => {
console.error('Failed to detect series with AI', error)
this.$toast.error(error.response?.data || this.$strings.ToastFailedToUpdate)
})
.finally(() => {
this.$emit('update:processing', false)
})
},
removeAllMetadataClick(ext) {
const payload = {
message: this.$getString('MessageConfirmRemoveMetadataFiles', [ext]),

View file

@ -151,6 +151,38 @@
<div class="py-2">
<ui-multi-select v-model="newServerSettings.allowedOrigins" :items="newServerSettings.allowedOrigins" :label="$strings.LabelCorsAllowed" class="max-w-72" @input="updateCorsOrigins" />
</div>
<div class="pt-4">
<h2 class="font-semibold">AI</h2>
</div>
<div class="border border-white/10 rounded-lg p-4 mt-2">
<div class="flex items-center">
<p class="text-base font-semibold">OpenAI Series Tools</p>
<ui-tooltip text="Environment overrides supported: OPENAI_API_KEY, OPENAI_MODEL, OPENAI_BASE_URL.">
<span class="material-symbols icon-text ml-2 cursor-help">info</span>
</ui-tooltip>
</div>
<p class="text-sm text-white/70 mt-2">Use OpenAI to detect missing series in a book library and organize books inside a series into story order.</p>
<p class="text-sm text-white/70 mt-2">Status: {{ openAIStatusLabel }}</p>
<div class="mt-4">
<ui-text-input-with-label v-model="openAISettings.openAIModel" :disabled="savingOpenAISettings" label="Model" />
</div>
<div class="mt-2">
<ui-text-input-with-label v-model="openAISettings.openAIBaseURL" :disabled="savingOpenAISettings" label="Base URL" />
</div>
<div class="mt-2">
<ui-text-input-with-label v-model="openAISettings.openAIApiKey" type="password" :disabled="savingOpenAISettings" :placeholder="openAIConfigured ? 'Leave blank to keep current key' : 'sk-...'" label="API Key" />
</div>
<div class="flex items-center pt-4">
<ui-btn color="bg-success" small :loading="savingOpenAISettings" @click="saveOpenAISettings">Save AI Settings</ui-btn>
<ui-btn v-if="serverSettings && serverSettings.openAIConfigurationSource === 'settings'" color="bg-bg" small class="ml-2" :loading="savingOpenAISettings" @click="clearOpenAISettingsKey">Clear Saved Key</ui-btn>
</div>
</div>
</div>
</div>
</app-settings-content>
@ -232,7 +264,13 @@ export default {
hasPrefixesChanged: false,
newServerSettings: {},
showConfirmPurgeCache: false,
savingPrefixes: false
savingPrefixes: false,
savingOpenAISettings: false,
openAISettings: {
openAIModel: 'gpt-5.4-mini',
openAIBaseURL: 'https://api.openai.com/v1',
openAIApiKey: ''
}
}
},
watch: {
@ -256,6 +294,14 @@ export default {
timeFormats() {
return this.$store.state.globals.timeFormats
},
openAIConfigured() {
return !!this.serverSettings?.openAIConfigured
},
openAIStatusLabel() {
if (!this.serverSettings?.openAIConfigured) return 'Not configured'
if (this.serverSettings.openAIConfigurationSource === 'environment') return 'Configured from environment variables'
return 'Configured in server settings'
},
dateExample() {
const date = new Date(2014, 2, 25)
return this.$formatJsDate(date, this.newServerSettings.dateFormat)
@ -362,11 +408,68 @@ export default {
}
})
},
saveOpenAISettings() {
const openAIModel = this.openAISettings.openAIModel.trim()
const openAIBaseURL = this.openAISettings.openAIBaseURL.trim().replace(/\/+$/, '')
const payload = {
openAIModel,
openAIBaseURL
}
if (!openAIModel) {
this.$toast.error('OpenAI model is required')
return
}
if (!openAIBaseURL) {
this.$toast.error('OpenAI base URL is required')
return
}
try {
new URL(openAIBaseURL)
} catch {
this.$toast.error('OpenAI base URL must be a valid URL')
return
}
const openAIApiKey = this.openAISettings.openAIApiKey.trim()
if (openAIApiKey) {
payload.openAIApiKey = openAIApiKey
}
this.savingOpenAISettings = true
this.$store.dispatch('updateServerSettings', payload).then((response) => {
if (response.error) {
this.$toast.error(response.error)
} else {
this.$toast.success(this.$strings.ToastServerSettingsUpdateSuccess)
this.initServerSettings()
}
this.savingOpenAISettings = false
})
},
clearOpenAISettingsKey() {
this.savingOpenAISettings = true
this.$store.dispatch('updateServerSettings', { openAIApiKey: null }).then((response) => {
if (response.error) {
this.$toast.error(response.error)
} else {
this.$toast.success(this.$strings.ToastServerSettingsUpdateSuccess)
this.initServerSettings()
}
this.savingOpenAISettings = false
})
},
initServerSettings() {
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
this.newServerSettings.sortingPrefixes = [...(this.newServerSettings.sortingPrefixes || [])]
this.newServerSettings.allowedOrigins = [...(this.newServerSettings.allowedOrigins || [])]
this.scannerEnableWatcher = !this.newServerSettings.scannerDisableWatcher
this.openAISettings = {
openAIModel: this.serverSettings?.openAIModel || 'gpt-5.4-mini',
openAIBaseURL: this.serverSettings?.openAIBaseURL || 'https://api.openai.com/v1',
openAIApiKey: ''
}
this.homepageUseBookshelfView = this.newServerSettings.homeBookshelfView != this.$constants.BookshelfView.DETAIL
this.useBookshelfView = this.newServerSettings.bookshelfView != this.$constants.BookshelfView.DETAIL