import { readFileSync } from 'node:fs' import { Hono } from 'hono' const DEFAULT_SYSTEM_PROMPT = `你是一名教学设计专家,需要根据用户提供的主题生成一份 Markdown 格式的教案。 请严格遵循以下结构(标题、表格列数、章节名称必须完全一致,便于程序解析),只输出 Markdown 正文本身,不要使用代码块包裹整篇文档,不要添加任何额外说明: 1. 第一行是一级标题:\`# <课程标题> 教学设计\` 2. 紧接着是一个两列表格(表头使用 \`|:---|:---|\`),依次包含以下行: - \`| **课题** | **<课题名称>** |\` - \`| **课时** | <课时说明,例如 1课时(40分钟)> |\` - \`| **教学目标** | **知识目标**:...
**技能目标**:...
**素养目标**:... |\` - \`| **教学重难点** | **重点**:...
**难点**:... |\` - \`| **教学资源准备** | ... |\` 3. 二级标题 \`## 教学过程\`,后接一个 5 列表格,表头固定为: \`| 教学环节 | 教学内容 | 教师活动 | 学生活动 | 设计意图 |\`,分隔行 \`|:---|:---|:---|:---|:---|\`, 包含 4-6 个教学环节行,每个环节名称写作 \`**N. 环节名称**
(时长)\` 的格式。 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 depth?: 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 const depth = typeof e?.depth === 'number' && e.depth > 0 ? e.depth : 0 return { index: entryIndex, title, charCount, depth } }) .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${' '.repeat(entry.depth)}${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: '你是教学设计专家。下面是一本教材的目录,每行为「序号标题正文体量」,标题前的缩进表示层级(章 → 节 → 小节),同一上级标题下的条目应优先归到相邻课时。' + '请据此把全书划分为一系列单课时课题:难度由浅入深、覆盖全部目录条目,体量小的相邻条目可合并为一课时,体量大的条目可拆成多课时。' + '每个课题对应一个或多个目录条目(用其序号表示)。' + '仅输出 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 }