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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -43,6 +43,8 @@ export function TextbookTiptapEditor({ content, readonly }: Props) {
|
||||
],
|
||||
content,
|
||||
editable: !readonly,
|
||||
// SSR 必填:避免 hydration mismatches(Tiptap v2.4+ 要求显式设置)
|
||||
immediatelyRender: false,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "lp-textbook-editor prose prose-sm max-w-none focus:outline-none",
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
111
src/modules/lesson-preparation/hooks/use-node-ai-assist.ts
Normal file
111
src/modules/lesson-preparation/hooks/use-node-ai-assist.ts
Normal 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.data;patch 字段由 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 };
|
||||
}
|
||||
250
src/modules/lesson-preparation/lib/ai-node-assist.ts
Normal file
250
src/modules/lesson-preparation/lib/ai-node-assist.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user