diff --git a/electron/ai-runtime.ts b/electron/ai-runtime.ts index 9b8aa1f..9b81622 100644 --- a/electron/ai-runtime.ts +++ b/electron/ai-runtime.ts @@ -717,6 +717,34 @@ export class AIRuntime { return operation.then(() => this.getSettings()); } + /** Background prose rewrite, without conversation history, tools or thinking. */ + async rewriteProfile(prompt: string): Promise { + await this.settingsWrite; + if (this.closed || !this.getSettings().configured) throw new Error(MISSING_KEY); + const model = this.models.getModel("openai", this.model); + if (!model) throw new Error("Missing model"); + const reply = await this.models.completeSimple( + model, + { messages: [{ role: "user", content: prompt, timestamp: Date.now() }] }, + { + maxTokens: 1600, + fetch: globalThis.fetch, + signal: AbortSignal.timeout(90000), + // Compatible gateways may prefix model ids (e.g. deepseek/deepseek-v4.1-flash), + // which pi-ai does not recognize as DeepSeek for its automatic thinking-off payload. + onPayload: (payload) => + /(?:^|\/)deepseek-/i.test(model.id) + ? { ...(payload as Record), thinking: { type: "disabled" } } + : undefined, + }, + ); + if (reply.stopReason !== "stop") throw new Error("Profile rewrite failed"); + return reply.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join(""); + } + private installProvider(): void { this.models.setProvider(buildProvider(this.baseUrl, this.model)); } diff --git a/electron/main.ts b/electron/main.ts index ed1cb28..14e54fe 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -10,6 +10,8 @@ import path from "node:path"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; +import { ProfileStore } from "./profile-store.js"; +import { boardEvidence } from "../src/shared/profile.js"; import { BoardStore } from "./board-store.js"; import { importTransferredBoard } from "./board-transfer.js"; import { @@ -156,6 +158,21 @@ async function bootstrap(): Promise { window.webContents.send("studyboard:canvas-run", { run }); }, ); + const profile = new ProfileStore(path.join(app.getPath("userData"), "profile.json"), + prompt => runtime.rewriteProfile(prompt), value => { + if (window && !window.isDestroyed()) window.webContents.send("studyboard:profile-changed", value); + }); + const profileReady = profile.initialize(async () => { + const boards = []; + // Oldest first: the bounded queue keeps the most recent learning results. + for (const item of (await store.list()).reverse()) { + const board = await store.load(item.id); + if (board) boards.push(board); + } + return boards; + }); + // Learning observations follow successful saves in order, including the final close flush. + let saves: Promise = Promise.resolve(); function assertSender(event: Electron.IpcMainInvokeEvent) { const url = event.senderFrame?.url; if (event.sender !== window?.webContents || !url) @@ -175,7 +192,18 @@ async function bootstrap(): Promise { } handle("studyboard:list", () => store.list()); handle("studyboard:load", (value) => store.load(id.parse(value))); - handle("studyboard:save", (value) => store.save(boardSchema.parse(value))); + handle("studyboard:profile", async () => { await profileReady; return profile.get(); }); + handle("studyboard:save", (value) => { + const board = boardSchema.parse(value); + const task = saves.then(async () => { + const previous = await store.load(board.id); + await store.save(board); + await profileReady; + await profile.enqueue(boardEvidence(board, previous)); + }); + saves = task.catch(() => undefined); + return task; + }); handle("studyboard:delete", (value) => store.delete(id.parse(value))); handle("studyboard:import", (value) => { if (closePending) throw new Error("正在关闭应用,请稍后重试。"); diff --git a/electron/preload.cts b/electron/preload.cts index d72348f..4b48a14 100644 --- a/electron/preload.cts +++ b/electron/preload.cts @@ -32,6 +32,12 @@ const api: AtlasAPI = { exportBoard: (board, format, image) => ipcRenderer.invoke("studyboard:export", board, format, image), importBoard: (text) => ipcRenderer.invoke("studyboard:import", text), + getProfile: () => ipcRenderer.invoke("studyboard:profile"), + onProfileChanged: callback => { + const listener = (_event: Electron.IpcRendererEvent, value: Parameters[0]) => callback(value); + ipcRenderer.on("studyboard:profile-changed", listener); + return () => ipcRenderer.removeListener("studyboard:profile-changed", listener); + }, getAISettings: () => ipcRenderer.invoke("studyboard:ai-settings"), setAISettings: (settings) => ipcRenderer.invoke("studyboard:configure-ai", settings), diff --git a/electron/profile-store.ts b/electron/profile-store.ts new file mode 100644 index 0000000..f4c73e7 --- /dev/null +++ b/electron/profile-store.ts @@ -0,0 +1,144 @@ +import { constants } from "node:fs"; +import { mkdir, open, readFile, rename, unlink } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { + boardEvidence, + emptyProfile, + enqueueEvidence, + profileSchema, + rewritePrompt, + shouldRewrite, + type ProfileEvent, + type UserProfile, +} from "../src/shared/profile.js"; +import type { Board } from "../src/shared/types.js"; + +/** File mutations are serialized; model calls run outside that queue so reads/saves stay responsive. */ +export class ProfileStore { + private profile = emptyProfile(); + private queue: Promise = Promise.resolve(); + private rewriting = false; + private immediate = false; + constructor( + private file: string, + private rewrite: (prompt: string) => Promise, + private changed: (profile: UserProfile) => void = () => {}, + ) {} + private serial(operation: () => Promise): Promise { + const task = this.queue.then(operation); + this.queue = task.catch(() => undefined); + return task; + } + async initialize(boards: () => Promise): Promise { + let missing = false; + await this.serial(async () => { + try { + this.profile = profileSchema.parse( + JSON.parse(await readFile(this.file, "utf8")), + ); + } catch (error) { + missing = (error as NodeJS.ErrnoException).code === "ENOENT"; + if (!missing) + await rename(this.file, `${this.file}.corrupt-${randomUUID()}`); + this.profile = emptyProfile(); + if (missing) + this.profile = enqueueEvidence( + this.profile, + (await boards()).flatMap((b) => boardEvidence(b)), + ); + await this.persist(); + } + }); + this.kick(missing ? "probe" : "startup"); + } + async get(): Promise { + await this.queue; + return structuredClone(this.profile); + } + async enqueue(events: ProfileEvent[]): Promise { + if (!events.length) return; + await this.serial(async () => { + this.profile = enqueueEvidence(this.profile, events); + await this.persist(); + }); + this.kick(events.some((e) => e.kind === "probe") ? "probe" : "queue"); + } + private kick(trigger: "probe" | "queue" | "startup") { + if (!shouldRewrite(this.profile, trigger)) return; + if (this.rewriting) { + if (trigger === "probe") this.immediate = true; + return; + } + this.rewriting = true; + const batch = structuredClone(this.profile.pending); + const prompt = rewritePrompt(this.profile, batch); + void this.rewrite(prompt) + .then((text) => + this.serial(async () => { + const clean = text.trim(); + if ( + !clean || + clean.length > 500 || + /[\r\n]|^\s*(?:[\[{#*]|-\s|\d+[.、])/.test(clean) + ) + throw new Error("Invalid profile prose"); + const time = new Date().toISOString(); + const next = { + ...this.profile, + text: clean, + updatedAt: time, + lastRewriteAt: time, + pending: this.profile.pending.filter( + (e) => !batch.some((b) => b.id === e.id && b.detail === e.detail), + ), + }; + const previous = this.profile; + this.profile = next; + try { + await this.persist(); + this.changed(structuredClone(this.profile)); + } catch (error) { + this.profile = previous; + throw error; + } + }), + ) + .then(() => { + this.rewriting = false; + const next = this.immediate ? "probe" : "queue"; + this.immediate = false; + this.kick(next); + }) + .catch(() => { + this.rewriting = false; + this.immediate = false; + }); + } + private async persist() { + const dir = dirname(this.file); + await mkdir(dir, { recursive: true, mode: 0o700 }); + const temporary = join(dir, `.profile.${randomUUID()}.tmp`); + const file = await open(temporary, "wx", 0o600); + try { + await file.writeFile(JSON.stringify(profileSchema.parse(this.profile))); + await file.sync(); + } catch (error) { + await file.close(); + await unlink(temporary).catch(() => undefined); + throw error; + } + await file.close(); + try { + await rename(temporary, this.file); + const directory = await open(dir, constants.O_RDONLY); + try { + await directory.sync(); + } finally { + await directory.close(); + } + } finally { + await unlink(temporary).catch(() => undefined); + } + } +} diff --git a/scripts/profile.sample.ts b/scripts/profile.sample.ts new file mode 100644 index 0000000..b088025 --- /dev/null +++ b/scripts/profile.sample.ts @@ -0,0 +1,132 @@ +// Manual sample: build Electron first, source llm-test.env, then run only this file with +// npx vitest run -c scripts/vitest.sample.config.ts scripts/profile.sample.ts +// Uses Electron's system fetch; credentials remain in the child environment. +import { it, expect } from "vitest"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import electron from "electron"; +import { emptyProfile, rewritePrompt } from "../src/shared/profile"; +import { chainPrompt, parsePoints, newProbe } from "../src/model/probe"; +import { tutorInstructions } from "../electron/prompts"; + +it.skipIf(!process.env.LLM_TEST_API_KEY)( + "samples profile rewriting and prior-aware probing through Electron", + async () => { + const dir = await mkdtemp(join(tmpdir(), "studyboard-profile-sample-")); + const profile = { + ...emptyProfile(), + text: "你已理解光在均匀介质中沿直线传播、反射定律和折射时光路偏折,对全反射的适用条件还不熟悉。", + }; + const events = [ + { + id: "a", + kind: "test" as const, + topic: "全反射", + detail: JSON.stringify({ + title: "全反射的两个必要条件", + claims: ["光从光密介质射向光疏介质,入射角大于临界角才发生全反射"], + }), + }, + { + id: "b", + kind: "miss" as const, + topic: "光学", + detail: JSON.stringify({ + title: "临界角", + fails: 2, + missing: "仍把入射角与界面的夹角当作入射角,而非与法线的夹角", + }), + }, + ]; + await writeFile( + join(dir, "input.json"), + JSON.stringify({ + rewrite: rewritePrompt(profile, events), + chain: chainPrompt("光纤为什么能传光", undefined, 5, [], profile), + system: tutorInstructions("probe"), + }), + ); + // Keep this small harness out of the application. AIRuntime performs the real rewrite; + // cloning its SSE response captures reported usage without changing the transport. + await writeFile( + join(dir, "sample.mjs"), + ` +import { app } from 'electron'; +import { readFile, writeFile } from 'node:fs/promises'; +import { installSystemFetch } from ${JSON.stringify(resolve("dist-electron/electron/system-fetch.js"))}; +import { AIRuntime } from ${JSON.stringify(resolve("dist-electron/electron/ai-runtime.js"))}; +app.whenReady().then(async () => { + let runtime; + try { + installSystemFetch(); + const input = JSON.parse(await readFile(${JSON.stringify(join(dir, "input.json"))}, 'utf8')); + const original = globalThis.fetch; + const reads = []; + let usage; + globalThis.fetch = async (...args) => { + const response = await original(...args); + reads.push(response.clone().text().then(body => { + for (const line of body.split('\\n')) { + if (!line.startsWith('data: ') || line.includes('[DONE]')) continue; + try { const data = JSON.parse(line.slice(6)); if (data.usage) usage = data.usage; } catch {} + } + })); + return response; + }; + runtime = new AIRuntime(${JSON.stringify(join(dir, "runtime"))}, () => {}); + await runtime.setSettings({ model: process.env.LLM_TEST_MODEL, baseUrl: process.env.LLM_TEST_BASE_URL, apiKey: process.env.LLM_TEST_API_KEY }); + const text = await runtime.rewriteProfile(input.rewrite); + await Promise.all(reads); + const response = await original(process.env.LLM_TEST_BASE_URL.replace(/\\/$/, '') + '/chat/completions', { + method: 'POST', headers: { 'content-type': 'application/json', authorization: 'Bearer ' + process.env.LLM_TEST_API_KEY }, + body: JSON.stringify({ model: process.env.LLM_TEST_MODEL, thinking: { type: 'disabled' }, temperature: 0.3, max_tokens: 4000, messages: [{ role: 'system', content: input.system }, { role: 'user', content: input.chain }] }), + signal: AbortSignal.timeout(120000), + }); + if (!response.ok) throw new Error('HTTP ' + response.status); + const chain = await response.json(); + await writeFile(${JSON.stringify(join(dir, "output.json"))}, JSON.stringify({ rewrite: { text, usage }, chain: { text: chain.choices[0].message.content, usage: chain.usage } })); + await runtime.close(); + app.exit(0); + } catch { if (runtime) await runtime.close(); console.error('Profile sample failed (provider details withheld)'); app.exit(1); } +}); +`, + ); + try { + await promisify(execFile)( + String(electron), + ["--no-sandbox", join(dir, "sample.mjs")], + { + timeout: 240_000, + env: { ...process.env, ELECTRON_DISABLE_SECURITY_WARNINGS: "true" }, + }, + ); + const result = JSON.parse( + await readFile(join(dir, "output.json"), "utf8"), + ); + await writeFile("/tmp/studyboard-profile-sample-raw.json", JSON.stringify(result, null, 2)); + expect(result.rewrite.text.length).toBeLessThanOrEqual(500); + expect(result.rewrite.usage?.completion_tokens_details?.reasoning_tokens ?? 0).toBe(0); + const parsed = parsePoints(result.chain.text); + // Match the production chain acceptance threshold; malformed questions are filtered. + expect(parsed.points.length).toBeGreaterThanOrEqual(3); + expect(parsed.points.every((p) => p.prior)).toBe(true); + const report = { + ...result, + rewrite: { ...result.rewrite, length: result.rewrite.text.length }, + rawPriorCount: JSON.parse(result.chain.text.slice(result.chain.text.indexOf("{"), result.chain.text.lastIndexOf("}") + 1)).points.filter((p: { prior?: string }) => p.prior).length, + points: parsed.points.map((p) => ({ concept: p.desc, prior: p.prior })), + mode: newProbe(parsed.points).meta?.mode, + }; + await writeFile( + "/tmp/studyboard-profile-sample.json", + JSON.stringify(report, null, 2), + ); + console.log(JSON.stringify(report, null, 2)); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, +); diff --git a/src/App.tsx b/src/App.tsx index a7bf99e..123b4c1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -148,7 +148,9 @@ import { } from "./model/chat"; import { runGate, roughTokens } from "./model/novelty"; import { applyGate } from "./model/gate"; -import { answerProbe, applyExpand, applyFrontier, applyRefine, bounds as probeBounds, cacheChain, cachedChain, chainKey, chainPrompt, failRecheck, gradePrompt, localGrade, newProbe, nextStep as probeStep, parseGrade, parsePoints, probeLearner, refinePrompt, restartProbe, skipProbe } from "./model/probe"; +import { learnerProfile, setLearnerProfile } from "./model/profile-context"; +import { profileText } from "./shared/profile"; +import { answerProbe, applyExpand, applyFrontier, applyRefine, bounds as probeBounds, cacheChain, cachedChain, chainKey, chainPrompt, failRecheck, gradePrompt, localGrade, newProbe, nextStep as probeStep, parseGrade, parsePoints, probeLearner, refinePrompt, restartProbe, leaveConfirmation, skipProbe } from "./model/probe"; import { afterChildSealed, analogyPrompt, isStuck, parseAnalogy, putOff, splitNote } from "./model/stuck"; import { parseWrap, wrapPrompt } from "./model/wrap"; import { ProbePanel } from "./components/ProbePanel"; @@ -554,6 +556,7 @@ export default function App() { try { const list = (await api.listBoards()).filter((b) => !b.archivedAt); const settings = await api.getAISettings(); + setLearnerProfile(await api.getProfile()); let initial = list.length ? await api.loadBoard( [...list].sort((a, b) => @@ -1299,7 +1302,8 @@ export default function App() { mode: "plan", prompt: planPrompt( b.topic, - b.probe && b.probe.status !== "asking" ? [probeLearner(b.probe), ...learnerAnswers(note)] : learnerAnswers(note), + learnerAnswers(note), + b.probe ? probeLearner(b.probe) : profileText(learnerProfile()), ), context: contextForNote( { ...b, notes: [virtual] }, @@ -1434,6 +1438,7 @@ export default function App() { [edit, expandPoints, finishProbe, genPoints, showToast], ); /** 摸底 step 1: open the root's kickoff drawer and draft the first chain (one batched call). */ + useEffect(() => api.onProfileChanged?.(setLearnerProfile), []); const startProbe = useCallback(() => { const b = boardRef.current; const root = b && rootOf(b); @@ -1502,8 +1507,14 @@ export default function App() { const skipProbeQ = useCallback(() => { const b = boardRef.current; if (!b) return; + if (b.probe?.meta?.mode === "confirm") { + const full = leaveConfirmation(b.probe); + edit((current) => ({ ...current, probe: full })); + void advanceProbe(full); + return; + } finishProbe(skipProbe(b.probe)); - }, [finishProbe]); + }, [advanceProbe, edit, finishProbe]); const reprobe = useCallback(() => { const b = boardRef.current; if (!b?.probe?.ladder.length) return startProbe(); diff --git a/src/api.ts b/src/api.ts index 78421c8..8b7165d 100644 --- a/src/api.ts +++ b/src/api.ts @@ -11,6 +11,7 @@ import { } from "./shared/transfer"; import { downloadFile } from "./export-file"; import { boardProgress, withProgress } from "./model/progress"; +import { emptyProfile, profileSchema } from "./shared/profile"; const key = PREVIEW_KEY; const historiesKey = "studyboard-preview-histories-v1"; function readHistories(): Record> { @@ -106,6 +107,16 @@ const preview: AtlasAPI = { } return board; }, + async getProfile() { + const key = "studyboard-preview-profile-v1"; + const raw = localStorage.getItem(key); + try { if (raw) return profileSchema.parse(JSON.parse(raw)); } catch { + localStorage.setItem(`${key}-corrupt-${Date.now()}`, raw!); + } + const profile = emptyProfile(); + localStorage.setItem(key, JSON.stringify(profile)); + return profile; + }, async getAISettings() { return { model: "gpt-4o-mini", diff --git a/src/components/NextStep.tsx b/src/components/NextStep.tsx index 75228f6..8cd51d5 100644 --- a/src/components/NextStep.tsx +++ b/src/components/NextStep.tsx @@ -55,7 +55,7 @@ export function NextStepBar({ : `下一步${station ? ` · 第 ${station} 站` : ""}`; const main = step.kind === "probe" - ? "继续摸底,找到你的起点" + ? step.confirm ? "先确认一下你的基础" : "继续摸底,找到你的起点" : step.kind === "wrap" ? "把所有站合起来,讲回你的起点问题" : step.kind === "continue" diff --git a/src/components/ProbePanel.tsx b/src/components/ProbePanel.tsx index 0188a6f..ba236c7 100644 --- a/src/components/ProbePanel.tsx +++ b/src/components/ProbePanel.tsx @@ -42,9 +42,17 @@ function useDelayed(on: boolean) { return { shown, slow }; } -function Dots({ n, total }: { n: number; total: number }) { +function Dots({ + n, + total, + confirm = false, +}: { + n: number; + total: number; + confirm?: boolean; +}) { return ( - + {Array.from({ length: total }, (_, i) => ( ))} @@ -52,16 +60,46 @@ function Dots({ n, total }: { n: number; total: number }) { ); } -function Skeleton({ phase, topic, slow }: { phase: Exclude; topic: string; slow: boolean }) { - const line = - phase === "first" ? `正在为「${topic}」出第一道题…` : phase === "deeper" ? "答对了,在往更深处找…" : phase === "refine" ? "在缩小范围…" : "正在确定你的起点…"; +function Skeleton({ + phase, + topic, + slow, + confirm, +}: { + phase: Exclude; + topic: string; + slow: boolean; + confirm: boolean; +}) { + const line = confirm + ? phase === "result" + ? "正在对照你的了解排路线…" + : phase === "first" + ? "正在对照你的了解出确认题…" + : "答对了,再确认下一个…" + : phase === "first" + ? `正在为「${topic}」出第一道题…` + : phase === "deeper" + ? "答对了,在往更深处找…" + : phase === "refine" + ? "在缩小范围…" + : "正在确定你的起点…"; return ( -
+

{line}

- {phase === "first" &&

大约 5–8 题,随时可以跳过

} + {phase === "first" && ( +

+ {confirm ? "只要 1–2 道,随时可以跳过" : "大约 5–8 题,随时可以跳过"} +

+ )} {phase === "result" ? (
@@ -83,7 +121,18 @@ function Skeleton({ phase, topic, slow }: { phase: Exclude; to } /** 摸底 v2 (README): no levels, no scores, no right/wrong marks; one question at a time. */ -export function ProbePanel({ state, busy, phase, topic, stations, onAnswer, onSkip, onStart, onRestart, onFromStart }: ProbeProps) { +export function ProbePanel({ + state, + busy, + phase, + topic, + stations, + onAnswer, + onSkip, + onStart, + onRestart, + onFromStart, +}: ProbeProps) { const [choice, setChoice] = useState(null); const [text, setText] = useState(""); const [why, setWhy] = useState(false); @@ -91,7 +140,11 @@ export function ProbePanel({ state, busy, phase, topic, stations, onAnswer, onSk const asking = state.status === "asking"; const step = asking && state.ladder.length ? nextStep(state) : undefined; const point = step?.kind === "ask" ? state.ladder[step.index] : undefined; - const n = state.answers.length; + const confirm = state.meta?.mode === "confirm"; + const n = state.answers.length - (state.meta?.confirmFailedAt ?? 0); + const transitioning = + asking && state.meta?.confirmFailedAt !== undefined && n === 0; + const skipLabel = confirm ? "跳过确认,直接开始" : "跳过摸底"; const total = Math.max(6, Math.min(MAX_QUESTIONS, n + 3)); const opts = point?.probe.options ?? []; const submit = () => { @@ -101,28 +154,53 @@ export function ProbePanel({ state, busy, phase, topic, stations, onAnswer, onSk setText(""); setWhy(false); }; + const skip = () => { + setChoice(null); + setText(""); + setWhy(false); + onSkip(); + }; const head = ( -
-

找到你的起点

- {asking && ( - - 第 {n + 1} 题 - + <> +
+

{confirm ? "确认你的起点" : "找到你的起点"}

+ {asking && ( + + {confirm ? `确认 ${n + 1}/2` : `第 ${n + 1} 题`}{" "} + + + )} +
+ {asking && confirm && n === 0 && ( +

+ {state.meta?.confirmIntro?.trim() || + "我记得你已经懂前面这些,先确认一下"} +

)} -
+ {transitioning && ( +

+ + 这题没答上来,我从基础重新摸一下。 +

+ )} + ); if (phase) { return (
{head} - {shown ? :
} + {shown ? ( + + ) : ( +
+ )}
{phase !== "result" && ( - )}
@@ -133,23 +211,49 @@ export function ProbePanel({ state, busy, phase, topic, stations, onAnswer, onSk return (
{head} -

- - 正在找你的起点 -

+ {!confirm && !transitioning && ( +

+ + 正在找你的起点 +

+ )} {point ? (

{point.probe.q}

-
- {[...opts, IDK].map((o, i) => { +
+ {(confirm ? opts : [...opts, IDK]).map((o, i) => { const v = i === opts.length ? -1 : i; return ( - ); })}
+ {confirm && ( + + )}