openwriter 0.15.0 → 0.17.0
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-0ttVnjRp.css +1 -0
- package/dist/client/assets/{index-B5MXw2pg.js → index-BZ7LCzrR.js} +64 -64
- package/dist/client/index.html +2 -2
- package/dist/plugins/authors-voice/dist/index.d.ts +41 -0
- package/dist/plugins/authors-voice/dist/index.js +206 -0
- package/dist/plugins/authors-voice/package.json +23 -0
- package/dist/plugins/image-gen/dist/index.d.ts +35 -0
- package/dist/plugins/image-gen/dist/index.js +141 -0
- package/dist/plugins/image-gen/package.json +26 -0
- package/dist/plugins/publish/dist/helpers.d.ts +66 -0
- package/dist/plugins/publish/dist/helpers.js +199 -0
- package/dist/plugins/publish/dist/index.d.ts +3 -0
- package/dist/plugins/publish/dist/index.js +1130 -0
- package/dist/plugins/publish/dist/newsletter-tools.d.ts +2 -0
- package/dist/plugins/publish/dist/newsletter-tools.js +394 -0
- package/dist/plugins/publish/package.json +31 -0
- package/dist/plugins/x-api/dist/index.d.ts +27 -0
- package/dist/plugins/x-api/dist/index.js +240 -0
- package/dist/plugins/x-api/package.json +27 -0
- package/dist/server/compact.js +28 -2
- package/dist/server/documents.js +234 -3
- package/dist/server/enrichment.js +125 -0
- package/dist/server/export-routes.js +2 -0
- package/dist/server/install-skill.js +15 -0
- package/dist/server/markdown-parse.js +153 -14
- package/dist/server/markdown-serialize.js +100 -17
- package/dist/server/mcp.js +291 -25
- package/dist/server/node-blocks.js +41 -1
- package/dist/server/node-fingerprint.js +347 -73
- package/dist/server/node-matcher.js +19 -44
- package/dist/server/pending-overlay.js +21 -4
- package/dist/server/state.js +225 -41
- package/dist/server/workspaces.js +27 -5
- package/dist/server/ws.js +10 -0
- package/package.json +2 -1
- package/skill/SKILL.md +38 -7
- package/skill/agents/openwriter-enrichment-minion.md +177 -0
- package/skill/docs/enrichment.md +179 -0
- package/skill/docs/footnotes.md +178 -0
- package/dist/client/assets/index-B3iORmCT.css +0 -1
package/dist/client/index.html
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
11
11
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
12
12
|
<link href="https://fonts.googleapis.com/css2?family=Charter:ital,wght@0,400;0,700;1,400&family=Crimson+Pro:ital,wght@0,300;0,400;0,600;0,700;1,400&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=DM+Serif+Display&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600;700&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,600;0,7..72,700;1,7..72,400&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;1,6..72,400&family=Playfair+Display:wght@400;600;700;900&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
|
|
13
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
13
|
+
<script type="module" crossorigin src="/assets/index-BZ7LCzrR.js"></script>
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/assets/index-0ttVnjRp.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
17
17
|
<div id="root"></div>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Author's Voice plugin for OpenWriter.
|
|
3
|
+
* Proxies /api/voice/* to the AV backend and adds context menu items
|
|
4
|
+
* for rewriting, shrinking, expanding, and custom instructions.
|
|
5
|
+
* Also registers sidebar menu items for document-level transforms.
|
|
6
|
+
*/
|
|
7
|
+
import type { Express } from 'express';
|
|
8
|
+
interface PluginConfigField {
|
|
9
|
+
type: 'string' | 'number' | 'boolean';
|
|
10
|
+
required?: boolean;
|
|
11
|
+
env?: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
}
|
|
14
|
+
interface PluginRouteContext {
|
|
15
|
+
app: Express;
|
|
16
|
+
config: Record<string, string>;
|
|
17
|
+
}
|
|
18
|
+
interface PluginContextMenuItem {
|
|
19
|
+
label: string;
|
|
20
|
+
shortcut?: string;
|
|
21
|
+
action: string;
|
|
22
|
+
condition?: 'has-selection' | 'empty-node' | 'always';
|
|
23
|
+
promptForInput?: boolean;
|
|
24
|
+
}
|
|
25
|
+
interface PluginSidebarMenuItem {
|
|
26
|
+
label: string;
|
|
27
|
+
action: string;
|
|
28
|
+
promptForFocus?: boolean;
|
|
29
|
+
}
|
|
30
|
+
interface OpenWriterPlugin {
|
|
31
|
+
name: string;
|
|
32
|
+
version: string;
|
|
33
|
+
description?: string;
|
|
34
|
+
category?: 'writing' | 'social-media' | 'image-generation';
|
|
35
|
+
configSchema?: Record<string, PluginConfigField>;
|
|
36
|
+
registerRoutes?(ctx: PluginRouteContext): void | Promise<void>;
|
|
37
|
+
contextMenuItems?(): PluginContextMenuItem[];
|
|
38
|
+
sidebarMenuItems?(): PluginSidebarMenuItem[];
|
|
39
|
+
}
|
|
40
|
+
declare const plugin: OpenWriterPlugin;
|
|
41
|
+
export default plugin;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Author's Voice plugin for OpenWriter.
|
|
3
|
+
* Proxies /api/voice/* to the AV backend and adds context menu items
|
|
4
|
+
* for rewriting, shrinking, expanding, and custom instructions.
|
|
5
|
+
* Also registers sidebar menu items for document-level transforms.
|
|
6
|
+
*/
|
|
7
|
+
/** Simple HTML → markdown conversion for document creation */
|
|
8
|
+
function htmlToMarkdown(html) {
|
|
9
|
+
let md = html;
|
|
10
|
+
// <hr> → horizontal rule
|
|
11
|
+
md = md.replace(/<hr\s*\/?>/gi, '\n---\n');
|
|
12
|
+
// <br> → newline
|
|
13
|
+
md = md.replace(/<br\s*\/?>/gi, '\n');
|
|
14
|
+
// <strong>/<b> → **bold**
|
|
15
|
+
md = md.replace(/<(strong|b)>([\s\S]*?)<\/\1>/gi, '**$2**');
|
|
16
|
+
// <em>/<i> → *italic*
|
|
17
|
+
md = md.replace(/<(em|i)>([\s\S]*?)<\/\1>/gi, '*$2*');
|
|
18
|
+
// <p> → paragraph boundaries
|
|
19
|
+
md = md.replace(/<p[^>]*>/gi, '');
|
|
20
|
+
md = md.replace(/<\/p>/gi, '\n\n');
|
|
21
|
+
// Strip remaining tags
|
|
22
|
+
md = md.replace(/<[^>]+>/g, '');
|
|
23
|
+
// Normalize whitespace
|
|
24
|
+
md = md.replace(/\n{3,}/g, '\n\n');
|
|
25
|
+
return md.trim();
|
|
26
|
+
}
|
|
27
|
+
const plugin = {
|
|
28
|
+
name: '@openwriter/plugin-authors-voice',
|
|
29
|
+
version: '0.1.0',
|
|
30
|
+
description: "Rewrite text in your voice using Author's Voice",
|
|
31
|
+
category: 'writing',
|
|
32
|
+
configSchema: {
|
|
33
|
+
'api-key': {
|
|
34
|
+
type: 'string',
|
|
35
|
+
required: true,
|
|
36
|
+
env: 'AV_API_KEY',
|
|
37
|
+
description: 'Author\'s Voice API key',
|
|
38
|
+
},
|
|
39
|
+
'backend-url': {
|
|
40
|
+
type: 'string',
|
|
41
|
+
env: 'AV_BACKEND_URL',
|
|
42
|
+
description: 'AV backend URL',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
registerRoutes(ctx) {
|
|
46
|
+
const backendUrl = ctx.config['backend-url'] || process.env.AV_BACKEND_URL || 'https://authors-voice.com';
|
|
47
|
+
const apiKey = ctx.config['api-key'] || process.env.AV_API_KEY || '';
|
|
48
|
+
const authHeaders = () => {
|
|
49
|
+
const h = { 'Content-Type': 'application/json' };
|
|
50
|
+
if (apiKey)
|
|
51
|
+
h['Authorization'] = `Bearer ${apiKey}`;
|
|
52
|
+
return h;
|
|
53
|
+
};
|
|
54
|
+
// Sidebar action handler — must be registered BEFORE the wildcard
|
|
55
|
+
ctx.app.post('/api/voice/sidebar-action', async (req, res) => {
|
|
56
|
+
try {
|
|
57
|
+
const { action, filename, title, instructions, content } = req.body;
|
|
58
|
+
console.log(`[AV Plugin] Sidebar action: ${action} on "${title}"`);
|
|
59
|
+
if (!content) {
|
|
60
|
+
res.status(400).json({ error: 'Document content is required' });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// Call AV backend transform endpoint
|
|
64
|
+
const transformUrl = `${backendUrl}/api/voice/transform`;
|
|
65
|
+
const upstream = await fetch(transformUrl, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: authHeaders(),
|
|
68
|
+
body: JSON.stringify({ action, content, title, instructions }),
|
|
69
|
+
});
|
|
70
|
+
if (!upstream.ok) {
|
|
71
|
+
const errData = await upstream.json().catch(() => ({}));
|
|
72
|
+
console.error('[AV Plugin] Transform failed:', upstream.status, errData);
|
|
73
|
+
res.status(upstream.status).json(errData);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const transformResult = await upstream.json();
|
|
77
|
+
// Convert HTML output to markdown for document creation
|
|
78
|
+
let markdownContent = htmlToMarkdown(transformResult.html);
|
|
79
|
+
// Threadify: always create as tweet template
|
|
80
|
+
const createBody = {
|
|
81
|
+
title: transformResult.newTitle,
|
|
82
|
+
content: markdownContent,
|
|
83
|
+
markPending: true,
|
|
84
|
+
agentCreated: true,
|
|
85
|
+
};
|
|
86
|
+
if (action === 'threadify') {
|
|
87
|
+
// Build TipTap JSON directly to avoid markdown parsing issues.
|
|
88
|
+
// Markdown parser converts "- item" lines to bulletList nodes that the
|
|
89
|
+
// tweet editor can't render (bulletList extension is disabled), causing
|
|
90
|
+
// empty gaps. By building JSON with only paragraph + hardBreak nodes,
|
|
91
|
+
// all tweet text stays as plain text.
|
|
92
|
+
if (transformResult.thread?.tweets?.length) {
|
|
93
|
+
const docContent = [];
|
|
94
|
+
transformResult.thread.tweets.forEach((t, i) => {
|
|
95
|
+
// Single paragraph per tweet. Split on \n only:
|
|
96
|
+
// \n → one hardBreak (tight line), \n\n → two hardBreaks (blank line spacing)
|
|
97
|
+
const lines = t.text.split('\n');
|
|
98
|
+
const nodes = [];
|
|
99
|
+
lines.forEach((line, j) => {
|
|
100
|
+
if (j > 0)
|
|
101
|
+
nodes.push({ type: 'hardBreak' });
|
|
102
|
+
if (line)
|
|
103
|
+
nodes.push({ type: 'text', text: line });
|
|
104
|
+
});
|
|
105
|
+
if (nodes.length) {
|
|
106
|
+
docContent.push({ type: 'paragraph', content: nodes });
|
|
107
|
+
}
|
|
108
|
+
if (i < transformResult.thread.tweets.length - 1) {
|
|
109
|
+
docContent.push({ type: 'horizontalRule' });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
createBody.content = { type: 'doc', content: docContent };
|
|
113
|
+
}
|
|
114
|
+
createBody.metadata = { tweetContext: { mode: 'tweet' } };
|
|
115
|
+
}
|
|
116
|
+
// Create new document in OpenWriter via internal HTTP call
|
|
117
|
+
const host = req.get('host') || 'localhost:5050';
|
|
118
|
+
const protocol = req.protocol || 'http';
|
|
119
|
+
const createUrl = `${protocol}://${host}/api/documents`;
|
|
120
|
+
const createRes = await fetch(createUrl, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: { 'Content-Type': 'application/json' },
|
|
123
|
+
body: JSON.stringify(createBody),
|
|
124
|
+
});
|
|
125
|
+
if (!createRes.ok) {
|
|
126
|
+
const errData = await createRes.json().catch(() => ({}));
|
|
127
|
+
console.error('[AV Plugin] Document creation failed:', errData);
|
|
128
|
+
res.status(500).json({ error: 'Failed to create result document' });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const docResult = await createRes.json();
|
|
132
|
+
res.json({
|
|
133
|
+
success: true,
|
|
134
|
+
action,
|
|
135
|
+
filename: docResult.filename,
|
|
136
|
+
title: transformResult.newTitle,
|
|
137
|
+
metadata: transformResult.metadata,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
console.error('[AV Plugin] Sidebar action error:', err?.message || err);
|
|
142
|
+
res.status(500).json({ error: 'Sidebar action failed' });
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
// Wildcard proxy for all other /api/voice/* routes
|
|
146
|
+
ctx.app.post('/api/voice/*', async (req, res) => {
|
|
147
|
+
try {
|
|
148
|
+
const subPath = req.params[0] || '';
|
|
149
|
+
const targetUrl = `${backendUrl}/api/voice/${subPath}`;
|
|
150
|
+
console.log(`[AV Plugin] ${req.method} ${req.path} → ${targetUrl}`);
|
|
151
|
+
const upstream = await fetch(targetUrl, {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
headers: authHeaders(),
|
|
154
|
+
body: JSON.stringify(req.body),
|
|
155
|
+
});
|
|
156
|
+
res.status(upstream.status);
|
|
157
|
+
const forwardHeaders = ['x-usage-rewrite-count', 'x-usage-rewrite-limit', 'x-usage-resets-at'];
|
|
158
|
+
for (const h of forwardHeaders) {
|
|
159
|
+
const val = upstream.headers.get(h);
|
|
160
|
+
if (val)
|
|
161
|
+
res.setHeader(h, val);
|
|
162
|
+
}
|
|
163
|
+
const responseText = await upstream.text();
|
|
164
|
+
try {
|
|
165
|
+
const data = JSON.parse(responseText);
|
|
166
|
+
res.json(data);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
console.error('[AV Plugin] Non-JSON response:', responseText.substring(0, 500));
|
|
170
|
+
res.status(502).json({ error: 'AV backend returned non-JSON response' });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
console.error('[AV Plugin] Backend error:', err?.message || err);
|
|
175
|
+
res.status(502).json({ error: 'AV backend unreachable' });
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
},
|
|
179
|
+
contextMenuItems() {
|
|
180
|
+
return [
|
|
181
|
+
// Selection actions (require highlighted text)
|
|
182
|
+
{ label: 'Enhance', shortcut: 'R', action: 'av:rewrite', condition: 'has-selection' },
|
|
183
|
+
{ label: 'Modify...', action: 'av:custom', condition: 'has-selection', promptForInput: true },
|
|
184
|
+
{ label: 'Shrink', shortcut: 'S', action: 'av:shrink', condition: 'has-selection' },
|
|
185
|
+
{ label: 'Expand', shortcut: 'E', action: 'av:expand', condition: 'has-selection' },
|
|
186
|
+
// Empty node actions (cursor on empty line)
|
|
187
|
+
{ label: 'Insert', shortcut: 'I', action: 'av:insert', condition: 'empty-node', promptForInput: true },
|
|
188
|
+
{ label: 'Fill paragraph', shortcut: 'F', action: 'av:fill', condition: 'empty-node' },
|
|
189
|
+
{ label: 'Fill sentence', action: 'av:fill-sentence', condition: 'empty-node' },
|
|
190
|
+
];
|
|
191
|
+
},
|
|
192
|
+
// Sidebar transforms disabled — now handled by publish plugin.
|
|
193
|
+
// Kept commented for reference during transition.
|
|
194
|
+
// sidebarMenuItems() {
|
|
195
|
+
// return [
|
|
196
|
+
// { label: 'Vary', action: 'voice:vary', promptForFocus: true },
|
|
197
|
+
// { label: 'Shrinkify', action: 'voice:shrinkify', promptForFocus: true },
|
|
198
|
+
// { label: 'Expandify', action: 'voice:expandify', promptForFocus: true },
|
|
199
|
+
// { label: 'Threadify', action: 'voice:threadify', promptForFocus: true },
|
|
200
|
+
// { label: 'Storify', action: 'voice:storify', promptForFocus: true },
|
|
201
|
+
// { label: 'Emailify', action: 'voice:emailify', promptForFocus: true },
|
|
202
|
+
// { label: 'Postify', action: 'voice:postify', promptForFocus: true },
|
|
203
|
+
// ];
|
|
204
|
+
// },
|
|
205
|
+
};
|
|
206
|
+
export default plugin;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openwriter/plugin-authors-voice",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Rewrite text in your voice using Author's Voice",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"dev": "tsc --watch"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"@types/express": "^5.0.0",
|
|
13
|
+
"typescript": "^5.6.0"
|
|
14
|
+
},
|
|
15
|
+
"openwriter": {
|
|
16
|
+
"displayName": "Author's Voice",
|
|
17
|
+
"category": "writing"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist/",
|
|
21
|
+
"package.json"
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image Generation plugin for OpenWriter.
|
|
3
|
+
* Right-click an empty paragraph → "Generate image" → AI creates an image inline.
|
|
4
|
+
* Uses Google Gemini (Nano Banana 2) for generation, saves to /_images/.
|
|
5
|
+
*/
|
|
6
|
+
import type { Express } from 'express';
|
|
7
|
+
interface PluginConfigField {
|
|
8
|
+
type: 'string' | 'number' | 'boolean';
|
|
9
|
+
required?: boolean;
|
|
10
|
+
env?: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
}
|
|
13
|
+
interface PluginRouteContext {
|
|
14
|
+
app: Express;
|
|
15
|
+
config: Record<string, string>;
|
|
16
|
+
dataDir: string;
|
|
17
|
+
}
|
|
18
|
+
interface PluginContextMenuItem {
|
|
19
|
+
label: string;
|
|
20
|
+
shortcut?: string;
|
|
21
|
+
action: string;
|
|
22
|
+
condition?: 'has-selection' | 'empty-node' | 'always';
|
|
23
|
+
promptForInput?: boolean;
|
|
24
|
+
}
|
|
25
|
+
interface OpenWriterPlugin {
|
|
26
|
+
name: string;
|
|
27
|
+
version: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
category?: 'writing' | 'social-media' | 'image-generation';
|
|
30
|
+
configSchema?: Record<string, PluginConfigField>;
|
|
31
|
+
registerRoutes?(ctx: PluginRouteContext): void | Promise<void>;
|
|
32
|
+
contextMenuItems?(): PluginContextMenuItem[];
|
|
33
|
+
}
|
|
34
|
+
declare const plugin: OpenWriterPlugin;
|
|
35
|
+
export default plugin;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image Generation plugin for OpenWriter.
|
|
3
|
+
* Right-click an empty paragraph → "Generate image" → AI creates an image inline.
|
|
4
|
+
* Uses Google Gemini (Nano Banana 2) for generation, saves to /_images/.
|
|
5
|
+
*/
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
|
|
8
|
+
import { randomUUID } from 'crypto';
|
|
9
|
+
import { homedir } from 'os';
|
|
10
|
+
/** Fallback: generate image via the publish platform API, download and save locally */
|
|
11
|
+
async function generateViaPlatform(prompt, dataDir, aspectRatio = '16:9') {
|
|
12
|
+
const configPath = join(homedir(), '.openwriter', 'config.json');
|
|
13
|
+
if (!existsSync(configPath))
|
|
14
|
+
return null;
|
|
15
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
16
|
+
const publishConfig = config.plugins?.['@openwriter/plugin-publish']?.config || {};
|
|
17
|
+
const platformKey = publishConfig['api-key'];
|
|
18
|
+
const apiUrl = publishConfig['api-url'] || 'https://publish.openwriter.io';
|
|
19
|
+
const profile = config.activeProfile || 'Default';
|
|
20
|
+
if (!platformKey)
|
|
21
|
+
return null;
|
|
22
|
+
console.log(`[ImageGen] Generating image (platform): "${prompt.slice(0, 80)}..."`);
|
|
23
|
+
const res = await fetch(`${apiUrl}/images/generate`, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: {
|
|
26
|
+
'Content-Type': 'application/json',
|
|
27
|
+
Authorization: `Bearer ${platformKey}`,
|
|
28
|
+
'X-Profile': profile,
|
|
29
|
+
},
|
|
30
|
+
body: JSON.stringify({ prompt, aspect_ratio: aspectRatio }),
|
|
31
|
+
});
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
|
34
|
+
throw new Error(err.error || 'Platform image generation failed');
|
|
35
|
+
}
|
|
36
|
+
const data = await res.json();
|
|
37
|
+
// Download image and save locally
|
|
38
|
+
const imageRes = await fetch(data.url);
|
|
39
|
+
const imageBuffer = Buffer.from(await imageRes.arrayBuffer());
|
|
40
|
+
const imagesDir = join(dataDir, '_images');
|
|
41
|
+
if (!existsSync(imagesDir))
|
|
42
|
+
mkdirSync(imagesDir, { recursive: true });
|
|
43
|
+
const filename = `${randomUUID().slice(0, 8)}.png`;
|
|
44
|
+
writeFileSync(join(imagesDir, filename), imageBuffer);
|
|
45
|
+
console.log(`[ImageGen] Saved (platform): ${join(imagesDir, filename)}`);
|
|
46
|
+
return { success: true, src: `/_images/${filename}` };
|
|
47
|
+
}
|
|
48
|
+
const plugin = {
|
|
49
|
+
name: '@openwriter/plugin-image-gen',
|
|
50
|
+
version: '0.1.0',
|
|
51
|
+
description: 'Generate images with AI — right-click empty paragraphs',
|
|
52
|
+
category: 'image-generation',
|
|
53
|
+
configSchema: {
|
|
54
|
+
'gemini-api-key': {
|
|
55
|
+
type: 'string',
|
|
56
|
+
env: 'GEMINI_API_KEY',
|
|
57
|
+
required: false,
|
|
58
|
+
description: 'Google Gemini API key for image generation (optional — falls back to publish platform)',
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
registerRoutes(ctx) {
|
|
62
|
+
ctx.app.post('/api/image-gen/generate', async (req, res) => {
|
|
63
|
+
try {
|
|
64
|
+
const { prompt } = req.body;
|
|
65
|
+
if (!prompt || typeof prompt !== 'string') {
|
|
66
|
+
res.status(400).json({ success: false, error: 'prompt is required' });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (prompt.length > 1000) {
|
|
70
|
+
res.status(400).json({ success: false, error: 'prompt must be under 1000 characters' });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const apiKey = ctx.config['gemini-api-key'] || process.env.GEMINI_API_KEY || '';
|
|
74
|
+
let imageBytes;
|
|
75
|
+
if (apiKey) {
|
|
76
|
+
// Local Gemini generation
|
|
77
|
+
const { GoogleGenAI } = await import('@google/genai');
|
|
78
|
+
const ai = new GoogleGenAI({ apiKey });
|
|
79
|
+
console.log(`[ImageGen] Generating image (local): "${prompt.slice(0, 80)}..."`);
|
|
80
|
+
const response = await ai.models.generateContent({
|
|
81
|
+
model: 'gemini-3.1-flash-image-preview',
|
|
82
|
+
contents: `Generate a 16:9 aspect ratio image: ${prompt}`,
|
|
83
|
+
config: {
|
|
84
|
+
responseModalities: ['IMAGE'],
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
const parts = response.candidates?.[0]?.content?.parts;
|
|
88
|
+
if (!parts || parts.length === 0) {
|
|
89
|
+
res.status(422).json({ success: false, error: 'No image generated — content may have been filtered' });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const imagePart = parts.find((p) => p.inlineData);
|
|
93
|
+
imageBytes = imagePart?.inlineData?.data;
|
|
94
|
+
if (!imageBytes) {
|
|
95
|
+
res.status(422).json({ success: false, error: 'No image data in response' });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
// Fallback: generate via publish platform API
|
|
101
|
+
const platformResult = await generateViaPlatform(prompt, ctx.dataDir);
|
|
102
|
+
if (!platformResult) {
|
|
103
|
+
res.status(400).json({ success: false, error: 'No GEMINI_API_KEY and publish platform not configured. Set GEMINI_API_KEY or log in to the publish plugin.' });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// platformResult already saved to disk
|
|
107
|
+
res.json(platformResult);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// Save to dataDir/_images/
|
|
111
|
+
const imagesDir = join(ctx.dataDir, '_images');
|
|
112
|
+
if (!existsSync(imagesDir))
|
|
113
|
+
mkdirSync(imagesDir, { recursive: true });
|
|
114
|
+
const filename = `${randomUUID().slice(0, 8)}.png`;
|
|
115
|
+
const filepath = join(imagesDir, filename);
|
|
116
|
+
writeFileSync(filepath, Buffer.from(imageBytes, 'base64'));
|
|
117
|
+
console.log(`[ImageGen] Saved: ${filepath}`);
|
|
118
|
+
res.json({ success: true, src: `/_images/${filename}` });
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
const msg = err?.message || 'Image generation failed';
|
|
122
|
+
console.error('[ImageGen] Generation failed:', msg);
|
|
123
|
+
const friendly = /Unexpected token.*<!DOCTYPE/i.test(msg)
|
|
124
|
+
? 'Image generation failed — your Gemini API key may be invalid or expired. Check your GEMINI_API_KEY.'
|
|
125
|
+
: msg;
|
|
126
|
+
res.status(500).json({ success: false, error: friendly });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
contextMenuItems() {
|
|
131
|
+
return [
|
|
132
|
+
{
|
|
133
|
+
label: 'Generate image',
|
|
134
|
+
action: 'img:generate',
|
|
135
|
+
condition: 'always',
|
|
136
|
+
promptForInput: true,
|
|
137
|
+
},
|
|
138
|
+
];
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
export default plugin;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openwriter/plugin-image-gen",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Generate images with AI — right-click empty paragraphs",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"dev": "tsc --watch"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@google/genai": "^1.42.0"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@types/express": "^5.0.0",
|
|
16
|
+
"typescript": "^5.6.0"
|
|
17
|
+
},
|
|
18
|
+
"openwriter": {
|
|
19
|
+
"displayName": "Image Generator",
|
|
20
|
+
"category": "image-generation"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist/",
|
|
24
|
+
"package.json"
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import MarkdownIt from 'markdown-it';
|
|
2
|
+
export interface ServerModules {
|
|
3
|
+
tiptapToMarkdown: (doc: any, title: string, metadata?: Record<string, any>) => string;
|
|
4
|
+
getDocument: () => any;
|
|
5
|
+
getTitle: () => string;
|
|
6
|
+
getMetadata: () => Record<string, any>;
|
|
7
|
+
getActiveProfile: () => string;
|
|
8
|
+
getDataDir: () => string;
|
|
9
|
+
getDocId: () => string;
|
|
10
|
+
platformFetch: (path: string, options?: RequestInit) => Promise<Response>;
|
|
11
|
+
}
|
|
12
|
+
export declare function getServerModules(): Promise<ServerModules>;
|
|
13
|
+
export interface PluginConfigField {
|
|
14
|
+
type: 'string' | 'number' | 'boolean';
|
|
15
|
+
required?: boolean;
|
|
16
|
+
env?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface PluginMcpTool {
|
|
20
|
+
name: string;
|
|
21
|
+
description: string;
|
|
22
|
+
inputSchema: Record<string, unknown>;
|
|
23
|
+
handler: (params: Record<string, unknown>) => Promise<unknown>;
|
|
24
|
+
}
|
|
25
|
+
export interface PluginRouteContext {
|
|
26
|
+
app: import('express').Router;
|
|
27
|
+
config: Record<string, string>;
|
|
28
|
+
dataDir: string;
|
|
29
|
+
}
|
|
30
|
+
export interface PluginSidebarMenuItem {
|
|
31
|
+
label: string;
|
|
32
|
+
action: string;
|
|
33
|
+
promptForFocus?: boolean;
|
|
34
|
+
}
|
|
35
|
+
export interface OpenWriterPlugin {
|
|
36
|
+
name: string;
|
|
37
|
+
version: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
category?: 'writing' | 'social-media' | 'image-generation' | 'publishing' | 'productivity' | 'analytics';
|
|
40
|
+
configSchema?: Record<string, PluginConfigField>;
|
|
41
|
+
registerRoutes?(ctx: PluginRouteContext): void | Promise<void>;
|
|
42
|
+
mcpTools?(config: Record<string, string>): PluginMcpTool[];
|
|
43
|
+
sidebarMenuItems?(): PluginSidebarMenuItem[];
|
|
44
|
+
}
|
|
45
|
+
export declare const md: MarkdownIt;
|
|
46
|
+
/** Strip YAML frontmatter and TipTap empty markers from markdown output */
|
|
47
|
+
export declare function stripFrontmatter(markdown: string): string;
|
|
48
|
+
/** Scan HTML for /_images/ references, read local files, return base64 array for R2 upload */
|
|
49
|
+
export declare function extractLocalImages(html: string): Promise<Array<{
|
|
50
|
+
path: string;
|
|
51
|
+
data: string;
|
|
52
|
+
content_type: string;
|
|
53
|
+
}>>;
|
|
54
|
+
/** Convert current document's TipTap JSON to body HTML + plain text */
|
|
55
|
+
export declare function documentToEmail(): Promise<{
|
|
56
|
+
html: string;
|
|
57
|
+
text: string;
|
|
58
|
+
subject: string;
|
|
59
|
+
json: any;
|
|
60
|
+
}>;
|
|
61
|
+
/** Strip markdown syntax to produce clean plain text for email */
|
|
62
|
+
export declare function markdownToPlainText(markdown: string): string;
|
|
63
|
+
/** Strip inline markdown marks from a string */
|
|
64
|
+
export declare function stripInline(text: string): string;
|
|
65
|
+
/** Make an authenticated request to the Publish API via platform proxy */
|
|
66
|
+
export declare function publishFetch(_config: Record<string, string>, path: string, options?: RequestInit): Promise<Response>;
|