From db79890730268aef76b46ad4d2f229a33ba730ae Mon Sep 17 00:00:00 2001 From: hectorqin Date: Tue, 29 Sep 2026 11:31:34 +0800 Subject: [PATCH] feat(ai): add configurable scanning and chapter summaries --- server/src/http/app.ts | 2 ++ server/src/http/routes/ai.ts | 12 ++++++++++++ server/src/services/ai.ts | 21 +++++++++++++++++++++ server/src/services/business-settings.ts | 16 ++++++++++++++++ web/src/api/client.ts | 3 +++ web/src/media/api.ts | 1 + web/src/media/screen.tsx | 2 +- web/src/ui/business-settings.tsx | 9 +++++++-- web/src/ui/reader-chrome.tsx | 6 ++++++ web/src/ui/reader-screen.tsx | 16 ++++++++++++++++ 10 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 server/src/http/routes/ai.ts create mode 100644 server/src/services/ai.ts diff --git a/server/src/http/app.ts b/server/src/http/app.ts index 1fc5184..dee9866 100644 --- a/server/src/http/app.ts +++ b/server/src/http/app.ts @@ -17,6 +17,7 @@ import { isOriginAllowed, resolveCorsOrigin } from './cors.ts'; import {BusinessSettings} from '../services/business-settings.ts'; import {TtsService} from '../services/tts.ts'; import {registerBusinessSettingsRoutes} from './routes/business-settings.ts'; +import {registerAiRoutes} from './routes/ai.ts'; export function buildApp(ctx: AppContext,mediaRuntime?:MediaService): FastifyInstance { ctx.settings??=new BusinessSettings(ctx.db,ctx.config); @@ -70,6 +71,7 @@ export function buildApp(ctx: AppContext,mediaRuntime?:MediaService): FastifyIns registerAuthRoutes(app, ctx); registerBusinessSettingsRoutes(app,ctx); + registerAiRoutes(app,ctx); registerLibraryRoutes(app, ctx); registerSyncRoutes(app, ctx); registerTtsRoutes(app, ctx); diff --git a/server/src/http/routes/ai.ts b/server/src/http/routes/ai.ts new file mode 100644 index 0000000..d8f0851 --- /dev/null +++ b/server/src/http/routes/ai.ts @@ -0,0 +1,12 @@ +import type {FastifyInstance} from 'fastify'; +import type {AppContext} from '../context.ts'; +import {authenticate,currentUser,requireAdmin} from '../auth.ts'; +import {badRequest} from '../../lib/errors.ts'; +import {AiService} from '../../services/ai.ts'; +import {BusinessSettingsReader} from '../../services/business-settings.ts'; +export function registerAiRoutes(app:FastifyInstance,ctx:AppContext){ + const service=new AiService(ctx.db,()=>new BusinessSettingsReader(ctx.db).read('ai')); const auth=authenticate(ctx), admin=async(req:any,reply:any)=>{await auth(req,reply);requireAdmin(req);}; + app.get('/api/v1/admin/ai/models',{preHandler:admin},async()=>({models:await service.models()})); + app.post<{Body:{libraryId:string;paths?:string[]}}>('/api/v1/admin/ai/scan',{preHandler:admin},async req=>{const body=req.body;if(!body?.libraryId)throw badRequest('缺少媒体库'); let paths=body.paths;if(!paths){paths=ctx.db.all<{ref:string}>('SELECT ref FROM media_assets WHERE library_id=? AND available=1 ORDER BY ref',body.libraryId).map(x=>x.ref);}if(paths.length>50000)throw badRequest('路径数量过多');const c=new BusinessSettingsReader(ctx.db).read('ai');const groups=new Map();for(const p of paths){const dir=p.replace(/[\\/][^\\/]*$/,'');const list=groups.get(dir)||[];list.push(p);groups.set(dir,list);}const batches:string[][]=[];let batch:string[]=[];for(const group of groups.values()){if(batch.length&&batch.length+group.length>c.batchSize){batches.push(batch);batch=[];}batch.push(...group);if(batch.length>=c.batchSize){batches.push(batch);batch=[];}}if(batch.length)batches.push(batch);const items=[];for(const part of batches){const result=await service.scan(body.libraryId,part);items.push(...result.items);}return {items,total:paths.length,batches:batches.length};}); + app.post<{Body:{bookId:string;chapterId:string;content:string}}>('/api/v1/ai/summary',{preHandler:auth},async req=>{const body=req.body;if(!body||typeof body.bookId!=='string'||typeof body.chapterId!=='string'||typeof body.content!=='string')throw badRequest('缺少章节内容'); return service.summary(currentUser(req).id,body.bookId,body.chapterId,body.content);}); +} diff --git a/server/src/services/ai.ts b/server/src/services/ai.ts new file mode 100644 index 0000000..3331ea0 --- /dev/null +++ b/server/src/services/ai.ts @@ -0,0 +1,21 @@ +import {createHash, randomUUID} from 'node:crypto'; +import type {Db} from '../db/index.ts'; +import type {BusinessValues} from './business-settings.ts'; +import {AppError, badRequest} from '../lib/errors.ts'; + +export type AiConfig=BusinessValues['ai']; +export class AiService { + constructor(private readonly db:Db, private readonly config:()=>AiConfig){ + db.run(`CREATE TABLE IF NOT EXISTS ai_scan_results(id TEXT PRIMARY KEY,library_id TEXT NOT NULL,path TEXT NOT NULL,category TEXT NOT NULL,payload_json TEXT NOT NULL,raw TEXT NOT NULL,created_at INTEGER NOT NULL,UNIQUE(library_id,path))`); + db.run(`CREATE TABLE IF NOT EXISTS ai_summaries(user_id TEXT NOT NULL,book_id TEXT NOT NULL,chapter_id TEXT NOT NULL,content_hash TEXT NOT NULL,prompt_hash TEXT NOT NULL,summary TEXT NOT NULL,created_at INTEGER NOT NULL,PRIMARY KEY(user_id,book_id,chapter_id,content_hash,prompt_hash))`); + } + private settings(required:'scanEnabled'|'summaryEnabled') { const c=this.config(); if(!c.enabled||!c[required]) throw new AppError(403,'AI_DISABLED','AI 功能未启用'); if(!c.baseUrl||!c.apiKey||!c.model) throw badRequest('AI 尚未完成配置'); return c; } + private async chat(prompt:string,system:string,c:AiConfig):Promise{ + const base=c.baseUrl.replace(/\/$/,''); const response=await fetch(`${base}/chat/completions`,{method:'POST',headers:{'content-type':'application/json',authorization:`Bearer ${c.apiKey}`},body:JSON.stringify({model:c.model,temperature:0.2,messages:[{role:'system',content:system},{role:'user',content:prompt}]}) ,signal:AbortSignal.timeout(120000)}); + if(!response.ok) throw new AppError(502,'AI_REQUEST_FAILED',`AI 请求失败(HTTP ${response.status})`); + const body=await response.json() as {choices?:Array<{message?:{content?:string}}>}; const text=body.choices?.[0]?.message?.content; if(typeof text!=='string'||!text.trim()) throw new AppError(502,'AI_EMPTY_RESPONSE','AI 未返回有效内容'); return text.trim(); + } + async models(){const c=this.config(); if(!c.baseUrl||!c.apiKey) throw badRequest('请先填写 Base URL 和 API Key'); const response=await fetch(`${c.baseUrl.replace(/\/$/,'')}/models`,{headers:{authorization:`Bearer ${c.apiKey}`},signal:AbortSignal.timeout(20000)}); if(!response.ok) throw new AppError(502,'AI_MODELS_FAILED',`获取模型失败(HTTP ${response.status})`); const body=await response.json() as {data?:Array<{id?:string}>}; return (body.data||[]).map(x=>x.id).filter((x):x is string=>!!x);} + async scan(libraryId:string,paths:string[]){const c=this.settings('scanEnabled'); if(!paths.length)return {items:[]}; const prompt=`${c.scanPrompt}\n\n媒体路径列表:\n${paths.map(p=>JSON.stringify(p)).join('\n')}`; const raw=await this.chat(prompt,'你是媒体库分类器,只输出合法 JSON 数组。',c); let parsed:unknown; try{parsed=JSON.parse(raw.replace(/^```json\s*|\s*```$/g,''));}catch{throw new AppError(502,'AI_INVALID_JSON','AI 返回的扫描结果不是合法 JSON');} if(!Array.isArray(parsed))throw new AppError(502,'AI_INVALID_JSON','AI 扫描结果必须是数组'); const allowed=new Set(['movie','series','music','audiobook','other']); const items=parsed.flatMap((x:any)=>typeof x==='object'&&typeof x.path==='string'?[{...x,category:allowed.has(x.category)?x.category:'other'}]:[]); this.db.transaction(()=>{for(const item of items)this.db.run('INSERT INTO ai_scan_results(id,library_id,path,category,payload_json,raw,created_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(library_id,path) DO UPDATE SET category=excluded.category,payload_json=excluded.payload_json,raw=excluded.raw,created_at=excluded.created_at',randomUUID(),libraryId,item.path,item.category,JSON.stringify(item),raw,Date.now());}); return {items,raw};} + async summary(userId:string,bookId:string,chapterId:string,content:string){const c=this.settings('summaryEnabled'); const limited=content.slice(0,c.maxInputChars); const contentHash=createHash('sha256').update(limited).digest('hex'),promptHash=createHash('sha256').update(c.summaryPrompt).digest('hex'); const hit=this.db.get<{summary:string}>('SELECT summary FROM ai_summaries WHERE user_id=? AND book_id=? AND chapter_id=? AND content_hash=? AND prompt_hash=?',userId,bookId,chapterId,contentHash,promptHash); if(hit)return {summary:hit.summary,cached:true}; const summary=await this.chat(`${c.summaryPrompt}\n\n章节内容:\n${limited}`,'你是专业阅读助手。',c); this.db.run('INSERT INTO ai_summaries(user_id,book_id,chapter_id,content_hash,prompt_hash,summary,created_at) VALUES(?,?,?,?,?,?,?)',userId,bookId,chapterId,contentHash,promptHash,summary,Date.now()); return {summary,cached:false};} +} diff --git a/server/src/services/business-settings.ts b/server/src/services/business-settings.ts index c858add..625db3c 100644 --- a/server/src/services/business-settings.ts +++ b/server/src/services/business-settings.ts @@ -3,6 +3,7 @@ import type { AppConfig } from '../config/index.ts'; import { badRequest, conflict } from '../lib/errors.ts'; export interface BusinessValues { + ai:{enabled:boolean;scanEnabled:boolean;summaryEnabled:boolean;baseUrl:string;apiKey:string;model:string;scanPrompt:string;summaryPrompt:string;batchSize:number;maxInputChars:number}; playback:{mode:'auto'|'direct'|'proxy'}; tmdb:{enabled:boolean;token:string;apiKey:string;language:string}; musicbrainz:{enabled:boolean;userAgent:string}; @@ -15,6 +16,18 @@ export interface BusinessValues { export type SettingsGroup=keyof BusinessValues; type Field={key:string;label:string;type:'text'|'password'|'number'|'checkbox'|'url'|'select';default:string|number|boolean;min?:number;max?:number;help?:string;options?:Array<{value:string;label:string}>}; export const SETTINGS_SCHEMA:Record= { + ai:{label:'AI 功能',fields:[ + {key:'enabled',label:'启用 AI',type:'checkbox',default:false}, + {key:'scanEnabled',label:'启用 AI 扫描',type:'checkbox',default:false}, + {key:'summaryEnabled',label:'启用 AI 总结',type:'checkbox',default:false}, + {key:'baseUrl',label:'Base URL',type:'url',default:'',help:'OpenAI 兼容接口地址,例如 https://api.openai.com/v1'}, + {key:'apiKey',label:'API Key',type:'password',default:''}, + {key:'model',label:'模型',type:'text',default:'',help:'保存后可通过“获取模型”刷新候选列表'}, + {key:'scanPrompt',label:'AI 扫描 Prompt',type:'text',default:'请根据以下媒体路径识别类型。必须返回 JSON 数组,每项包含 path、category(movie/series/music/audiobook/other)、title、year、season、artist、album。不要添加解释。'}, + {key:'summaryPrompt',label:'AI 总结 Prompt',type:'text',default:'请用中文总结以下章节内容,提炼主要情节、人物和关键信息,输出简洁连贯的段落。'}, + {key:'batchSize',label:'扫描批次大小',type:'number',default:500,min:20,max:1000}, + {key:'maxInputChars',label:'总结最大输入字符数',type:'number',default:60000,min:1000,max:500000}, + ]}, playback:{label:'媒体播放',fields:[{key:'mode',label:'OpenList 播放方式',type:'select',default:'auto',options:[{value:'auto',label:'自动(直连优先,失败尝试代理)'},{value:'direct',label:'浏览器直连'},{value:'proxy',label:'服务器代理'}],help:'直连由浏览器访问临时下载地址,不经过 Reader 转发。仅服务器可访问的资源请选择代理;OpenList 自身的 Web 代理设置仍然有效。'}]}, tmdb:{label:'TMDB',fields:[ {key:'enabled',label:'启用 TMDB',type:'checkbox',default:false}, @@ -98,6 +111,7 @@ export class BusinessSettings extends BusinessSettingsReader { else if(field.type==='number'){if(typeof value!=='number'||!Number.isSafeInteger(value)||valuefield.max!)throw badRequest(`${field.label}须在 ${field.min}–${field.max} 之间`);} else if(typeof value!=='string'||value.length>4096||/[\u0000-\u001f\u007f]/.test(value))throw badRequest(`${field.label}格式无效`); if(field.type==='select'&&!field.options?.some(option=>option.value===value))throw badRequest(`${field.label}选项无效`); + if(group==='ai'&&key==='apiKey'&&field.type==='password'&&value==='') continue; values[key]=typeof value==='string'&&field.type!=='password'?value.trim():value; if(field.type==='url'&&values[key])validateUrl(String(values[key])); } @@ -107,6 +121,7 @@ export class BusinessSettings extends BusinessSettingsReader { } if(group==='musicbrainz'&&values.enabled&&!/\S+\/\S+.*(?:@|https?:\/\/)/.test(String(values.userAgent)))throw badRequest('请填写应用名称/版本及有效联系地址'); if(group==='tts'&&values.enabled&&!values.url)throw badRequest('启用 HTTP 朗读前请填写接口地址'); + if(group==='ai'&&values.enabled&&(!values.baseUrl||!values.apiKey||!values.model))throw badRequest('启用 AI 前请填写 Base URL、API Key 和模型'); if(group==='access'){ for(const origin of String(values.corsOrigins).split(',').map(value=>value.trim()).filter(Boolean))if(origin!=='null'&&validateUrl(origin).origin!==origin)throw badRequest('浏览器来源须为协议与域名(可含端口),不能包含路径'); if(Number(values.refreshTokenTtl)(group:K,config:AppConfig):Busin const integer=(name:string,fallback:number)=>{const value=Number(env[name]);return env[name]&&Number.isSafeInteger(value)&&value>=0?value:fallback;}; const intervals=[config.scanInterval,config.watchInterval].filter(value=>value>0); const legacy:Partial={ + ai:{enabled:false,scanEnabled:false,summaryEnabled:false,baseUrl:text('AI_BASE_URL'),apiKey:text('AI_API_KEY'),model:text('AI_MODEL'),scanPrompt:'请根据以下媒体路径识别类型。必须返回 JSON 数组,每项包含 path、category(movie/series/music/audiobook/other)、title、year、season、artist、album。不要添加解释。',summaryPrompt:'请用中文总结以下章节内容,提炼主要情节、人物和关键信息,输出简洁连贯的段落。',batchSize:500,maxInputChars:60000}, tmdb:{enabled:!!(text('MEDIA_TMDB_TOKEN')||text('MEDIA_TMDB_API_KEY')),token:text('MEDIA_TMDB_TOKEN'),apiKey:text('MEDIA_TMDB_API_KEY'),language:'zh-CN'}, musicbrainz:{enabled:!!text('MEDIA_MUSICBRAINZ_USER_AGENT'),userAgent:text('MEDIA_MUSICBRAINZ_USER_AGENT')}, tts:{enabled:!!text('TTS_URL'),url:text('TTS_URL'),token:text('TTS_TOKEN'),voicesUrl:text('TTS_VOICES_URL'),timeoutMs:integer('TTS_TIMEOUT_MS',20000),cacheMaxBytes:integer('TTS_CACHE_BYTES',268435456)}, diff --git a/web/src/api/client.ts b/web/src/api/client.ts index da916f5..201102d 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -715,6 +715,9 @@ export class ReaderApi { const result=await this.request('/api/v1/admin/settings/tts/preview','POST',{values,voice},{binary:true}); return new Blob([new Uint8Array(result.bytes??[]).buffer],{type:result.headers['content-type']||'audio/mpeg'}); } + async businessAiModels():Promise { return (await this.call<{models:string[]}>('/api/v1/admin/ai/models','GET')).models; } + async aiSummary(bookId:string,chapterId:string,content:string):Promise<{summary:string;cached:boolean}>{ return this.call('/api/v1/ai/summary','POST',{bookId,chapterId,content}); } + async aiScan(libraryId:string,paths?:string[]):Promise<{total:number;items:unknown[];batches:number}>{ return this.call('/api/v1/admin/ai/scan','POST',{libraryId,paths}); } private async get(path: string, options: RequestOptions = {}): Promise { const response = await this.request(path, 'GET', undefined, options); diff --git a/web/src/media/api.ts b/web/src/media/api.ts index 4190d52..b68f3d2 100644 --- a/web/src/media/api.ts +++ b/web/src/media/api.ts @@ -22,6 +22,7 @@ export class MediaApi { constructor(private readonly reader:ReaderApi) {} businessSettingsRequest(path='',method='GET',body?:unknown,signal?:AbortSignal){return this.reader.businessSettingsRequest(path,method,body,signal);} businessTtsPreview(values:Record,voice:string){return this.reader.businessTtsPreview(values,voice);} + aiScan(libraryId:string,paths?:string[]){return this.reader.aiScan(libraryId,paths);} preferenceScope(){return JSON.stringify([this.reader.baseUrl,this.reader.currentSession()?.user.id??'anonymous']);} accountInfo():MediaAccount|null {const user=this.reader.currentSession()?.user;return user?{username:user.username,displayName:user.displayName,role:user.role,server:this.reader.baseUrl}:null;} request(path:string,method='GET',body?:unknown,signal?:AbortSignal):Promise { diff --git a/web/src/media/screen.tsx b/web/src/media/screen.tsx index f426d22..60ae092 100644 --- a/web/src/media/screen.tsx +++ b/web/src/media/screen.tsx @@ -506,7 +506,7 @@ export class MediaScreen {

扫描只读取原始目录。封面、索引和刮削缓存保存在应用数据目录。

接入方式

:<> -
扫描与刮削分开执行
{this.scanNotice&&

{this.scanNotice}

} +
扫描与刮削分开执行
{this.scanNotice&&

{this.scanNotice}

} {this.scanActionError&&

{this.scanActionError}

}

扫描任务{this.jobLibraryId?' · '+this.allLibraries.find(l=>l.id===this.jobLibraryId)?.name:''}

diff --git a/web/src/ui/business-settings.tsx b/web/src/ui/business-settings.tsx index 1130b9a..fe4f55b 100644 --- a/web/src/ui/business-settings.tsx +++ b/web/src/ui/business-settings.tsx @@ -6,6 +6,7 @@ import '../styles/business-settings.css'; export interface SettingsTransport { businessSettingsRequest(path?:string,method?:string,body?:unknown,signal?:AbortSignal):Promise; businessTtsPreview(values:Record,voice:string):Promise; + businessAiModels?():Promise; } interface SettingsView { group:string;label:string;revision:number;values:Record;secrets:Record; @@ -15,6 +16,7 @@ export function BusinessSettings({api,group,onSaved}:{api:SettingsTransport;grou const [groups,setGroups]=useState([]),[selected,setSelected]=useState(group||'tts'); const [values,setValues]=useState>({}),[busy,setBusy]=useState(false),[error,setError]=useState(''),[message,setMessage]=useState(''),[retry,setRetry]=useState(0); const [voices,setVoices]=useState>([]),[voice,setVoice]=useState(''),[audio,setAudio]=useState(''); + const [models,setModels]=useState([]); const [directory,setDirectory]=useState(''),[name,setName]=useState('reader-backup.zip'),[clear,setClear]=useState(false); useEffect(()=>{ const controller=new AbortController();setBusy(true);setError(''); @@ -23,7 +25,7 @@ export function BusinessSettings({api,group,onSaved}:{api:SettingsTransport;grou },[api,retry]); useEffect(()=>{if(group)setSelected(group);},[group]); const current=groups.find(item=>item.group===selected); - useLayoutEffect(()=>{setValues(current?.values||{});setVoices([]);setVoice('');setAudio('');},[current]); + useLayoutEffect(()=>{setValues(current?.values||{});setVoices([]);setVoice('');setAudio('');setModels([]);},[current]); useEffect(()=>()=>{if(audio)URL.revokeObjectURL(audio);},[audio]); async function work(action:()=>Promise){if(busy)return;setBusy(true);setError('');setMessage('');try{await action();}catch(error){setError(error instanceof Error?error.message:'操作失败');}finally{setBusy(false);}} return
@@ -39,17 +41,20 @@ export function BusinessSettings({api,group,onSaved}:{api:SettingsTransport;grou {current.fields.map(field=>)} + {selected==='ai'&&{models.map(model=>}
+ {selected==='ai'&&api.businessAiModels&&} {['tmdb','musicbrainz','tts','webdav'].includes(selected)&&}
+ {selected==='ai'&&models.length>0&&

可用模型:{models.join('、')}

} {selected==='tts'&&
{audio&&
{state.bookInfoOpen && handlers.onBookInfo?.()}>

{state.title}

{state.author || '作者未知'}

书源
{state.sourceName || (state.canRefresh ? '远程书源' : '本地书籍')}
当前章节
{state.chapterLabel || '暂无信息'}

内容简介

{state.description || '暂无简介'}

} + {state.aiSummaryBusy &&
正在生成章节总结…
} + {state.aiSummary && handlers.onAiSummary?.()}>

{state.aiSummary}

}
diff --git a/web/src/ui/reader-screen.tsx b/web/src/ui/reader-screen.tsx index 1ca5f2c..95fe0cd 100644 --- a/web/src/ui/reader-screen.tsx +++ b/web/src/ui/reader-screen.tsx @@ -261,6 +261,7 @@ export class ReaderScreen { onTocEntry: (ref) => void this.goToChapterRef(ref), onChapter: (delta) => void this.goToChapter(delta), onBookInfo: () => this.patch({ bookInfoOpen: !this.chrome.bookInfoOpen }), + onAiSummary: () => void this.generateAiSummary(), onRefresh: () => void this.refreshPublication(), onRefreshChapter: () => void this.refreshChapter(), onAlternatives: cursor => void this.loadAlternatives(cursor), @@ -286,6 +287,21 @@ export class ReaderScreen { this.bindSyncStatus(); } + private async generateAiSummary(): Promise { + if (this.chrome.aiSummary) { this.patch({ aiSummary: '' }); return; } + if (!this.book || !this.chrome.currentSectionId || this.chrome.aiSummaryBusy) return; + const content = this.stage.textContent?.trim() || ''; + if (!content) { this.flashStatus('当前章节没有可总结的正文', 2400); return; } + this.patch({ aiSummaryBusy: true, aiSummary: '' }); + try { + const result = await this.options.api.aiSummary(this.book.id, this.chrome.currentSectionId, content); + this.patch({ aiSummary: result.summary, aiSummaryBusy: false }); + } catch (error) { + this.patch({ aiSummaryBusy: false }); + this.flashStatus(error instanceof Error ? error.message : 'AI 总结失败', 3200); + } + } + /** Opens a book: fetch bytes, parse, restore position, start reporting. */ async open(book: Book): Promise { this.closeAlternatives(); this.chapterRefresh?.abort(); this.chapterRefresh = null;