openwriter 0.8.7 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/index-BYx7Ckkf.js +210 -0
- package/dist/client/assets/index-BZqt_dqW.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/plugins/image-gen/dist/index.js +75 -26
- package/dist/server/connection-routes.js +1 -1
- package/dist/server/documents.js +139 -1
- package/dist/server/index.js +28 -2
- package/dist/server/mcp.js +82 -43
- package/dist/server/state.js +47 -8
- package/dist/server/ws.js +8 -3
- package/package.json +1 -1
- package/skill/SKILL.md +14 -31
- package/dist/client/assets/index-BP-NVo6E.js +0 -210
- package/dist/client/assets/index-Cd7iUO_s.css +0 -1
package/dist/server/documents.js
CHANGED
|
@@ -9,7 +9,7 @@ import matter from 'gray-matter';
|
|
|
9
9
|
import trash from 'trash';
|
|
10
10
|
import { tiptapToMarkdown, markdownToTiptap } from './markdown.js';
|
|
11
11
|
import { parseMarkdownContent } from './compact.js';
|
|
12
|
-
import { getDocument, getTitle, getFilePath, getIsTemp, getMetadata, save, cancelDebouncedSave, setActiveDocument, registerExternalDoc, unregisterExternalDoc, getExternalDocs, cacheActiveDocument, getCachedDocument, invalidateDocCache, removePendingCacheEntry, } from './state.js';
|
|
12
|
+
import { getDocument, getTitle, getFilePath, getIsTemp, getMetadata, save, cancelDebouncedSave, setActiveDocument, registerExternalDoc, unregisterExternalDoc, getExternalDocs, cacheActiveDocument, getCachedDocument, invalidateDocCache, removePendingCacheEntry, resetDocVersion, } from './state.js';
|
|
13
13
|
import { getDataDir, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, resolveDocPath, isExternalDoc, atomicWriteFileSync } from './helpers.js';
|
|
14
14
|
import { ensureDocId } from './versions.js';
|
|
15
15
|
import { renameDocInAllWorkspaces, removeDocFromAllWorkspaces } from './workspaces.js';
|
|
@@ -71,6 +71,22 @@ function writeDocOrder(order) {
|
|
|
71
71
|
export function reorderDocs(orderedFilenames) {
|
|
72
72
|
writeDocOrder(orderedFilenames);
|
|
73
73
|
}
|
|
74
|
+
/** Derive content_type from frontmatter — explicit field first, then fallback from context keys. */
|
|
75
|
+
function deriveContentType(data) {
|
|
76
|
+
if (data.content_type)
|
|
77
|
+
return data.content_type;
|
|
78
|
+
if (data.tweetContext)
|
|
79
|
+
return data.tweetContext.mode || 'tweet';
|
|
80
|
+
if (data.articleContext)
|
|
81
|
+
return 'article';
|
|
82
|
+
if (data.linkedinContext)
|
|
83
|
+
return 'linkedin';
|
|
84
|
+
if (data.newsletterContext)
|
|
85
|
+
return 'newsletter';
|
|
86
|
+
if (data.blogContext)
|
|
87
|
+
return 'blog';
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
74
90
|
export function listDocuments() {
|
|
75
91
|
ensureDataDir();
|
|
76
92
|
const currentPath = getFilePath();
|
|
@@ -103,6 +119,7 @@ export function listDocuments() {
|
|
|
103
119
|
...(data.newsletterContext?.lastSend?.sentAt ? { lastSent: data.newsletterContext.lastSend.sentAt } : data.tweetContext?.lastPost?.postedAt ? { lastSent: data.tweetContext.lastPost.postedAt } : data.blogContext?.lastPublish?.publishedAt ? { lastSent: data.blogContext.lastPublish.publishedAt } : data.articleContext?.lastPost?.postedAt ? { lastSent: data.articleContext.lastPost.postedAt } : data.manualPost?.postedAt ? { lastSent: data.manualPost.postedAt } : {}),
|
|
104
120
|
...(data.tweetContext?.lastPost?.tweetUrl ? { postedUrl: data.tweetContext.lastPost.tweetUrl } : {}),
|
|
105
121
|
...(data.newsletterContext ? { isNewsletter: true } : {}),
|
|
122
|
+
...(deriveContentType(data) ? { contentType: deriveContentType(data) } : {}),
|
|
106
123
|
};
|
|
107
124
|
}
|
|
108
125
|
catch {
|
|
@@ -140,6 +157,7 @@ export function listDocuments() {
|
|
|
140
157
|
...(data.newsletterContext?.lastSend?.sentAt ? { lastSent: data.newsletterContext.lastSend.sentAt } : data.tweetContext?.lastPost?.postedAt ? { lastSent: data.tweetContext.lastPost.postedAt } : data.blogContext?.lastPublish?.publishedAt ? { lastSent: data.blogContext.lastPublish.publishedAt } : {}),
|
|
141
158
|
...(data.tweetContext?.lastPost?.tweetUrl ? { postedUrl: data.tweetContext.lastPost.tweetUrl } : {}),
|
|
142
159
|
...(data.newsletterContext ? { isNewsletter: true } : {}),
|
|
160
|
+
...(deriveContentType(data) ? { contentType: deriveContentType(data) } : {}),
|
|
143
161
|
});
|
|
144
162
|
}
|
|
145
163
|
catch { /* skip unreadable external files */ }
|
|
@@ -363,6 +381,8 @@ export function switchDocument(filename) {
|
|
|
363
381
|
save();
|
|
364
382
|
// Cache current doc before switching (preserves node IDs)
|
|
365
383
|
cacheActiveDocument();
|
|
384
|
+
// Reset version counter — new document starts a fresh version lineage
|
|
385
|
+
resetDocVersion();
|
|
366
386
|
// Read target from disk — markdownToTiptap rehydrates pending state
|
|
367
387
|
const targetPath = resolveDocPath(filename);
|
|
368
388
|
if (!existsSync(targetPath)) {
|
|
@@ -673,3 +693,121 @@ export function promoteTempFile(newTitle) {
|
|
|
673
693
|
renameMark(oldFilename, newFilename);
|
|
674
694
|
return newFilename;
|
|
675
695
|
}
|
|
696
|
+
// ============================================================================
|
|
697
|
+
// BATCH RESOLVE — accept/reject pending changes across multiple docs
|
|
698
|
+
// ============================================================================
|
|
699
|
+
const PENDING_ATTRS = ['pendingStatus', 'pendingOriginalContent', 'pendingGroupId', 'pendingSelectionFrom', 'pendingSelectionTo', 'pendingOriginalFrom', 'pendingOriginalTo'];
|
|
700
|
+
function clearPendingAttrs(attrs) {
|
|
701
|
+
const clean = { ...attrs };
|
|
702
|
+
for (const key of PENDING_ATTRS)
|
|
703
|
+
delete clean[key];
|
|
704
|
+
return clean;
|
|
705
|
+
}
|
|
706
|
+
/** Walk TipTap JSON, accept all pending changes in-place. Returns count of resolved nodes. */
|
|
707
|
+
function acceptAllInDoc(doc) {
|
|
708
|
+
let count = 0;
|
|
709
|
+
function walk(nodes) {
|
|
710
|
+
const result = [];
|
|
711
|
+
for (const node of nodes) {
|
|
712
|
+
const status = node.attrs?.pendingStatus;
|
|
713
|
+
if (status === 'delete') {
|
|
714
|
+
count++;
|
|
715
|
+
continue; // Remove delete nodes
|
|
716
|
+
}
|
|
717
|
+
if (status === 'insert' || status === 'rewrite') {
|
|
718
|
+
node.attrs = clearPendingAttrs(node.attrs);
|
|
719
|
+
count++;
|
|
720
|
+
}
|
|
721
|
+
if (node.content) {
|
|
722
|
+
node.content = walk(node.content);
|
|
723
|
+
}
|
|
724
|
+
result.push(node);
|
|
725
|
+
}
|
|
726
|
+
return result;
|
|
727
|
+
}
|
|
728
|
+
if (doc.content)
|
|
729
|
+
doc.content = walk(doc.content);
|
|
730
|
+
return count;
|
|
731
|
+
}
|
|
732
|
+
/** Walk TipTap JSON, reject all pending changes in-place. Returns count of resolved nodes. */
|
|
733
|
+
function rejectAllInDoc(doc) {
|
|
734
|
+
let count = 0;
|
|
735
|
+
function walk(nodes) {
|
|
736
|
+
const result = [];
|
|
737
|
+
for (const node of nodes) {
|
|
738
|
+
const status = node.attrs?.pendingStatus;
|
|
739
|
+
if (status === 'insert') {
|
|
740
|
+
count++;
|
|
741
|
+
continue; // Remove inserted nodes
|
|
742
|
+
}
|
|
743
|
+
if (status === 'rewrite') {
|
|
744
|
+
const original = node.attrs?.pendingOriginalContent;
|
|
745
|
+
if (original) {
|
|
746
|
+
// Replace with original content
|
|
747
|
+
result.push(original);
|
|
748
|
+
}
|
|
749
|
+
// If no original, just drop the node
|
|
750
|
+
count++;
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
if (status === 'delete') {
|
|
754
|
+
// Keep the node, just clear pending status
|
|
755
|
+
node.attrs = clearPendingAttrs(node.attrs);
|
|
756
|
+
count++;
|
|
757
|
+
}
|
|
758
|
+
if (node.content) {
|
|
759
|
+
node.content = walk(node.content);
|
|
760
|
+
}
|
|
761
|
+
result.push(node);
|
|
762
|
+
}
|
|
763
|
+
return result;
|
|
764
|
+
}
|
|
765
|
+
if (doc.content)
|
|
766
|
+
doc.content = walk(doc.content);
|
|
767
|
+
return count;
|
|
768
|
+
}
|
|
769
|
+
/** Resolve a single doc file on disk. Returns number of changes resolved. */
|
|
770
|
+
function resolveDocFile(filePath, action) {
|
|
771
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
772
|
+
const { data } = matter(raw);
|
|
773
|
+
// Skip docs with no pending changes
|
|
774
|
+
if (!data.pending)
|
|
775
|
+
return 0;
|
|
776
|
+
// Pass full raw file — markdownToTiptap calls matter() internally and rehydrates pending state
|
|
777
|
+
const parsed = markdownToTiptap(raw);
|
|
778
|
+
const doc = parsed.document;
|
|
779
|
+
const count = action === 'accept' ? acceptAllInDoc(doc) : rejectAllInDoc(doc);
|
|
780
|
+
if (count === 0)
|
|
781
|
+
return 0;
|
|
782
|
+
// Re-serialize — pending attrs are cleared so pending key will be removed from frontmatter
|
|
783
|
+
const newRaw = tiptapToMarkdown(doc, parsed.title, parsed.metadata);
|
|
784
|
+
atomicWriteFileSync(filePath, newRaw);
|
|
785
|
+
return count;
|
|
786
|
+
}
|
|
787
|
+
export function batchResolve(filenames, action) {
|
|
788
|
+
let docsResolved = 0;
|
|
789
|
+
let changesResolved = 0;
|
|
790
|
+
for (const filename of filenames) {
|
|
791
|
+
const filePath = isExternalDoc(filename) ? filename : join(getDataDir(), filename);
|
|
792
|
+
if (!existsSync(filePath))
|
|
793
|
+
continue;
|
|
794
|
+
try {
|
|
795
|
+
const count = resolveDocFile(filePath, action);
|
|
796
|
+
if (count > 0) {
|
|
797
|
+
docsResolved++;
|
|
798
|
+
changesResolved += count;
|
|
799
|
+
// Active doc: update in-memory state directly (no reload flicker)
|
|
800
|
+
if (filePath === getFilePath()) {
|
|
801
|
+
const currentDoc = getDocument();
|
|
802
|
+
if (action === 'accept')
|
|
803
|
+
acceptAllInDoc(currentDoc);
|
|
804
|
+
else
|
|
805
|
+
rejectAllInDoc(currentDoc);
|
|
806
|
+
save();
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
catch { /* skip unreadable files */ }
|
|
811
|
+
}
|
|
812
|
+
return { docsResolved, changesResolved };
|
|
813
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -11,9 +11,9 @@ import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadc
|
|
|
11
11
|
import { TOOL_REGISTRY } from './mcp.js';
|
|
12
12
|
import { z } from 'zod';
|
|
13
13
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
14
|
-
import { save, cancelDebouncedSave, load, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc, clearAllCaches } from './state.js';
|
|
14
|
+
import { save, cancelDebouncedSave, load, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc, removePendingCacheEntry, clearAllCaches } from './state.js';
|
|
15
15
|
import { syncPostHistory } from './post-sync.js';
|
|
16
|
-
import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument, getActiveFilename } from './documents.js';
|
|
16
|
+
import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument, getActiveFilename, batchResolve } from './documents.js';
|
|
17
17
|
import { createWorkspaceRouter } from './workspace-routes.js';
|
|
18
18
|
import { createLinkRouter } from './link-routes.js';
|
|
19
19
|
import { createTweetRouter } from './tweet-routes.js';
|
|
@@ -267,6 +267,32 @@ export async function startHttpServer(options = {}) {
|
|
|
267
267
|
res.status(400).json({ error: err.message });
|
|
268
268
|
}
|
|
269
269
|
});
|
|
270
|
+
app.post('/api/documents/batch-resolve', (req, res) => {
|
|
271
|
+
try {
|
|
272
|
+
const { filenames, action } = req.body;
|
|
273
|
+
if (!Array.isArray(filenames) || !filenames.length) {
|
|
274
|
+
res.status(400).json({ error: 'filenames array is required' });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (action !== 'accept' && action !== 'reject') {
|
|
278
|
+
res.status(400).json({ error: 'action must be "accept" or "reject"' });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const result = batchResolve(filenames, action);
|
|
282
|
+
if (result.docsResolved > 0) {
|
|
283
|
+
// Clear pending cache for resolved docs + broadcast
|
|
284
|
+
for (const fn of filenames)
|
|
285
|
+
removePendingCacheEntry(fn);
|
|
286
|
+
updatePendingCacheForActiveDoc();
|
|
287
|
+
broadcastPendingDocsChanged();
|
|
288
|
+
broadcastDocumentsChanged();
|
|
289
|
+
}
|
|
290
|
+
res.json(result);
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
res.status(400).json({ error: err.message });
|
|
294
|
+
}
|
|
295
|
+
});
|
|
270
296
|
app.post('/api/documents/open', (req, res) => {
|
|
271
297
|
try {
|
|
272
298
|
const { path } = req.body;
|
package/dist/server/mcp.js
CHANGED
|
@@ -24,15 +24,15 @@ import { markdownToTiptap, tiptapToMarkdown } from './markdown.js';
|
|
|
24
24
|
import { getMarks, getMarkCount, getGlobalMarkSummary, resolveMarks } from './marks.js';
|
|
25
25
|
import { readTasks, addTask, updateTask, removeTask } from './tasks.js';
|
|
26
26
|
/** Map a content type string to its frontmatter metadata object. */
|
|
27
|
-
function resolveTypeMeta(type) {
|
|
27
|
+
function resolveTypeMeta(type, url) {
|
|
28
28
|
switch (type) {
|
|
29
|
-
case 'tweet': return { tweetContext: { mode: 'tweet' } };
|
|
30
|
-
case 'reply': return { tweetContext: { mode: 'reply' } };
|
|
31
|
-
case 'quote': return { tweetContext: { mode: 'quote' } };
|
|
32
|
-
case 'article': return { articleContext: { active: true } };
|
|
33
|
-
case 'linkedin': return { linkedinContext: { active: true } };
|
|
34
|
-
case 'newsletter': return { newsletterContext: { active: true } };
|
|
35
|
-
case 'blog': return { blogContext: { active: true } };
|
|
29
|
+
case 'tweet': return { content_type: 'tweet', tweetContext: { mode: 'tweet' } };
|
|
30
|
+
case 'reply': return { content_type: 'reply', tweetContext: { mode: 'reply', ...(url ? { url } : {}) } };
|
|
31
|
+
case 'quote': return { content_type: 'quote', tweetContext: { mode: 'quote', ...(url ? { url } : {}) } };
|
|
32
|
+
case 'article': return { content_type: 'article', articleContext: { active: true } };
|
|
33
|
+
case 'linkedin': return { content_type: 'linkedin', linkedinContext: { active: true } };
|
|
34
|
+
case 'newsletter': return { content_type: 'newsletter', newsletterContext: { active: true } };
|
|
35
|
+
case 'blog': return { content_type: 'blog', blogContext: { active: true } };
|
|
36
36
|
default: return undefined;
|
|
37
37
|
}
|
|
38
38
|
}
|
|
@@ -271,18 +271,23 @@ export const TOOL_REGISTRY = [
|
|
|
271
271
|
},
|
|
272
272
|
{
|
|
273
273
|
name: 'create_document',
|
|
274
|
-
description: 'Create a new document. Always provide a title. By default shows a sidebar spinner — call populate_document next to deliver content and clear it. This two-step flow is REQUIRED for all content documents: create_document → populate_document.
|
|
274
|
+
description: 'Create a new document. content_type is REQUIRED — use "document" for plain docs, or "tweet"/"reply"/"quote"/"article"/"linkedin"/"newsletter"/"blog" for typed docs. Always provide a title. By default shows a sidebar spinner — call populate_document next to deliver content and clear it. This two-step flow is REQUIRED for all content documents: create_document → populate_document. Use empty=true ONLY for typed docs (tweets, articles) that start blank and get written to incrementally via write_to_pad. If workspace is provided, the doc is automatically added to it (workspace is created if it doesn\'t exist). If container is also provided, the doc is placed inside that container (created if it doesn\'t exist).',
|
|
275
275
|
schema: {
|
|
276
276
|
title: z.string().optional().describe('Title for the new document. Defaults to "Untitled".'),
|
|
277
277
|
path: z.string().optional().describe('Absolute file path to create the document at (e.g. "C:/projects/doc.md"). If omitted, creates in ~/.openwriter/.'),
|
|
278
278
|
workspace: z.string().optional().describe('Workspace title to add this doc to. Creates the workspace if it doesn\'t exist.'),
|
|
279
279
|
container: z.string().optional().describe('Container name within the workspace (e.g. "Chapters", "Notes", "References"). Creates the container if it doesn\'t exist. Requires workspace.'),
|
|
280
280
|
empty: z.boolean().optional().describe('ONLY for content_type template docs (tweets, articles) that start blank. Skips the spinner and switches immediately. Do NOT set this for content documents — use the two-step flow (create_document → populate_document) instead.'),
|
|
281
|
-
content_type: z.
|
|
281
|
+
content_type: z.enum(['document', 'tweet', 'reply', 'quote', 'article', 'linkedin', 'newsletter', 'blog']).describe('Required. Use "document" for plain documents. Tweet/reply/quote/article/linkedin/newsletter/blog set type-specific metadata automatically.'),
|
|
282
|
+
url: z.string().optional().describe('Tweet URL — REQUIRED for content_type "reply" or "quote" (e.g. "https://x.com/user/status/123"). Sets tweetContext.url automatically. Ignored for other content types.'),
|
|
282
283
|
},
|
|
283
|
-
handler: async ({ title, path, workspace, container, empty, content_type }) => {
|
|
284
|
+
handler: async ({ title, path, workspace, container, empty, content_type, url }) => {
|
|
285
|
+
// Require url for reply/quote
|
|
286
|
+
if ((content_type === 'reply' || content_type === 'quote') && !url) {
|
|
287
|
+
return { content: [{ type: 'text', text: `Error: content_type "${content_type}" requires a url parameter (e.g. "https://x.com/user/status/123").` }] };
|
|
288
|
+
}
|
|
284
289
|
// Default title from content_type if not provided
|
|
285
|
-
if (!title && content_type) {
|
|
290
|
+
if (!title && content_type && content_type !== 'document') {
|
|
286
291
|
const typeDefaults = {
|
|
287
292
|
tweet: 'Tweet', reply: 'Reply', quote: 'Quote Tweet', article: 'Article',
|
|
288
293
|
linkedin: 'LinkedIn Post', newsletter: 'Newsletter', blog: 'Blog Post',
|
|
@@ -313,7 +318,7 @@ export const TOOL_REGISTRY = [
|
|
|
313
318
|
const result = createDocument(title, undefined, path);
|
|
314
319
|
// Apply type-specific metadata
|
|
315
320
|
if (content_type) {
|
|
316
|
-
const typeMeta = resolveTypeMeta(content_type);
|
|
321
|
+
const typeMeta = resolveTypeMeta(content_type, url);
|
|
317
322
|
if (typeMeta) {
|
|
318
323
|
setMetadata(typeMeta);
|
|
319
324
|
}
|
|
@@ -337,7 +342,7 @@ export const TOOL_REGISTRY = [
|
|
|
337
342
|
}
|
|
338
343
|
// Two-step flow: create file on disk WITHOUT switching the user's view.
|
|
339
344
|
// The spinner persists in the sidebar until populate_document is called.
|
|
340
|
-
const typeMeta = content_type ? resolveTypeMeta(content_type) : undefined;
|
|
345
|
+
const typeMeta = content_type ? resolveTypeMeta(content_type, url) : undefined;
|
|
341
346
|
const result = createDocumentFile(title, path, typeMeta);
|
|
342
347
|
let wsInfo = '';
|
|
343
348
|
if (wsTarget) {
|
|
@@ -875,7 +880,7 @@ export const TOOL_REGISTRY = [
|
|
|
875
880
|
},
|
|
876
881
|
{
|
|
877
882
|
name: 'insert_image',
|
|
878
|
-
description: 'Generate an image via Gemini and optionally insert it inline or set it as article cover. Three modes: (1) docId + afterNodeId → generate + insert inline with pending decoration. (2) set_cover: true → generate + set as article cover. (3) Neither → generate to disk only, returns path.
|
|
883
|
+
description: 'Generate an image via Gemini and optionally insert it inline or set it as article cover. Three modes: (1) docId + afterNodeId → generate + insert inline with pending decoration. (2) set_cover: true → generate + set as article cover. (3) Neither → generate to disk only, returns path. Uses local GEMINI_API_KEY if set, otherwise falls back to publish platform API.',
|
|
879
884
|
schema: {
|
|
880
885
|
prompt: z.string().max(1000).describe('Gemini image generation prompt (max 1000 chars).'),
|
|
881
886
|
docId: z.string().optional().describe('Target document by docId (8-char hex). Required for inline insert.'),
|
|
@@ -885,10 +890,6 @@ export const TOOL_REGISTRY = [
|
|
|
885
890
|
set_cover: z.boolean().optional().describe('If true, set the generated image as the article cover (articleContext.coverImage in metadata).'),
|
|
886
891
|
},
|
|
887
892
|
handler: async ({ prompt, docId, afterNodeId, aspect_ratio, alt, set_cover }) => {
|
|
888
|
-
const apiKey = process.env.GEMINI_API_KEY;
|
|
889
|
-
if (!apiKey) {
|
|
890
|
-
return { content: [{ type: 'text', text: 'Error: GEMINI_API_KEY environment variable is not set.' }] };
|
|
891
|
-
}
|
|
892
893
|
const inlineMode = docId && afterNodeId;
|
|
893
894
|
const filename = inlineMode ? resolveDocId(docId) : undefined;
|
|
894
895
|
const targetIsNonActive = filename && filename !== getActiveFilename();
|
|
@@ -905,32 +906,70 @@ export const TOOL_REGISTRY = [
|
|
|
905
906
|
applyChanges([loadingChange]);
|
|
906
907
|
}
|
|
907
908
|
try {
|
|
908
|
-
|
|
909
|
-
const
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
909
|
+
let imgFilename;
|
|
910
|
+
const apiKey = process.env.GEMINI_API_KEY;
|
|
911
|
+
if (apiKey) {
|
|
912
|
+
// Generate image via local Gemini
|
|
913
|
+
const { GoogleGenAI } = await import('@google/genai');
|
|
914
|
+
const ai = new GoogleGenAI({ apiKey });
|
|
915
|
+
const response = await ai.models.generateContent({
|
|
916
|
+
model: 'gemini-3.1-flash-image-preview',
|
|
917
|
+
contents: `Generate a ${aspect_ratio || '16:9'} aspect ratio image: ${prompt}`,
|
|
918
|
+
config: {
|
|
919
|
+
responseModalities: ['IMAGE'],
|
|
920
|
+
},
|
|
921
|
+
});
|
|
922
|
+
const parts = response.candidates?.[0]?.content?.parts;
|
|
923
|
+
const imagePart = parts?.find((p) => p.inlineData);
|
|
924
|
+
if (!imagePart?.inlineData?.data) {
|
|
925
|
+
if (inlineMode && !targetIsNonActive) {
|
|
926
|
+
applyChanges([{ operation: 'delete', nodeId: loadingNodeId }]);
|
|
927
|
+
}
|
|
928
|
+
return { content: [{ type: 'text', text: 'Error: Gemini returned no image data.' }] };
|
|
929
|
+
}
|
|
930
|
+
ensureDataDir();
|
|
931
|
+
const imagesDir = join(getDataDir(), '_images');
|
|
932
|
+
if (!existsSync(imagesDir))
|
|
933
|
+
mkdirSync(imagesDir, { recursive: true });
|
|
934
|
+
imgFilename = `${randomUUID().slice(0, 8)}.png`;
|
|
935
|
+
writeFileSync(join(imagesDir, imgFilename), Buffer.from(imagePart.inlineData.data, 'base64'));
|
|
936
|
+
}
|
|
937
|
+
else {
|
|
938
|
+
// Fallback: generate via publish platform API
|
|
939
|
+
const { platformFetch, isAuthenticated } = await import('./connections.js');
|
|
940
|
+
if (!isAuthenticated()) {
|
|
941
|
+
if (inlineMode && !targetIsNonActive) {
|
|
942
|
+
try {
|
|
943
|
+
applyChanges([{ operation: 'delete', nodeId: loadingNodeId }]);
|
|
944
|
+
}
|
|
945
|
+
catch { }
|
|
946
|
+
}
|
|
947
|
+
return { content: [{ type: 'text', text: 'Error: No GEMINI_API_KEY and publish platform not configured. Set GEMINI_API_KEY or log in to the publish plugin.' }] };
|
|
948
|
+
}
|
|
949
|
+
const res = await platformFetch('/images/generate', {
|
|
950
|
+
method: 'POST',
|
|
951
|
+
body: JSON.stringify({ prompt, aspect_ratio: aspect_ratio || '16:9' }),
|
|
952
|
+
});
|
|
953
|
+
if (!res.ok) {
|
|
954
|
+
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
|
955
|
+
if (inlineMode && !targetIsNonActive) {
|
|
956
|
+
try {
|
|
957
|
+
applyChanges([{ operation: 'delete', nodeId: loadingNodeId }]);
|
|
958
|
+
}
|
|
959
|
+
catch { }
|
|
960
|
+
}
|
|
961
|
+
return { content: [{ type: 'text', text: `Error: ${err.error || 'Platform generation failed'}` }] };
|
|
923
962
|
}
|
|
924
|
-
|
|
963
|
+
const data = await res.json();
|
|
964
|
+
const imageRes = await fetch(data.url);
|
|
965
|
+
const imageBuffer = Buffer.from(await imageRes.arrayBuffer());
|
|
966
|
+
ensureDataDir();
|
|
967
|
+
const imagesDir = join(getDataDir(), '_images');
|
|
968
|
+
if (!existsSync(imagesDir))
|
|
969
|
+
mkdirSync(imagesDir, { recursive: true });
|
|
970
|
+
imgFilename = `${randomUUID().slice(0, 8)}.png`;
|
|
971
|
+
writeFileSync(join(imagesDir, imgFilename), imageBuffer);
|
|
925
972
|
}
|
|
926
|
-
// Save to ~/.openwriter/_images/
|
|
927
|
-
ensureDataDir();
|
|
928
|
-
const imagesDir = join(getDataDir(), '_images');
|
|
929
|
-
if (!existsSync(imagesDir))
|
|
930
|
-
mkdirSync(imagesDir, { recursive: true });
|
|
931
|
-
const imgFilename = `${randomUUID().slice(0, 8)}.png`;
|
|
932
|
-
const imgPath = join(imagesDir, imgFilename);
|
|
933
|
-
writeFileSync(imgPath, Buffer.from(imagePart.inlineData.data, 'base64'));
|
|
934
973
|
const src = `/_images/${imgFilename}`;
|
|
935
974
|
// Mode 1: Inline insert
|
|
936
975
|
if (inlineMode) {
|
package/dist/server/state.js
CHANGED
|
@@ -456,6 +456,24 @@ export function setAgentLock() {
|
|
|
456
456
|
export function isAgentLocked() {
|
|
457
457
|
return Date.now() - lastAgentWriteTime < AGENT_LOCK_MS;
|
|
458
458
|
}
|
|
459
|
+
// ---- Document version counter: prevents stale browser doc-updates ----
|
|
460
|
+
let docVersion = 0;
|
|
461
|
+
/** Increment version after agent writes. Returns the new version. */
|
|
462
|
+
export function bumpDocVersion() {
|
|
463
|
+
return ++docVersion;
|
|
464
|
+
}
|
|
465
|
+
/** Get current document version. */
|
|
466
|
+
export function getDocVersion() {
|
|
467
|
+
return docVersion;
|
|
468
|
+
}
|
|
469
|
+
/** Check if a browser doc-update version is current. */
|
|
470
|
+
export function isVersionCurrent(browserVersion) {
|
|
471
|
+
return browserVersion >= docVersion;
|
|
472
|
+
}
|
|
473
|
+
/** Reset version on document switch (new document = new version lineage). */
|
|
474
|
+
export function resetDocVersion() {
|
|
475
|
+
docVersion = 0;
|
|
476
|
+
}
|
|
459
477
|
// ---- Debounced save: coalesces rapid agent writes into a single disk write ----
|
|
460
478
|
let saveTimer = null;
|
|
461
479
|
const SAVE_DEBOUNCE_MS = 500;
|
|
@@ -477,11 +495,12 @@ export function cancelDebouncedSave() {
|
|
|
477
495
|
export function applyChanges(changes) {
|
|
478
496
|
// Apply to server-side document (source of truth)
|
|
479
497
|
const processed = applyChangesToDocument(changes);
|
|
480
|
-
//
|
|
498
|
+
// Bump version + lock browser doc-updates to prevent stale state overwrite
|
|
499
|
+
const version = bumpDocVersion();
|
|
481
500
|
setAgentLock();
|
|
482
|
-
// Broadcast processed changes (with server-assigned IDs) to browser clients
|
|
501
|
+
// Broadcast processed changes (with server-assigned IDs + version) to browser clients
|
|
483
502
|
for (const listener of listeners) {
|
|
484
|
-
listener(processed);
|
|
503
|
+
listener(processed, version);
|
|
485
504
|
}
|
|
486
505
|
// Debounced save — coalesces rapid agent writes into a single disk write
|
|
487
506
|
debouncedSave();
|
|
@@ -543,6 +562,10 @@ function findNodeInDoc(nodes, id) {
|
|
|
543
562
|
*/
|
|
544
563
|
function applyChangesToDoc(doc, changes) {
|
|
545
564
|
const processed = [];
|
|
565
|
+
// Track last insert anchor → last inserted node ID, so consecutive inserts
|
|
566
|
+
// with the same afterNodeId chain naturally (array order = document order).
|
|
567
|
+
let lastInsertAnchor = null;
|
|
568
|
+
let lastInsertedId = null;
|
|
546
569
|
for (const change of changes) {
|
|
547
570
|
if (change.operation === 'rewrite' && change.nodeId && change.content) {
|
|
548
571
|
const found = findNode(doc.content, change.nodeId, doc.content);
|
|
@@ -608,6 +631,12 @@ function applyChangesToDoc(doc, changes) {
|
|
|
608
631
|
// Mark leaf blocks as pending (not containers) for correct serialization
|
|
609
632
|
markLeafBlocksAsPending(contentWithIds, 'insert');
|
|
610
633
|
let resolvedAfterId;
|
|
634
|
+
// Auto-chain: if this insert targets the same anchor as the previous insert,
|
|
635
|
+
// redirect it to insert after the last inserted node instead (preserves array order).
|
|
636
|
+
let effectiveAfterId = change.afterNodeId;
|
|
637
|
+
if (effectiveAfterId && effectiveAfterId === lastInsertAnchor && lastInsertedId) {
|
|
638
|
+
effectiveAfterId = lastInsertedId;
|
|
639
|
+
}
|
|
611
640
|
if (change.nodeId && !change.afterNodeId) {
|
|
612
641
|
// Replace empty node
|
|
613
642
|
const found = findNode(doc.content, change.nodeId, doc.content);
|
|
@@ -615,8 +644,8 @@ function applyChangesToDoc(doc, changes) {
|
|
|
615
644
|
continue;
|
|
616
645
|
found.parent.splice(found.index, 1, ...contentWithIds);
|
|
617
646
|
}
|
|
618
|
-
else if (
|
|
619
|
-
const found = findNode(doc.content,
|
|
647
|
+
else if (effectiveAfterId) {
|
|
648
|
+
const found = findNode(doc.content, effectiveAfterId, doc.content);
|
|
620
649
|
if (!found)
|
|
621
650
|
continue;
|
|
622
651
|
// Resolve "end" sentinel to actual node ID so browser can find it
|
|
@@ -626,11 +655,21 @@ function applyChangesToDoc(doc, changes) {
|
|
|
626
655
|
else {
|
|
627
656
|
continue;
|
|
628
657
|
}
|
|
629
|
-
//
|
|
658
|
+
// Track for auto-chaining: remember original anchor + last inserted ID
|
|
659
|
+
const newLastId = contentWithIds[contentWithIds.length - 1]?.attrs?.id;
|
|
660
|
+
if (change.afterNodeId && newLastId) {
|
|
661
|
+
if (change.afterNodeId !== lastInsertAnchor) {
|
|
662
|
+
// New anchor — start fresh chain
|
|
663
|
+
lastInsertAnchor = change.afterNodeId;
|
|
664
|
+
}
|
|
665
|
+
lastInsertedId = newLastId;
|
|
666
|
+
}
|
|
667
|
+
// Broadcast with server-assigned IDs and resolved anchor so browser inserts at the correct position
|
|
630
668
|
processed.push({
|
|
631
669
|
...change,
|
|
632
|
-
|
|
633
|
-
|
|
670
|
+
afterNodeId: resolvedAfterId && change.afterNodeId === 'end'
|
|
671
|
+
? resolvedAfterId
|
|
672
|
+
: effectiveAfterId ?? change.afterNodeId,
|
|
634
673
|
content: contentWithIds.length === 1 ? contentWithIds[0] : contentWithIds,
|
|
635
674
|
});
|
|
636
675
|
}
|
package/dist/server/ws.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* WebSocket handler: pushes NodeChanges to browser, receives doc updates + signals.
|
|
3
3
|
*/
|
|
4
4
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
5
|
-
import { updateDocument, getDocument, getTitle, getFilePath, getDocId, getMetadata, setMetadata, save, onChanges, isAgentLocked, setAgentLock, getPendingDocInfo, updatePendingCacheForActiveDoc, stripPendingAttrs, saveDocToFile, stripPendingAttrsFromFile, } from './state.js';
|
|
5
|
+
import { updateDocument, getDocument, getTitle, getFilePath, getDocId, getMetadata, setMetadata, save, onChanges, isAgentLocked, setAgentLock, getDocVersion, isVersionCurrent, getPendingDocInfo, updatePendingCacheForActiveDoc, stripPendingAttrs, saveDocToFile, stripPendingAttrsFromFile, } from './state.js';
|
|
6
6
|
import { switchDocument, createDocument, deleteDocument, getActiveFilename, promoteTempFile } from './documents.js';
|
|
7
7
|
import { removeDocFromAllWorkspaces } from './workspaces.js';
|
|
8
8
|
const clients = new Set();
|
|
@@ -45,7 +45,7 @@ export function setupWebSocket(server) {
|
|
|
45
45
|
},
|
|
46
46
|
});
|
|
47
47
|
// Push agent changes to all browser clients
|
|
48
|
-
onChanges((changes) => {
|
|
48
|
+
onChanges((changes, version) => {
|
|
49
49
|
// Check if changes include HR nodes in a tweet thread document.
|
|
50
50
|
// Tweet editors don't support horizontalRule in their schema, so individual
|
|
51
51
|
// node-changes with HRs silently fail. Send a full document resync instead,
|
|
@@ -81,7 +81,7 @@ export function setupWebSocket(server) {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
else {
|
|
84
|
-
const msg = JSON.stringify({ type: 'node-changes', changes });
|
|
84
|
+
const msg = JSON.stringify({ type: 'node-changes', changes, version });
|
|
85
85
|
for (const ws of clients) {
|
|
86
86
|
if (ws.readyState === WebSocket.OPEN)
|
|
87
87
|
ws.send(msg);
|
|
@@ -123,9 +123,14 @@ export function setupWebSocket(server) {
|
|
|
123
123
|
const docContent = msg.document?.content || [];
|
|
124
124
|
const nodeCount = docContent.length;
|
|
125
125
|
const currentNodeCount = getDocument()?.content?.length || 0;
|
|
126
|
+
const browserVersion = typeof msg.version === 'number' ? msg.version : -1;
|
|
127
|
+
const serverVersion = getDocVersion();
|
|
126
128
|
if (isAgentLocked()) {
|
|
127
129
|
console.log(`[WS] doc-update BLOCKED by agent lock (browser: ${nodeCount} nodes, server: ${currentNodeCount} nodes)`);
|
|
128
130
|
}
|
|
131
|
+
else if (browserVersion >= 0 && !isVersionCurrent(browserVersion)) {
|
|
132
|
+
console.log(`[WS] doc-update BLOCKED by stale version (browser: v${browserVersion}, server: v${serverVersion})`);
|
|
133
|
+
}
|
|
129
134
|
else if (msg.filename && msg.filename !== getActiveFilename()) {
|
|
130
135
|
// Browser sent a doc-update for a different document (race: server switched away).
|
|
131
136
|
// Save directly to that file on disk instead of corrupting the active doc.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "The open-source writing surface for AI agents. Markdown-native editor with pending change review — your agent writes, you accept or reject.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|