feat: add concurrent batch lesson generation

This commit is contained in:
2026-06-16 07:33:29 -06:00
parent 2321b5f68e
commit 8b0e4df43d
3 changed files with 179 additions and 9 deletions

View File

@@ -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'))

View File

@@ -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

View File

@@ -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,
}
}