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..5eba75f --- /dev/null +++ b/server/src/http/routes/ai.ts @@ -0,0 +1,11 @@ +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.post<{Body:{baseUrl?:string;apiKey?:string}}>('/api/v1/admin/ai/models',{preHandler:admin,schema:{body:{type:'object',additionalProperties:false,properties:{baseUrl:{type:'string',maxLength:4096},apiKey:{type:'string',maxLength:4096}}}}},async req=>({models:await service.models(req.body?.baseUrl,req.body?.apiKey)})); + 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);}); +} \ No newline at end of file diff --git a/server/src/http/routes/media.ts b/server/src/http/routes/media.ts index 43e7991..8a53c24 100644 --- a/server/src/http/routes/media.ts +++ b/server/src/http/routes/media.ts @@ -31,6 +31,7 @@ import type { MediaDatabase } from '../../media/read-database.ts'; import type { Db } from '../../db/index.ts'; import {BusinessSettingsReader} from '../../services/business-settings.ts'; import {configuredProviders} from '../../media/configured-providers.ts'; +import {AiService} from '../../services/ai.ts'; export type MediaRouteContext=Pick & {db:MediaDatabase & Pick;users:Pick}; @@ -39,6 +40,7 @@ export function registerMediaRoutes(app:FastifyInstance,ctx:MediaRouteContext,op const accounts=new DatabaseMediaAccounts(ctx.db); const references=database!==ctx.db?new MediaAccountReferences(database,accounts):undefined; const settings=new BusinessSettingsReader(ctx.db); + const ai=new AiService(database,()=>settings.read('ai')); const libraries=new MediaLibraries(database,true,accounts,references),scanner=new MediaScanner(database,libraries,undefined,app.log,()=>settings.read('scanning')); const playback=new MediaPlayback(database,libraries,accounts,()=>settings.read('playback').mode); const background=new MediaBackgroundGrants(database,libraries,accounts); @@ -71,6 +73,14 @@ export function registerMediaRoutes(app:FastifyInstance,ctx:MediaRouteContext,op return result.result; }; app.get<{Params:{id:string};Querystring:{path?:string;offset?:number;limit?:number}}>('/api/v1/media/libraries/:id/folders',{preHandler:auth,schema:{querystring:{type:'object',properties:{path:{type:'string',maxLength:4000},offset:{type:'integer',minimum:0,default:0},limit:{type:'integer',minimum:1,maximum:200,default:60}}}}},async (request,reply)=>queryCatalog(request,reply,{method:'folders',args:[currentUser(request),request.params.id,request.query.path,request.query.offset,request.query.limit]})); + app.post<{Params:{id:string};Body:{path?:string}}>('/api/v1/media/libraries/:id/folders/ai-scan',{preHandler:[auth,async request=>requireAdmin(request)],schema:{body:{type:'object',additionalProperties:false,properties:{path:{type:'string',maxLength:4000}}}}},async request=>{ + libraries.get(currentUser(request),request.params.id); + const path=(request.body?.path??'').replace(/^\/+|\/+$/g,''); + if(path.split('/').includes('..')||path.includes('\\'))throw badRequest('目录路径无效'); + const prefix=path?path+'/':''; + const rows=database.all<{ref:string}>('SELECT ref FROM media_assets WHERE library_id=? AND available=1 ORDER BY ref',request.params.id); + return ai.scanBatches(request.params.id,rows.map(row=>row.ref).filter(ref=>ref.startsWith(prefix))); + }); app.get<{Params:{id:string};Querystring:{path:string}}>('/api/v1/media/libraries/:id/missing-resources',{preHandler:[auth,async request=>requireAdmin(request)],schema:{querystring:{type:'object',additionalProperties:false,required:['path'],properties:{path:{type:'string',maxLength:4000}}}}},async(request,reply)=>reply.header('cache-control','no-store').send(cleanup.preview(currentUser(request),request.params.id,request.query.path))); app.post<{Params:{id:string};Body:{path:string;revision:string}}>('/api/v1/media/libraries/:id/missing-resources/cleanup',{preHandler:[auth,async request=>requireAdmin(request)],schema:{body:{type:'object',additionalProperties:false,required:['path','revision'],properties:{path:{type:'string',maxLength:4000},revision:{type:'string',pattern:'^[a-f0-9]{64}$'}}}}},async(request,reply)=>reply.header('cache-control','no-store').send(cleanup.remove(currentUser(request),request.params.id,request.body.path,request.body.revision))); app.get<{Params:{id:string};Querystring:{path:string}}>('/api/v1/media/libraries/:id/recognition-rule',{preHandler:[auth,async request=>requireAdmin(request)],schema:{querystring:{type:'object',additionalProperties:false,required:['path'],properties:{path:{type:'string',maxLength:4000}}}}},async(request,reply)=>reply.header('cache-control','no-store').send(directoryRules.get(currentUser(request),libraries,request.params.id,request.query.path))); diff --git a/server/src/services/ai.ts b/server/src/services/ai.ts new file mode 100644 index 0000000..d9f5788 --- /dev/null +++ b/server/src/services/ai.ts @@ -0,0 +1,35 @@ +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:Pick, 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 async fetch(url:string,init:RequestInit):Promise{ + let parsed:URL;try{parsed=new URL(url);}catch{throw badRequest('请输入有效的 AI 服务地址');} + if(!['http:','https:'].includes(parsed.protocol)||parsed.username||parsed.password)throw badRequest('请输入有效的 HTTP 或 HTTPS 地址'); + try{return await fetch(url,{...init,redirect:'error'});}catch{throw new AppError(502,'AI_CONNECTION_FAILED','AI 连接失败或超时,请检查服务地址及网络');} + } + async scanBatches(libraryId:string,paths:string[]){ + const c=this.settings('scanEnabled'),groups=new Map(); + for(const path of paths){const index=path.lastIndexOf('/'),dir=index<0?'':path.slice(0,index);const group=groups.get(dir)??[];group.push(path);groups.set(dir,group);} + 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)batches.push(batch); + const items:unknown[]=[];for(const part of batches)items.push(...(await this.scan(libraryId,part)).items); + return {items,total:paths.length,batches:batches.length}; + } + 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 this.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(baseUrl?:string,apiKey?:string){const c=this.config(),url=(baseUrl||c.baseUrl).trim(),key=apiKey||c.apiKey; if(!url||!key) throw badRequest('请先填写 Base URL 和 API Key'); const response=await this.fetch(`${url.replace(/\/$/,'')}/models`,{headers:{authorization:`Bearer ${key}`},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)=>x!==null&&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..0648e1d 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}; @@ -13,8 +14,20 @@ export interface BusinessValues { webdav:{url:string;username:string;password:string;maxBytes:number}; } 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}>}; +type Field={key:string;label:string;type:'text'|'textarea'|'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:'textarea',default:'请根据以下媒体路径识别类型。必须返回 JSON 数组,每项包含 path、category(movie/series/music/audiobook/other)、title、year、season、artist、album。不要添加解释。'}, + {key:'summaryPrompt',label:'AI 总结 Prompt',type:'textarea',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}, @@ -96,8 +109,9 @@ export class BusinessSettings extends BusinessSettingsReader { if(!field)throw badRequest('未知配置项'); if(field.type==='checkbox'){if(typeof value!=='boolean')throw badRequest(`${field.label}格式无效`);} 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}格式无效`); + else if(typeof value!=='string'||value.length>4096||(field.type==='textarea'?/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/:/[\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..fba213c 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(values?:{baseUrl?:string;apiKey?:string}):Promise { return (await this.call<{models:string[]}>('/api/v1/admin/ai/models','POST',values??{})).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):Promise<{total:number;items:unknown[];batches:number}>{ return this.call('/api/v1/media/libraries/'+encodeURIComponent(libraryId)+'/folders/ai-scan','POST',{path:''}); } 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..a46bc8a 100644 --- a/web/src/media/api.ts +++ b/web/src/media/api.ts @@ -22,6 +22,9 @@ 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){return this.reader.aiScan(libraryId);} + businessAiModels(values?:{baseUrl?:string;apiKey?:string}){return this.reader.businessAiModels(values);} + aiFolderScan(libraryId:string,path:string){return this.request<{total:number;items:unknown[];batches:number}>(`libraries/${encodeURIComponent(libraryId)}/folders/ai-scan`,'POST',{path});} 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/folders.tsx b/web/src/media/folders.tsx index 4eb6de4..9a44f2f 100644 --- a/web/src/media/folders.tsx +++ b/web/src/media/folders.tsx @@ -44,6 +44,7 @@ export function MediaFolders({api,admin=false,video=false,libraryId,initialLocat {(path||assetId)&&} {error&&setRetry(value=>value+1)}/>}{actionError&&
{actionError}
}{notice&&

{notice}

} {loading&&} + {listing&&admin&&} {cleanupNotice&&

{cleanupNotice}

} {listing&&<>{admin&&video&&{setCleanupNotice('已应用识别结果,原文件与播放进度保留。');setRetry(value=>value+1);}}/>}{admin&&{setCleanupNotice('已清理 '+result.assets+' 个失效资源记录'+(result.returnPath!==path?',已返回上级有效目录。':'。'));setPath(result.returnPath);setOffset(0);setRetry(value=>value+1);}}/>}{listing.items.map(entry=>
)}{!listing.total&&

暂无已扫描媒体文件,请先扫描媒体库。

}{listing.total>60&&}

按最近扫描结果显示媒体文件;不包含空目录及其他文件。原始目录只读。

} {file&&<>
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..b6a41eb 100644 --- a/web/src/ui/business-settings.tsx +++ b/web/src/ui/business-settings.tsx @@ -6,15 +6,17 @@ 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?(values?:{baseUrl?:string;apiKey?:string}):Promise; } interface SettingsView { group:string;label:string;revision:number;values:Record;secrets:Record; - fields:Array<{key:string;label:string;type:'text'|'password'|'number'|'checkbox'|'url'|'select';min?:number;max?:number;help?:string;options?:Array<{value:string;label:string}>}>; + fields:Array<{key:string;label:string;type:'text'|'textarea'|'password'|'number'|'checkbox'|'url'|'select';min?:number;max?:number;help?:string;options?:Array<{value:string;label:string}>}>; } export function BusinessSettings({api,group,onSaved}:{api:SettingsTransport;group?:string;onSaved?:()=>void}){ 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
@@ -38,18 +40,22 @@ export function BusinessSettings({api,group,onSaved}:{api:SettingsTransport;grou });}}> {current.fields.map(field=>