新增「从书籍生成」流程:用户粘贴教材目录(多行)并上传内含各章
.md 的 ZIP(文件名=目录标题),前端用 JSZip 解压匹配,AI 按章节
体量划分课时,预览/编辑后批量生成,每篇以对应章节正文作为上下文。
服务端
- generate.ts: 新增 POST /lessons-from-book(目录条目→AI 划分课时
JSON,剥围栏/校验/丢非法行);扩展 POST / 支持可选 content 上下文,
无 content 时请求体不变(向后兼容)。
前端
- bookImport.ts: normalizeTitle / parseZip / matchToc / assembleContent。
- booksApi.ts: generateLesson 兼容签名 + divideLessonsFromBook。
- useTeachingBook.ts: 抽出 runGenerationPool,新增 generateLessonsFromBook。
- BookImportDialog.vue 多阶段对话框;UploadDropzone 加 accept/multiple;
菜单串接「从书籍生成」。
顺带修复 5 个遗留失败测试:import 功能已于 fb0b8d1 删除但测试漏删,
清理孤儿测试;批量生成对齐为单参 generateLesson(topic)。
docs/book-import-spec.md 记录规格。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
269 lines
9.8 KiB
TypeScript
269 lines
9.8 KiB
TypeScript
import { readFileSync } from 'node:fs'
|
||
import { Hono } from 'hono'
|
||
|
||
const DEFAULT_SYSTEM_PROMPT = `你是一名教学设计专家,需要根据用户提供的主题生成一份 Markdown 格式的教案。
|
||
请严格遵循以下结构(标题、表格列数、章节名称必须完全一致,便于程序解析),只输出 Markdown 正文本身,不要使用代码块包裹整篇文档,不要添加任何额外说明:
|
||
|
||
1. 第一行是一级标题:\`# <课程标题> 教学设计\`
|
||
2. 紧接着是一个两列表格(表头使用 \`|:---|:---|\`),依次包含以下行:
|
||
- \`| **课题** | **<课题名称>** |\`
|
||
- \`| **课时** | <课时说明,例如 1课时(40分钟)> |\`
|
||
- \`| **教学目标** | **知识目标**:...<br>**技能目标**:...<br>**素养目标**:... |\`
|
||
- \`| **教学重难点** | **重点**:...<br>**难点**:... |\`
|
||
- \`| **教学资源准备** | ... |\`
|
||
3. 二级标题 \`## 教学过程\`,后接一个 5 列表格,表头固定为:
|
||
\`| 教学环节 | 教学内容 | 教师活动 | 学生活动 | 设计意图 |\`,分隔行 \`|:---|:---|:---|:---|:---|\`,
|
||
包含 4-6 个教学环节行,每个环节名称写作 \`**N. 环节名称**<br>(时长)\` 的格式。
|
||
4. 二级标题 \`## 板书设计\`,内容放在 \`\`\`text 代码块中。
|
||
5. 二级标题 \`## 教学成效与反思\`,后接一个两列表格:
|
||
- \`| **教学成效** | ... |\`
|
||
- \`| **教学反思** | ... |\`
|
||
`
|
||
|
||
function loadSystemPrompt(): string {
|
||
try {
|
||
return readFileSync('data/SKILLS.md', 'utf8')
|
||
} catch {
|
||
return DEFAULT_SYSTEM_PROMPT
|
||
}
|
||
}
|
||
|
||
function sanitizeFilename(topic: string): string {
|
||
const sanitized = topic.trim().replace(/[\\/:*?"<>|]/g, '_')
|
||
return sanitized || 'lesson'
|
||
}
|
||
|
||
export function createGenerateRouter(apiKey: string | undefined): Hono {
|
||
const app = new Hono()
|
||
|
||
app.post('/', async (c) => {
|
||
const body = (await c.req.json().catch(() => null)) as
|
||
| { topic?: unknown; content?: unknown }
|
||
| null
|
||
const topic = body?.topic
|
||
|
||
if (typeof topic !== 'string' || topic.trim() === '') {
|
||
return c.json({ error: '请提供教案主题。' }, 400)
|
||
}
|
||
|
||
if (!apiKey) {
|
||
return c.json({ error: '未配置 DEEPSEEK_API_KEY。' }, 500)
|
||
}
|
||
|
||
const content = typeof body?.content === 'string' ? body.content.trim() : ''
|
||
const userContent = content
|
||
? `请围绕主题"${topic.trim()}"生成一份教案。\n\n以下是教材相关章节内容,作为编写依据(请提炼要点,不要照抄):\n"""\n${content.slice(0, 6000)}\n"""`
|
||
: `请围绕主题"${topic.trim()}"生成一份教案。`
|
||
|
||
const systemPrompt = loadSystemPrompt()
|
||
|
||
let response: Response
|
||
try {
|
||
response = await fetch('https://api.deepseek.com/chat/completions', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${apiKey}`,
|
||
},
|
||
body: JSON.stringify({
|
||
model: 'deepseek-v4-flash',
|
||
messages: [
|
||
{ role: 'system', content: systemPrompt },
|
||
{ role: 'user', content: userContent },
|
||
],
|
||
}),
|
||
})
|
||
} catch {
|
||
return c.json({ error: 'Deepseek 请求失败,请检查网络后重试。' }, 502)
|
||
}
|
||
|
||
if (!response.ok) {
|
||
return c.json({ error: `Deepseek 请求失败(状态码 ${response.status})。` }, 502)
|
||
}
|
||
|
||
const payload = (await response.json().catch(() => null)) as
|
||
| { choices?: Array<{ message?: { content?: string } }> }
|
||
| null
|
||
const raw = payload?.choices?.[0]?.message?.content
|
||
|
||
if (!raw) {
|
||
return c.json({ error: 'Deepseek 返回内容为空。' }, 502)
|
||
}
|
||
|
||
const fenceMatch = raw.trim().match(/^```(?:\w+)?\n([\s\S]*?)\n```\s*$/)
|
||
const markdown = fenceMatch ? fenceMatch[1]! : raw
|
||
|
||
return c.json({ filename: `${sanitizeFilename(topic)}.md`, markdown })
|
||
})
|
||
|
||
app.post('/outline', async (c) => {
|
||
const body = (await c.req.json().catch(() => null)) as
|
||
| { theme?: unknown; count?: unknown }
|
||
| null
|
||
const theme = body?.theme
|
||
|
||
if (typeof theme !== 'string' || theme.trim() === '') {
|
||
return c.json({ error: '请提供课程主题。' }, 400)
|
||
}
|
||
|
||
const rawCount = typeof body?.count === 'number' ? body.count : 18
|
||
const count = Math.min(50, Math.max(1, Math.round(rawCount)))
|
||
|
||
if (!apiKey) {
|
||
return c.json({ error: '未配置 DEEPSEEK_API_KEY。' }, 500)
|
||
}
|
||
|
||
let response: Response
|
||
try {
|
||
response = await fetch('https://api.deepseek.com/chat/completions', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${apiKey}`,
|
||
},
|
||
body: JSON.stringify({
|
||
model: 'deepseek-v4-flash',
|
||
messages: [
|
||
{
|
||
role: 'system',
|
||
content:
|
||
`你是教学设计专家。根据用户提供的课程主题,生成一份完整的课时大纲标题列表,共约${count}条。` +
|
||
'每个标题格式为"项目名——课时任务",一行一个,不加序号、不加任何说明,直接输出标题列表。',
|
||
},
|
||
{ role: 'user', content: `课程主题:${theme.trim()}` },
|
||
],
|
||
}),
|
||
})
|
||
} catch {
|
||
return c.json({ error: 'Deepseek 请求失败,请检查网络后重试。' }, 502)
|
||
}
|
||
|
||
if (!response.ok) {
|
||
return c.json({ error: `Deepseek 请求失败(状态码 ${response.status})。` }, 502)
|
||
}
|
||
|
||
const payload = (await response.json().catch(() => null)) as
|
||
| { choices?: Array<{ message?: { content?: string } }> }
|
||
| null
|
||
const raw = payload?.choices?.[0]?.message?.content
|
||
|
||
if (!raw) {
|
||
return c.json({ error: 'Deepseek 返回内容为空。' }, 502)
|
||
}
|
||
|
||
const titles = raw
|
||
.split('\n')
|
||
.map((line) => line.trim())
|
||
.filter(Boolean)
|
||
|
||
return c.json({ titles })
|
||
})
|
||
|
||
app.post('/lessons-from-book', async (c) => {
|
||
const body = (await c.req.json().catch(() => null)) as { entries?: unknown } | null
|
||
const rawEntries = Array.isArray(body?.entries) ? body!.entries : null
|
||
|
||
if (!rawEntries || rawEntries.length === 0) {
|
||
return c.json({ error: '请提供教材目录。' }, 400)
|
||
}
|
||
|
||
const entries = rawEntries
|
||
.map((entry, index) => {
|
||
const e = entry as { index?: unknown; title?: unknown; charCount?: unknown }
|
||
const title = typeof e?.title === 'string' ? e.title.trim() : ''
|
||
const entryIndex = typeof e?.index === 'number' ? e.index : index
|
||
const charCount = typeof e?.charCount === 'number' ? e.charCount : 0
|
||
return { index: entryIndex, title, charCount }
|
||
})
|
||
.filter((entry) => entry.title !== '')
|
||
|
||
if (entries.length === 0) {
|
||
return c.json({ error: '目录为空或格式无效。' }, 400)
|
||
}
|
||
|
||
if (!apiKey) {
|
||
return c.json({ error: '未配置 DEEPSEEK_API_KEY。' }, 500)
|
||
}
|
||
|
||
const tocText = entries
|
||
.map((entry) => `${entry.index}\t${entry.title}\t约${entry.charCount}字`)
|
||
.join('\n')
|
||
|
||
let response: Response
|
||
try {
|
||
response = await fetch('https://api.deepseek.com/chat/completions', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${apiKey}`,
|
||
},
|
||
body: JSON.stringify({
|
||
model: 'deepseek-v4-flash',
|
||
messages: [
|
||
{
|
||
role: 'system',
|
||
content:
|
||
'你是教学设计专家。下面是一本教材的目录,每行为「序号<TAB>标题<TAB>正文体量」。' +
|
||
'请据此把全书划分为一系列单课时课题:难度由浅入深、覆盖全部目录条目,体量小的相邻条目可合并为一课时,体量大的条目可拆成多课时。' +
|
||
'每个课题对应一个或多个目录条目(用其序号表示)。' +
|
||
'仅输出 JSON,不要解释、不要代码块围栏,形如:' +
|
||
'{"lessons":[{"title":"项目名——课时任务","sourceIndexes":[0]}]}。',
|
||
},
|
||
{ role: 'user', content: tocText },
|
||
],
|
||
}),
|
||
})
|
||
} catch {
|
||
return c.json({ error: 'Deepseek 请求失败,请检查网络后重试。' }, 502)
|
||
}
|
||
|
||
if (!response.ok) {
|
||
return c.json({ error: `Deepseek 请求失败(状态码 ${response.status})。` }, 502)
|
||
}
|
||
|
||
const payload = (await response.json().catch(() => null)) as
|
||
| { choices?: Array<{ message?: { content?: string } }> }
|
||
| null
|
||
const raw = payload?.choices?.[0]?.message?.content
|
||
|
||
if (!raw) {
|
||
return c.json({ error: 'Deepseek 返回内容为空。' }, 502)
|
||
}
|
||
|
||
const fenceMatch = raw.trim().match(/^```(?:\w+)?\n([\s\S]*?)\n```\s*$/)
|
||
const jsonText = fenceMatch ? fenceMatch[1]! : raw
|
||
|
||
let parsed: unknown
|
||
try {
|
||
parsed = JSON.parse(jsonText)
|
||
} catch {
|
||
return c.json({ error: 'Deepseek 返回的课时格式无法解析。' }, 502)
|
||
}
|
||
|
||
const maxIndex = entries.reduce((max, entry) => Math.max(max, entry.index), 0)
|
||
const rawLessons = (parsed as { lessons?: unknown })?.lessons
|
||
const lessons = Array.isArray(rawLessons)
|
||
? rawLessons
|
||
.map((lesson) => {
|
||
const l = lesson as { title?: unknown; sourceIndexes?: unknown }
|
||
const title = typeof l?.title === 'string' ? l.title.trim() : ''
|
||
const sourceIndexes = Array.isArray(l?.sourceIndexes)
|
||
? l.sourceIndexes.filter(
|
||
(n): n is number => typeof n === 'number' && n >= 0 && n <= maxIndex,
|
||
)
|
||
: []
|
||
return { title, sourceIndexes }
|
||
})
|
||
.filter((lesson) => lesson.title !== '')
|
||
: []
|
||
|
||
if (lessons.length === 0) {
|
||
return c.json({ error: 'Deepseek 未返回有效课时。' }, 502)
|
||
}
|
||
|
||
return c.json({ lessons })
|
||
})
|
||
|
||
return app
|
||
}
|