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
|
|
@ -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) };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue