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
+23 -23
View File
@@ -10,11 +10,11 @@ function makeIo(initial = {}) {
const files = new Map(Object.entries(initial));
return {
files,
readFile: (p) => (files.has(p) ? files.get(p) : null),
writeFile: (p, c) => {
readFile: async (p) => (files.has(p) ? files.get(p) : null),
writeFile: async (p, c) => {
files.set(p, c);
},
fileExists: (p) => files.has(p),
fileExists: async (p) => files.has(p),
ensureDirectory: () => {},
};
}
@@ -24,7 +24,7 @@ describe('comment-store', () => {
const sidecar = path.join('/docs', '.comments', 'notes.md.json');
describe('commentsFilePathFor', () => {
it('places the sidecar in .comments/ next to the document', () => {
it('places the sidecar in .comments/ next to the document', async () => {
expect(store.commentsFilePathFor(doc, path)).toBe(sidecar);
expect(store.commentsFilePathFor('/a/b/c.md', path)).toBe(
path.join('/a/b/.comments', 'c.md.json')
@@ -33,29 +33,29 @@ describe('comment-store', () => {
});
describe('load/save round-trip', () => {
it('returns [] for documents with no sidecar yet', () => {
expect(store.loadComments(doc, makeIo(), path)).toEqual([]);
it('returns [] for documents with no sidecar yet', async () => {
expect(await store.loadComments(doc, makeIo(), path)).toEqual([]);
});
it('returns [] for corrupt JSON instead of throwing', () => {
it('returns [] for corrupt JSON instead of throwing', async () => {
const io = makeIo({ [sidecar]: '{oops' });
expect(store.loadComments(doc, io, path)).toEqual([]);
expect(await store.loadComments(doc, io, path)).toEqual([]);
});
it('persists and reloads comments sorted by line', () => {
it('persists and reloads comments sorted by line', async () => {
const io = makeIo();
const comments = [];
store.addComment(comments, { line: 12, anchorText: 'para two', text: 'expand this' });
store.addComment(comments, { line: 3, anchorText: 'intro', text: 'strong opener' });
store.saveComments(doc, comments, io, path);
await store.saveComments(doc, comments, io, path);
const loaded = store.loadComments(doc, io, path);
const loaded = await store.loadComments(doc, io, path);
expect(loaded.map((c) => c.line)).toEqual([3, 12]);
expect(loaded[0].text).toBe('strong opener');
expect(loaded[0].resolved).toBe(false);
});
it('normalizes malformed entries on load', () => {
it('normalizes malformed entries on load', async () => {
const io = makeIo({
[sidecar]: JSON.stringify({
comments: [
@@ -64,7 +64,7 @@ describe('comment-store', () => {
],
}),
});
const loaded = store.loadComments(doc, io, path);
const loaded = await store.loadComments(doc, io, path);
// '7' (string line) is dropped; the valid one survives normalization
expect(loaded).toHaveLength(1);
expect(loaded[0]).toMatchObject({ line: 2, author: 'ana' });
@@ -72,7 +72,7 @@ describe('comment-store', () => {
});
describe('mutators', () => {
it('addComment clamps lines and caps field lengths', () => {
it('addComment clamps lines and caps field lengths', async () => {
const comments = [];
const c = store.addComment(comments, {
line: -5,
@@ -86,7 +86,7 @@ describe('comment-store', () => {
expect(c.id).toBeTruthy();
});
it('toggleResolved flips state and returns null for unknown ids', () => {
it('toggleResolved flips state and returns null for unknown ids', async () => {
const comments = [];
const c = store.addComment(comments, { line: 1, text: 'hi' });
expect(store.toggleResolved(comments, c.id)).toBe(true);
@@ -94,7 +94,7 @@ describe('comment-store', () => {
expect(store.toggleResolved(comments, 'missing')).toBeNull();
});
it('deleteComment reports whether anything was removed', () => {
it('deleteComment reports whether anything was removed', async () => {
const comments = [];
const c = store.addComment(comments, { line: 1, text: 'hi' });
expect(store.deleteComment(comments, c.id)).toBe(true);
@@ -104,7 +104,7 @@ describe('comment-store', () => {
});
describe('nextUnresolved (F8 navigation)', () => {
it('returns the first open comment below the cursor line', () => {
it('returns the first open comment below the cursor line', async () => {
const comments = [
{ id: 'a', line: 5, resolved: false },
{ id: 'b', line: 20, resolved: false },
@@ -112,7 +112,7 @@ describe('comment-store', () => {
expect(store.nextUnresolved(comments, 7).id).toBe('b');
});
it('wraps to the top when no open comments are below', () => {
it('wraps to the top when no open comments are below', async () => {
const comments = [
{ id: 'a', line: 5, resolved: false },
{ id: 'b', line: 20, resolved: true },
@@ -120,7 +120,7 @@ describe('comment-store', () => {
expect(store.nextUnresolved(comments, 10).id).toBe('a');
});
it('returns null when everything is resolved or empty', () => {
it('returns null when everything is resolved or empty', async () => {
expect(store.nextUnresolved([{ id: 'a', line: 1, resolved: true }], 1)).toBeNull();
expect(store.nextUnresolved([], 1)).toBeNull();
});
@@ -129,20 +129,20 @@ describe('comment-store', () => {
describe('anchorStatus (drift detection)', () => {
const base = { line: 2, anchorText: 'original text' };
it('reports ok when the line still matches', () => {
it('reports ok when the line still matches', async () => {
expect(store.anchorStatus(base, ['first', 'original text', 'third'])).toBe('ok');
});
it('reports changed when the line text differs', () => {
it('reports changed when the line text differs', async () => {
expect(store.anchorStatus(base, ['first', 'edited text', 'third'])).toBe('changed');
});
it('reports missing when the line is beyond the document', () => {
it('reports missing when the line is beyond the document', async () => {
expect(store.anchorStatus({ ...base, line: 99 }, ['only'])).toBe('missing');
expect(store.anchorStatus(base, [])).toBe('missing');
});
it('reports moved for comments without an anchor snippet', () => {
it('reports moved for comments without an anchor snippet', async () => {
expect(store.anchorStatus({ line: 1, anchorText: '' }, ['whatever'])).toBe('moved');
});
});