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
28 changes: 28 additions & 0 deletions electron/ai-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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<string, unknown>), 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));
}
Expand Down
30 changes: 29 additions & 1 deletion electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -156,6 +158,21 @@ async function bootstrap(): Promise<void> {
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<unknown> = Promise.resolve();
function assertSender(event: Electron.IpcMainInvokeEvent) {
const url = event.senderFrame?.url;
if (event.sender !== window?.webContents || !url)
Expand All @@ -175,7 +192,18 @@ async function bootstrap(): Promise<void> {
}
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("正在关闭应用,请稍后重试。");
Expand Down
6 changes: 6 additions & 0 deletions electron/preload.cts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof callback>[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),
Expand Down
144 changes: 144 additions & 0 deletions electron/profile-store.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> = Promise.resolve();
private rewriting = false;
private immediate = false;
constructor(
private file: string,
private rewrite: (prompt: string) => Promise<string>,
private changed: (profile: UserProfile) => void = () => {},
) {}
private serial<T>(operation: () => Promise<T>): Promise<T> {
const task = this.queue.then(operation);
this.queue = task.catch(() => undefined);
return task;
}
async initialize(boards: () => Promise<Board[]>): Promise<void> {
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<UserProfile> {
await this.queue;
return structuredClone(this.profile);
}
async enqueue(events: ProfileEvent[]): Promise<void> {
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);
}
}
}
132 changes: 132 additions & 0 deletions scripts/profile.sample.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
},
);
Loading
Loading