feat(ai): add Anthropic-compatible provider; fix async plugin backends

Anthropic-compatible provider:
- New 'anthropic-compatible' option for any base URL speaking the Anthropic
  messages schema (LiteLLM proxies, Bedrock gateways, local servers)
- Sends x-api-key AND Bearer auth when a key is set (gateway-friendly,
  harmless for the official API); keyless proxies supported
- Tolerates base URLs with or without a trailing /v1 segment
- Settings modal, manifest, and provider docs updated

Runtime bug fixes found by booting the app (run-to-verify pass):
- PDF editor: File > Open PDF sends operation=null which matched no switch
  case and crashed on getElementById(undefined); now defaults to the merge
  section
- backlinks-panel: wrong require depth (../../utils -> ../utils) threw at
  panel registration time
- writing-studio stack was written against a synchronous settings backend but
  the real one is IPC-backed: GoalTracker/SnapshotManager/ProjectManager and
  all four panels now await; JSON.parse(Promise) crashes eliminated
- manuscript panel used window.prompt (unavailable in Electron); replaced
  with an inline dialog
- collaboration comment-store/save-load made async to match its IPC IO

617/617 tests green; 4 consecutive clean app boots (no uncaught errors)
This commit is contained in:
2026-09-05 21:53:34 +05:30
parent efca458495
commit 7ab5a0ddb4
20 changed files with 426 additions and 225 deletions
@@ -1,3 +1,11 @@
/**
* Manuscript project management (.project.json + chapter files).
*
* All methods are async: the fs backend injected by the plugin is the
* IPC-backed file API (readFile/writeFile/fileExists/listDir return
* Promises); `await` also works against the synchronous fakes used in unit
* tests.
*/
class ProjectManager {
/**
* @param {object} fs - { readFile(path), writeFile(path, content), fileExists(path), listDir(path) }
@@ -6,7 +14,7 @@ class ProjectManager {
this.fs = fs;
}
createProject(dir, opts) {
async createProject(dir, opts) {
const project = {
title: opts.title,
type: opts.type || 'manuscript',
@@ -14,51 +22,53 @@ class ProjectManager {
chapters: [],
metadata: opts.metadata || {},
};
this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
await this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
return project;
}
loadProject(dir) {
const raw = this.fs.readFile(dir + '/.project.json');
async loadProject(dir) {
const exists = await this.fs.fileExists(dir + '/.project.json');
if (!exists) return null;
const raw = await this.fs.readFile(dir + '/.project.json');
if (!raw) return null;
return JSON.parse(raw);
}
_saveProject(dir, project) {
this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
async _saveProject(dir, project) {
await this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
}
addChapter(dir, chapter) {
const project = this.loadProject(dir);
async addChapter(dir, chapter) {
const project = await this.loadProject(dir);
if (!project) throw new Error('Project not found');
project.chapters.push(chapter);
this._saveProject(dir, project);
await this._saveProject(dir, project);
}
updateChapter(dir, index, updates) {
const project = this.loadProject(dir);
async updateChapter(dir, index, updates) {
const project = await this.loadProject(dir);
if (!project) throw new Error('Project not found');
Object.assign(project.chapters[index], updates);
this._saveProject(dir, project);
await this._saveProject(dir, project);
}
compileManuscript(dir) {
const project = this.loadProject(dir);
async compileManuscript(dir) {
const project = await this.loadProject(dir);
if (!project) throw new Error('Project not found');
const parts = [];
for (const ch of project.chapters) {
const content = this.fs.readFile(dir + '/' + ch.file);
const content = await this.fs.readFile(dir + '/' + ch.file);
if (content) parts.push(content);
}
return parts.join('\n\n---\n\n');
}
getStats(dir) {
const project = this.loadProject(dir);
async getStats(dir) {
const project = await this.loadProject(dir);
if (!project) throw new Error('Project not found');
let totalWords = 0;
for (const ch of project.chapters) {
const content = this.fs.readFile(dir + '/' + ch.file);
const content = await this.fs.readFile(dir + '/' + ch.file);
if (content) totalWords += content.split(/\s+/).filter(Boolean).length;
}
const target = project.target.words || 0;