+
+ 将重新生成「{p.title}」的下层(替换 {p.replaces} 张子卡{p.below ? `,含其下 ${p.below} 张孙卡` : ""})
+
+ {p.state === "pending" && kids.length > 0 && (
+
+ {kids.slice(0, 5).map((k) => (
+ -
+
{k.title}
+
+ ))}
+ {kids.length > 5 && - …共 {kids.length} 张
}
+
+ )}
+
按:{p.instruction}
+
+ {p.state === "pending" && (
+ <>
+
+
+ >
+ )}
+ {p.state === "applied" && (
+ <>
+ 已重做下层{p.added ? `(新 ${p.added} 张,旧卡已归档)` : ""}
+
+ >
+ )}
+ {p.state === "undone" && 已撤销}
+ {p.state === "cancelled" && 已取消}
+ {p.state === "expired" && 已过期}
+
+
+ );
+}
+
function RouteDiff({ message, chat }: { message: ChatMessage; chat: ChatActions }) {
const p = message.meta?.proposal;
if (p?.type !== "editRoute") return null;
diff --git a/src/model/boards.ts b/src/model/boards.ts
index 8dae19d..798a608 100644
--- a/src/model/boards.ts
+++ b/src/model/boards.ts
@@ -10,6 +10,7 @@ import {
aiExpansionSchema,
aiMapSchema,
boardSchema,
+ LIMITS,
pointSchema,
} from "../shared/validation";
import { applyOverviews } from "./overview";
@@ -297,7 +298,7 @@ export function buildMapFromAI(
const result = schema.safeParse(parsed);
if (!result.success)
throw new Error(
- "AI 返回的图谱不符合格式要求。请要求 1–8 个纯文本节点,并重试。",
+ `AI 返回的图谱不符合格式要求。请要求 1–${mode === "expand" ? LIMITS.expansionCards : LIMITS.mapCards} 个纯文本节点,并重试。`,
);
const map = result.data;
const anchor = board.nodes.find(
diff --git a/src/model/chat.ts b/src/model/chat.ts
index f11da81..85ecf94 100644
--- a/src/model/chat.ts
+++ b/src/model/chat.ts
@@ -2,31 +2,54 @@
// structured proposal per answer. No classifier and no modes; free text never writes cards.
import type { AIRequest, Board, ChatMessage, ChatProposal, StickyNote } from "../shared/types";
import { chatProposalSchema, routeOpSchema } from "../shared/validation";
-import { routeOf, sealed, stationOf, validRouteOps } from "./route";
+import { descendantIds, routeOf, sealed, stationOf, validRouteOps } from "./route";
import { z } from "zod";
/** Rough token estimate (CJK ≈ 1 token per 1.5 characters; ASCII is cheaper, so this overshoots). */
export const estimateTokens = (text: string) => Math.ceil(text.length / 1.5);
/** Per-turn budget: the system prompt + this prompt + the answer stay under 2.5k tokens. */
export const TURN_CAP = 2_500;
-const SYSTEM_TOKENS = 450;
+const SYSTEM_TOKENS = 800;
const OUTPUT_TOKENS = 400;
const HISTORY = 6;
const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}…` : s);
const plain = (s: string) => s.replace(/\s+/g, " ").trim();
-/** Which stop the chat is about: the drawer's card when it is on the route, else the next unsealed one. */
-export function currentStop(board: Board, note?: StickyNote) {
+/**
+ * Which stop the chat is about: the card selected on the canvas when it is on the route, else the
+ * thread's card when it is, else the next unsealed one.
+ */
+export function currentStop(board: Board, note?: StickyNote, selectedId?: string) {
const stops = routeOf(board);
const anchor = note?.attachedTo?.kind === "node" ? note.attachedTo.id : undefined;
- return stops.find((s) => s.id === anchor) ?? stops.find((s) => !sealed(s));
+ return (
+ stops.find((s) => s.id === selectedId) ?? stops.find((s) => s.id === anchor) ?? stops.find((s) => !sealed(s))
+ );
+}
+
+const SHOWN_CHILDREN = 8;
+/** What the learner has selected on the canvas, with the cards directly below it (L2): title + 40 chars. */
+function selectedBlock(board: Board, selectedId?: string): string {
+ const card = board.nodes.find((n) => n.id === selectedId);
+ // The root's L2 is the route itself, which the prompt already lists.
+ if (!card || card.parentId === null) return "";
+ const no = stationOf(board, card.id);
+ const kids = board.nodes.filter((n) => n.parentId === card.id).sort((a, b) => a.order - b.order);
+ const list = kids
+ .slice(0, SHOWN_CHILDREN)
+ .map((k) => `- 「${clip(plain(k.title), 24)}」${k.summary || k.body ? `:${clip(plain(k.summary || k.body), 40)}` : ""}`);
+ if (kids.length > SHOWN_CHILDREN) list.push(`- …共 ${kids.length} 张`);
+ return (
+ `选中卡:${no ? `第 ${no} 站` : ""}「${clip(plain(card.title), 40)}」\n` +
+ (list.length ? `下层(L2) ${kids.length} 张:\n${list.join("\n")}\n` : "下层(L2):还没有\n")
+ );
}
/** The user prompt: topic, numbered route, current stop, the last 6 turns and the new message. */
-export function chatPrompt(board: Board, note: StickyNote | undefined, text: string): string {
+export function chatPrompt(board: Board, note: StickyNote | undefined, text: string, selectedId?: string): string {
const stops = routeOf(board);
- const current = currentStop(board, note);
+ const current = currentStop(board, note, selectedId);
const route = stops.length
? stops.map((s, i) => `${i + 1}. ${clip(plain(s.title), 40)}${sealed(s) ? " ✓" : ""}`).join("\n")
: "(还没有路线)";
@@ -35,7 +58,8 @@ export function chatPrompt(board: Board, note: StickyNote | undefined, text: str
`路线(✓ 为已讲通):\n${route}\n` +
(current
? `当前站:第 ${stationOf(board, current.id)} 站「${clip(plain(current.title), 40)}」${current.body ? `:${clip(plain(current.body), 200)}` : ""}\n`
- : "");
+ : "") +
+ selectedBlock(board, selectedId);
// A card off the route that the thread hangs on, and cards the learner added on purpose.
const anchorId = note?.attachedTo?.kind === "node" ? note.attachedTo.id : undefined;
const anchor = board.nodes.find((n) => n.id === anchorId);
@@ -63,13 +87,13 @@ export function chatRequest(
board: Board,
note: StickyNote | undefined,
text: string,
- ids: { requestId: string; stickyId: string },
+ ids: { requestId: string; stickyId: string; selectedId?: string },
): AIRequest {
return {
boardId: board.id,
stickyId: ids.stickyId,
requestId: ids.requestId,
- prompt: chatPrompt(board, note, text),
+ prompt: chatPrompt(board, note, text, ids.selectedId),
context: { topic: board.topic, references: [], summary: "" },
mode: "chat",
canvas: false,
@@ -79,6 +103,9 @@ export function chatRequest(
const rawProposal = z.union([
z.object({ type: z.literal("editRoute"), ops: z.array(z.unknown()).min(1) }).passthrough(),
z.object({ type: z.literal("startStop"), stop: z.number() }).passthrough(),
+ z
+ .object({ type: z.literal("reExpand"), stop: z.union([z.number(), z.string()]), instruction: z.string() })
+ .passthrough(),
]);
/** Where the answer text ends: a trailing ```json block or a bare {"proposal":…} tail. */
@@ -116,8 +143,32 @@ export function parseChatReply(text: string, board: Board): { reply: string; pro
return proposal ? { reply, proposal } : { reply };
}
-/** Local validation: unknown stations are dropped; startStop only for an unsealed stop. */
+/** A station number, or a card title that exactly one non-root card has; the root cannot be re-expanded. */
+function reExpandTarget(board: Board, ref: number | string) {
+ if (typeof ref === "number") return routeOf(board)[ref - 1];
+ const title = plain(ref);
+ const hits = board.nodes.filter((n) => n.parentId !== null && plain(n.title) === title);
+ return hits.length === 1 ? hits[0] : undefined;
+}
+
+/** Local validation: unknown stations are dropped; startStop only for an unsealed stop; reExpand only for a real card. */
export function checkProposal(raw: z.infer