Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 37 additions & 10 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { assessmentPrompt, gapMessage, goalText, parseAssessment, structuredGoal, upgradeCard } from "./model/goal";
import { assessmentPrompt, cardMastered, gapMessage, goalText, knownIds, parseAssessment, structuredGoal, upgradeCard } from "./model/goal";
import { AgentEdits } from "./components/AgentEditProse";
import { planAgentEdits, editDuration, existingBodyChanges, type AgentEdit } from "./model/agent-edit";
import {
Expand Down Expand Up @@ -1448,9 +1448,10 @@ export default function App() {
let next: Board = { ...boardRef.current!, probe: state };
if (next.route?.length) next = applyFrontier(next);
edit(() => next);
if (!next.route?.length && !next.plan) requestPlan(next);
// New question: 摸底 first, then the goal questions (kickoff); the plan follows the kickoff's ready:true.
if (!next.route?.length && !next.plan) startKickoff();
},
[edit, requestPlan],
[edit, startKickoff],
);
/** Run the machine until it needs an answer (ask) or is done; EXPAND / REFINE are generation calls. */
const advanceProbe = useCallback(
Expand Down Expand Up @@ -1771,7 +1772,8 @@ export default function App() {
}
// A new question starts the guided kickoff (or leads to AI settings) instead of a silent map.
if (kickoff) {
startKickoff();
if (settings.configured) startProbe();
else startKickoff();
}
}
async function importBoardFile(file: File) {
Expand Down Expand Up @@ -1930,7 +1932,14 @@ export default function App() {
}, [addNote, edit, showToast]);
readCardRef.current = (id) => {
const card = boardRef.current?.nodes.find(n => n.id === id);
if (!card || card.status === "explained") return;
if (!card || cardMastered(card)) return;
// 摸底 already showed this is known, so a reviewed 懂了 counts as mastery (Yaphet's call).
if (knownIds(boardRef.current!.nodes).has(id)) {
patchNode(id, { status: "explained", beat: "done", seal: "probe-reviewed", fails: 0 });
void assessStop(id);
return;
}
if (card.status === "explained") return;
// Reading is recorded separately from demonstrated mastery.
patchNode(id, { status: "exploring", beat: "done" });
void assessStop(id);
Expand All @@ -1944,15 +1953,17 @@ export default function App() {
if (boardRef.current?.id !== initial.id || !noteId) return;
const current = boardRef.current.nodes.find(n => n.id === id);
if (!current || current.body !== card.body || current.weight !== card.weight) return;
// A miss on a 摸底「已会」card only revokes that card; probe-revoked keeps re-probing from re-sealing it.
const wasKnown = !!mark && !mark.pass && knownIds(boardRef.current.nodes).has(id);
edit(b => ({
...b,
nodes: b.nodes.map(n => n.id === id ? { ...n,
...(mark?.pass ? { status: "explained" as const, beat: "done" as const, seal: "test" as const, fails: 0 } : mark ? { ...upgradeCard(n), fails: Math.min(99, (n.fails ?? 0) + 1) } : {}),
...(mark?.pass ? { status: "explained" as const, beat: "done" as const, seal: "test" as const, fails: 0 } : mark ? { ...(wasKnown ? { seal: "probe-revoked" as const, status: "exploring" as const } : {}), ...upgradeCard(n), fails: Math.min(99, (n.fails ?? 0) + 1) } : {}),
explain: answer,
} : n),
notes: b.notes.map(n => n.id === noteId ? { ...n, messages: cacheMessages([...n.messages,
{ id: uid(), role: "user", text: answer, createdAt: now(), meta: { chat: true } },
{ id: uid(), role: "assistant", text: mark ? mark.pass ? "讲清楚了。" : `再想想:${mark.missing || "结合卡片里的依据说说为什么。"}` : "这次没判出来,稍后再提交一次。", createdAt: now(), meta: { chat: true } },
{ id: uid(), role: "assistant", text: mark ? mark.pass ? "讲清楚了。" : `${wasKnown ? "这张我之前估计你会,看来还值得再看一下,已经放回路线里。\n\n" : ""}再想想:${mark.missing || "结合卡片里的依据说说为什么。"}` : "这次没判出来,稍后再提交一次。", createdAt: now(), meta: { chat: true } },
]) } : n),
}));
if (mark?.pass) void assessStop(id);
Expand Down Expand Up @@ -2130,7 +2141,9 @@ export default function App() {
addNote({ kind: "node", id }, { cards: false });
};
startFirstRef.current = () => {
const first = boardRef.current && routeOf(boardRef.current)[0];
// Stations the 摸底 already sealed are skipped: start at the first one above the learner's edge.
const route = boardRef.current ? routeOf(boardRef.current) : [];
const first = route.find((c) => !c.seal) ?? route[0];
if (first) {
patchNode(first.id, { status: "exploring", beat: first.weight === "background" ? "learn" : first.weight === "core" ? "test" : "explain" });
addNote({ kind: "node", id: first.id }, { cards: false });
Expand Down Expand Up @@ -2590,7 +2603,7 @@ export default function App() {
});
api.ask(request).catch(() => requestMeta.current.delete(request.requestId));
};
const kickoffReady = needsKickoff(board) && !board?.probe && !mapBusy && !study;
const kickoffReady = needsKickoff(board) && board?.probe?.status !== "asking" && !mapBusy && !study;
const drawerNote = board?.notes.find((n) => n.id === drawer?.id);
// Before 应用: the open thread's pending route edit marks the stops it would replace or remove.
const routePreview = useMemo(() => {
Expand Down Expand Up @@ -2677,8 +2690,20 @@ export default function App() {
() => sectionNumbers(board?.nodes ?? []),
[board?.nodes],
);
// 摸底「已会」only shows on weighted AI routes.
const knownSet = useMemo(
() => (board?.kind === "ai" ? knownIds(board.nodes) : new Set<string>()),
[board?.kind, board?.nodes],
);
const nodes = useMemo<CanvasNode[]>(() => {
if (!shown || !board) return [];
const knownStation = (id: string): number | undefined => {
for (let n = board.nodes.find((x) => x.id === id), hops = 0; n && hops < 64; n = board.nodes.find((x) => x.id === n!.parentId), hops++) {
const s = stationOf(board, n.id);
if (s !== undefined) return s;
}
return undefined;
};
const generatingCards = new Set<string>();
for (const { request, anchor } of requestMeta.current.values()) {
if (
Expand Down Expand Up @@ -2764,6 +2789,7 @@ export default function App() {
: {}),
onPreviewAdd: hoverAdd,
station: stationOf(board, n.id),
...(knownSet.has(n.id) ? { known: knownStation(n.id) } : {}),
isNext: nextId === n.id,
lit: !!board.wrap?.lit.includes(n.id),
...(routePreview.get(n.id) ? { preview: routePreview.get(n.id) } : {}),
Expand Down Expand Up @@ -2805,6 +2831,7 @@ export default function App() {
return out;
}, [
routePreview,
knownSet,
shown,
board,
layout,
Expand Down Expand Up @@ -3209,7 +3236,7 @@ export default function App() {
return (
<div className="canvas-corner">
{paused > 0 && <PausedPill onOpen={() => setShowCanvasActivity(true)} />}
<Progress nodes={board.nodes} />
<Progress nodes={board.nodes} known={board.kind === "ai" ? knownSet : undefined} />
<CanvasMenu
paused={paused}
onReprobe={reprobe}
Expand Down
44 changes: 38 additions & 6 deletions src/components/CanvasNodes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ export type CardData = {
kickoffStarted?: boolean;
/** Station number on the learning route (第 n 站). */
station?: number;
/** 摸底「已会」(weighted routes): the station number it was inferred at. */
known?: number;
/** This card is the one next step: red outline + 下一步 label. */
isNext?: boolean;
/** 合起来讲 reached this station: it lights up. */
Expand Down Expand Up @@ -486,12 +488,37 @@ export function faceText(body: string): string {
return out.trim();
}

export const KNOWN_EXIT_MS = 240;

/** When a 摸底「已会」seal is revoked (seal → probe-revoked), keep the old station number for
* KNOWN_EXIT_MS so the mark and quiet styling fade out; reduced motion switches at once. */
function useKnownExit(known: number | undefined, seal: ConceptNode["seal"]): number | undefined {
const prev = useRef(known);
const [leaving, setLeaving] = useState<number | undefined>(undefined);
useEffect(() => {
const was = prev.current;
prev.current = known;
if (known !== undefined) {
setLeaving(undefined);
return;
}
if (was === undefined || seal !== "probe-revoked") return;
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
setLeaving(was);
const t = setTimeout(() => setLeaving(undefined), KNOWN_EXIT_MS);
return () => clearTimeout(t);
}, [known, seal]);
return leaving;
}

export function ConceptCard({ data, selected }: NodeProps<CardNode>) {
const n = data.concept;
// Weighted AI cards (weightActions) only count core/hard answers as mastery; manual boards keep
// the learner's own 「我能解释」 mark as the seal.
const mastered = data.weightActions ? cardMastered(n) : n.status === "explained";
const edits = useContext(AgentEdits);
const leaving = useKnownExit(data.known, n.seal);
const markNo = data.known ?? leaving;
// Stamp only when the status changes while mounted, not on first paint.
const first = useRef(n.status);
const animate = first.current !== n.status;
Expand All @@ -506,18 +533,23 @@ export function ConceptCard({ data, selected }: NodeProps<CardNode>) {
return (
<article
{...attention}
className={`card s-${n.status} ${selected || data.noteOpen ? "is-selected" : ""} ${animate ? "animate" : ""} ${data.dimmed ? "dimmed" : ""} ${data.lit ? "lit" : ""} ${data.preview ? `pv-${data.preview}` : ""} ${hintsOpen ? "hints-open" : ""} ${controlsActive ? "hint-controls-active" : ""}`}
className={`card s-${n.status} ${data.known !== undefined ? "known" : ""} ${leaving !== undefined ? "known-out" : ""} ${selected || data.noteOpen ? "is-selected" : ""} ${animate ? "animate" : ""} ${data.dimmed ? "dimmed" : ""} ${data.lit ? "lit" : ""} ${data.preview ? `pv-${data.preview}` : ""} ${hintsOpen ? "hints-open" : ""} ${controlsActive ? "hint-controls-active" : ""}`}
aria-busy={data.generating || undefined}
>
{data.generating && <BorderBeam />}
{data.noteOpen && <NoteTab />}
<Ports linkMode={data.linkMode} />
{mastered && (!data.weightActions || data.station === undefined) && <Seal light={n.seal === "probe"} />}
{data.preview === "replace" && <span className="pv-tag">待替换</span>}
{data.station !== undefined && data.preview !== "remove" && (
<span className={`station ${data.isNext ? "next" : ""} ${mastered ? "sealed" : ""}`} aria-label={`第 ${data.station} 站`}>
{n.status !== "unseen" && <i className={`station-dot ${mastered ? "mastered" : "read"}`} aria-label={mastered ? "掌握" : "读过"} />}
<span>第 {data.station} 站</span>
{(data.station !== undefined || markNo !== undefined) && data.preview !== "remove" && (
<span className={`station ${data.isNext ? "next" : ""} ${mastered ? "sealed" : ""} ${markNo !== undefined ? "known" : ""}`} aria-label={`第 ${data.station ?? markNo} 站`}>
{markNo !== undefined ? (
<span className={`known-mark ${leaving !== undefined ? "leaving" : ""}`} role="img" aria-label="你已经会了(摸底推断),可跳过或复习">
<Seal outline size={13} />
<span>已会</span>
</span>
) : n.status !== "unseen" && !(data.weightActions && n.seal === "probe") && <i className={`station-dot ${mastered ? "mastered" : "read"}`} aria-label={mastered ? "掌握" : "读过"} />}
<span>{markNo !== undefined ? "· " : ""}第 {data.station ?? markNo} 站</span>
</span>
)}
{data.isNext && <span className="next-tag">下一步</span>}
Expand All @@ -528,7 +560,7 @@ export function ConceptCard({ data, selected }: NodeProps<CardNode>) {
{/* Compact face: the first sentence or two of the prose; the drawer shows all of it. */}
{(n.body || edits.get(n.id)?.after === n.body) && <AgentEditProse body={n.body} cardId={n.id} className="face-body" project={n.weight ? undefined : faceText} />}
<CardSourceLine card={n} />
{data.weightActions && <WeightFooter card={n} actions={data.weightActions} />}
{data.weightActions && <WeightFooter card={n} actions={data.weightActions} known={data.known !== undefined} />}
{!data.weightActions && data.isNext && data.onStartNext && (
<button
className="btn primary start-stop nodrag nopan"
Expand Down
23 changes: 15 additions & 8 deletions src/components/Chrome.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { cardMastered } from "../model/progress";
import { useEffect, useMemo, useState } from "react";
import {
MiniMap,
Expand Down Expand Up @@ -129,17 +130,22 @@ export function Palette({
);
}

export function Progress({ nodes }: { nodes: ConceptNode[] }) {
export function Progress({ nodes, known }: { nodes: ConceptNode[]; known?: Set<string> }) {
const concepts = nodes.filter((n) => n.parentId !== null);
const done = concepts.filter((n) => n.status === "explained").length;
// Weighted routes: 已会 (摸底) is counted apart from 掌握 and never as a sealed segment.
const weighted = !!known && concepts.some((n) => known.has(n.id));
const stateOf = (n: ConceptNode) =>
weighted ? (known!.has(n.id) ? "known" : cardMastered(n) ? "explained" : n.status === "unseen" ? "unseen" : "exploring") : n.status;
const done = concepts.filter((n) => stateOf(n) === "explained").length;
const knownCount = weighted ? concepts.filter((n) => known!.has(n.id)).length : 0;
return (
<div className="chrome progress">
<div className="chrome progress" title={knownCount ? "● 掌握 ○ 读过 已会(摸底)" : undefined}>
<span className="lab">理解进度</span>
{concepts.length > 16 ? (
// Many concepts: one continuous bar, filled by share of each state.
<span className="bar long" aria-hidden="true">
{(["explained", "exploring", "unseen"] as const).map((st) => {
const k = concepts.filter((n) => n.status === st).length;
{(["explained", "known", "exploring", "unseen"] as const).map((st) => {
const k = concepts.filter((n) => stateOf(n) === st).length;
return k ? (
<i key={st} className={`s-${st}`} style={{ flexGrow: k }} />
) : null;
Expand All @@ -148,16 +154,17 @@ export function Progress({ nodes }: { nodes: ConceptNode[] }) {
) : (
<span className="bar" aria-hidden="true">
{concepts.map((n) => (
<i key={n.id} className={`s-${n.status}`} />
<i key={n.id} className={`s-${stateOf(n)}`} />
))}
</span>
)}
<span
className="count"
aria-label={`${concepts.length} 个概念里讲清楚了 ${done} 个`}
aria-label={`${concepts.length} 个概念里讲清楚了 ${done} 个${knownCount ? `,摸底已会 ${knownCount} 个` : ""}`}
>
{knownCount ? `掌握 ${done} · 已会 ${knownCount} ` : null}
<b>
{done} / {concepts.length}
{knownCount ? `/ ${concepts.length}` : `${done} / ${concepts.length}`}
</b>
</span>
</div>
Expand Down
10 changes: 9 additions & 1 deletion src/components/Seal.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
/** The 「我能解释」 seal: the round-cap A from the app icon on a vermilion rounded square. */
export const Seal = ({ size = 26, light = false }: { size?: number; light?: boolean }) => (
export const Seal = ({ size = 26, light = false, outline = false }: { size?: number; light?: boolean; outline?: boolean }) =>
outline ? (
// 摸底「已会」: the same stamp, outlined in currentColor so it never reads as a filled/hollow dot.
<svg className="seal seal-known" width={size} height={size} viewBox="-50 -50 100 100" aria-hidden="true">
<rect x="-44" y="-44" width="88" height="88" rx="24" fill="none" stroke="currentColor" strokeWidth="8" />
<path d="M-20 26 L0 -26 L20 26" fill="none" stroke="currentColor" strokeWidth="9.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M-24 9 H24" stroke="currentColor" strokeWidth="7.5" strokeLinecap="round" />
</svg>
) : (
<svg
className={`seal ${light ? "light" : ""}`}
width={size}
Expand Down
17 changes: 14 additions & 3 deletions src/components/WeightFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ export interface WeightActions {
export function WeightFooter({
card,
actions,
known = false,
}: {
card: ConceptNode;
actions: WeightActions;
/** 摸底「已会」: one shared 复习一下 footer until the learner opens the card's own one. */
known?: boolean;
}) {
const [reviewing, setReviewing] = useState(false);
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
const initialWeight = useRef(card.weight);
Expand All @@ -31,13 +35,20 @@ export function WeightFooter({
return (
<div
key={weight}
className={`weight-footer nodrag nopan ${weight === "background" ? "background" : ""} ${initialWeight.current !== card.weight ? "upgraded" : ""}`}
className={`weight-footer nodrag nopan ${weight === "background" && !(known && !reviewing) ? "background" : ""} ${initialWeight.current !== card.weight ? "upgraded" : ""}`}
data-weight={weight}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{cardMastered(card) ||
(weight === "background" && (card.beat === "done" || card.status === "explained")) ? (
{known && !reviewing ? (
<div className="known-footer">
<button className="btn sm quiet" onClick={() => setReviewing(true)}>
复习一下
</button>
<span className="lab">可跳过</span>
</div>
) : cardMastered(card) ||
(!known && weight === "background" && (card.beat === "done" || card.status === "explained")) ? (
<span className="lab">
{cardMastered(card) ? "已掌握" : "读过"}
</span>
Expand Down
3 changes: 1 addition & 2 deletions src/model/goal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@ export const structuredGoal = (board: Board) =>
typeof board.plan?.goal === "object" ? board.plan.goal : undefined;

/** Background reading never demonstrates mastery, including legacy explained cards. */
export const cardMastered = (card: Pick<ConceptNode, "weight" | "status">) =>
(card.weight === "core" || card.weight === "hard") && card.status === "explained";
export { cardMastered, knownIds } from "./progress";

/** A single promotion per learner action. Questions are prepared by generation, never invented facts. */
export function upgradeCard(card: ConceptNode): Partial<ConceptNode> {
Expand Down
2 changes: 1 addition & 1 deletion src/model/probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@ export function applyFrontier(board: Board): Board {
const f = probe.frontier ?? 0;
const levels = stationLevels(board);
const nodes = board.nodes.map((n): ConceptNode => {
if (n.seal === "test") return n;
if (n.seal === "test" || n.seal === "probe-revoked" || n.seal === "probe-reviewed") return n;
const level = levels.get(n.id);
const below = level !== undefined && level < f;
if (below) return { ...n, seal: "probe", status: "explained", beat: "done" };
Expand Down
Loading
Loading