mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-24 20:28:16 +02:00
feat: compose new email with contact autocomplete and AI drafting (#616)
* feat: compose new email with contact autocomplete and AI drafting - Add a compose-new-email box to the email view with a recipient field that autocompletes from Gmail contacts (keyboard navigation, match highlighting, avatar chips) - Build contact indices in core: gmail_sent_contacts syncs the SENT label via the Gmail API for full coverage of people you've emailed, with gmail_contacts as an instant local-snapshot fallback; both are pre-warmed at startup so the first keystroke is instant - Add generateOneShot() one-shot text generation for the composer's "write with AI", resolving to the active default model/provider - Add getAccountName() (parsed from a recent SENT message's From header, no extra OAuth scope) so AI drafts sign off with the real name - New IPC channels: gmail:searchContacts, gmail:getAccountName, llm:generate, llm:getDefaultModel * feat: attachments, undo/redo, and unified compose for new emails - Merge ComposeNewBox into ComposeBox via a new 'new' mode, memoizing the component so inbox sync ticks no longer jank the open composer. - Add file attachments: stage files in the renderer (25MB cap), pass raw base64 over IPC, and build a multipart/mixed MIME on send. - Add undo/redo buttons to the compose toolbar. - Single Write/Edit AI bar that generates a draft, then iteratively rewrites it; drop the hardcoded Gemini Flash model and use the default Copilot model. - Suppress inbox reloads while the compose-new modal is open. - Log llm:generate provider/model/output for debugging. * fix: remove redundant Subject placeholder in composer The subject row already has a 'Subject' gutter label, so the input's placeholder repeated the word — an empty field read 'Subject' twice. Drop the placeholder to match the To/Cc/Bcc fields. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3fe7f307ab
commit
c38ddef93f
6 changed files with 772 additions and 62 deletions
|
|
@ -26,7 +26,8 @@ import { RunEvent } from '@x/shared/dist/runs.js';
|
|||
import { ServiceEvent } from '@x/shared/dist/service-events.js';
|
||||
import container from '@x/core/dist/di/container.js';
|
||||
import { listOnboardingModels } from '@x/core/dist/models/models-dev.js';
|
||||
import { testModelConnection } from '@x/core/dist/models/models.js';
|
||||
import { testModelConnection, generateOneShot } from '@x/core/dist/models/models.js';
|
||||
import { getDefaultModelAndProvider } from '@x/core/dist/models/defaults.js';
|
||||
import { isSignedIn } from '@x/core/dist/account/account.js';
|
||||
import { listGatewayModels } from '@x/core/dist/models/gateway.js';
|
||||
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
|
||||
|
|
@ -67,7 +68,7 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
|
|||
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
||||
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
||||
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
|
||||
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js';
|
||||
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js';
|
||||
import { searchContacts as searchGmailContacts, warmContactIndex } from '@x/core/dist/knowledge/gmail_contacts.js';
|
||||
import { searchSentContacts, warmSentContacts } from '@x/core/dist/knowledge/gmail_sent_contacts.js';
|
||||
import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
|
||||
|
|
@ -742,6 +743,9 @@ export function setupIpcHandlers() {
|
|||
'gmail:getAccountEmail': async () => {
|
||||
return { email: await getAccountEmail() };
|
||||
},
|
||||
'gmail:getAccountName': async () => {
|
||||
return { name: await getAccountName() };
|
||||
},
|
||||
'gmail:archiveThread': async (_event, args) => {
|
||||
return archiveThread(args.threadId);
|
||||
},
|
||||
|
|
@ -848,6 +852,15 @@ export function setupIpcHandlers() {
|
|||
'models:test': async (_event, args) => {
|
||||
return await testModelConnection(args.provider, args.model);
|
||||
},
|
||||
'llm:getDefaultModel': async () => {
|
||||
return await getDefaultModelAndProvider();
|
||||
},
|
||||
'llm:generate': async (_event, args) => {
|
||||
console.log(`[llm:generate] requested provider=${args.provider ?? '(default)'} model=${args.model ?? '(default)'}`);
|
||||
const result = await generateOneShot(args);
|
||||
console.log(`[llm:generate] -> provider=${result.provider ?? '?'} model=${result.model ?? '?'} chars=${result.text?.length ?? 0}${result.error ? ` error=${result.error}` : ''}`);
|
||||
return result;
|
||||
},
|
||||
'models:saveConfig': async (_event, args) => {
|
||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||
await repo.setConfig(args);
|
||||
|
|
|
|||
|
|
@ -160,6 +160,13 @@
|
|||
border-bottom: 1px solid var(--gm-border);
|
||||
}
|
||||
|
||||
.gmail-topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.gmail-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -707,6 +714,112 @@
|
|||
border-color: var(--gm-border-strong);
|
||||
}
|
||||
|
||||
/* Standalone "new email" composer — centered modal popup */
|
||||
.gmail-compose-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
.gmail-compose-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(840px, 100%);
|
||||
height: min(720px, calc(100vh - 64px));
|
||||
max-height: calc(100vh - 64px);
|
||||
border: 1px solid var(--gm-border-strong);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: var(--gm-bg-card);
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.gmail-compose-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 40px;
|
||||
padding: 0 8px 0 14px;
|
||||
background: var(--gm-bg-input);
|
||||
color: var(--gm-text-body);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gmail-compose-modal-header > span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gmail-compose-modal .gmail-compose-editor {
|
||||
flex: 1;
|
||||
min-height: 160px;
|
||||
max-height: none;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.gmail-compose-ai-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--gm-border);
|
||||
}
|
||||
|
||||
.gmail-compose-ai-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--gm-border-strong);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
background: var(--gm-bg-input);
|
||||
color: var(--gm-text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.gmail-compose-ai-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 0 12px 10px;
|
||||
border-bottom: 1px solid var(--gm-border);
|
||||
}
|
||||
|
||||
.gmail-compose-ai-presets button {
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--gm-border-strong);
|
||||
border-radius: 999px;
|
||||
background: var(--gm-bg-pill);
|
||||
color: var(--gm-text-muted);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.gmail-compose-ai-presets button:hover:not(:disabled) {
|
||||
background: var(--gm-bg-pill-hover);
|
||||
border-color: var(--gm-accent);
|
||||
color: var(--gm-accent);
|
||||
}
|
||||
|
||||
.gmail-compose-ai-presets button:disabled,
|
||||
.gmail-compose-ai-input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.gmail-compose-card {
|
||||
max-width: 720px;
|
||||
margin-left: 40px;
|
||||
|
|
@ -987,7 +1100,10 @@
|
|||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
padding-left: 10px;
|
||||
margin-left: 2px;
|
||||
border-left: 1px solid var(--gm-border-strong);
|
||||
}
|
||||
|
||||
.gmail-compose-link-popover {
|
||||
|
|
@ -1059,11 +1175,16 @@
|
|||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.gmail-compose-tool:hover {
|
||||
.gmail-compose-tool:hover:not(:disabled) {
|
||||
background: var(--gm-bg-pill-hover);
|
||||
color: var(--gm-text);
|
||||
}
|
||||
|
||||
.gmail-compose-tool:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.gmail-compose-tool.is-active {
|
||||
background: var(--gm-bg-pill-hover);
|
||||
color: var(--gm-accent);
|
||||
|
|
@ -1154,6 +1275,52 @@
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gmail-compose-attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px 12px 0;
|
||||
}
|
||||
|
||||
.gmail-compose-attachment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 240px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--gm-border);
|
||||
border-radius: 6px;
|
||||
background: var(--gm-bg-pill);
|
||||
font-size: 12px;
|
||||
color: var(--gm-text);
|
||||
}
|
||||
|
||||
.gmail-compose-attachment-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gmail-compose-attachment-size {
|
||||
color: var(--gm-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gmail-compose-attachment-remove {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--gm-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
padding: 0 0 0 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gmail-compose-attachment-remove:hover {
|
||||
color: var(--gm-text);
|
||||
}
|
||||
|
||||
.gmail-compose-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, RefreshCw, Reply, ReplyAll, Search, Send, Sparkles, Strikethrough, Trash2 } from 'lucide-react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, Redo2, RefreshCw, Reply, ReplyAll, Search, Send, Sparkles, SquarePen, Strikethrough, Trash2, Undo2 } from 'lucide-react'
|
||||
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
|
|
@ -258,6 +258,15 @@ function escapeHtml(text: string): string {
|
|||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
// Convert AI-generated plain text into the simple paragraph HTML the Tiptap
|
||||
// editor expects (blank lines → paragraphs, single newlines → <br />).
|
||||
function plainTextToHtml(text: string): string {
|
||||
return text
|
||||
.split(/\n{2,}/)
|
||||
.map((para) => `<p>${escapeHtml(para.trim()).replace(/\n/g, '<br />')}</p>`)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function splitPlainTextQuote(text: string): { visible: string; quoted: string | null } {
|
||||
const re = /(?:^|\n)On\s+.+?\swrote:\s*(?:\n|$)/
|
||||
const match = re.exec(text)
|
||||
|
|
@ -514,7 +523,7 @@ function MessageAttachments({ attachments }: { attachments: NonNullable<GmailThr
|
|||
)
|
||||
}
|
||||
|
||||
type ComposeMode = 'reply' | 'replyAll' | 'forward'
|
||||
type ComposeMode = 'reply' | 'replyAll' | 'forward' | 'new'
|
||||
|
||||
function ComposeToolbarButton({
|
||||
editor,
|
||||
|
|
@ -550,6 +559,29 @@ function ComposeToolbarButton({
|
|||
function ComposeToolbar({ editor, onOpenLink }: { editor: Editor; onOpenLink: () => void }) {
|
||||
return (
|
||||
<div className="gmail-compose-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="gmail-compose-tool"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
aria-label="Undo"
|
||||
title="Undo"
|
||||
>
|
||||
<Undo2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="gmail-compose-tool"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
aria-label="Redo"
|
||||
title="Redo"
|
||||
>
|
||||
<Redo2 size={14} />
|
||||
</button>
|
||||
<span className="gmail-compose-tool-sep" />
|
||||
<ComposeToolbarButton
|
||||
editor={editor}
|
||||
command={() => editor.chain().focus().toggleBold().run()}
|
||||
|
|
@ -866,20 +898,76 @@ function RecipientField({
|
|||
)
|
||||
}
|
||||
|
||||
function ComposeBox({
|
||||
const AI_GENERATE_SYSTEM =
|
||||
'You write complete emails. Given an instruction, produce a subject line and a body. ' +
|
||||
'Respond in EXACTLY this format and nothing else:\n' +
|
||||
'Subject: <a concise, specific subject line>\n' +
|
||||
'\n' +
|
||||
'<the email body as plain text>\n' +
|
||||
'Do not use markdown. Do not add any commentary, labels, or surrounding quotes. ' +
|
||||
'When recipient names are provided, address them naturally (e.g. "Hi <first name>,"). ' +
|
||||
'When the sender\'s name is provided, sign off with it; otherwise omit the sign-off name ' +
|
||||
'(never write a placeholder like "[Your Name]").'
|
||||
|
||||
const AI_REWRITE_SYSTEM =
|
||||
'You rewrite emails. Given the current subject and body plus an edit instruction, ' +
|
||||
'produce the revised subject line and body. Keep the subject if it still fits, or ' +
|
||||
'refine it so it matches the rewritten body. Respond in EXACTLY this format and nothing else:\n' +
|
||||
'Subject: <the subject line>\n' +
|
||||
'\n' +
|
||||
'<the rewritten email body as plain text>\n' +
|
||||
'Do not use markdown. Do not add any commentary, labels, or surrounding quotes. ' +
|
||||
'Preserve the existing sign-off; do not invent placeholder names like "[Your Name]".'
|
||||
|
||||
// Split AI output of the form "Subject: …\n\n<body>" into its parts. If no
|
||||
// subject line is present, the whole text is treated as the body.
|
||||
function parseGeneratedEmail(text: string): { subject: string | null; body: string } {
|
||||
const match = text.match(/^\s*Subject:\s*(.+?)(?:\r?\n|$)/i)
|
||||
if (match) {
|
||||
const subject = match[1].trim()
|
||||
const body = text.slice(match.index! + match[0].length).replace(/^\s+/, '')
|
||||
return { subject, body }
|
||||
}
|
||||
return { subject: null, body: text }
|
||||
}
|
||||
|
||||
// Guarantee the sender's name signs off the email. If the model already ended
|
||||
// with the name (e.g. "Best,\nHarsh"), leave it; otherwise append it.
|
||||
function ensureSignature(body: string, name: string): string {
|
||||
const signer = name.trim()
|
||||
if (!signer) return body
|
||||
const trimmed = body.replace(/\s+$/, '')
|
||||
// Check the last couple of lines so we don't double up an existing sign-off.
|
||||
const tail = trimmed.split('\n').slice(-2).join('\n').toLowerCase()
|
||||
if (tail.includes(signer.toLowerCase())) return trimmed
|
||||
return `${trimmed}\n\n${signer}`
|
||||
}
|
||||
|
||||
const TONE_PRESETS: Array<{ key: string; label: string; instruction: string }> = [
|
||||
{ key: 'formal', label: 'Formal', instruction: 'Rewrite this email to be more formal and professional.' },
|
||||
{ key: 'casual', label: 'Casual', instruction: 'Rewrite this email to be more casual and friendly.' },
|
||||
{ key: 'shorter', label: 'Shorter', instruction: 'Rewrite this email to be more concise, keeping the key points.' },
|
||||
{ key: 'longer', label: 'Longer', instruction: 'Rewrite this email to be more detailed and thorough.' },
|
||||
]
|
||||
|
||||
// Composer for replies, forwards, and (mode 'new') from-scratch emails. With a
|
||||
// thread it renders as an inline card under the thread; in 'new' mode it has no
|
||||
// thread and renders as a centered modal with the AI writing bar.
|
||||
const ComposeBox = memo(function ComposeBox({
|
||||
mode,
|
||||
thread,
|
||||
selfEmail,
|
||||
selfEmail = '',
|
||||
onClose,
|
||||
}: {
|
||||
mode: ComposeMode
|
||||
thread: GmailThread
|
||||
selfEmail: string
|
||||
thread?: GmailThread
|
||||
selfEmail?: string
|
||||
onClose: () => void
|
||||
}) {
|
||||
const latest = latestMessage(thread)
|
||||
const isNew = mode === 'new'
|
||||
const latest = thread ? latestMessage(thread) : undefined
|
||||
const initialRecipients = useMemo(
|
||||
() => buildRecipients(mode, thread, selfEmail),
|
||||
() => (thread ? buildRecipients(mode, thread, selfEmail) : { to: [], cc: [] }),
|
||||
[mode, thread, selfEmail],
|
||||
)
|
||||
|
||||
|
|
@ -888,10 +976,11 @@ function ComposeBox({
|
|||
const [bccList, setBccList] = useState<string[]>([])
|
||||
const [showCc, setShowCc] = useState<boolean>(initialRecipients.cc.length > 0)
|
||||
const [showBcc, setShowBcc] = useState<boolean>(false)
|
||||
const [subject, setSubject] = useState<string>(() => composeSubject(mode, thread.subject))
|
||||
const modeLabel = mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply all' : 'Reply'
|
||||
const [subject, setSubject] = useState<string>(() => (thread ? composeSubject(mode, thread.subject) : ''))
|
||||
const modeLabel = isNew ? 'New message' : mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply all' : 'Reply'
|
||||
|
||||
const initialContent = useMemo(() => {
|
||||
if (!thread) return ''
|
||||
if (mode === 'forward') return buildForwardedContent(thread)
|
||||
// Gmail-side draft (user's own work) wins over the AI-generated draft.
|
||||
const source = stripQuotedReplyText(thread.gmail_draft || thread.draft_response || '')
|
||||
|
|
@ -907,7 +996,7 @@ function ComposeBox({
|
|||
StarterKit.configure({ link: false }),
|
||||
Link.configure({ openOnClick: false, autolink: true }),
|
||||
Placeholder.configure({
|
||||
placeholder: mode === 'forward' ? 'Write a message…' : 'Write your reply…',
|
||||
placeholder: isNew || mode === 'forward' ? 'Write a message…' : 'Write your reply…',
|
||||
}),
|
||||
],
|
||||
editorProps: {
|
||||
|
|
@ -959,13 +1048,176 @@ function ComposeBox({
|
|||
if (editor && sel) editor.chain().focus().setTextSelection(sel).run()
|
||||
}
|
||||
|
||||
// The signed-in account's display name, used to sign off AI-generated emails.
|
||||
const [selfName, setSelfName] = useState<string>('')
|
||||
useEffect(() => {
|
||||
if (!isNew) return
|
||||
let cancelled = false
|
||||
window.ipc.invoke('gmail:getAccountName', {})
|
||||
.then((res) => { if (!cancelled && res?.name) setSelfName(res.name) })
|
||||
.catch(() => {})
|
||||
return () => { cancelled = true }
|
||||
}, [isNew])
|
||||
|
||||
const [aiPrompt, setAiPrompt] = useState('')
|
||||
const [generating, setGenerating] = useState(false)
|
||||
// Once a draft has been generated, show a follow-up bar for iterative edits
|
||||
// ("add a line about…", "remove the last paragraph", etc.). It hides again if
|
||||
// the draft is emptied (e.g. undone), tracked via hasContent below.
|
||||
const [hasGenerated, setHasGenerated] = useState(false)
|
||||
const [hasContent, setHasContent] = useState(false)
|
||||
|
||||
// Keep hasContent in sync with the editor across typing, undo/redo, and clears.
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
const sync = () => setHasContent(!editor.isEmpty)
|
||||
sync()
|
||||
editor.on('update', sync)
|
||||
return () => { editor.off('update', sync) }
|
||||
}, [editor])
|
||||
|
||||
// Clearing the body reverts the AI control to its "Write" state and drops the
|
||||
// generated subject, so an emptied composer behaves like a fresh one. The
|
||||
// hasGenerated guard avoids wiping a subject typed before any generation.
|
||||
useEffect(() => {
|
||||
if (hasGenerated && !hasContent) {
|
||||
setHasGenerated(false)
|
||||
setSubject('')
|
||||
}
|
||||
}, [hasGenerated, hasContent])
|
||||
|
||||
const runAi = async (instruction: string, aiMode: 'generate' | 'rewrite') => {
|
||||
if (!editor || generating) return
|
||||
const current = editor.getText().trim()
|
||||
let prompt: string
|
||||
let system: string
|
||||
if (aiMode === 'generate') {
|
||||
if (!instruction.trim()) { toast('Describe what to write.', 'error'); return }
|
||||
system = AI_GENERATE_SYSTEM
|
||||
const ctx: string[] = []
|
||||
// Use the recipients' names (from the contacts picker) so the AI can
|
||||
// address them naturally; fall back to the address when there's no name.
|
||||
const recipientNames = toList
|
||||
.map((token) => {
|
||||
const name = extractName(token)
|
||||
return name && name !== 'Unknown' ? name : extractAddress(token)
|
||||
})
|
||||
.filter(Boolean)
|
||||
if (recipientNames.length) ctx.push(`Recipient(s): ${recipientNames.join(', ')}`)
|
||||
if (selfName) ctx.push(`Sender's name (sign off as this): ${selfName}`)
|
||||
if (subject.trim()) ctx.push(`Desired subject hint: ${subject.trim()}`)
|
||||
if (current) ctx.push(`Existing draft (revise or build on it):\n${current}`)
|
||||
prompt = `${ctx.length ? ctx.join('\n') + '\n\n' : ''}Instruction: ${instruction.trim()}`
|
||||
} else {
|
||||
if (!instruction.trim()) { toast('Describe the edit to make.', 'error'); return }
|
||||
if (!current) { toast('Write something first.', 'error'); return }
|
||||
system = AI_REWRITE_SYSTEM
|
||||
const subjectLine = subject.trim() ? `Subject: ${subject.trim()}\n\n` : ''
|
||||
prompt = `Instruction: ${instruction}\n\n---\n${subjectLine}${current}`
|
||||
}
|
||||
|
||||
setGenerating(true)
|
||||
try {
|
||||
// Draft through Copilot: no model override, so the backend resolves the
|
||||
// same default model/provider the Copilot chat uses (models.json).
|
||||
const res = await window.ipc.invoke('llm:generate', { prompt, system })
|
||||
if (res.error || !res.text) {
|
||||
toast(res.error || 'No text was generated.', 'error')
|
||||
return
|
||||
}
|
||||
// Replace via a tracked transaction (selectAll + insertContent) so the AI
|
||||
// draft lands in the editor's undo history and the toolbar's Undo reverts it.
|
||||
if (aiMode === 'generate') {
|
||||
const { subject: generatedSubject, body } = parseGeneratedEmail(res.text)
|
||||
if (generatedSubject) setSubject(generatedSubject)
|
||||
// Always sign off with the account name, even if the model omitted it.
|
||||
const signed = ensureSignature(body, selfName)
|
||||
editor.chain().focus().selectAll().insertContent(plainTextToHtml(signed)).run()
|
||||
setHasGenerated(true)
|
||||
} else {
|
||||
// Rewrites also regenerate the subject so it stays in sync with the body.
|
||||
const { subject: rewrittenSubject, body } = parseGeneratedEmail(res.text)
|
||||
if (rewrittenSubject) setSubject(rewrittenSubject)
|
||||
editor.chain().focus().selectAll().insertContent(plainTextToHtml(body)).run()
|
||||
}
|
||||
} catch (err) {
|
||||
toast(`Generation failed: ${err instanceof Error ? err.message : String(err)}`, 'error')
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
// The single Write/Edit bar: generate a fresh draft until one exists, then
|
||||
// switch to rewriting it. Clears the prompt after a run kicks off.
|
||||
const runAiBar = async () => {
|
||||
await runAi(aiPrompt, hasGenerated ? 'rewrite' : 'generate')
|
||||
setAiPrompt('')
|
||||
}
|
||||
|
||||
// Attachments staged for this message. contentBase64 is the raw file bytes,
|
||||
// read in the renderer; the main process wraps them into the MIME on send.
|
||||
const [attachments, setAttachments] = useState<
|
||||
Array<{ id: string; filename: string; mimeType: string; size: number; contentBase64: string }>
|
||||
>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Gmail rejects messages over ~25MB; base64 inflates bytes by ~33%.
|
||||
const MAX_TOTAL_BYTES = 25 * 1024 * 1024
|
||||
|
||||
// Read a file's bytes as raw base64 (the part after the data: URL prefix).
|
||||
const readAsBase64 = (file: File) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onerror = () => reject(reader.error ?? new Error('read failed'))
|
||||
reader.onload = () => {
|
||||
const result = String(reader.result)
|
||||
const comma = result.indexOf(',')
|
||||
resolve(comma >= 0 ? result.slice(comma + 1) : result)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
|
||||
const addFiles = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return
|
||||
const staged: typeof attachments = []
|
||||
for (const file of Array.from(files)) {
|
||||
try {
|
||||
staged.push({
|
||||
id: `${file.name}-${file.size}-${file.lastModified}`,
|
||||
filename: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
contentBase64: await readAsBase64(file),
|
||||
})
|
||||
} catch {
|
||||
toast(`Could not read ${file.name}.`, 'error')
|
||||
}
|
||||
}
|
||||
setAttachments((prev) => {
|
||||
const merged = [...prev]
|
||||
for (const item of staged) {
|
||||
if (!merged.some((a) => a.id === item.id)) merged.push(item)
|
||||
}
|
||||
const total = merged.reduce((sum, a) => sum + a.size, 0)
|
||||
if (total > MAX_TOTAL_BYTES) {
|
||||
toast('Attachments exceed the 25MB limit.', 'error')
|
||||
return prev
|
||||
}
|
||||
return merged
|
||||
})
|
||||
}
|
||||
|
||||
const removeAttachment = (id: string) => {
|
||||
setAttachments((prev) => prev.filter((a) => a.id !== id))
|
||||
}
|
||||
|
||||
const [sending, setSending] = useState(false)
|
||||
const sendInGmail = async () => {
|
||||
if (!editor || sending) return
|
||||
const html = editor.getHTML()
|
||||
const text = editor.getText().trim()
|
||||
if (!text) {
|
||||
toast('Draft is empty.', 'error')
|
||||
toast(isNew ? 'Message is empty.' : 'Draft is empty.', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -975,25 +1227,29 @@ function ComposeBox({
|
|||
}
|
||||
|
||||
// Build References chain from all known message ids (newest last).
|
||||
const messageIds = thread.messages
|
||||
const messageIds = (thread?.messages ?? [])
|
||||
.map((m) => m.messageIdHeader)
|
||||
.filter((v): v is string => Boolean(v))
|
||||
const references = messageIds.join(' ')
|
||||
const inReplyTo = latest?.messageIdHeader
|
||||
const isForward = mode === 'forward'
|
||||
// Only replies stay on the thread; forwards and new emails start fresh.
|
||||
const isThreaded = Boolean(thread) && mode !== 'forward' && !isNew
|
||||
|
||||
setSending(true)
|
||||
try {
|
||||
const result = await window.ipc.invoke('gmail:sendReply', {
|
||||
threadId: isForward ? undefined : thread.threadId,
|
||||
threadId: isThreaded ? thread?.threadId : undefined,
|
||||
to: toList.join(', '),
|
||||
cc: ccList.length ? ccList.join(', ') : undefined,
|
||||
bcc: bccList.length ? bccList.join(', ') : undefined,
|
||||
subject: subject.trim() || composeSubject(mode, thread.subject),
|
||||
subject: subject.trim() || (thread ? composeSubject(mode, thread.subject) : '(No subject)'),
|
||||
bodyHtml: html,
|
||||
bodyText: text,
|
||||
inReplyTo: isForward ? undefined : inReplyTo,
|
||||
references: isForward ? undefined : references || undefined,
|
||||
inReplyTo: isThreaded ? inReplyTo : undefined,
|
||||
references: isThreaded ? references || undefined : undefined,
|
||||
attachments: attachments.length
|
||||
? attachments.map(({ filename, mimeType, contentBase64 }) => ({ filename, mimeType, contentBase64 }))
|
||||
: undefined,
|
||||
})
|
||||
if (result.error) {
|
||||
toast(`Send failed: ${result.error}`, 'error')
|
||||
|
|
@ -1009,7 +1265,7 @@ function ComposeBox({
|
|||
}
|
||||
|
||||
const refineWithCopilot = () => {
|
||||
if (!editor) return
|
||||
if (!editor || !thread) return
|
||||
const currentDraft = editor.getText().trim()
|
||||
const threadSubject = thread.subject || '(No subject)'
|
||||
|
||||
|
|
@ -1039,17 +1295,25 @@ function ComposeBox({
|
|||
window.dispatchEvent(new Event('email-block:draft-with-assistant'))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="gmail-compose-card">
|
||||
<div className="gmail-compose-header">
|
||||
const card = (
|
||||
<div
|
||||
className={isNew ? 'gmail-compose-modal' : 'gmail-compose-card'}
|
||||
onClick={isNew ? (event) => event.stopPropagation() : undefined}
|
||||
>
|
||||
<div className={isNew ? 'gmail-compose-modal-header' : 'gmail-compose-header'}>
|
||||
<span>{modeLabel}</span>
|
||||
<button type="button" onClick={onClose} aria-label="Close compose">×</button>
|
||||
<button
|
||||
type="button"
|
||||
className={isNew ? 'gmail-icon-button' : undefined}
|
||||
onClick={onClose}
|
||||
aria-label="Close compose"
|
||||
>×</button>
|
||||
</div>
|
||||
<RecipientField
|
||||
label="To"
|
||||
value={toList}
|
||||
onChange={setToList}
|
||||
autoFocus={mode === 'forward'}
|
||||
autoFocus={isNew || mode === 'forward'}
|
||||
trailing={
|
||||
<div className="gmail-recipient-toggles">
|
||||
{!showCc && <button type="button" onClick={() => setShowCc(true)}>Cc</button>}
|
||||
|
|
@ -1059,18 +1323,83 @@ function ComposeBox({
|
|||
/>
|
||||
{showCc && <RecipientField label="Cc" value={ccList} onChange={setCcList} />}
|
||||
{showBcc && <RecipientField label="Bcc" value={bccList} onChange={setBccList} />}
|
||||
{mode === 'forward' && (
|
||||
{isNew && (
|
||||
<>
|
||||
<div className="gmail-compose-ai-bar">
|
||||
<input
|
||||
className="gmail-compose-ai-input"
|
||||
value={aiPrompt}
|
||||
onChange={(event) => setAiPrompt(event.target.value)}
|
||||
placeholder={hasGenerated
|
||||
? 'Edit the draft (e.g. add a line about…, remove the last paragraph)…'
|
||||
: 'Describe the email and let AI write it…'}
|
||||
disabled={generating}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void runAiBar()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="gmail-refine-button"
|
||||
onClick={() => { void runAiBar() }}
|
||||
disabled={generating}
|
||||
title={hasGenerated ? 'Apply this edit to the draft' : 'Write a draft with AI'}
|
||||
>
|
||||
{generating ? <LoaderIcon size={15} className="animate-spin" /> : <Sparkles size={15} />}
|
||||
{generating
|
||||
? (hasGenerated ? 'Editing…' : 'Writing…')
|
||||
: (hasGenerated ? 'Edit' : 'Write')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="gmail-compose-ai-presets">
|
||||
<button type="button" onClick={() => { void runAi('Improve the clarity, grammar, and flow of this email while preserving its meaning.', 'rewrite') }} disabled={generating}>Improve</button>
|
||||
{TONE_PRESETS.map((preset) => (
|
||||
<button key={preset.key} type="button" onClick={() => { void runAi(preset.instruction, 'rewrite') }} disabled={generating}>{preset.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{(isNew || mode === 'forward') && (
|
||||
<div className="gmail-compose-line">
|
||||
<span className="gmail-compose-label">Subject</span>
|
||||
<input
|
||||
className="gmail-compose-subject-input"
|
||||
value={subject}
|
||||
onChange={(event) => setSubject(event.target.value)}
|
||||
placeholder="Subject"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<EditorContent editor={editor} className="gmail-compose-editor" />
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={(event) => {
|
||||
void addFiles(event.target.value ? event.currentTarget.files : null)
|
||||
event.currentTarget.value = ''
|
||||
}}
|
||||
/>
|
||||
{attachments.length > 0 && (
|
||||
<div className="gmail-compose-attachments">
|
||||
{attachments.map((att) => (
|
||||
<div key={att.id} className="gmail-compose-attachment" title={att.filename}>
|
||||
<Paperclip size={13} />
|
||||
<span className="gmail-compose-attachment-name">{att.filename}</span>
|
||||
<span className="gmail-compose-attachment-size">{formatAttachmentSize(att.size)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="gmail-compose-attachment-remove"
|
||||
onClick={() => removeAttachment(att.id)}
|
||||
aria-label={`Remove ${att.filename}`}
|
||||
>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{linkOpen && (
|
||||
<div className="gmail-compose-link-popover" onMouseDown={(event) => event.preventDefault()}>
|
||||
<input
|
||||
|
|
@ -1099,7 +1428,7 @@ function ComposeBox({
|
|||
className="gmail-send-button"
|
||||
onClick={() => { void sendInGmail() }}
|
||||
disabled={sending}
|
||||
title="Send this reply via Gmail"
|
||||
title={isNew ? 'Send this email via Gmail' : 'Send this reply via Gmail'}
|
||||
>
|
||||
{sending ? <LoaderIcon size={15} className="animate-spin" /> : <Send size={15} />}
|
||||
{sending ? 'Sending…' : 'Send'}
|
||||
|
|
@ -1107,19 +1436,40 @@ function ComposeBox({
|
|||
<button
|
||||
type="button"
|
||||
className="gmail-refine-button"
|
||||
onClick={refineWithCopilot}
|
||||
title="Refine this draft with Copilot"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={sending}
|
||||
title="Attach files"
|
||||
>
|
||||
<Sparkles size={15} />
|
||||
Refine
|
||||
<Paperclip size={15} />
|
||||
Attach
|
||||
</button>
|
||||
{thread && (
|
||||
<button
|
||||
type="button"
|
||||
className="gmail-refine-button"
|
||||
onClick={refineWithCopilot}
|
||||
title="Refine this draft with Copilot"
|
||||
>
|
||||
<Sparkles size={15} />
|
||||
Refine
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{editor && <ComposeToolbar editor={editor} onOpenLink={openLink} />}
|
||||
<button type="button" className="gmail-compose-link" onClick={onClose}>Discard</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isNew) {
|
||||
return (
|
||||
<div className="gmail-compose-overlay" onClick={onClose}>
|
||||
{card}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return card
|
||||
})
|
||||
|
||||
function ThreadDetail({
|
||||
thread,
|
||||
|
|
@ -1301,6 +1651,9 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
|||
const [refreshing, setRefreshing] = useState(!hadPersistedDataOnMount.current)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [composeOpen, setComposeOpen] = useState(false)
|
||||
// Stable so the open composer isn't re-rendered on every inbox sync tick.
|
||||
const closeCompose = useCallback(() => setComposeOpen(false), [])
|
||||
// Gmail sync uses the native Google OAuth connection.
|
||||
const [emailConnection, setEmailConnection] = useState<GmailConnectionStatus | null>(null)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
|
|
@ -1526,12 +1879,18 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
|||
// when files change. Throttled to at most one reload per ~3s so a burst of
|
||||
// backend writes (sync processing many threads sequentially) coalesces into
|
||||
// a small number of in-place updates rather than a flicker storm.
|
||||
// Suppressed while a thread is open (composing/reading); deferred until close.
|
||||
// Suppressed while a thread is open (reading/replying) or the compose-new
|
||||
// modal is open; deferred until whichever is open closes. A reload replaces
|
||||
// the threads array and re-renders the whole inbox list (and any mounted
|
||||
// ThreadDetail iframes) on the main thread — that re-render janks an open
|
||||
// composer even though ComposeBox itself is memoized, so we pause it.
|
||||
const pendingReloadRef = useRef(false)
|
||||
const reloadDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const lastReloadAtRef = useRef(0)
|
||||
const isSelectedRef = useRef<string | null>(null)
|
||||
isSelectedRef.current = selectedThreadId
|
||||
const composeOpenRef = useRef(false)
|
||||
composeOpenRef.current = composeOpen
|
||||
const isRefreshingRef = useRef(false)
|
||||
isRefreshingRef.current = refreshing
|
||||
const otherHasThreadsRef = useRef(false)
|
||||
|
|
@ -1541,7 +1900,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
|||
|
||||
const doReload = useCallback(() => {
|
||||
if (isRefreshingRef.current) return
|
||||
if (isSelectedRef.current !== null) {
|
||||
if (isSelectedRef.current !== null || composeOpenRef.current) {
|
||||
pendingReloadRef.current = true
|
||||
return
|
||||
}
|
||||
|
|
@ -1596,9 +1955,10 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
|||
}
|
||||
}, [triggerLiveReload])
|
||||
|
||||
// When user closes a thread, if updates arrived while they were reading, flush now.
|
||||
// When the user closes the open thread or the compose-new modal, if updates
|
||||
// arrived while it was open, flush them now.
|
||||
useEffect(() => {
|
||||
if (selectedThreadId !== null) return
|
||||
if (selectedThreadId !== null || composeOpen) return
|
||||
if (!pendingReloadRef.current) return
|
||||
pendingReloadRef.current = false
|
||||
lastReloadAtRef.current = Date.now()
|
||||
|
|
@ -1606,7 +1966,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
|||
if (otherHasThreadsRef.current) {
|
||||
void reloadFirstPage('other', { silent: true })
|
||||
}
|
||||
}, [selectedThreadId, reloadFirstPage])
|
||||
}, [selectedThreadId, composeOpen, reloadFirstPage])
|
||||
|
||||
// Manual refresh: wake the background sync loop. It updates inbox_lists/,
|
||||
// the watcher fires, and triggerLiveReload picks up the changes. The
|
||||
|
|
@ -1745,9 +2105,14 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
|||
placeholder="Search loaded mail"
|
||||
/>
|
||||
</div>
|
||||
<button type="button" className="gmail-icon-button" onClick={() => void refresh()} aria-label="Refresh">
|
||||
{refreshing ? <LoaderIcon size={18} className="animate-spin" /> : <RefreshCw size={18} />}
|
||||
</button>
|
||||
<div className="gmail-topbar-actions">
|
||||
<button type="button" className="gmail-icon-button" onClick={() => void refresh()} aria-label="Refresh">
|
||||
{refreshing ? <LoaderIcon size={18} className="animate-spin" /> : <RefreshCw size={18} />}
|
||||
</button>
|
||||
<button type="button" className="gmail-icon-button" onClick={() => setComposeOpen(true)} aria-label="Compose new email">
|
||||
<SquarePen size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && !hasAny ? (
|
||||
|
|
@ -1814,6 +2179,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
{composeOpen && <ComposeBox mode="new" onClose={closeCompose} />}
|
||||
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} defaultTab="connections" />
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1406,6 +1406,8 @@ export interface SendReplyOptions {
|
|||
bodyText: string;
|
||||
inReplyTo?: string;
|
||||
references?: string;
|
||||
/** Files to attach. contentBase64 is the raw (unwrapped) base64 of the file bytes. */
|
||||
attachments?: Array<{ filename: string; mimeType: string; contentBase64: string }>;
|
||||
}
|
||||
|
||||
export interface SendReplyResult {
|
||||
|
|
@ -1427,6 +1429,44 @@ export async function getAccountEmail(): Promise<string | null> {
|
|||
return getUserEmail(auth);
|
||||
}
|
||||
|
||||
let cachedAccountName: string | null | undefined;
|
||||
|
||||
/**
|
||||
* The connected account's display name, parsed from the `From` header of a
|
||||
* recent SENT message (which is the user themselves). Cached for the process
|
||||
* lifetime. Uses only the existing gmail.modify scope — no profile/userinfo
|
||||
* scope, so it never triggers a re-consent. Used by the composer to sign off
|
||||
* AI-generated emails with the real name.
|
||||
*/
|
||||
export async function getAccountName(): Promise<string | null> {
|
||||
if (cachedAccountName !== undefined) return cachedAccountName;
|
||||
try {
|
||||
const auth = await GoogleClientFactory.getClient();
|
||||
if (!auth) return null;
|
||||
const gmailClient = google.gmail({ version: 'v1', auth });
|
||||
const list = await gmailClient.users.messages.list({ userId: 'me', labelIds: ['SENT'], maxResults: 1 });
|
||||
const id = list.data.messages?.[0]?.id;
|
||||
if (!id) {
|
||||
cachedAccountName = null;
|
||||
return null;
|
||||
}
|
||||
const msg = await gmailClient.users.messages.get({
|
||||
userId: 'me',
|
||||
id,
|
||||
format: 'metadata',
|
||||
metadataHeaders: ['From'],
|
||||
});
|
||||
const from = msg.data.payload?.headers?.find((h) => h.name?.toLowerCase() === 'from')?.value || '';
|
||||
// Pull the display name out of `"Name" <email>` / `Name <email>`.
|
||||
const name = from.match(/^\s*"?([^"<]+?)"?\s*</)?.[1]?.trim() || null;
|
||||
cachedAccountName = name;
|
||||
return name;
|
||||
} catch (err) {
|
||||
console.warn('[Gmail] getAccountName failed:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConnectionStatus(): Promise<GmailConnectionStatus> {
|
||||
const status = await GoogleClientFactory.getCredentialStatus(REQUIRED_SCOPE);
|
||||
let email: string | null = null;
|
||||
|
|
@ -1467,6 +1507,17 @@ function encodeMimeBase64(text: string): string {
|
|||
?.join('\r\n') ?? '';
|
||||
}
|
||||
|
||||
// Re-wrap an already-base64 string into 76-char lines (RFC 2045) and strip any
|
||||
// whitespace the renderer may have included.
|
||||
function wrapBase64(base64: string): string {
|
||||
return base64.replace(/\s+/g, '').match(/.{1,76}/g)?.join('\r\n') ?? '';
|
||||
}
|
||||
|
||||
// Quote a filename for a MIME header, dropping characters that would break it.
|
||||
function sanitizeAttachmentName(name: string): string {
|
||||
return (name || 'attachment').replace(/[\r\n"\\]/g, '_').trim() || 'attachment';
|
||||
}
|
||||
|
||||
export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReplyResult> {
|
||||
try {
|
||||
const auth = await GoogleClientFactory.getClient();
|
||||
|
|
@ -1486,7 +1537,10 @@ export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReply
|
|||
: { bodyHtml: opts.bodyHtml.trim(), bodyText: opts.bodyText.trim() };
|
||||
if (!replyBody.bodyText.trim()) return { error: 'Draft is empty.' };
|
||||
|
||||
const boundary = `b_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const seed = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const altBoundary = `alt_${seed}`;
|
||||
const attachments = (opts.attachments ?? []).filter((a) => a.contentBase64);
|
||||
|
||||
const headers: string[] = [];
|
||||
headers.push(`From: ${requireSafeHeaderValue('From', userEmail)}`);
|
||||
headers.push(`To: ${safeTo}`);
|
||||
|
|
@ -1496,24 +1550,52 @@ export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReply
|
|||
if (safeInReplyTo) headers.push(`In-Reply-To: ${safeInReplyTo}`);
|
||||
if (safeReferences) headers.push(`References: ${safeReferences}`);
|
||||
headers.push('MIME-Version: 1.0');
|
||||
headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push(`--${boundary}`);
|
||||
parts.push('Content-Type: text/plain; charset="UTF-8"');
|
||||
parts.push('Content-Transfer-Encoding: base64');
|
||||
parts.push('');
|
||||
parts.push(encodeMimeBase64(replyBody.bodyText));
|
||||
parts.push('');
|
||||
parts.push(`--${boundary}`);
|
||||
parts.push('Content-Type: text/html; charset="UTF-8"');
|
||||
parts.push('Content-Transfer-Encoding: base64');
|
||||
parts.push('');
|
||||
parts.push(encodeMimeBase64(replyBody.bodyHtml));
|
||||
parts.push('');
|
||||
parts.push(`--${boundary}--`);
|
||||
// The text+html body as a self-contained multipart/alternative block.
|
||||
const altParts: string[] = [];
|
||||
altParts.push(`--${altBoundary}`);
|
||||
altParts.push('Content-Type: text/plain; charset="UTF-8"');
|
||||
altParts.push('Content-Transfer-Encoding: base64');
|
||||
altParts.push('');
|
||||
altParts.push(encodeMimeBase64(replyBody.bodyText));
|
||||
altParts.push('');
|
||||
altParts.push(`--${altBoundary}`);
|
||||
altParts.push('Content-Type: text/html; charset="UTF-8"');
|
||||
altParts.push('Content-Transfer-Encoding: base64');
|
||||
altParts.push('');
|
||||
altParts.push(encodeMimeBase64(replyBody.bodyHtml));
|
||||
altParts.push('');
|
||||
altParts.push(`--${altBoundary}--`);
|
||||
|
||||
const message = `${headers.join('\r\n')}\r\n\r\n${parts.join('\r\n')}`;
|
||||
let body: string;
|
||||
if (attachments.length) {
|
||||
// Wrap the alternative body plus each attachment in a multipart/mixed.
|
||||
const mixedBoundary = `mixed_${seed}`;
|
||||
headers.push(`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`);
|
||||
const mixed: string[] = [];
|
||||
mixed.push(`--${mixedBoundary}`);
|
||||
mixed.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
|
||||
mixed.push('');
|
||||
mixed.push(altParts.join('\r\n'));
|
||||
for (const att of attachments) {
|
||||
const name = sanitizeAttachmentName(att.filename);
|
||||
const mime = sanitizeAttachmentName(att.mimeType) || 'application/octet-stream';
|
||||
mixed.push(`--${mixedBoundary}`);
|
||||
mixed.push(`Content-Type: ${mime}; name="${name}"`);
|
||||
mixed.push('Content-Transfer-Encoding: base64');
|
||||
mixed.push(`Content-Disposition: attachment; filename="${name}"`);
|
||||
mixed.push('');
|
||||
mixed.push(wrapBase64(att.contentBase64));
|
||||
mixed.push('');
|
||||
}
|
||||
mixed.push(`--${mixedBoundary}--`);
|
||||
body = mixed.join('\r\n');
|
||||
} else {
|
||||
headers.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
|
||||
body = altParts.join('\r\n');
|
||||
}
|
||||
|
||||
const message = `${headers.join('\r\n')}\r\n\r\n${body}`;
|
||||
const raw = Buffer.from(message, 'utf8')
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
|||
import { LlmModelConfig, LlmProvider } from "@x/shared/dist/models.js";
|
||||
import z from "zod";
|
||||
import { getGatewayProvider } from "./gateway.js";
|
||||
import { getDefaultModelAndProvider, resolveProviderConfig } from "./defaults.js";
|
||||
import { withUseCase } from "../analytics/use_case.js";
|
||||
|
||||
export const Provider = LlmProvider;
|
||||
export const ModelConfig = LlmModelConfig;
|
||||
|
|
@ -96,3 +98,47 @@ export async function testModelConnection(
|
|||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export interface GenerateTextOptions {
|
||||
prompt: string;
|
||||
system?: string;
|
||||
/** Model id. Falls back to the active default when omitted. */
|
||||
model?: string;
|
||||
/** Provider name (e.g. "rowboat", "openai"). Falls back to the active default. */
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
export interface GenerateTextResult {
|
||||
text?: string;
|
||||
/** The model/provider actually used (after resolving defaults). */
|
||||
model?: string;
|
||||
provider?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot text generation for lightweight UI features (e.g. the email
|
||||
* composer's "write with AI"). Resolves the requested model+provider, falling
|
||||
* back to the active default, and returns the generated text. Never throws —
|
||||
* errors are returned in the result so the renderer can surface them.
|
||||
*/
|
||||
export async function generateOneShot(opts: GenerateTextOptions): Promise<GenerateTextResult> {
|
||||
try {
|
||||
const def = await getDefaultModelAndProvider();
|
||||
const modelId = opts.model || def.model;
|
||||
const providerName = opts.provider || def.provider;
|
||||
const providerConfig = await resolveProviderConfig(providerName);
|
||||
const languageModel = createProvider(providerConfig).languageModel(modelId);
|
||||
const result = await withUseCase(
|
||||
{ useCase: "copilot_chat", subUseCase: "email_compose" },
|
||||
() => generateText({
|
||||
model: languageModel,
|
||||
...(opts.system ? { system: opts.system } : {}),
|
||||
prompt: opts.prompt,
|
||||
}),
|
||||
);
|
||||
return { text: result.text.trim(), model: modelId, provider: providerName };
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -190,6 +190,15 @@ const ipcSchemas = {
|
|||
bodyText: z.string(),
|
||||
inReplyTo: z.string().optional(),
|
||||
references: z.string().optional(),
|
||||
attachments: z
|
||||
.array(
|
||||
z.object({
|
||||
filename: z.string(),
|
||||
mimeType: z.string(),
|
||||
contentBase64: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
messageId: z.string().optional(),
|
||||
|
|
@ -211,6 +220,12 @@ const ipcSchemas = {
|
|||
email: z.string().nullable(),
|
||||
}),
|
||||
},
|
||||
'gmail:getAccountName': {
|
||||
req: z.object({}),
|
||||
res: z.object({
|
||||
name: z.string().nullable(),
|
||||
}),
|
||||
},
|
||||
'gmail:archiveThread': {
|
||||
req: z.object({ threadId: z.string().min(1) }),
|
||||
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
|
||||
|
|
@ -388,6 +403,27 @@ const ipcSchemas = {
|
|||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'llm:getDefaultModel': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
model: z.string(),
|
||||
provider: z.string(),
|
||||
}),
|
||||
},
|
||||
'llm:generate': {
|
||||
req: z.object({
|
||||
prompt: z.string().min(1),
|
||||
system: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
provider: z.string().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
text: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
provider: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'models:saveConfig': {
|
||||
req: LlmModelConfig,
|
||||
res: z.object({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue