From 005b9be99ea07635c03f05f1b61deff9e2b874cf Mon Sep 17 00:00:00 2001 From: hectorqin Date: Sat, 31 Oct 2026 11:55:19 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20AI=20=E6=89=AB=E6=8F=8F=20follows=20the?= =?UTF-8?q?=20AI=20switches,=20like=20AI=20=E6=80=BB=E7=BB=93=20already=20?= =?UTF-8?q?did?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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 --- server/src/http/routes/ai.ts | 2 +- server/test/business-settings.test.ts | 10 ++++--- web/src/api/client.ts | 2 +- .../features/media/pages/MediaTasksPage.tsx | 19 +++++++++++-- web/test/media-tasks-route.test.tsx | 27 ++++++++++++++++++- 5 files changed, 52 insertions(+), 8 deletions(-) diff --git a/server/src/http/routes/ai.ts b/server/src/http/routes/ai.ts index b0f3432..c7c4132 100644 --- a/server/src/http/routes/ai.ts +++ b/server/src/http/routes/ai.ts @@ -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);}); } diff --git a/server/test/business-settings.test.ts b/server/test/business-settings.test.ts index 6d2fc6b..b2c0938 100644 --- a/server/test/business-settings.test.ts +++ b/server/test/business-settings.test.ts @@ -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',()=>{ diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 20ac762..ab02650 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -774,7 +774,7 @@ export class ReaderApi { } async businessAiModels(values?:{baseUrl?:string;apiKey?:string}):Promise { 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(path: string, options: RequestOptions = {}): Promise { diff --git a/web/src/features/media/pages/MediaTasksPage.tsx b/web/src/features/media/pages/MediaTasksPage.tsx index d71d700..d15eea5 100644 --- a/web/src/features/media/pages/MediaTasksPage.tsx +++ b/web/src/features/media/pages/MediaTasksPage.tsx @@ -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() { ; return
- + {requestedLibraryId && !libraryId && !libraries.isPending && !libraries.error && 所选媒体库不存在,已切换为全部媒体库。} diff --git a/web/test/media-tasks-route.test.tsx b/web/test/media-tasks-route.test.tsx index 237c7ad..9cb946a 100644 --- a/web/test/media-tasks-route.test.tsx +++ b/web/test/media-tasks-route.test.tsx @@ -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('.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' });