@bobfrankston/mailx 1.0.101 → 1.0.102
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/client/app.js +17 -0
- package/client/compose/compose.css +16 -0
- package/client/compose/compose.js +12 -2
- package/client/compose/editor.js +28 -2
- package/client/compose/ghost-text.js +122 -0
- package/client/index.html +2 -0
- package/client/lib/api-client.js +12 -0
- package/client/package.json +1 -1
- package/package.json +1 -1
- package/packages/mailx-api/index.js +21 -0
- package/packages/mailx-api/package.json +1 -1
- package/packages/mailx-service/index.d.ts +4 -1
- package/packages/mailx-service/index.js +94 -1
- package/packages/mailx-service/package.json +1 -1
- package/packages/mailx-settings/index.d.ts +18 -3
- package/packages/mailx-settings/index.js +35 -1
- package/packages/mailx-types/index.d.ts +20 -0
package/client/app.js
CHANGED
|
@@ -809,6 +809,23 @@ optEditorTiptap?.addEventListener("change", () => {
|
|
|
809
809
|
if (optEditorTiptap.checked)
|
|
810
810
|
saveEditorSetting("tiptap");
|
|
811
811
|
});
|
|
812
|
+
// ── AI autocomplete toggle ──
|
|
813
|
+
const optAutocomplete = document.getElementById("opt-autocomplete");
|
|
814
|
+
// Load current autocomplete setting
|
|
815
|
+
fetch("/api/autocomplete/settings").then(r => r.json()).then(ac => {
|
|
816
|
+
if (optAutocomplete)
|
|
817
|
+
optAutocomplete.checked = ac.enabled || false;
|
|
818
|
+
}).catch(() => { });
|
|
819
|
+
optAutocomplete?.addEventListener("change", () => {
|
|
820
|
+
fetch("/api/autocomplete/settings").then(r => r.json()).then(ac => {
|
|
821
|
+
ac.enabled = optAutocomplete.checked;
|
|
822
|
+
fetch("/api/autocomplete/settings", {
|
|
823
|
+
method: "POST",
|
|
824
|
+
headers: { "Content-Type": "application/json" },
|
|
825
|
+
body: JSON.stringify(ac),
|
|
826
|
+
});
|
|
827
|
+
}).catch(() => { });
|
|
828
|
+
});
|
|
812
829
|
const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
|
|
813
830
|
fetch("/api/version").then(r => r.json()).then(d => {
|
|
814
831
|
const el = document.getElementById("app-version");
|
|
@@ -261,3 +261,19 @@ body {
|
|
|
261
261
|
margin-top: var(--gap-md);
|
|
262
262
|
color: var(--color-text-muted);
|
|
263
263
|
}
|
|
264
|
+
|
|
265
|
+
/* Ghost text autocomplete overlay */
|
|
266
|
+
.ghost-text {
|
|
267
|
+
position: absolute;
|
|
268
|
+
color: var(--color-text-muted);
|
|
269
|
+
font-style: italic;
|
|
270
|
+
opacity: 0.5;
|
|
271
|
+
pointer-events: none;
|
|
272
|
+
white-space: pre-wrap;
|
|
273
|
+
z-index: 10;
|
|
274
|
+
font-family: inherit;
|
|
275
|
+
font-size: inherit;
|
|
276
|
+
line-height: inherit;
|
|
277
|
+
max-width: 60ch;
|
|
278
|
+
}
|
|
279
|
+
.ql-editor, .tt-content .tiptap { position: relative; }
|
|
@@ -41,14 +41,15 @@ async function loadEditorAssets(type) {
|
|
|
41
41
|
}
|
|
42
42
|
// ── Determine editor type from settings ──
|
|
43
43
|
let editorType = "quill";
|
|
44
|
+
let appSettings = null;
|
|
44
45
|
try {
|
|
45
46
|
const res = await fetch("/api/version");
|
|
46
47
|
if (res.ok) {
|
|
47
48
|
// Check settings for editor preference
|
|
48
49
|
const settingsRes = await fetch("/api/settings");
|
|
49
50
|
if (settingsRes.ok) {
|
|
50
|
-
|
|
51
|
-
if (
|
|
51
|
+
appSettings = await settingsRes.json();
|
|
52
|
+
if (appSettings.ui?.editor === "tiptap")
|
|
52
53
|
editorType = "tiptap";
|
|
53
54
|
}
|
|
54
55
|
}
|
|
@@ -65,6 +66,15 @@ const toInput = document.getElementById("compose-to");
|
|
|
65
66
|
const ccInput = document.getElementById("compose-cc");
|
|
66
67
|
const bccInput = document.getElementById("compose-bcc");
|
|
67
68
|
const subjectInput = document.getElementById("compose-subject");
|
|
69
|
+
// ── AI ghost text autocomplete ──
|
|
70
|
+
if (appSettings?.autocomplete?.enabled && appSettings.autocomplete.provider !== "off") {
|
|
71
|
+
import("./ghost-text.js").then(({ initGhostText }) => {
|
|
72
|
+
initGhostText(editor, {
|
|
73
|
+
getSubject: () => subjectInput.value,
|
|
74
|
+
getTo: () => toInput.value,
|
|
75
|
+
}, { debounceMs: appSettings.autocomplete.debounceMs || 600 });
|
|
76
|
+
}).catch(() => { });
|
|
77
|
+
}
|
|
68
78
|
/** Populate the From dropdown with accounts */
|
|
69
79
|
function populateFromSelect(accounts, selectedId) {
|
|
70
80
|
fromSelect.innerHTML = "";
|
package/client/compose/editor.js
CHANGED
|
@@ -34,7 +34,21 @@ function createQuillEditor(container) {
|
|
|
34
34
|
setCursor(pos) {
|
|
35
35
|
q.setSelection(pos, 0);
|
|
36
36
|
},
|
|
37
|
-
root: q.root
|
|
37
|
+
root: q.root,
|
|
38
|
+
getScrollContainer() {
|
|
39
|
+
return q.root;
|
|
40
|
+
},
|
|
41
|
+
onContentChange(handler) {
|
|
42
|
+
q.on("text-change", handler);
|
|
43
|
+
},
|
|
44
|
+
onKeyDown(handler) {
|
|
45
|
+
q.root.addEventListener("keydown", handler);
|
|
46
|
+
},
|
|
47
|
+
insertTextAtCursor(text) {
|
|
48
|
+
const sel = q.getSelection();
|
|
49
|
+
if (sel)
|
|
50
|
+
q.insertText(sel.index, text);
|
|
51
|
+
}
|
|
38
52
|
};
|
|
39
53
|
}
|
|
40
54
|
// ── tiptap ──
|
|
@@ -147,7 +161,19 @@ async function createTiptapEditor(container) {
|
|
|
147
161
|
setCursor(pos) {
|
|
148
162
|
ed.commands.focus("start");
|
|
149
163
|
},
|
|
150
|
-
root: editorEl
|
|
164
|
+
root: editorEl,
|
|
165
|
+
getScrollContainer() {
|
|
166
|
+
return editorEl;
|
|
167
|
+
},
|
|
168
|
+
onContentChange(handler) {
|
|
169
|
+
ed.on("update", handler);
|
|
170
|
+
},
|
|
171
|
+
onKeyDown(handler) {
|
|
172
|
+
editorEl.addEventListener("keydown", handler);
|
|
173
|
+
},
|
|
174
|
+
insertTextAtCursor(text) {
|
|
175
|
+
ed.commands.insertContent(text);
|
|
176
|
+
}
|
|
151
177
|
};
|
|
152
178
|
}
|
|
153
179
|
// ── Factory ──
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ghost text autocomplete — shows AI suggestions as translucent overlay at cursor.
|
|
3
|
+
* Tab accepts, Escape dismisses, any other key dismisses and re-triggers after debounce.
|
|
4
|
+
* Editor-agnostic: works with both Quill and tiptap via MailxEditor interface.
|
|
5
|
+
*/
|
|
6
|
+
import { autocomplete } from "../lib/api-client.js";
|
|
7
|
+
let ghostEl = null;
|
|
8
|
+
let currentSuggestion = null;
|
|
9
|
+
let debounceTimer = null;
|
|
10
|
+
let abortController = null;
|
|
11
|
+
let activeEditor = null;
|
|
12
|
+
let debounceMs = 600;
|
|
13
|
+
function dismiss() {
|
|
14
|
+
currentSuggestion = null;
|
|
15
|
+
if (ghostEl) {
|
|
16
|
+
ghostEl.remove();
|
|
17
|
+
ghostEl = null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function positionGhost(editor, text) {
|
|
21
|
+
dismiss();
|
|
22
|
+
const sel = window.getSelection();
|
|
23
|
+
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed)
|
|
24
|
+
return;
|
|
25
|
+
const range = sel.getRangeAt(0);
|
|
26
|
+
let rect = range.getBoundingClientRect();
|
|
27
|
+
// Collapsed range may return zero-width rect — use a temp span to measure
|
|
28
|
+
if (rect.width === 0 && rect.height === 0) {
|
|
29
|
+
const span = document.createElement("span");
|
|
30
|
+
span.textContent = "\u200B"; // zero-width space
|
|
31
|
+
range.insertNode(span);
|
|
32
|
+
rect = span.getBoundingClientRect();
|
|
33
|
+
span.remove();
|
|
34
|
+
// Restore selection
|
|
35
|
+
sel.removeAllRanges();
|
|
36
|
+
sel.addRange(range);
|
|
37
|
+
}
|
|
38
|
+
const container = editor.getScrollContainer();
|
|
39
|
+
const containerRect = container.getBoundingClientRect();
|
|
40
|
+
ghostEl = document.createElement("span");
|
|
41
|
+
ghostEl.className = "ghost-text";
|
|
42
|
+
ghostEl.textContent = text;
|
|
43
|
+
ghostEl.style.top = `${rect.top - containerRect.top + container.scrollTop}px`;
|
|
44
|
+
ghostEl.style.left = `${rect.left - containerRect.left + container.scrollLeft}px`;
|
|
45
|
+
container.appendChild(ghostEl);
|
|
46
|
+
currentSuggestion = text;
|
|
47
|
+
}
|
|
48
|
+
function requestSuggestion(editor, context) {
|
|
49
|
+
// Cancel any in-flight request
|
|
50
|
+
if (abortController) {
|
|
51
|
+
abortController.abort();
|
|
52
|
+
abortController = null;
|
|
53
|
+
}
|
|
54
|
+
const bodyText = editor.getText();
|
|
55
|
+
if (!bodyText || bodyText.trim().length < 10)
|
|
56
|
+
return; // too short to suggest
|
|
57
|
+
abortController = new AbortController();
|
|
58
|
+
const signal = abortController.signal;
|
|
59
|
+
autocomplete({
|
|
60
|
+
subject: context.getSubject(),
|
|
61
|
+
to: context.getTo(),
|
|
62
|
+
bodyText,
|
|
63
|
+
cursorOffset: bodyText.length,
|
|
64
|
+
}, signal).then((result) => {
|
|
65
|
+
if (signal.aborted)
|
|
66
|
+
return;
|
|
67
|
+
if (result.suggestion) {
|
|
68
|
+
positionGhost(editor, result.suggestion);
|
|
69
|
+
}
|
|
70
|
+
}).catch((e) => {
|
|
71
|
+
if (e.name === "AbortError")
|
|
72
|
+
return;
|
|
73
|
+
// Silently ignore autocomplete errors
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
export function initGhostText(editor, context, options) {
|
|
77
|
+
activeEditor = editor;
|
|
78
|
+
if (options?.debounceMs)
|
|
79
|
+
debounceMs = options.debounceMs;
|
|
80
|
+
// Debounced content change → request suggestion
|
|
81
|
+
editor.onContentChange(() => {
|
|
82
|
+
dismiss();
|
|
83
|
+
if (debounceTimer)
|
|
84
|
+
clearTimeout(debounceTimer);
|
|
85
|
+
debounceTimer = setTimeout(() => {
|
|
86
|
+
requestSuggestion(editor, context);
|
|
87
|
+
}, debounceMs);
|
|
88
|
+
});
|
|
89
|
+
// Key handler: Tab accepts, Escape dismisses
|
|
90
|
+
editor.onKeyDown((e) => {
|
|
91
|
+
if (!currentSuggestion)
|
|
92
|
+
return;
|
|
93
|
+
if (e.key === "Tab") {
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
e.stopPropagation();
|
|
96
|
+
const text = currentSuggestion;
|
|
97
|
+
dismiss();
|
|
98
|
+
editor.insertTextAtCursor(text);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (e.key === "Escape") {
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
e.stopPropagation();
|
|
104
|
+
dismiss();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// Any other key: dismiss (new suggestion will come after debounce)
|
|
108
|
+
dismiss();
|
|
109
|
+
});
|
|
110
|
+
// Dismiss on blur/scroll
|
|
111
|
+
editor.root.addEventListener("blur", dismiss);
|
|
112
|
+
editor.getScrollContainer().addEventListener("scroll", dismiss);
|
|
113
|
+
}
|
|
114
|
+
export function destroyGhostText() {
|
|
115
|
+
dismiss();
|
|
116
|
+
if (debounceTimer)
|
|
117
|
+
clearTimeout(debounceTimer);
|
|
118
|
+
if (abortController)
|
|
119
|
+
abortController.abort();
|
|
120
|
+
activeEditor = null;
|
|
121
|
+
}
|
|
122
|
+
//# sourceMappingURL=ghost-text.js.map
|
package/client/index.html
CHANGED
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
<span class="tb-menu-label">Editor</span>
|
|
35
35
|
<label class="tb-menu-item"><input type="radio" name="opt-editor" value="quill" id="opt-editor-quill" checked> Quill</label>
|
|
36
36
|
<label class="tb-menu-item"><input type="radio" name="opt-editor" value="tiptap" id="opt-editor-tiptap"> tiptap</label>
|
|
37
|
+
<hr class="tb-menu-sep">
|
|
38
|
+
<label class="tb-menu-item"><input type="checkbox" id="opt-autocomplete"> AI autocomplete</label>
|
|
37
39
|
</div>
|
|
38
40
|
</div>
|
|
39
41
|
<span id="app-version" class="app-version"></span>
|
package/client/lib/api-client.js
CHANGED
|
@@ -222,6 +222,18 @@ export function connectEvents() {
|
|
|
222
222
|
};
|
|
223
223
|
}
|
|
224
224
|
}
|
|
225
|
+
// ── Autocomplete ──
|
|
226
|
+
export function autocomplete(body, signal) {
|
|
227
|
+
if (hasIPC)
|
|
228
|
+
return mailxapi.autocomplete?.(body);
|
|
229
|
+
return api("/autocomplete", { method: "POST", body: JSON.stringify(body), signal });
|
|
230
|
+
}
|
|
231
|
+
export function getAutocompleteSettings() {
|
|
232
|
+
return api("/autocomplete/settings");
|
|
233
|
+
}
|
|
234
|
+
export function saveAutocompleteSettings(settings) {
|
|
235
|
+
return api("/autocomplete/settings", { method: "POST", body: JSON.stringify(settings) });
|
|
236
|
+
}
|
|
225
237
|
// Legacy exports for backward compatibility
|
|
226
238
|
export const connectWebSocket = connectEvents;
|
|
227
239
|
export const onWsEvent = onEvent;
|
package/client/package.json
CHANGED
package/package.json
CHANGED
|
@@ -84,6 +84,27 @@ export function createApiRouter(db, imapManager) {
|
|
|
84
84
|
res.status(500).json({ error: e.message });
|
|
85
85
|
}
|
|
86
86
|
});
|
|
87
|
+
// ── Autocomplete ──
|
|
88
|
+
router.post("/autocomplete", async (req, res) => {
|
|
89
|
+
try {
|
|
90
|
+
res.json(await svc.autocomplete(req.body));
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
res.status(500).json({ error: e.message, suggestion: "" });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
router.get("/autocomplete/settings", (req, res) => {
|
|
97
|
+
res.json(svc.getAutocompleteSettings());
|
|
98
|
+
});
|
|
99
|
+
router.post("/autocomplete/settings", (req, res) => {
|
|
100
|
+
try {
|
|
101
|
+
svc.saveAutocompleteSettings(req.body);
|
|
102
|
+
res.json({ ok: true });
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
res.status(500).json({ error: e.message });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
87
108
|
// ── Send ──
|
|
88
109
|
router.post("/send", async (req, res) => {
|
|
89
110
|
try {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { MailxDB } from "@bobfrankston/mailx-store";
|
|
7
7
|
import { ImapManager } from "@bobfrankston/mailx-imap";
|
|
8
|
-
import type { Folder } from "@bobfrankston/mailx-types";
|
|
8
|
+
import type { Folder, AutocompleteRequest, AutocompleteResponse, AutocompleteSettings } from "@bobfrankston/mailx-types";
|
|
9
9
|
export declare function sanitizeHtml(html: string): {
|
|
10
10
|
html: string;
|
|
11
11
|
hasRemoteContent: boolean;
|
|
@@ -56,5 +56,8 @@ export declare class MailxService {
|
|
|
56
56
|
provider: string;
|
|
57
57
|
mode: string;
|
|
58
58
|
};
|
|
59
|
+
getAutocompleteSettings(): AutocompleteSettings;
|
|
60
|
+
saveAutocompleteSettings(settings: AutocompleteSettings): void;
|
|
61
|
+
autocomplete(req: AutocompleteRequest): Promise<AutocompleteResponse>;
|
|
59
62
|
}
|
|
60
63
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Pure business logic — no HTTP, no Express.
|
|
4
4
|
* Both the Express API (mailx-api) and the Android bridge call these functions.
|
|
5
5
|
*/
|
|
6
|
-
import { loadSettings, saveSettings, loadAllowlist, saveAllowlist, getStorePath, getStorageInfo } from "@bobfrankston/mailx-settings";
|
|
6
|
+
import { loadSettings, saveSettings, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, getStorePath, getStorageInfo } from "@bobfrankston/mailx-settings";
|
|
7
7
|
import { simpleParser } from "mailparser";
|
|
8
8
|
// ── Sanitize ──
|
|
9
9
|
export function sanitizeHtml(html) {
|
|
@@ -466,5 +466,98 @@ export class MailxService {
|
|
|
466
466
|
getStorageInfo() {
|
|
467
467
|
return getStorageInfo();
|
|
468
468
|
}
|
|
469
|
+
// ── Autocomplete ──
|
|
470
|
+
getAutocompleteSettings() {
|
|
471
|
+
return loadAutocomplete();
|
|
472
|
+
}
|
|
473
|
+
saveAutocompleteSettings(settings) {
|
|
474
|
+
saveAutocomplete(settings);
|
|
475
|
+
}
|
|
476
|
+
async autocomplete(req) {
|
|
477
|
+
const acConfig = loadAutocomplete();
|
|
478
|
+
if (!acConfig.enabled || acConfig.provider === "off") {
|
|
479
|
+
return { suggestion: "" };
|
|
480
|
+
}
|
|
481
|
+
const bodyText = req.bodyText || "";
|
|
482
|
+
const prompt = `You are an email writing assistant. Complete the following email naturally.\nOutput ONLY the completion text — no explanation, no greeting repeat.\nKeep it to 1-2 sentences max.\n\nTo: ${req.to || ""}\nSubject: ${req.subject || ""}\n\n${bodyText}`;
|
|
483
|
+
try {
|
|
484
|
+
if (acConfig.provider === "ollama") {
|
|
485
|
+
const truncated = bodyText.slice(-500);
|
|
486
|
+
const ollamaPrompt = prompt.replace(bodyText, truncated);
|
|
487
|
+
const res = await fetch(`${acConfig.ollamaUrl}/api/generate`, {
|
|
488
|
+
method: "POST",
|
|
489
|
+
headers: { "Content-Type": "application/json" },
|
|
490
|
+
body: JSON.stringify({
|
|
491
|
+
model: acConfig.ollamaModel,
|
|
492
|
+
prompt: ollamaPrompt,
|
|
493
|
+
stream: false,
|
|
494
|
+
options: { num_predict: acConfig.maxTokens },
|
|
495
|
+
}),
|
|
496
|
+
});
|
|
497
|
+
if (!res.ok)
|
|
498
|
+
return { suggestion: "" };
|
|
499
|
+
const data = await res.json();
|
|
500
|
+
return { suggestion: trimSuggestion(data.response || "") };
|
|
501
|
+
}
|
|
502
|
+
if (acConfig.provider === "claude") {
|
|
503
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
504
|
+
method: "POST",
|
|
505
|
+
headers: {
|
|
506
|
+
"Content-Type": "application/json",
|
|
507
|
+
"x-api-key": acConfig.cloudApiKey,
|
|
508
|
+
"anthropic-version": "2023-06-01",
|
|
509
|
+
},
|
|
510
|
+
body: JSON.stringify({
|
|
511
|
+
model: acConfig.cloudModel,
|
|
512
|
+
max_tokens: acConfig.maxTokens,
|
|
513
|
+
messages: [{ role: "user", content: prompt }],
|
|
514
|
+
}),
|
|
515
|
+
});
|
|
516
|
+
if (!res.ok)
|
|
517
|
+
return { suggestion: "" };
|
|
518
|
+
const data = await res.json();
|
|
519
|
+
const text = data.content?.[0]?.text || "";
|
|
520
|
+
return { suggestion: trimSuggestion(text) };
|
|
521
|
+
}
|
|
522
|
+
if (acConfig.provider === "openai") {
|
|
523
|
+
const res = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
524
|
+
method: "POST",
|
|
525
|
+
headers: {
|
|
526
|
+
"Content-Type": "application/json",
|
|
527
|
+
"Authorization": `Bearer ${acConfig.cloudApiKey}`,
|
|
528
|
+
},
|
|
529
|
+
body: JSON.stringify({
|
|
530
|
+
model: acConfig.cloudModel,
|
|
531
|
+
max_tokens: acConfig.maxTokens,
|
|
532
|
+
messages: [
|
|
533
|
+
{ role: "system", content: "You are an email writing assistant. Output ONLY the completion text." },
|
|
534
|
+
{ role: "user", content: prompt },
|
|
535
|
+
],
|
|
536
|
+
}),
|
|
537
|
+
});
|
|
538
|
+
if (!res.ok)
|
|
539
|
+
return { suggestion: "" };
|
|
540
|
+
const data = await res.json();
|
|
541
|
+
const text = data.choices?.[0]?.message?.content || "";
|
|
542
|
+
return { suggestion: trimSuggestion(text) };
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
catch (e) {
|
|
546
|
+
console.error(` [autocomplete] ${acConfig.provider} error: ${e.message}`);
|
|
547
|
+
}
|
|
548
|
+
return { suggestion: "" };
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
/** Trim suggestion: remove leading/trailing whitespace, cap at sentence boundary */
|
|
552
|
+
function trimSuggestion(text) {
|
|
553
|
+
let s = text.trim();
|
|
554
|
+
if (!s)
|
|
555
|
+
return "";
|
|
556
|
+
// Cap at 2 sentences
|
|
557
|
+
const sentences = s.match(/[^.!?]*[.!?]/g);
|
|
558
|
+
if (sentences && sentences.length > 2) {
|
|
559
|
+
s = sentences.slice(0, 2).join("").trim();
|
|
560
|
+
}
|
|
561
|
+
return s;
|
|
469
562
|
}
|
|
470
563
|
//# sourceMappingURL=index.js.map
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* The old settings.jsonc is still supported for backward compatibility.
|
|
17
17
|
*/
|
|
18
|
-
import type { MailxSettings, AccountConfig } from "@bobfrankston/mailx-types";
|
|
18
|
+
import type { MailxSettings, AccountConfig, AutocompleteSettings } from "@bobfrankston/mailx-types";
|
|
19
19
|
declare const LOCAL_DIR: string;
|
|
20
20
|
declare function getSharedDir(): string;
|
|
21
21
|
/** Read a file via cloud API (when filesystem mount not available) */
|
|
@@ -41,7 +41,18 @@ declare const DEFAULT_PREFERENCES: {
|
|
|
41
41
|
intervalMinutes: number;
|
|
42
42
|
historyDays: number;
|
|
43
43
|
};
|
|
44
|
+
autocomplete: {
|
|
45
|
+
enabled: boolean;
|
|
46
|
+
provider: "ollama";
|
|
47
|
+
ollamaUrl: string;
|
|
48
|
+
ollamaModel: string;
|
|
49
|
+
cloudApiKey: string;
|
|
50
|
+
cloudModel: string;
|
|
51
|
+
debounceMs: number;
|
|
52
|
+
maxTokens: number;
|
|
53
|
+
};
|
|
44
54
|
};
|
|
55
|
+
declare const DEFAULT_AUTOCOMPLETE: AutocompleteSettings;
|
|
45
56
|
declare const DEFAULT_ALLOWLIST: {
|
|
46
57
|
senders: string[];
|
|
47
58
|
domains: string[];
|
|
@@ -54,7 +65,11 @@ export declare function saveAccounts(accounts: AccountConfig[]): void;
|
|
|
54
65
|
/** Load preferences (shared + local overrides, with legacy fallback) */
|
|
55
66
|
export declare function loadPreferences(): typeof DEFAULT_PREFERENCES;
|
|
56
67
|
/** Save preferences */
|
|
57
|
-
export declare function savePreferences(prefs:
|
|
68
|
+
export declare function savePreferences(prefs: any): void;
|
|
69
|
+
/** Load autocomplete settings */
|
|
70
|
+
export declare function loadAutocomplete(): AutocompleteSettings;
|
|
71
|
+
/** Save autocomplete settings */
|
|
72
|
+
export declare function saveAutocomplete(settings: AutocompleteSettings): void;
|
|
58
73
|
/** Load remote content allow-list */
|
|
59
74
|
export declare function loadAllowlist(): typeof DEFAULT_ALLOWLIST;
|
|
60
75
|
/** Save allow-list */
|
|
@@ -74,5 +89,5 @@ export declare function initLocalConfig(sharedDir?: string, storePath?: string):
|
|
|
74
89
|
declare const DEFAULT_SETTINGS: MailxSettings;
|
|
75
90
|
/** Get historyDays for an account: per-account override > system override > shared default */
|
|
76
91
|
export declare function getHistoryDays(accountId?: string): number;
|
|
77
|
-
export { DEFAULT_SETTINGS, DEFAULT_ALLOWLIST, DEFAULT_PREFERENCES, LOCAL_DIR };
|
|
92
|
+
export { DEFAULT_SETTINGS, DEFAULT_ALLOWLIST, DEFAULT_PREFERENCES, DEFAULT_AUTOCOMPLETE, LOCAL_DIR };
|
|
78
93
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -319,6 +319,26 @@ const DEFAULT_PREFERENCES = {
|
|
|
319
319
|
intervalMinutes: 5,
|
|
320
320
|
historyDays: 30,
|
|
321
321
|
},
|
|
322
|
+
autocomplete: {
|
|
323
|
+
enabled: false,
|
|
324
|
+
provider: "ollama",
|
|
325
|
+
ollamaUrl: "http://localhost:11434",
|
|
326
|
+
ollamaModel: "qwen2.5-coder:1.5b",
|
|
327
|
+
cloudApiKey: "",
|
|
328
|
+
cloudModel: "claude-sonnet-4-20250514",
|
|
329
|
+
debounceMs: 600,
|
|
330
|
+
maxTokens: 60,
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
const DEFAULT_AUTOCOMPLETE = {
|
|
334
|
+
enabled: false,
|
|
335
|
+
provider: "ollama",
|
|
336
|
+
ollamaUrl: "http://localhost:11434",
|
|
337
|
+
ollamaModel: "qwen2.5-coder:1.5b",
|
|
338
|
+
cloudApiKey: "",
|
|
339
|
+
cloudModel: "claude-sonnet-4-20250514",
|
|
340
|
+
debounceMs: 600,
|
|
341
|
+
maxTokens: 60,
|
|
322
342
|
};
|
|
323
343
|
const DEFAULT_ALLOWLIST = {
|
|
324
344
|
senders: [],
|
|
@@ -391,12 +411,24 @@ export function loadPreferences() {
|
|
|
391
411
|
return {
|
|
392
412
|
ui: { ...DEFAULT_PREFERENCES.ui, ...shared.ui },
|
|
393
413
|
sync: { ...DEFAULT_PREFERENCES.sync, ...shared.sync },
|
|
414
|
+
autocomplete: { ...DEFAULT_AUTOCOMPLETE, ...shared.autocomplete },
|
|
394
415
|
};
|
|
395
416
|
}
|
|
396
417
|
/** Save preferences */
|
|
397
418
|
export function savePreferences(prefs) {
|
|
398
419
|
saveFile("preferences.jsonc", prefs);
|
|
399
420
|
}
|
|
421
|
+
/** Load autocomplete settings */
|
|
422
|
+
export function loadAutocomplete() {
|
|
423
|
+
const prefs = loadPreferences();
|
|
424
|
+
return prefs.autocomplete;
|
|
425
|
+
}
|
|
426
|
+
/** Save autocomplete settings */
|
|
427
|
+
export function saveAutocomplete(settings) {
|
|
428
|
+
const prefs = loadPreferences();
|
|
429
|
+
prefs.autocomplete = settings;
|
|
430
|
+
savePreferences(prefs);
|
|
431
|
+
}
|
|
400
432
|
/** Load remote content allow-list */
|
|
401
433
|
export function loadAllowlist() {
|
|
402
434
|
return loadFile("allowlist.jsonc", DEFAULT_ALLOWLIST);
|
|
@@ -426,6 +458,7 @@ export function loadSettings() {
|
|
|
426
458
|
accounts,
|
|
427
459
|
ui: prefs.ui,
|
|
428
460
|
sync: prefs.sync,
|
|
461
|
+
autocomplete: prefs.autocomplete,
|
|
429
462
|
store: {
|
|
430
463
|
basePath: localConfig.storePath || DEFAULT_STORE_PATH,
|
|
431
464
|
compressionBoundaryDays: 365,
|
|
@@ -512,6 +545,7 @@ const DEFAULT_SETTINGS = {
|
|
|
512
545
|
accounts: [],
|
|
513
546
|
ui: DEFAULT_PREFERENCES.ui,
|
|
514
547
|
sync: DEFAULT_PREFERENCES.sync,
|
|
548
|
+
autocomplete: DEFAULT_AUTOCOMPLETE,
|
|
515
549
|
store: { basePath: DEFAULT_STORE_PATH, compressionBoundaryDays: 365 },
|
|
516
550
|
};
|
|
517
551
|
/** Get historyDays for an account: per-account override > system override > shared default */
|
|
@@ -525,5 +559,5 @@ export function getHistoryDays(accountId) {
|
|
|
525
559
|
const prefs = loadPreferences();
|
|
526
560
|
return prefs.sync.historyDays || 0;
|
|
527
561
|
}
|
|
528
|
-
export { DEFAULT_SETTINGS, DEFAULT_ALLOWLIST, DEFAULT_PREFERENCES, LOCAL_DIR };
|
|
562
|
+
export { DEFAULT_SETTINGS, DEFAULT_ALLOWLIST, DEFAULT_PREFERENCES, DEFAULT_AUTOCOMPLETE, LOCAL_DIR };
|
|
529
563
|
//# sourceMappingURL=index.js.map
|
|
@@ -196,6 +196,26 @@ export interface MailxSettings {
|
|
|
196
196
|
basePath: string; /** Where message bodies are stored */
|
|
197
197
|
compressionBoundaryDays: number; /** Messages older than this get compressed */
|
|
198
198
|
};
|
|
199
|
+
autocomplete?: AutocompleteSettings;
|
|
200
|
+
}
|
|
201
|
+
export interface AutocompleteSettings {
|
|
202
|
+
enabled: boolean;
|
|
203
|
+
provider: "ollama" | "claude" | "openai" | "off";
|
|
204
|
+
ollamaUrl: string;
|
|
205
|
+
ollamaModel: string;
|
|
206
|
+
cloudApiKey: string;
|
|
207
|
+
cloudModel: string;
|
|
208
|
+
debounceMs: number;
|
|
209
|
+
maxTokens: number;
|
|
210
|
+
}
|
|
211
|
+
export interface AutocompleteRequest {
|
|
212
|
+
subject: string;
|
|
213
|
+
to: string;
|
|
214
|
+
bodyText: string;
|
|
215
|
+
cursorOffset: number;
|
|
216
|
+
}
|
|
217
|
+
export interface AutocompleteResponse {
|
|
218
|
+
suggestion: string;
|
|
199
219
|
}
|
|
200
220
|
/** Body storage backend interface -- implementations are swappable */
|
|
201
221
|
export interface MessageStore {
|