mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 13:31:09 +08:00
feat: AI 扫描 follows the AI switches, like AI 总结 already did
/api/v1/ai/capabilities now also answers `scan`: 启用 AI and 启用 AI 扫描 on and Base URL, key and model filled in. 扫描与刮削 shows the AI 扫描 tab only then; while the answer is unknown there is no tab, a ?tab=ai link falls back to 媒体库扫描, and a server without the endpoint keeps the tab as before. The answer is re-asked each time the page opens, so a switch changed in 系统设置 takes effect on the next visit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
eb54e0556b
commit
005b9be99e
5 files changed
+52
-8
No files matched your search
@@ -8,6 +8,6 @@ export function registerAiRoutes(app:FastifyInstance,ctx:AppContext){
|
||||
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)}));
|
||||
// What a reader may ask for, so the client can leave out a button that would only answer AI_DISABLED.
|
||||
app.get('/api/v1/ai/capabilities',{preHandler:auth},async()=>{const c=new BusinessSettingsReader(ctx.db).read('ai');const configured=!!(c.baseUrl&&c.apiKey&&c.model);return {summary:c.enabled&&c.summaryEnabled&&configured};});
|
||||
app.get('/api/v1/ai/capabilities',{preHandler:auth},async()=>{const c=new BusinessSettingsReader(ctx.db).read('ai');const configured=!!(c.baseUrl&&c.apiKey&&c.model);return {summary:c.enabled&&c.summaryEnabled&&configured,scan:c.enabled&&c.scanEnabled&&configured};});
|
||||
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);});
|
||||
}
|
||||
@@ -81,13 +81,17 @@ test('AI capabilities tell a reader whether 章节总结 is available, without t
|
||||
t.after(async()=>{await app.close();db.close();await rm(root,{recursive:true,force:true});});
|
||||
const admin={authorization:'Bearer '+signAccessToken(cfg,{id:'admin',role:'admin'}).token},member={authorization:'Bearer '+signAccessToken(cfg,{id:'member',role:'member'}).token};
|
||||
assert.equal((await app.inject({url:'/api/v1/ai/capabilities'})).statusCode,401);
|
||||
assert.deepEqual((await app.inject({url:'/api/v1/ai/capabilities',headers:member})).json(),{summary:false});
|
||||
assert.deepEqual((await app.inject({url:'/api/v1/ai/capabilities',headers:member})).json(),{summary:false,scan:false});
|
||||
const save=(values:object)=>app.inject({method:'PATCH',url:'/api/v1/admin/settings/ai',headers:admin,payload:{values,revision:ctx.settings!.view('ai').revision}});
|
||||
const on=await save({enabled:true,baseUrl:'https://ai.example/v1',apiKey:'secret-key',model:'m'});assert.equal(on.statusCode,200,on.body);
|
||||
assert.deepEqual((await app.inject({url:'/api/v1/ai/capabilities',headers:member})).json(),{summary:false},'AI on, 总结 still off');
|
||||
assert.deepEqual((await app.inject({url:'/api/v1/ai/capabilities',headers:member})).json(),{summary:false,scan:false},'AI on, 总结 and 扫描 still off');
|
||||
assert.equal((await save({summaryEnabled:true})).statusCode,200);
|
||||
const ready=await app.inject({url:'/api/v1/ai/capabilities',headers:member});
|
||||
assert.deepEqual(ready.json(),{summary:true});assert.ok(!ready.body.includes('secret-key'));
|
||||
assert.deepEqual(ready.json(),{summary:true,scan:false});
|
||||
assert.equal((await save({scanEnabled:true})).statusCode,200);
|
||||
assert.deepEqual((await app.inject({url:'/api/v1/ai/capabilities',headers:member})).json(),{summary:true,scan:true});
|
||||
assert.equal((await save({enabled:false})).statusCode,200);
|
||||
assert.deepEqual((await app.inject({url:'/api/v1/ai/capabilities',headers:member})).json(),{summary:false,scan:false},'the master switch turns both off');assert.ok(!ready.body.includes('secret-key'));
|
||||
});
|
||||
|
||||
test('registration stores legacy default once instead of falling back after later env changes',()=>{
|
||||
|
||||
@@ -774,7 +774,7 @@ export class ReaderApi {
|
||||
}
|
||||
async businessAiModels(values?:{baseUrl?:string;apiKey?:string}):Promise<string[]> { return (await this.call<{models:string[]}>('/api/v1/admin/ai/models','POST',values??{})).models; }
|
||||
/** What the server's AI can do for a reader; older servers answer 404. */
|
||||
async aiCapabilities(options: RequestOptions = {}): Promise<{ summary: boolean }> { return this.get('/api/v1/ai/capabilities', options); }
|
||||
async aiCapabilities(options: RequestOptions = {}): Promise<{ summary: boolean; scan?: boolean }> { return this.get('/api/v1/ai/capabilities', options); }
|
||||
async aiSummary(bookId:string,chapterId:string,content:string):Promise<{summary:string;cached:boolean}>{ return this.call('/api/v1/ai/summary','POST',{bookId,chapterId,content}); }
|
||||
|
||||
private async get<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ScanJobs } from '../components/scan-jobs.tsx';
|
||||
import { AiScanJobs } from '../components/ai-scan-jobs.tsx';
|
||||
import { ScrapeJobs } from '../components/scrape-jobs.tsx';
|
||||
import { MediaSelect } from '../components/select.tsx';
|
||||
import { ApiError } from '../../../api/errors.ts';
|
||||
import type { MediaChannel } from '../api/media-api.ts';
|
||||
|
||||
export function MediaTasksPage() {
|
||||
@@ -24,7 +25,21 @@ export function MediaTasksPage() {
|
||||
const [actionBusy, setActionBusy] = useState(false);
|
||||
const [actionError, setActionError] = useState('');
|
||||
const [actionNotice, setActionNotice] = useState('');
|
||||
const tab = params.get('tab') === 'ai' || params.get('tab') === 'scrape' ? params.get('tab')! : 'scan';
|
||||
// AI 扫描 is offered only while 启用 AI and 启用 AI 扫描 are on and AI is configured (系统设置 → AI 功能).
|
||||
// Unknown (still asking) draws no tab, like the reader's AI 总结; a server without the
|
||||
// capabilities endpoint predates the switches and keeps the tab.
|
||||
const capabilities = useQuery({
|
||||
queryKey: ['ai', 'capabilities'],
|
||||
queryFn: async ({ signal }) => {
|
||||
try { return await runtime.api.aiCapabilities({ signal }); }
|
||||
catch (error) { if (error instanceof ApiError && error.kind === 'not_found') return { summary: true, scan: true }; throw error; }
|
||||
},
|
||||
refetchOnMount: 'always',
|
||||
retry: false,
|
||||
});
|
||||
const aiScan = capabilities.data?.scan === true;
|
||||
const requestedTab = params.get('tab');
|
||||
const tab = requestedTab === 'scrape' || (requestedTab === 'ai' && aiScan) ? requestedTab : 'scan';
|
||||
const requestedLibraryId = params.get('library') ?? '';
|
||||
const stateReturn = location.state?.returnTo;
|
||||
const returnTo = typeof stateReturn === 'string' && (stateReturn.startsWith('/media/') || stateReturn === '/me')
|
||||
@@ -86,7 +101,7 @@ export function MediaTasksPage() {
|
||||
</div>;
|
||||
return <MediaPageFrame className="media-manager-workspace" title="扫描与刮削" backTo={returnTo} backLabel={returnLabel}>
|
||||
<section className="media-manager">
|
||||
<nav className="media-task-tabs" aria-label="任务类型">{[['scan','媒体库扫描'],['ai','AI 扫描'],['scrape','刮削']].map(([value,label]) => <button type="button" key={value} aria-current={tab === value ? 'page' : undefined} onClick={() => setTab(value)}>{label}</button>)}</nav>
|
||||
<nav className="media-task-tabs" aria-label="任务类型">{([['scan','媒体库扫描'],aiScan&&['ai','AI 扫描'],['scrape','刮削']].filter(Boolean) as string[][]).map(([value,label]) => <button type="button" key={value} aria-current={tab === value ? 'page' : undefined} onClick={() => setTab(value)}>{label}</button>)}</nav>
|
||||
{requestedLibraryId && !libraryId && !libraries.isPending && !libraries.error && <Callout tone="warning">所选媒体库不存在,已切换为全部媒体库。</Callout>}
|
||||
<FloatingNotice message={actionError} error />
|
||||
<FloatingNotice message={actionNotice} kind="success" />
|
||||
|
||||
@@ -16,7 +16,8 @@ vi.mock('../src/features/media/queries/media.queries.ts', () => ({
|
||||
|
||||
const root = document.createElement('div');
|
||||
document.body.append(root);
|
||||
const runtime = { mediaApi: { preferenceScope: () => 'media-task-route-test' } } as any;
|
||||
let capabilities = { summary: false, scan: true };
|
||||
const runtime = { mediaApi: { preferenceScope: () => 'media-task-route-test' }, api: { aiCapabilities: async () => capabilities } } as any;
|
||||
|
||||
function mount(initialEntry: { pathname: string; state?: unknown }) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
@@ -36,6 +37,12 @@ function mount(initialEntry: { pathname: string; state?: unknown }) {
|
||||
));
|
||||
}
|
||||
|
||||
/** Lets the capabilities query resolve; fails after a second if `done` never holds. */
|
||||
async function settle(done: () => boolean) {
|
||||
for (let i = 0; i < 50 && !done(); i++) await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
||||
expect(done()).toBe(true);
|
||||
}
|
||||
|
||||
function backLink() {
|
||||
return root.querySelector<HTMLAnchorElement>('.media-back-button')!;
|
||||
}
|
||||
@@ -49,6 +56,7 @@ describe('media task page return route', () => {
|
||||
it('uses the source route and keeps it when switching task tabs', async () => {
|
||||
useAuthStore.setState({ verifiedUser: { role: 'admin' } as any });
|
||||
mount({ pathname: '/media/music/settings/tasks', state: { returnTo: '/media/music/settings/libraries' } });
|
||||
await settle(() => root.querySelectorAll('.media-task-tabs button').length === 3);
|
||||
expect(backLink().getAttribute('href')).toBe('/media/music/settings/libraries');
|
||||
expect(backLink().getAttribute('title')).toBe('返回媒体库管理');
|
||||
|
||||
@@ -59,6 +67,23 @@ describe('media task page return route', () => {
|
||||
expect(backLink().getAttribute('title')).toBe('返回媒体库管理');
|
||||
});
|
||||
|
||||
it('offers AI 扫描 only while the AI switches allow it', async () => {
|
||||
useAuthStore.setState({ verifiedUser: { role: 'admin' } as any });
|
||||
const tabs = () => [...root.querySelectorAll('.media-task-tabs button')].map(button => button.textContent);
|
||||
capabilities = { summary: true, scan: false };
|
||||
mount({ pathname: '/media/video/settings/tasks', search: '?tab=ai' } as any);
|
||||
await settle(() => root.querySelectorAll('.media-task-tabs button').length > 0);
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
||||
expect(tabs()).toEqual(['媒体库扫描', '刮削']);
|
||||
// A link straight to the AI tab lands on 媒体库扫描 instead of an empty panel.
|
||||
expect(root.querySelector('.media-task-tabs [aria-current=page]')?.textContent).toBe('媒体库扫描');
|
||||
act(() => render(null, root));
|
||||
capabilities = { summary: false, scan: true };
|
||||
mount({ pathname: '/media/video/settings/tasks' });
|
||||
await settle(() => tabs().length === 3);
|
||||
expect(tabs()).toEqual(['媒体库扫描', 'AI 扫描', '刮削']);
|
||||
});
|
||||
|
||||
it('falls back to 我的 for a direct deep link', () => {
|
||||
useAuthStore.setState({ verifiedUser: { role: 'admin' } as any });
|
||||
mount({ pathname: '/media/video/settings/tasks' });
|
||||
|
||||
Reference in new issue
Block a user