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:
hectorqinandClaude Opus 5.5 committed 2026-10-31 11:55:19 +00:00
1 parent eb54e0556b
commit 005b9be99e
5 files changed
+52 -8

No files matched your search

+1 -1
View File
@@ -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);});
}
+7 -3
View File
@@ -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',()=>{
+1 -1
View File
@@ -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" />
+26 -1
View File
@@ -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' });