feat: add concurrent batch lesson generation
This commit is contained in:
@@ -2,6 +2,7 @@ import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createEmptyBook, createEmptyTeachingDesign } from '../domain/teachingDesign'
|
||||
import * as booksApi from '../services/booksApi'
|
||||
import BatchGenerateDialog from './BatchGenerateDialog.vue'
|
||||
import GenerateLessonDialog from './GenerateLessonDialog.vue'
|
||||
import WorkspaceView from './WorkspaceView.vue'
|
||||
|
||||
@@ -16,6 +17,32 @@ function mockBook(data = createEmptyBook()): void {
|
||||
})
|
||||
}
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (reason?: unknown) => void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve
|
||||
reject = promiseReject
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function generatedLesson(topic: string): booksApi.GenerateResult {
|
||||
return {
|
||||
filename: `${topic}.md`,
|
||||
markdown: [
|
||||
`# ${topic} 教学设计`,
|
||||
'|:---|:---|',
|
||||
`| **课题** | **${topic}** |`,
|
||||
'| **课时** | 1课时(40分钟) |',
|
||||
].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
describe('WorkspaceView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -87,6 +114,68 @@ describe('WorkspaceView', () => {
|
||||
expect(wrapper.text()).not.toContain('封面')
|
||||
})
|
||||
|
||||
it('batch generates up to three lessons concurrently and keeps outline order', async () => {
|
||||
mockBook()
|
||||
const requests = new Map<string, ReturnType<typeof deferred<booksApi.GenerateResult>>>()
|
||||
vi.mocked(booksApi.generateLesson).mockImplementation((topic) => {
|
||||
const request = deferred<booksApi.GenerateResult>()
|
||||
requests.set(topic, request)
|
||||
return request.promise
|
||||
})
|
||||
|
||||
const wrapper = mount(WorkspaceView, { props: { bookId: 'b1' } })
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-testid="batch-generate"]').trigger('click')
|
||||
wrapper.getComponent(BatchGenerateDialog).vm.$emit('start', [
|
||||
'第一课',
|
||||
'第二课',
|
||||
'第三课',
|
||||
'第四课',
|
||||
'第五课',
|
||||
])
|
||||
await flushPromises()
|
||||
|
||||
expect(booksApi.generateLesson).toHaveBeenCalledTimes(3)
|
||||
expect(booksApi.generateLesson).toHaveBeenNthCalledWith(1, '第一课')
|
||||
expect(booksApi.generateLesson).toHaveBeenNthCalledWith(2, '第二课')
|
||||
expect(booksApi.generateLesson).toHaveBeenNthCalledWith(3, '第三课')
|
||||
|
||||
requests.get('第三课')!.resolve(generatedLesson('第三课'))
|
||||
await flushPromises()
|
||||
expect(booksApi.generateLesson).toHaveBeenCalledTimes(4)
|
||||
expect(booksApi.generateLesson).toHaveBeenNthCalledWith(4, '第四课')
|
||||
expect(wrapper.findAll('.lesson-sidebar-topic').map((node) => node.text())).toEqual([])
|
||||
|
||||
requests.get('第四课')!.resolve(generatedLesson('第四课'))
|
||||
await flushPromises()
|
||||
expect(booksApi.generateLesson).toHaveBeenCalledTimes(5)
|
||||
expect(booksApi.generateLesson).toHaveBeenNthCalledWith(5, '第五课')
|
||||
|
||||
requests.get('第一课')!.resolve(generatedLesson('第一课'))
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('.lesson-sidebar-topic').map((node) => node.text())).toEqual(['第一课'])
|
||||
|
||||
requests.get('第二课')!.resolve(generatedLesson('第二课'))
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('.lesson-sidebar-topic').map((node) => node.text())).toEqual([
|
||||
'第一课',
|
||||
'第二课',
|
||||
'第三课',
|
||||
'第四课',
|
||||
])
|
||||
|
||||
requests.get('第五课')!.resolve(generatedLesson('第五课'))
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('.lesson-sidebar-topic').map((node) => node.text())).toEqual([
|
||||
'第一课',
|
||||
'第二课',
|
||||
'第三课',
|
||||
'第四课',
|
||||
'第五课',
|
||||
])
|
||||
})
|
||||
|
||||
it('clears the lessons after confirmation', async () => {
|
||||
const data = createEmptyBook()
|
||||
data.designs.push(createEmptyTeachingDesign('1.md'))
|
||||
|
||||
@@ -13,6 +13,8 @@ import PrintBook from './PrintBook.vue'
|
||||
import UploadDropzone from './UploadDropzone.vue'
|
||||
import WorkspaceToolbar from './WorkspaceToolbar.vue'
|
||||
|
||||
const BATCH_GENERATE_CONCURRENCY = 3
|
||||
|
||||
const props = defineProps<{ bookId: string }>()
|
||||
|
||||
defineEmits<{ back: [] }>()
|
||||
@@ -35,6 +37,7 @@ const {
|
||||
updateDesign,
|
||||
clearBook,
|
||||
generateLesson,
|
||||
generateLessons,
|
||||
regenerateLesson,
|
||||
} = useTeachingBook(props.bookId)
|
||||
|
||||
@@ -153,15 +156,19 @@ async function handleBatchStart(topics: string[]): Promise<void> {
|
||||
batchTotal.value = topics.length
|
||||
batchError.value = null
|
||||
|
||||
for (const topic of topics) {
|
||||
if (batchCancelled.value) break
|
||||
batchCurrentTopic.value = topic
|
||||
const result = await generateLesson(topic)
|
||||
if (!result.ok) {
|
||||
batchError.value = result.message
|
||||
break
|
||||
}
|
||||
batchDone.value++
|
||||
const result = await generateLessons(topics, {
|
||||
concurrency: BATCH_GENERATE_CONCURRENCY,
|
||||
isCancelled: () => batchCancelled.value,
|
||||
onTopicStart: (topic) => {
|
||||
batchCurrentTopic.value = topic
|
||||
},
|
||||
onLessonComplete: (count) => {
|
||||
batchDone.value += count
|
||||
},
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
batchError.value = result.message
|
||||
}
|
||||
|
||||
batchRunning.value = false
|
||||
|
||||
@@ -19,6 +19,17 @@ export type LoadStatus = 'loading' | 'loaded' | 'error'
|
||||
|
||||
export type GenerateLessonResult = { ok: true } | { ok: false; message: string }
|
||||
|
||||
export type BatchGenerateLessonResult =
|
||||
| { ok: true; completed: number }
|
||||
| { ok: false; completed: number; message: string }
|
||||
|
||||
export interface BatchGenerateLessonOptions {
|
||||
concurrency?: number
|
||||
isCancelled?: () => boolean
|
||||
onTopicStart?: (topic: string) => void
|
||||
onLessonComplete?: (count: number) => void
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
imported: number
|
||||
failed: Array<{ filename: string; message: string }>
|
||||
@@ -43,6 +54,10 @@ export interface TeachingBookStore {
|
||||
updateDesign: (id: DesignId, updater: (design: TeachingDesign) => void) => void
|
||||
clearBook: () => void
|
||||
generateLesson: (topic: string) => Promise<GenerateLessonResult>
|
||||
generateLessons: (
|
||||
topics: readonly string[],
|
||||
options?: BatchGenerateLessonOptions,
|
||||
) => Promise<BatchGenerateLessonResult>
|
||||
regenerateLesson: (id: DesignId) => Promise<GenerateLessonResult>
|
||||
}
|
||||
|
||||
@@ -242,6 +257,64 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
|
||||
}
|
||||
}
|
||||
|
||||
async function generateLessons(
|
||||
topics: readonly string[],
|
||||
options: BatchGenerateLessonOptions = {},
|
||||
): Promise<BatchGenerateLessonResult> {
|
||||
const concurrency = Math.max(1, options.concurrency ?? 3)
|
||||
const workerCount = Math.min(concurrency, topics.length)
|
||||
const results = new Array<TeachingDesign | undefined>(topics.length)
|
||||
let nextStartIndex = 0
|
||||
let nextAppendIndex = 0
|
||||
let appendedCount = 0
|
||||
let firstError: string | null = null
|
||||
|
||||
function appendReadyLessons(): void {
|
||||
let readyCount = 0
|
||||
|
||||
while (nextAppendIndex < results.length) {
|
||||
const design = results[nextAppendIndex]
|
||||
if (!design) break
|
||||
book.value.designs.push(design)
|
||||
book.value.selectedId = design.id
|
||||
nextAppendIndex++
|
||||
readyCount++
|
||||
}
|
||||
|
||||
if (readyCount > 0) {
|
||||
appendedCount += readyCount
|
||||
touch()
|
||||
options.onLessonComplete?.(readyCount)
|
||||
}
|
||||
}
|
||||
|
||||
async function runWorker(): Promise<void> {
|
||||
while (!firstError && !options.isCancelled?.()) {
|
||||
const index = nextStartIndex
|
||||
if (index >= topics.length) return
|
||||
|
||||
nextStartIndex++
|
||||
const topic = topics[index]!
|
||||
options.onTopicStart?.(topic)
|
||||
|
||||
try {
|
||||
const result = await booksApi.generateLesson(topic)
|
||||
results[index] = parseTeachingDesign(result.filename, result.markdown)
|
||||
appendReadyLessons()
|
||||
} catch (error) {
|
||||
firstError = error instanceof Error ? error.message : '生成失败。'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: workerCount }, () => runWorker()))
|
||||
appendReadyLessons()
|
||||
|
||||
return firstError
|
||||
? { ok: false, completed: appendedCount, message: firstError }
|
||||
: { ok: true, completed: appendedCount }
|
||||
}
|
||||
|
||||
async function regenerateLesson(id: DesignId): Promise<GenerateLessonResult> {
|
||||
const existing = book.value.designs.find((d) => d.id === id)
|
||||
if (!existing) return { ok: false, message: '找不到该教案。' }
|
||||
@@ -282,6 +355,7 @@ export function useTeachingBook(bookId: string): TeachingBookStore {
|
||||
updateDesign,
|
||||
clearBook,
|
||||
generateLesson,
|
||||
generateLessons,
|
||||
regenerateLesson,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user