Skip to content

Commit 44627f2

Browse files
committed
fix(studio): address recent issue regressions
1 parent 005819f commit 44627f2

14 files changed

Lines changed: 441 additions & 21 deletions

File tree

packages/core/src/__tests__/edit-controller.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,45 @@ describe("edit controller", () => {
283283
expect(result.reviewRequired).toBe(true);
284284
});
285285

286+
it("patches chapter text when the target only differs by whitespace", async () => {
287+
const bookDir = join(projectRoot, "books", "harbor");
288+
await writeFile(
289+
join(bookDir, "chapters", "0004_雨巷.md"),
290+
"# 第4章 雨巷\n\n她把账本\n塞进外套里,继续往前走。",
291+
"utf-8",
292+
);
293+
const chapterIndex = [{
294+
number: 4,
295+
title: "雨巷",
296+
status: "ready-for-review" as const,
297+
wordCount: 18,
298+
createdAt: new Date().toISOString(),
299+
updatedAt: new Date().toISOString(),
300+
auditIssues: [],
301+
lengthWarnings: [],
302+
}];
303+
304+
const result = await executeEditTransaction(
305+
{
306+
bookDir: (bookId) => join(projectRoot, "books", bookId),
307+
loadChapterIndex: async () => chapterIndex,
308+
saveChapterIndex: async () => undefined,
309+
},
310+
{
311+
kind: "chapter-local-edit",
312+
bookId: "harbor",
313+
chapterNumber: 4,
314+
instruction: "Patch wrapped text",
315+
targetText: "她把账本 塞进外套里",
316+
replacementText: "她把账本贴着胸口藏好",
317+
},
318+
);
319+
320+
await expect(readFile(join(bookDir, "chapters", "0004_雨巷.md"), "utf-8"))
321+
.resolves.toContain("她把账本贴着胸口藏好,继续往前走。");
322+
expect(result.reviewRequired).toBe(true);
323+
});
324+
286325
it("executes whole-chapter replacement and marks the chapter for review", async () => {
287326
const bookDir = join(projectRoot, "books", "replacebook");
288327
await mkdir(join(bookDir, "chapters"), { recursive: true });

packages/core/src/__tests__/state-manager.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,22 @@ describe("StateManager", () => {
9595
expect(loaded).toEqual([]);
9696
});
9797

98+
it("rebuilds the chapter index from chapter files when index.json is empty", async () => {
99+
const bookDir = manager.bookDir("rebuild-book");
100+
await mkdir(join(bookDir, "chapters"), { recursive: true });
101+
await writeFile(join(bookDir, "chapters", "index.json"), "[]", "utf-8");
102+
await writeFile(join(bookDir, "chapters", "0001_雨棚.md"), "# 第1章 雨棚\n\n正文。", "utf-8");
103+
await writeFile(join(bookDir, "chapters", "0002_账页.md"), "# 第2章 账页\n\n正文。", "utf-8");
104+
105+
const loaded = await manager.loadChapterIndex("rebuild-book");
106+
107+
expect(loaded.map((chapter) => chapter.number)).toEqual([1, 2]);
108+
expect(loaded[0]).toMatchObject({
109+
title: "雨棚",
110+
status: "ready-for-review",
111+
});
112+
});
113+
98114
it("creates the chapters directory on save", async () => {
99115
await manager.saveChapterIndex("book-b", []);
100116
const dirStat = await stat(

packages/core/src/interaction/edit-controller.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,7 @@ async function executeChapterLocalEdit(
354354
}
355355

356356
const content = await readFile(chapterPath, "utf-8");
357-
const nextContent = content.split(request.targetText).join(request.replacementText);
357+
const nextContent = replaceChapterTargetText(content, request.targetText, request.replacementText);
358358
if (nextContent === content) {
359359
throw new Error(`Target text was not found in chapter ${request.chapterNumber}.`);
360360
}
@@ -382,6 +382,27 @@ async function executeChapterLocalEdit(
382382
};
383383
}
384384

385+
function replaceChapterTargetText(content: string, targetText: string, replacementText: string): string {
386+
const exact = content.split(targetText).join(replacementText);
387+
if (exact !== content) return exact;
388+
389+
const pattern = flexibleWhitespacePattern(targetText);
390+
if (!pattern) return content;
391+
let matched = false;
392+
const replaced = content.replace(pattern, () => {
393+
matched = true;
394+
return replacementText;
395+
});
396+
return matched ? replaced : content;
397+
}
398+
399+
function flexibleWhitespacePattern(targetText: string): RegExp | null {
400+
const parts = targetText.trim().split(/\s+/).filter(Boolean);
401+
if (parts.length < 2) return null;
402+
const escaped = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
403+
return new RegExp(escaped.join("\\s+"), "g");
404+
}
405+
385406
export async function executeEditTransaction(
386407
deps: EditExecutionDeps,
387408
request: EditRequest,

packages/core/src/models/book.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ export const BookConfigSchema = z.object({
6565
updatedAt: z.string().datetime(),
6666
parentBookId: z.string().optional(),
6767
fanficMode: FanficModeSchema.optional(),
68+
writing: z.object({
69+
reviewMode: z.enum(["auto", "manual"]).optional(),
70+
}).optional(),
6871
});
6972

7073
export type BookConfig = z.infer<typeof BookConfigSchema>;

packages/core/src/state/manager.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,10 +267,55 @@ export class StateManager {
267267
const indexPath = join(this.bookDir(bookId), "chapters", "index.json");
268268
try {
269269
const raw = await readFile(indexPath, "utf-8");
270-
return JSON.parse(raw);
270+
const parsed = JSON.parse(raw) as unknown;
271+
if (Array.isArray(parsed) && parsed.length > 0) return parsed as ReadonlyArray<ChapterMeta>;
272+
if (Array.isArray(parsed)) {
273+
const rebuilt = await this.rebuildChapterIndexFromFiles(bookId);
274+
return rebuilt.length > 0 ? rebuilt : parsed as ReadonlyArray<ChapterMeta>;
275+
}
276+
} catch {
277+
const rebuilt = await this.rebuildChapterIndexFromFiles(bookId);
278+
if (rebuilt.length > 0) return rebuilt;
279+
}
280+
return [];
281+
}
282+
283+
private async rebuildChapterIndexFromFiles(bookId: string): Promise<ReadonlyArray<ChapterMeta>> {
284+
const chaptersDir = join(this.bookDir(bookId), "chapters");
285+
let files: string[];
286+
try {
287+
files = await readdir(chaptersDir);
271288
} catch {
272289
return [];
273290
}
291+
292+
const rows = await Promise.all(files.flatMap(async (file) => {
293+
const match = file.match(/^(\d+)[_-]?(.*?)\.md$/);
294+
if (!match) return [];
295+
const number = parseInt(match[1]!, 10);
296+
if (!Number.isFinite(number) || number <= 0) return [];
297+
const filePath = join(chaptersDir, file);
298+
const [metadata, content] = await Promise.all([
299+
stat(filePath).catch(() => null),
300+
readFile(filePath, "utf-8").catch(() => ""),
301+
]);
302+
const timestamp = (metadata?.mtime ?? new Date()).toISOString();
303+
const rawTitle = match[2]?.replace(/^_+/, "").replace(/_/g, " ").trim();
304+
return [{
305+
number,
306+
title: rawTitle || `第${number}章`,
307+
status: "ready-for-review" as const,
308+
wordCount: content.replace(/\s+/g, "").length,
309+
createdAt: timestamp,
310+
updatedAt: timestamp,
311+
auditIssues: [],
312+
lengthWarnings: [],
313+
}];
314+
}));
315+
316+
return rows
317+
.flat()
318+
.sort((a, b) => a.number - b.number);
274319
}
275320

276321
async saveChapterIndex(

packages/studio/src/api/server.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4132,6 +4132,80 @@ describe("createStudioServer daemon lifecycle", () => {
41324132
await expect(after.json()).resolves.toMatchObject({ mode: "manual" });
41334133
});
41344134

4135+
it("stores chapter review mode per book without changing the project default", async () => {
4136+
await writeCompleteBookFixture(root, "demo-book", "Demo Book");
4137+
const { createStudioServer } = await import("./server.js");
4138+
const app = createStudioServer(cloneProjectConfig() as never, root);
4139+
4140+
const saveBookMode = await app.request("http://localhost/api/v1/books/demo-book/chapter-review-mode", {
4141+
method: "PUT",
4142+
headers: { "Content-Type": "application/json" },
4143+
body: JSON.stringify({ mode: "manual" }),
4144+
});
4145+
await expect(saveBookMode.json()).resolves.toMatchObject({
4146+
ok: true,
4147+
mode: "manual",
4148+
bookMode: "manual",
4149+
projectMode: "auto",
4150+
});
4151+
4152+
const bookMode = await app.request("http://localhost/api/v1/books/demo-book/chapter-review-mode");
4153+
await expect(bookMode.json()).resolves.toMatchObject({
4154+
mode: "manual",
4155+
bookMode: "manual",
4156+
projectMode: "auto",
4157+
});
4158+
4159+
const projectMode = await app.request("http://localhost/api/v1/project/chapter-review-mode");
4160+
await expect(projectMode.json()).resolves.toMatchObject({ mode: "auto" });
4161+
const rawBook = JSON.parse(await readFile(join(root, "books", "demo-book", "book.json"), "utf-8"));
4162+
expect(rawBook.writing.reviewMode).toBe("manual");
4163+
});
4164+
4165+
it("uses a book-level manual review override when writing the next chapter", async () => {
4166+
await writeCompleteBookFixture(root, "demo-book", "Demo Book");
4167+
const rawBookPath = join(root, "books", "demo-book", "book.json");
4168+
const rawBook = JSON.parse(await readFile(rawBookPath, "utf-8"));
4169+
await writeFile(rawBookPath, JSON.stringify({
4170+
...rawBook,
4171+
writing: { reviewMode: "manual" },
4172+
}, null, 2), "utf-8");
4173+
4174+
const { createStudioServer } = await import("./server.js");
4175+
const app = createStudioServer(cloneProjectConfig() as never, root);
4176+
4177+
const response = await app.request("http://localhost/api/v1/books/demo-book/write-next", { method: "POST" });
4178+
4179+
expect(response.status).toBe(200);
4180+
expect(pipelineConfigs.at(-1)).toMatchObject({ chapterReviewMode: "manual" });
4181+
});
4182+
4183+
it("exposes a global default model endpoint backed by llm.defaultModel", async () => {
4184+
const { createStudioServer } = await import("./server.js");
4185+
const app = createStudioServer(cloneProjectConfig() as never, root);
4186+
4187+
const initial = await app.request("http://localhost/api/v1/project/default-model");
4188+
await expect(initial.json()).resolves.toMatchObject({
4189+
defaultModel: "gpt-5.4",
4190+
});
4191+
4192+
const save = await app.request("http://localhost/api/v1/project/default-model", {
4193+
method: "PUT",
4194+
headers: { "Content-Type": "application/json" },
4195+
body: JSON.stringify({ service: "kkaiapi", defaultModel: "deepseek-v4-flash" }),
4196+
});
4197+
await expect(save.json()).resolves.toMatchObject({
4198+
ok: true,
4199+
service: "kkaiapi",
4200+
defaultModel: "deepseek-v4-flash",
4201+
});
4202+
4203+
const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
4204+
expect(raw.llm.service).toBe("kkaiapi");
4205+
expect(raw.llm.defaultModel).toBe("deepseek-v4-flash");
4206+
expect(raw.llm.model).toBe("deepseek-v4-flash");
4207+
});
4208+
41354209
it("project advanced settings expose input governance and detection config", async () => {
41364210
const { createStudioServer } = await import("./server.js");
41374211
const app = createStudioServer(cloneProjectConfig() as never, root);

0 commit comments

Comments
 (0)