mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-12 05:50:54 +08:00
fix(ai): add structured troubleshooting logs
This commit is contained in:
1 parent
d039d1ca20
commit
df4ed11f56
4 files changed
+16
-8
No files matched your search
@@ -5,7 +5,7 @@ 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);};
|
||||
const service=new AiService(ctx.db,()=>new BusinessSettingsReader(ctx.db).read('ai'),app.log); 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);});
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export function registerMediaProxy(app:FastifyInstance,runtime:Pick<MediaRuntime
|
||||
return reply.send(stream);
|
||||
}catch(error){
|
||||
release();
|
||||
request.log.error({err:error,path:request.raw.url,method:request.method},'media proxy request failed');
|
||||
if(error instanceof AppError)throw error;
|
||||
throw new AppError(503,'MEDIA_REQUEST_UNCERTAIN','影音请求未完成;写入结果请查询后确认');
|
||||
}
|
||||
|
||||
@@ -40,7 +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 ai=new AiService(database,()=>settings.read('ai'),app.log);
|
||||
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);
|
||||
@@ -79,7 +79,9 @@ export function registerMediaRoutes(app:FastifyInstance,ctx:MediaRouteContext,op
|
||||
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)));
|
||||
const paths=rows.map(row=>row.ref).filter(ref=>ref.startsWith(prefix));
|
||||
app.log.info({libraryId:request.params.id,path,pCount:paths.length,actor:currentUser(request).id},'ai folder scan requested');
|
||||
try{return await ai.scanBatches(request.params.id,paths);}catch(error){app.log.error({err:error,libraryId:request.params.id,path,pCount:paths.length},'ai folder scan failed');throw error;}
|
||||
});
|
||||
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)));
|
||||
|
||||
@@ -2,25 +2,29 @@ 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';
|
||||
import type {FastifyBaseLogger} from 'fastify';
|
||||
|
||||
export type AiConfig=BusinessValues['ai'];
|
||||
export class AiService {
|
||||
constructor(private readonly db:Pick<Db,'run'|'get'|'transaction'>, private readonly config:()=>AiConfig){
|
||||
constructor(private readonly db:Pick<Db,'run'|'get'|'transaction'>, private readonly config:()=>AiConfig, private readonly log?:Pick<FastifyBaseLogger,'info'|'warn'|'error'>){
|
||||
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<Response>{
|
||||
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 连接失败或超时,请检查服务地址及网络');}
|
||||
const started=Date.now();this.log?.info({url:parsed.origin+parsed.pathname,method:init.method??'GET'},'ai upstream request started');
|
||||
try{const response=await fetch(url,{...init,redirect:'error'});this.log?.info({url:parsed.origin+parsed.pathname,status:response.status,elapsedMs:Date.now()-started},'ai upstream response received');return response;}catch(error){this.log?.error({err:error,url:parsed.origin+parsed.pathname,elapsedMs:Date.now()-started},'ai upstream request failed');throw new AppError(502,'AI_CONNECTION_FAILED','AI 连接失败或超时,请检查服务地址及网络');}
|
||||
}
|
||||
async scanBatches(libraryId:string,paths:string[]){
|
||||
const c=this.settings('scanEnabled'),groups=new Map<string,string[]>();
|
||||
this.log?.info({libraryId,pathCount:paths.length,batchSize:c.batchSize},'ai media scan started');
|
||||
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);
|
||||
const items:unknown[]=[];for(const [index,part] of batches.entries()){this.log?.info({libraryId,batch:index+1,totalBatches:batches.length,pathCount:part.length},'ai media scan batch started');items.push(...(await this.scan(libraryId,part)).items);this.log?.info({libraryId,batch:index+1,resultCount:items.length},'ai media scan batch completed');}
|
||||
this.log?.info({libraryId,pathCount:paths.length,batchCount:batches.length,resultCount:items.length},'ai media scan completed');
|
||||
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; }
|
||||
@@ -30,6 +34,7 @@ export class AiService {
|
||||
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 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)){this.log?.error({libraryId,responseType:typeof parsed},'ai media response shape invalid');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.log?.info({libraryId,pathCount:paths.length,resultCount:items.length},'ai media response parsed'); 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};}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user