mc-pdf-studio 1.0.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/src/server.js ADDED
@@ -0,0 +1,250 @@
1
+ const express = require('express');
2
+ const multer = require('multer');
3
+ const cors = require('cors');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+ const http = require('http');
8
+
9
+ let pdfParse;
10
+ try { pdfParse = require('pdf-parse'); } catch(e) {}
11
+
12
+ const app = express();
13
+ app.use(cors());
14
+ app.use(express.json());
15
+ app.use(express.static(path.join(__dirname, '../public')));
16
+
17
+ const upload = multer({
18
+ storage: multer.diskStorage({
19
+ destination: os.tmpdir(),
20
+ filename: (req, file, cb) => cb(null, `pdf-viewer-${Date.now()}-${file.originalname}`),
21
+ }),
22
+ fileFilter: (req, file, cb) => {
23
+ cb(null, file.mimetype === 'application/pdf' || file.originalname.endsWith('.pdf'));
24
+ },
25
+ });
26
+
27
+ let CONFIG = {};
28
+ let loadedPDFs = {}; // cache: filepath -> { text, pageCount, metadata }
29
+
30
+ async function extractPDFData(filePath) {
31
+ if (loadedPDFs[filePath]) return loadedPDFs[filePath];
32
+ const buf = fs.readFileSync(filePath);
33
+ const data = await pdfParse(buf);
34
+ const result = {
35
+ text: data.text,
36
+ pageCount: data.numpages,
37
+ metadata: data.metadata,
38
+ info: data.info,
39
+ filePath,
40
+ fileName: path.basename(filePath),
41
+ };
42
+ loadedPDFs[filePath] = result;
43
+ return result;
44
+ }
45
+
46
+ // ─── Routes ────────────────────────────────────────────────────────────────
47
+
48
+ app.get('/api/config', (req, res) => {
49
+ res.json({
50
+ enableAi: CONFIG.enableAi || false,
51
+ aiProvider: CONFIG.aiProvider || 'anthropic',
52
+ aiModel: CONFIG.aiModel || null,
53
+ aiBaseUrl: CONFIG.aiBaseUrl || null,
54
+ minimap: CONFIG.minimap !== false,
55
+ theme: CONFIG.theme || 'dark',
56
+ initialFile: CONFIG.initialFile || null,
57
+ initialFileName: CONFIG.initialFile ? path.basename(CONFIG.initialFile) : null,
58
+ });
59
+ });
60
+
61
+ // Update AI provider settings at runtime (from Settings UI)
62
+ app.post('/api/ai/settings', (req, res) => {
63
+ const { provider, model, baseUrl, enabled } = req.body;
64
+ if (provider !== undefined) CONFIG.aiProvider = provider;
65
+ if (model !== undefined) CONFIG.aiModel = model || null;
66
+ if (baseUrl !== undefined) CONFIG.aiBaseUrl = baseUrl || null;
67
+ if (enabled !== undefined) CONFIG.enableAi = !!enabled;
68
+ res.json({ ok: true });
69
+ });
70
+
71
+ // Serve the initial PDF file if provided at startup
72
+ app.get('/api/initial-pdf', (req, res) => {
73
+ if (!CONFIG.initialFile || !fs.existsSync(CONFIG.initialFile)) {
74
+ return res.status(404).json({ error: 'No initial file' });
75
+ }
76
+ res.setHeader('Content-Type', 'application/pdf');
77
+ res.setHeader('Content-Disposition', `inline; filename="${path.basename(CONFIG.initialFile)}"`);
78
+ fs.createReadStream(CONFIG.initialFile).pipe(res);
79
+ });
80
+
81
+ // Upload a new PDF
82
+ app.post('/api/upload', upload.single('pdf'), async (req, res) => {
83
+ try {
84
+ if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
85
+ res.json({ filePath: req.file.path, fileName: req.file.originalname });
86
+ } catch (err) {
87
+ res.status(500).json({ error: err.message });
88
+ }
89
+ });
90
+
91
+ // Serve an uploaded PDF by its temp path
92
+ app.get('/api/pdf', (req, res) => {
93
+ const { path: filePath } = req.query;
94
+ if (!filePath || !fs.existsSync(filePath)) {
95
+ return res.status(404).json({ error: 'File not found' });
96
+ }
97
+ res.setHeader('Content-Type', 'application/pdf');
98
+ res.setHeader('Content-Disposition', `inline; filename="${path.basename(filePath)}"`);
99
+ fs.createReadStream(filePath).pipe(res);
100
+ });
101
+
102
+ // Get PDF text & metadata (for AI context)
103
+ app.get('/api/pdf-info', async (req, res) => {
104
+ try {
105
+ const filePath = req.query.path || CONFIG.initialFile;
106
+ if (!filePath || !fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found' });
107
+ const data = await extractPDFData(filePath);
108
+ res.json({ pageCount: data.pageCount, metadata: data.metadata, info: data.info, fileName: data.fileName });
109
+ } catch (err) {
110
+ res.status(500).json({ error: err.message });
111
+ }
112
+ });
113
+
114
+ // ─── AI Chat ───────────────────────────────────────────────────────────────
115
+
116
+ const PROVIDER_DEFAULTS = {
117
+ anthropic: { model: 'claude-sonnet-4-20250514', baseUrl: 'https://api.anthropic.com' },
118
+ openai: { model: 'gpt-4o', baseUrl: 'https://api.openai.com' },
119
+ xai: { model: 'grok-beta', baseUrl: 'https://api.x.ai' },
120
+ ollama: { model: 'llama3.2:latest', baseUrl: 'http://localhost:11434' },
121
+ };
122
+
123
+ async function callAnthropic({ baseUrl, model, apiKey, systemPrompt, messages }) {
124
+ const response = await fetch(`${baseUrl}/v1/messages`, {
125
+ method: 'POST',
126
+ headers: {
127
+ 'Content-Type': 'application/json',
128
+ 'x-api-key': apiKey,
129
+ 'anthropic-version': '2023-06-01',
130
+ },
131
+ body: JSON.stringify({ model, max_tokens: 1024, system: systemPrompt, messages }),
132
+ });
133
+ if (!response.ok) {
134
+ const err = await response.json().catch(() => ({}));
135
+ throw new Error(err.error?.message || `HTTP ${response.status}`);
136
+ }
137
+ const data = await response.json();
138
+ return data.content?.[0]?.text || '';
139
+ }
140
+
141
+ async function callOpenAICompat({ baseUrl, model, apiKey, systemPrompt, messages }) {
142
+ const response = await fetch(`${baseUrl}/v1/chat/completions`, {
143
+ method: 'POST',
144
+ headers: {
145
+ 'Content-Type': 'application/json',
146
+ ...(apiKey ? { 'Authorization': `Bearer ${apiKey}` } : {}),
147
+ },
148
+ body: JSON.stringify({
149
+ model,
150
+ messages: [{ role: 'system', content: systemPrompt }, ...messages],
151
+ }),
152
+ });
153
+ if (!response.ok) {
154
+ const err = await response.json().catch(() => ({}));
155
+ throw new Error(err.error?.message || `HTTP ${response.status}`);
156
+ }
157
+ const data = await response.json();
158
+ return data.choices?.[0]?.message?.content || '';
159
+ }
160
+
161
+ app.post('/api/ai/chat', async (req, res) => {
162
+ if (!CONFIG.enableAi) return res.status(403).json({ error: 'AI not enabled. Start with --enable-ai flag.' });
163
+
164
+ const { message, history = [], filePath, currentPage } = req.body;
165
+ if (!message) return res.status(400).json({ error: 'No message provided' });
166
+
167
+ const provider = CONFIG.aiProvider || 'anthropic';
168
+ const defaults = PROVIDER_DEFAULTS[provider] || PROVIDER_DEFAULTS.ollama;
169
+ const model = CONFIG.aiModel || defaults.model;
170
+ const baseUrl = (CONFIG.aiBaseUrl || defaults.baseUrl).replace(/\/$/, '');
171
+
172
+ try {
173
+ let pdfContext = '';
174
+ const fp = filePath || CONFIG.initialFile;
175
+ if (fp && fs.existsSync(fp)) {
176
+ const data = await extractPDFData(fp);
177
+ const truncated = data.text.slice(0, 8000);
178
+ pdfContext = `\n\n<pdf_content filename="${data.fileName}" total_pages="${data.pageCount}" current_page="${currentPage || 1}">\n${truncated}${data.text.length > 8000 ? '\n... [content truncated]' : ''}\n</pdf_content>`;
179
+ }
180
+
181
+ const systemPrompt = `You are an AI Sidekick embedded in a PDF viewer. You help users understand, summarize, and ask questions about the PDF they are currently reading.
182
+
183
+ Be concise and helpful. Format your responses in markdown when appropriate — use bullet points, bold text, and code blocks where they add clarity.
184
+
185
+ When the user asks about specific pages or content, refer to the PDF context provided.${pdfContext}`;
186
+
187
+ const messages = [
188
+ ...history.map(h => ({ role: h.role, content: h.content })),
189
+ { role: 'user', content: message },
190
+ ];
191
+
192
+ let reply = '';
193
+ if (provider === 'anthropic') {
194
+ const apiKey = process.env.ANTHROPIC_API_KEY;
195
+ if (!apiKey) throw new Error('ANTHROPIC_API_KEY env var not set');
196
+ reply = await callAnthropic({ baseUrl, model, apiKey, systemPrompt, messages });
197
+ } else if (provider === 'openai') {
198
+ const apiKey = process.env.OPENAI_API_KEY;
199
+ if (!apiKey) throw new Error('OPENAI_API_KEY env var not set');
200
+ reply = await callOpenAICompat({ baseUrl, model, apiKey, systemPrompt, messages });
201
+ } else if (provider === 'xai') {
202
+ const apiKey = process.env.XAI_API_KEY;
203
+ if (!apiKey) throw new Error('XAI_API_KEY env var not set');
204
+ reply = await callOpenAICompat({ baseUrl, model, apiKey, systemPrompt, messages });
205
+ } else {
206
+ // ollama and any OpenAI-compatible local provider
207
+ reply = await callOpenAICompat({ baseUrl, model, apiKey: null, systemPrompt, messages });
208
+ }
209
+
210
+ res.json({ reply });
211
+ } catch (err) {
212
+ res.status(500).json({ error: err.message });
213
+ }
214
+ });
215
+
216
+ // ─── Server Start ──────────────────────────────────────────────────────────
217
+
218
+ function start(config) {
219
+ CONFIG = config;
220
+
221
+ const server = http.createServer(app);
222
+ server.listen(config.port, () => {
223
+ const url = `http://localhost:${config.port}`;
224
+ console.log('');
225
+ console.log(' ╔═══════════════════════════════════════╗');
226
+ console.log(' ║ ✦ MC PDF Studio Ready ✦ ║');
227
+ console.log(' ╚═══════════════════════════════════════╝');
228
+ console.log('');
229
+ console.log(` → Local: ${url}`);
230
+ if (config.initialFile) console.log(` → File: ${path.basename(config.initialFile)}`);
231
+ if (config.enableAi) {
232
+ const prov = config.aiProvider || 'anthropic';
233
+ const mod = config.aiModel || (PROVIDER_DEFAULTS[prov] || {}).model || '';
234
+ console.log(` → AI: Sidekick enabled ✓ (${prov} / ${mod})`);
235
+ }
236
+ console.log(` → Theme: ${config.theme}`);
237
+ console.log('');
238
+ console.log(' Press Ctrl+C to stop');
239
+ console.log('');
240
+
241
+ // Auto-open browser
242
+ const open = (url) => {
243
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
244
+ require('child_process').exec(`${cmd} ${url}`);
245
+ };
246
+ try { open(url); } catch(e) {}
247
+ });
248
+ }
249
+
250
+ module.exports = { start };