feat(lesson-preparation): update paper-editor, detail-panel, AI node assist, i18n

- Update actions-ai.ts and curriculum-map-view.tsx

- Update detail-panel: detail-panel, detail-props, qa-editor

- Update paper-editor: inline-node, inline-qa-dialog, paper-context-menu,

  paper-editor, textbook-tiptap-editor

- Update hooks/editor-slice.ts and lib/i18n-errors.ts

- Add hooks/use-node-ai-assist.ts and lib/ai-node-assist.ts

- Update en/zh-CN lesson-preparation i18n messages
This commit is contained in:
SpecialX
2026-07-04 23:02:04 +08:00
parent 0780524c08
commit e962b67050
16 changed files with 695 additions and 327 deletions

View File

@@ -16,7 +16,15 @@ import {
type CurriculumCheckItem,
type ExplainableAssessment,
} from "./lib/ai-differentiation";
import type { LessonPlanDocument } from "./types";
import {
generateNodeContent,
optimizeNodeExpression,
suggestNodeDifferentiation,
generateLayeredQuestions,
type NodeContentUpdate,
type NodeDifferentiationUpdate,
} from "./lib/ai-node-assist";
import type { LessonPlanDocument, LessonPlanNode, QATurn } from "./types";
import type { ActionState } from "@/shared/types/action-state";
export async function suggestKnowledgePointsAction(input: {
@@ -152,3 +160,100 @@ export async function getKnowledgePointsForAlignmentAction(
return handleActionError(e);
}
}
// ---- V4 节点级 AI 协助4 项) ----
/**
* V4-N1生成本节点内容。
* 根据节点 type 生成对应字段html / objectives / turns 等),返回 patch 由前端合并到 node.data。
*/
export async function generateNodeContentAction(
node: LessonPlanNode,
): Promise<ActionState<NodeContentUpdate>> {
try {
await Promise.all([
requirePermission(Permissions.LESSON_PLAN_READ),
requirePermission(Permissions.LESSON_PLAN_CREATE),
requirePermission(Permissions.AI_CHAT),
]);
const result = await generateNodeContent(node);
if (!result) {
return { success: false, message: "AI_GENERATE_FAILED" };
}
return { success: true, data: result };
} catch (e) {
return handleActionError(e);
}
}
/**
* V4-N2优化节点表达。
* 保留结构,仅优化字段值。
*/
export async function optimizeNodeExpressionAction(
node: LessonPlanNode,
): Promise<ActionState<NodeContentUpdate>> {
try {
await Promise.all([
requirePermission(Permissions.LESSON_PLAN_READ),
requirePermission(Permissions.LESSON_PLAN_CREATE),
requirePermission(Permissions.AI_CHAT),
]);
const result = await optimizeNodeExpression(node);
if (!result) {
return { success: false, message: "AI_OPTIMIZE_FAILED" };
}
return { success: true, data: result };
} catch (e) {
return handleActionError(e);
}
}
/**
* V4-N3差异化建议。
* 判定该节点适合的差异化层次,前端据此设置 node.differentiation。
*/
export async function suggestNodeDifferentiationAction(
node: LessonPlanNode,
): Promise<ActionState<NodeDifferentiationUpdate>> {
try {
await Promise.all([
requirePermission(Permissions.LESSON_PLAN_READ),
requirePermission(Permissions.LESSON_PLAN_CREATE),
requirePermission(Permissions.AI_CHAT),
]);
const result = await suggestNodeDifferentiation(node);
if (!result) {
return { success: false, message: "AI_DIFFERENTIATION_FAILED" };
}
return { success: true, data: result };
} catch (e) {
return handleActionError(e);
}
}
/**
* V4-N4生成分层提问仅 interaction 节点)。
* 返回 3 轮 teacher/student 对话,前端追加到 turns。
*/
export async function generateLayeredQuestionsAction(
node: LessonPlanNode,
): Promise<ActionState<{ turns: QATurn[] }>> {
try {
await Promise.all([
requirePermission(Permissions.LESSON_PLAN_READ),
requirePermission(Permissions.LESSON_PLAN_CREATE),
requirePermission(Permissions.AI_CHAT),
]);
if (node.type !== "interaction") {
return { success: false, message: "AI_LAYERED_REQUIRES_INTERACTION" };
}
const turns = await generateLayeredQuestions(node);
if (!turns) {
return { success: false, message: "AI_LAYERED_FAILED" };
}
return { success: true, data: { turns } };
} catch (e) {
return handleActionError(e);
}
}

View File

@@ -1,5 +1,6 @@
import type { JSX } from "react";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { cn } from "@/shared/lib/utils";
import type { GradeOption, SubjectOption } from "@/modules/school/data-access";
import type { StandardsCoverageCell } from "@/modules/lesson-preparation/data-access-analytics";
@@ -20,6 +21,7 @@ function getCoverageColor(percent: number): string {
}
export function CurriculumMapView({ grades, subjects, heatmap }: Props): JSX.Element {
const t = useTranslations("lessonPreparation");
// 构建查找表subjectId|gradeId -> cell
const cellMap = useMemo(() => {
const map = new Map<string, StandardsCoverageCell>();
@@ -99,8 +101,8 @@ export function CurriculumMapView({ grades, subjects, heatmap }: Props): JSX.Ele
{/* 图例 */}
<div className="flex flex-wrap items-center gap-4 text-xs">
<span className="text-muted-foreground"></span>
<LegendItem color="bg-muted/40" label="无数据" />
<span className="text-muted-foreground">{t("v4.heatmap.legendTitle")}</span>
<LegendItem color="bg-muted/40" label={t("v4.heatmap.noData")} />
<LegendItem color="bg-red-100 dark:bg-red-950/50" label="0-25%" />
<LegendItem color="bg-amber-100 dark:bg-amber-950/50" label="25-50%" />
<LegendItem color="bg-blue-100 dark:bg-blue-950/50" label="50-75%" />

View File

@@ -2,6 +2,7 @@
import { useTranslations } from "next-intl";
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
import { useNodeAiAssist, type NodeAiAction } from "../../hooks/use-node-ai-assist";
import { DetailHead } from "./detail-head";
import { DetailProps } from "./detail-props";
import { QaEditor } from "./qa-editor";
@@ -14,6 +15,7 @@ export function DetailPanel() {
const selectedNodeId = useLessonPlanEditor((s) => s.selectedNodeId);
const expandedNodeIds = useLessonPlanEditor((s) => s.expandedNodeIds);
const updateNode = useLessonPlanEditor((s) => s.updateNode);
const { run: runNodeAi, isRunning } = useNodeAiAssist();
const node = doc.nodes.find(
(n): n is LessonPlanNode => n.id === selectedNodeId && n.type !== "textbook_content",
@@ -36,6 +38,11 @@ export function DetailPanel() {
}
const isExpanded = expandedNodeIds.includes(node.id);
const isInteraction = node.type === "interaction";
const trigger = (action: NodeAiAction) => {
void runNodeAi(action, node.id);
};
return (
<aside
@@ -51,7 +58,7 @@ export function DetailPanel() {
<DetailProps node={node} />
<div style={{ flex: 1, padding: 20, overflowY: "auto" }}>
{node.type === "interaction" ? (
{isInteraction ? (
<QaEditor nodeId={node.id} data={node.data as InteractionBlockData} />
) : (
<BlockRenderer
@@ -81,28 +88,72 @@ export function DetailPanel() {
{t("v4.detail.aiAssist")}
</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
<AiButton label={t("v4.detail.aiGenerateLayered")} />
<AiButton label={t("v4.detail.aiFillExpected")} />
<AiButton label={t("v4.detail.aiOptimizeFollowup")} />
<AiButton label={t("v4.detail.aiDifferentiation")} />
{isInteraction ? (
<>
<AiButton
label={t("v4.detail.aiGenerateLayered")}
disabled={isRunning}
onClick={() => trigger("layered")}
/>
<AiButton
label={t("v4.detail.aiFillExpected")}
disabled={isRunning}
onClick={() => trigger("generate")}
/>
<AiButton
label={t("v4.detail.aiOptimizeFollowup")}
disabled={isRunning}
onClick={() => trigger("optimize")}
/>
</>
) : (
<>
<AiButton
label={t("v4.contextMenu.aiGenerate")}
disabled={isRunning}
onClick={() => trigger("generate")}
/>
<AiButton
label={t("v4.contextMenu.aiOptimize")}
disabled={isRunning}
onClick={() => trigger("optimize")}
/>
</>
)}
<AiButton
label={t("v4.detail.aiDifferentiation")}
disabled={isRunning}
onClick={() => trigger("differentiation")}
/>
</div>
</div>
</aside>
);
}
function AiButton({ label }: { label: string }) {
function AiButton({
label,
onClick,
disabled,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
style={{
padding: "5px 10px",
border: "1px solid var(--border)",
background: "var(--background)",
borderRadius: 4,
fontSize: 11.5,
cursor: "pointer",
cursor: disabled ? "not-allowed" : "pointer",
color: "var(--foreground)",
opacity: disabled ? 0.6 : 1,
}}
>
{label}

View File

@@ -1,5 +1,6 @@
"use client";
import { useTranslations } from "next-intl";
import type { Block, DifferentiationLevel, TeachingStage } from "../../types";
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
@@ -11,6 +12,7 @@ const STAGES: TeachingStage[] = ["import", "new_teaching", "consolidation", "sum
const LEVELS: DifferentiationLevel[] = ["basic", "intermediate", "advanced"];
export function DetailProps({ node }: Props) {
const t = useTranslations("lessonPreparation");
const updateNode = useLessonPlanEditor((s) => s.updateNode);
return (
@@ -25,13 +27,13 @@ export function DetailProps({ node }: Props) {
}}
>
<PropItem
label="教学阶段"
label={t("v4.detail.stageLabel")}
value={node.stage ?? "—"}
options={STAGES}
onChange={(v) => updateNode(node.id, { stage: v as TeachingStage })}
/>
<PropItem
label="差异化"
label={t("v4.detail.differentiationLabel")}
value={node.differentiation ?? "—"}
options={LEVELS}
onChange={(v) => updateNode(node.id, { differentiation: v as DifferentiationLevel })}

View File

@@ -208,7 +208,7 @@ function QaTurnEditor({ turn, index, total, onUpdate, onRemove, onMoveUp, onMove
value={turn.content}
onChange={(e) => onUpdate({ content: e.target.value })}
rows={2}
placeholder="内容"
placeholder={t("v4.detail.turnContentPlaceholder")}
style={{
width: "100%",
border: "none",

View File

@@ -73,6 +73,7 @@ export function InlineNode({ node }: Props) {
* 这里给出最常见的几种的简化摘要,完整编辑在右栏详情面板。
*/
function InlineNodeBody({ node }: { node: Block }) {
const t = useTranslations("lessonPreparation");
const data = node.data;
// 按类型渲染摘要
switch (node.type) {
@@ -100,7 +101,7 @@ function InlineNodeBody({ node }: { node: Block }) {
}
case "exercise": {
const d = data as { items: unknown[] };
return <p style={{ fontSize: 13 }}>{d.items?.length ?? 0} </p>;
return <p style={{ fontSize: 13 }}>{d.items?.length ?? 0} {t("v4.detail.exerciseCount")}</p>;
}
default:
return <p style={{ fontSize: 13, color: "var(--muted-foreground)" }}>{node.title}</p>;

View File

@@ -83,7 +83,7 @@ function QaTurnView({ turn, index }: { turn: QATurn; index: number }) {
<div className="lp-qa-content">
{turn.content}
{turn.role === "teacher" && turn.expectedAnswer && (
<span className="lp-qa-prompt">[{turn.expectedAnswer}]</span>
<span className="lp-qa-prompt">[{t("v4.detail.expectedAnswer")}{turn.expectedAnswer}]</span>
)}
</div>
</div>

View File

@@ -17,16 +17,15 @@ export interface ContextMenuState {
interface Props {
state: ContextMenuState;
onClose: () => void;
/** AI 协助回调V1: 仅 toast 提示"功能开发中"V2 接入 actions-ai.ts */
/** AI 协助回调V2接入 actions-ai.ts 的节点级 AI 服务 */
onAiAction?: (action: "generate" | "optimize" | "differentiation" | "layered", nodeId: string) => void;
}
/**
* V4 右键菜单:根据 state.nodeId 区分节点菜单 vs 锚定菜单。
*
* V1 范围:展开/收起、删除、锚定 已实现
* V1 占位上下移动、复制节点、AI 协助 4 项(按钮显示,点击 toast 提示)。
* 这些占位功能在 spec §15 YAGNI 边界内V2 接入。
* 已实现:展开/收起、删除、上下移动、复制节点、锚定。
* V2 接入AI 协助 4 项(通过 onAiAction 回调,由 PaperEditor 注入 useNodeAiAssist)。
*/
export function PaperContextMenu({ state, onClose, onAiAction }: Props) {
const t = useTranslations("lessonPreparation");
@@ -36,6 +35,7 @@ export function PaperContextMenu({ state, onClose, onAiAction }: Props) {
updateNode,
removeNode,
addAnchor,
duplicateNode,
doc,
} = useLessonPlanEditor();
@@ -54,18 +54,22 @@ export function PaperContextMenu({ state, onClose, onAiAction }: Props) {
updateNode(b.id, { order: a.order });
};
const copyNode = (nodeId: string) => {
void nodeId;
// V1 占位toast 提示
void import("sonner").then(({ toast }) => toast.info("复制节点功能将在 V2 提供"));
const copyNode = async (nodeId: string) => {
const newId = duplicateNode(nodeId);
const { toast } = await import("sonner");
if (newId) {
toast.success(t("v4.contextMenu.copied"));
} else {
toast.error(t("v4.contextMenu.copyFailed"));
}
};
// AI 协助默认实现V1 占位
// AI 协助:交由 PaperEditor 注入的 onAiAction 回调处理useNodeAiAssist
const handleAi = (action: "generate" | "optimize" | "differentiation" | "layered", nodeId: string) => {
if (onAiAction) {
onAiAction(action, nodeId);
} else {
void import("sonner").then(({ toast }) => toast.info("AI 协助功能将在 V2 提供"));
void import("sonner").then(({ toast }) => toast.info(t("v4.contextMenu.aiComingSoon")));
}
};

View File

@@ -3,6 +3,7 @@
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
import { useNodeAiAssist } from "../../hooks/use-node-ai-assist";
import { TextbookTiptapEditor } from "./textbook-tiptap-editor";
import { InlineNode } from "./inline-node";
import { PaperContextMenu, type ContextMenuState } from "./paper-context-menu";
@@ -23,6 +24,7 @@ export function PaperEditor({ readonly }: { readonly?: boolean }) {
const t = useTranslations("lessonPreparation");
const doc = useLessonPlanEditor((s) => s.doc);
const expandedNodeIds = useLessonPlanEditor((s) => s.expandedNodeIds);
const { run: runNodeAi } = useNodeAiAssist();
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
visible: false,
x: 0,
@@ -142,6 +144,7 @@ export function PaperEditor({ readonly }: { readonly?: boolean }) {
<PaperContextMenu
state={contextMenu}
onClose={() => setContextMenu((s) => ({ ...s, visible: false }))}
onAiAction={(action, nodeId) => void runNodeAi(action, nodeId)}
/>
</main>
);

View File

@@ -43,6 +43,8 @@ export function TextbookTiptapEditor({ content, readonly }: Props) {
],
content,
editable: !readonly,
// SSR 必填:避免 hydration mismatchesTiptap v2.4+ 要求显式设置)
immediatelyRender: false,
editorProps: {
attributes: {
class: "lp-textbook-editor prose prose-sm max-w-none focus:outline-none",

View File

@@ -3,7 +3,9 @@ import { createId } from "@paralleldrive/cuid2";
import type {
AnchorType,
Block,
BlockData,
BlockType,
InteractionBlockData,
LessonPlanDocument,
LessonPlanNode,
NodeAnchor,
@@ -13,6 +15,23 @@ import type {
import { defaultDataForType } from "../lib/document-migration";
import type { EditorState } from "./use-lesson-plan-editor";
/**
* V4深拷贝 BlockData为 interaction 节点的 turns 重新生成 id避免 id 冲突)。
* 其他类型直接结构化克隆JSON 序列化足够,数据全是 POJO
*/
function cloneBlockData(data: BlockData, type: BlockType): BlockData {
if (type === "interaction") {
const d = data as InteractionBlockData;
return {
designIntent: d.designIntent,
knowledgePointIds: [...d.knowledgePointIds],
turns: d.turns.map((t) => ({ ...t, id: createId() })),
};
}
// 其他类型JSON 深拷贝(数据全是 POJO无函数/Date/Symbol
return JSON.parse(JSON.stringify(data)) as BlockData;
}
export interface EditorSlice {
planId: string;
title: string;
@@ -22,6 +41,8 @@ export interface EditorSlice {
addNode: (type: BlockType, position?: { x: number; y: number }, title?: string) => string;
updateNode: (id: string, patch: Omit<Partial<Block>, "type">) => void;
removeNode: (id: string) => void;
/** V4复制节点深拷贝数据 + 新 id + 标题加"副本" + order 置末) */
duplicateNode: (id: string) => string | null;
updateTextbookContent: (data: Partial<TextbookContentNodeData>) => void;
getTextbookContentNode: () => TextbookContentNode | undefined;
/**
@@ -141,6 +162,37 @@ export const createEditorSlice: StateCreator<
});
},
duplicateNode: (id) => {
const state = get();
const src = state.doc.nodes.find(
(n): n is LessonPlanNode => n.id === id && n.type !== "textbook_content",
);
if (!src) return null;
state.pushHistory();
const newId = createId();
const teachingNodes = state.doc.nodes.filter(
(n): n is LessonPlanNode => n.type !== "textbook_content",
);
// 深拷贝 data避免引用共享interaction 节点的 turns 内 id 重新生成
const clonedData = cloneBlockData(src.data, src.type);
const newNode: LessonPlanNode = {
id: newId,
type: src.type,
title: `${src.title}(副本)`,
data: clonedData,
order: teachingNodes.length,
position: { x: 0, y: 0 },
stage: src.stage,
differentiation: src.differentiation,
};
set((s) => ({
doc: { ...s.doc, nodes: [...s.doc.nodes, newNode] },
isDirty: true,
selectedNodeId: newId,
}));
return newId;
},
updateTextbookContent: (data) => {
get().pushHistory(); // V5-2撤销/重做
set((s) => ({

View File

@@ -0,0 +1,111 @@
"use client";
import { useCallback, useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { useLessonPlanEditor } from "./use-lesson-plan-editor";
import {
generateNodeContentAction,
optimizeNodeExpressionAction,
suggestNodeDifferentiationAction,
generateLayeredQuestionsAction,
} from "../actions-ai";
import type {
BlockData,
InteractionBlockData,
LessonPlanNode,
} from "../types";
export type NodeAiAction = "generate" | "optimize" | "differentiation" | "layered";
/**
* V4 节点级 AI 协助 hook。
*
* - 调用对应 Server Action 获取 AI 结果
* - 将结果合并到 node.data 或 node.differentiation
* - 通过 toast 反馈运行/成功/失败状态
*
* 用法:
* const { run, isRunning } = useNodeAiAssist();
* <button onClick={() => run("generate", nodeId)}>生成</button>
*/
export function useNodeAiAssist() {
const t = useTranslations("lessonPreparation");
const doc = useLessonPlanEditor((s) => s.doc);
const updateNode = useLessonPlanEditor((s) => s.updateNode);
const [isRunning, setIsRunning] = useState(false);
const run = useCallback(
async (action: NodeAiAction, nodeId: string) => {
const node = doc.nodes.find(
(n): n is LessonPlanNode => n.id === nodeId && n.type !== "textbook_content",
);
if (!node) return;
setIsRunning(true);
toast.info(t("v4.contextMenu.aiRunning"));
try {
if (action === "generate") {
const res = await generateNodeContentAction(node);
if (res.success && res.data) {
// AI patch 合并到 node.datapatch 字段由 lib/ai-node-assist.ts 的 Zod schema 保证类型安全
const merged = { ...node.data, ...res.data.data } as BlockData;
updateNode(nodeId, { data: merged });
toast.success(t("v4.contextMenu.aiSuccess"));
} else {
toast.error(t("v4.contextMenu.aiFailed"));
}
return;
}
if (action === "optimize") {
const res = await optimizeNodeExpressionAction(node);
if (res.success && res.data) {
const merged = { ...node.data, ...res.data.data } as BlockData;
updateNode(nodeId, { data: merged });
toast.success(t("v4.contextMenu.aiSuccess"));
} else {
toast.error(t("v4.contextMenu.aiFailed"));
}
return;
}
if (action === "differentiation") {
const res = await suggestNodeDifferentiationAction(node);
if (res.success && res.data) {
updateNode(nodeId, { differentiation: res.data.level });
toast.success(`${t("v4.contextMenu.aiSuccess")}${res.data.reason}`);
} else {
toast.error(t("v4.contextMenu.aiFailed"));
}
return;
}
// layered
if (node.type !== "interaction") {
toast.error(t("v4.contextMenu.aiFailed"));
return;
}
const res = await generateLayeredQuestionsAction(node);
if (res.success && res.data) {
// interaction 节点 data 类型收窄(已通过 node.type 守卫)
const interactionData = node.data as InteractionBlockData;
const merged = {
...node.data,
turns: [...(interactionData.turns ?? []), ...res.data.turns],
} as BlockData;
updateNode(nodeId, { data: merged });
toast.success(t("v4.contextMenu.aiSuccess"));
} else {
toast.error(t("v4.contextMenu.aiFailed"));
}
} finally {
setIsRunning(false);
}
},
[doc.nodes, updateNode, t],
);
return { run, isRunning };
}

View File

@@ -0,0 +1,250 @@
/**
* V4 节点级 AI 协助4 项功能。
*
* - generate根据节点类型生成默认内容写入 data
* - optimize优化节点现有表达覆盖 data 中对应字段)
* - differentiation判定该节点适合的差异化层次写入 node.differentiation
* - layered为 interaction 节点生成分层提问(追加到 turns
*
* 全部纯服务端,复用 createAiChatCompletion。
* 失败时返回 null由调用方提示用户。
*/
import "server-only";
import { env } from "@/env.mjs";
import { createAiChatCompletion } from "@/shared/lib/ai";
import { isRecord } from "@/shared/lib/type-guards";
import { z } from "zod";
import type {
BlockData,
DifferentiationLevel,
InteractionBlockData,
LessonPlanNode,
QATurn,
} from "../types";
import { createId } from "@paralleldrive/cuid2";
// ---- 公共返回类型 ----
export interface NodeContentUpdate {
/** 合并到 node.data 的 patch */
data: Partial<BlockData>;
}
export interface NodeDifferentiationUpdate {
level: DifferentiationLevel;
reason: string;
}
// ---- Zod schemas ----
const GenerateResultSchema = z.object({
/** 生成的字段路径 → 值(如 { html: "..." } / { designIntent: "..." } / { objectives: [...] } */
patch: z.record(z.string(), z.unknown()),
});
const OptimizeResultSchema = z.object({
patch: z.record(z.string(), z.unknown()),
});
const DifferentiationResultSchema = z.object({
level: z.enum(["basic", "intermediate", "advanced"]),
reason: z.string(),
});
const LayeredQuestionsResultSchema = z.object({
turns: z.array(
z.object({
role: z.enum(["teacher", "student"]),
content: z.string(),
expectedAnswer: z.string().optional(),
}),
),
});
// ---- 节点摘要辅助 ----
function extractNodeText(node: LessonPlanNode): string {
const data = node.data as unknown;
if (!isRecord(data)) return "";
const html = typeof data.html === "string" ? data.html : "";
const sourceText = typeof data.sourceText === "string" ? data.sourceText : "";
const designIntent = typeof data.designIntent === "string" ? data.designIntent : "";
return html || sourceText || designIntent || "";
}
function nodeSummary(node: LessonPlanNode): string {
return JSON.stringify({
id: node.id,
type: node.type,
title: node.title,
stage: node.stage,
differentiation: node.differentiation,
text: extractNodeText(node).slice(0, 500),
data: node.data,
});
}
// ---- 1. 生成本节点内容 ----
const GENERATE_PROMPT = `你是资深教学设计专家。请根据课案节点信息生成对应内容。
节点信息:
---
{node}
---
要求:
- 根据节点 type 生成对应字段:
- rich_text / consolidation / summary / homework / reflection / blackboard / import / new_teaching生成 html 字段HTML 字符串,可含 <p>/<ul>/<h3> 等基础标签)
- objective生成 objectives 数组(含 dimension: knowledge|process|emotion 与 text
- key_point生成 keyPoints 数组(含 type: key|difficult 与 text
- interaction生成 designIntent 字符串 + turns 数组(每项含 role: teacher|student / content / 可选 expectedAnswer
- exercise生成 items 空数组占位即可
- 内容要紧扣节点标题与类型,体现教学设计专业性
- 中文课案用中文生成,英文课案用英文生成
返回 JSON 对象:{ patch: { 字段名: 值 } }`;
export async function generateNodeContent(
node: LessonPlanNode,
): Promise<NodeContentUpdate | null> {
const prompt = GENERATE_PROMPT.replace("{node}", nodeSummary(node));
try {
const { content } = await createAiChatCompletion({
messages: [{ role: "user", content: prompt }],
model: env.AI_MODEL ?? "gpt-4o-mini",
temperature: 0.6,
});
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (!jsonMatch) return null;
const parsed: unknown = JSON.parse(jsonMatch[0]);
const validated = GenerateResultSchema.safeParse(parsed);
if (!validated.success) return null;
return { data: validated.data.patch as Partial<BlockData> };
} catch {
return null;
}
}
// ---- 2. 优化表达 ----
const OPTIMIZE_PROMPT = `你是教学设计专家。请优化以下课案节点的表达,使其更清晰、专业、符合教学规范。
节点信息:
---
{node}
---
要求:
- 保留原有结构,仅优化文字表达
- 不改变字段集合,只修改字段值
- 学科术语使用准确
- 中文课案用中文优化,英文课案用英文优化
返回 JSON 对象:{ patch: { 字段名: 优化后的值 } }`;
export async function optimizeNodeExpression(
node: LessonPlanNode,
): Promise<NodeContentUpdate | null> {
const prompt = OPTIMIZE_PROMPT.replace("{node}", nodeSummary(node));
try {
const { content } = await createAiChatCompletion({
messages: [{ role: "user", content: prompt }],
model: env.AI_MODEL ?? "gpt-4o-mini",
temperature: 0.4,
});
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (!jsonMatch) return null;
const parsed: unknown = JSON.parse(jsonMatch[0]);
const validated = OptimizeResultSchema.safeParse(parsed);
if (!validated.success) return null;
return { data: validated.data.patch as Partial<BlockData> };
} catch {
return null;
}
}
// ---- 3. 差异化建议 ----
const DIFFERENTIATION_PROMPT = `你是教学设计专家。请根据以下课案节点内容,判定该节点最适合的差异化教学层次。
节点信息:
---
{node}
---
判定标准:
- basic基础知识点为基础概念或技能所有学生必学节点内容偏识记与模仿
- intermediate提高知识点为学科核心内容多数学生需掌握节点内容偏理解与应用
- advanced拓展知识点为延伸拓展或综合应用适合学有余力的学生
返回 JSON 对象:{ level: "basic"|"intermediate"|"advanced", reason: "判定依据(一句话)" }`;
export async function suggestNodeDifferentiation(
node: LessonPlanNode,
): Promise<NodeDifferentiationUpdate | null> {
const prompt = DIFFERENTIATION_PROMPT.replace("{node}", nodeSummary(node));
try {
const { content } = await createAiChatCompletion({
messages: [{ role: "user", content: prompt }],
model: env.AI_MODEL ?? "gpt-4o-mini",
temperature: 0.3,
});
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (!jsonMatch) return null;
const parsed: unknown = JSON.parse(jsonMatch[0]);
const validated = DifferentiationResultSchema.safeParse(parsed);
if (!validated.success) return null;
return { level: validated.data.level, reason: validated.data.reason };
} catch {
return null;
}
}
// ---- 4. 生成分层提问(仅 interaction 节点) ----
const LAYERED_PROMPT = `你是教学设计专家。请为以下师生交互节点生成分层提问,覆盖基础/提高/拓展三个层次。
节点信息:
---
{node}
---
要求:
- 生成 3 轮对话basic / intermediate / advanced 各一轮)
- 每轮含 teacher 提问 + student 预期回答
- 提问层层递进,从识记 → 理解 → 应用
- 中文课案用中文生成,英文课案用英文生成
返回 JSON 对象:{ turns: [{ role: "teacher"|"student", content: "提问/回答", expectedAnswer: "可选,仅 teacher 轮需要" }] }`;
export async function generateLayeredQuestions(
node: LessonPlanNode,
): Promise<QATurn[] | null> {
if (node.type !== "interaction") return null;
const prompt = LAYERED_PROMPT.replace("{node}", nodeSummary(node));
try {
const { content } = await createAiChatCompletion({
messages: [{ role: "user", content: prompt }],
model: env.AI_MODEL ?? "gpt-4o-mini",
temperature: 0.5,
});
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (!jsonMatch) return null;
const parsed: unknown = JSON.parse(jsonMatch[0]);
const validated = LayeredQuestionsResultSchema.safeParse(parsed);
if (!validated.success) return null;
// 为每条 turn 分配 id + order
const existing = (node.data as InteractionBlockData).turns ?? [];
let order = existing.length;
return validated.data.turns.map((t) => ({
id: createId(),
role: t.role,
content: t.content,
expectedAnswer: t.expectedAnswer,
order: order++,
}));
} catch {
return null;
}
}

View File

@@ -21,10 +21,9 @@ export async function translateFieldErrors(
result[field] = messages.map((msg) => {
// 仅翻译以 "error." 开头的 i18n 键,其他保持原样
if (msg.startsWith("error.")) {
// V4 P1-13 修复next-intl 的 t 函数对键有字面量类型约束,
// 动态字符串需断言为键类型。msg 已通过 startsWith 校验为合法 i18n 键前缀,
// 此处 as 属于"从 string 收窄到字面量联合类型"的类型收窄,符合项目规则例外。
return t(msg as Parameters<typeof t>[0]);
// 运行时守卫:先用 t.has() 检查键是否存在,避免 as 断言绕过类型检查
// 若键不存在schema 中误写不存在的键),返回原消息而非抛出 MISSING_MESSAGE
if (t.has(msg)) { return t(msg as Parameters<typeof t>[0]); } return msg;
}
return msg;
});