diff --git a/netlify/functions/process-step-background.ts b/netlify/functions/process-step-background.ts new file mode 100644 index 00000000..cf9eeca9 --- /dev/null +++ b/netlify/functions/process-step-background.ts @@ -0,0 +1,674 @@ +// SVELTEKIT SUBSTITUTE: Removed SvelteKit imports and replaced with Netlify Function structure +// SVELTEKIT SUBSTITUTE: Changed from '$env/dynamic/private' to process.env +// SVELTEKIT SUBSTITUTE: Changed import paths to netlify/functions + +import Anthropic from '@anthropic-ai/sdk' +import { JobStorage } from './storage' + +// Import types and configurations from server +import type { StepName, WriteState, StepConfig } from './write' +import { generateProgressString, prepareResponse } from './write' + +// SVELTEKIT SUBSTITUTE: Changed from env imports to process.env +const ANTHROPIC_API_KEY_FOR_WRITE = process.env.ANTHROPIC_API_KEY_FOR_WRITE || undefined +const ENABLE_WEB_SEARCH = process.env.ENABLE_WEB_SEARCH !== 'false' +const MAX_TOOL_CALLS_PER_STEP = parseInt(process.env.MAX_TOOL_CALLS_PER_STEP || '3') +const IS_API_AVAILABLE = !!ANTHROPIC_API_KEY_FOR_WRITE + +// Workflow configurations (duplicated from write.ts for background processing) +const workflowConfigs: Record = { + '1': { + steps: ['findTarget'], + description: 'Find Target Only' + }, + '2': { + steps: ['webSearch', 'research'], + description: 'Web Search + Autofill' + }, + '3': { + steps: ['research'], + description: 'Autofill only' + }, + '4': { + steps: ['firstDraft', 'firstCut', 'firstEdit', 'toneEdit', 'finalEdit'], + description: 'Full Email Generation' + } +} + +// Initialize Anthropic client +const anthropic = IS_API_AVAILABLE + ? new Anthropic({ + apiKey: ANTHROPIC_API_KEY_FOR_WRITE + }) + : null + +// System prompts for step processing +const System_Prompts: { [id: string]: string } = { + Basic: `You are a helpful AI assistant. + +Note: Some fields in the information may begin with a robot emoji (🤖). This indicates the field was automatically generated during research. You can use this information normally, just ignore the emoji marker.`, + + Mail: ` +What follows is the anatomy of a good email, a set of guidelines and +criteria for writing a good mail. Each paragraph, except the last represents +a distinct part of the email. + +Subject line: +The subject line should be short, informative and clearly communicate +the goal of the mail. It must grab the attention and capture the +interest of the recipient. Avoid cliché language. + +Greeting: +The greeting must match the tone of the mail. If possible, address the +recipient by the appropriate title. Keep it short, and mention the reason +for the mail. Establish a strong connection with the recipient: Are they +a politician meant to represent you? Is it regarding something they've +recently done? Make the recipient feel like they owe you an answer. + +First paragraph: +Explain what the purpose of the email is. It must be concise and captivating, +most people who receive many emails learn to quickly dismiss many. Make +sure the relation is established and they have a reason to read on. + +Body paragraph: +The main body of the email should be informative and contain the information +of the mail. Take great care not to overwhelm the reader: it must be +logically structured and not too full of facts. The message should remain +clear and the relation to the greeting and first paragraph must remain clear. +It should not be too long, otherwise it might get skimmed. Links to further +information can be provided. + +Conclusion: +Keep this short and sweet. Make sure it has a CLEAR CALL TO ACTION! +Restate the reason the recipient should feel the need to act. Thank them +for their time and/or your ask. + +General: +Make sure the formatting isn't too boring. Write in a manner the recipient +would respond well to: Do not argue with them, do not mention views they +probably won't share. Try to play to things they said before and that fit +their persona. Keep the tone consistent and not too emotional. Do not sound +crazy. +`, + + Checklist: ` +Checklist Before Sending +Message Verification + Is the purpose crystal clear? + Have I provided necessary context? + Is there a specific, achievable call to action? + Have I proofread for tone and clarity? +`, + + First_Draft: ` +Using the information that will be provided by the user, write the mail +according to the criteria. Get all the information into the mail. +Don't worry about it being too long. Keep the message powerful. +`, + + First_Cut: ` +You will be provided with an email by the user. +Remove redundant information and clean up the structure. The point of this pass is +to have the structure clear and the mail slightly longer than needed. The message +should be clear, the information still mostly present, with only what is +absolutely necessary being removed. +`, + + First_Edit: ` +You will be provided with an email by the user. The following points are paramount: +Make sure the flow of information is natural. All paragraphs should be +connected in a sensical manner. Remove odd, unfitting or overly emotional +language. Make sure the paragraphs fulfill their roles. +`, + + Tone_Edit: ` +You will be provided with an email by the user. The following points are paramount: +Adjust the language to match recipient's communication style. Remove potentially +offensive or dismissive language. Ensure the tone matches the relationship and +purpose. Make sure the points and information is relevant for the recipient. +Assume the recipient's position: How would they react to the mail? What information +would resonate with them? What wouldn't? Do not compromise on the message. +`, + + Final_Edit: ` +You will be provided with an email by the user. Make sure the email matches the +criteria initially described. Check spelling, grammar and tone. +`, + + Research: ` +Please replace all mentions of 'undefined' with the apropriate information that should +go in that space, derived from the rest of the information. + +Important: For any field you fill in that was originally 'undefined' or empty, prefix +your answer with a robot emoji (🤖) to indicate it was automatically generated. + +Example: +Original: "Preferred communication style: undefined" +Your output: "Preferred communication style: 🤖 Formal but approachable" + +Please remember that you are addressing this person, and try to make all inferences based on the information provided and your own knowledge. Err on the side of caution: if you are unsure, be polite and neutral. + +Output the full information, including your edits. Output nothing else. +`, + + Target: ` +Please use your internet search capability to find individuals involved with AI safety who match the following description. + +For each person you find (aim for 3-5 people), please provide: +1. Name and current position +2. Why they're relevant to AI safety +3. Their organization +4. Brief note on their public stance on AI safety + +Please cite your sources for each person. +`, + + webSearch: ` +Please use your internet search capability to research [Person's Name] who is [current role] at [organization/affiliation]. I plan to contact them about AI safety concerns. + +Search for and provide: +1. Professional background (education, career history, notable positions) +2. Their involvement with AI issues (policy positions, public statements, initiatives, articles, interviews) +3. Their public views on AI development and safety (with direct quotes where possible) +4. Recent activities related to technology policy or AI (last 6-12 months) +5. Communication style and key terms they use when discussing technology issues +6. Notable connections (organizations, committees, coalitions, or influential individuals they work with) +7. Contact information (professional email or official channels if publicly available) + +Please cite all sources you use and only include information you can verify through your internet search. If you encounter conflicting information, note this and provide the most reliable source. + +BE BRIEF! This is extremely important. Try to output only a few lines of text for each questions. +`, + + Results: ` +Only reply with the final results, a.k.a. the final email, and absolutely nothing else. +` +} + +// Step configurations +export const stepConfigs: Record = { + findTarget: { + toolsEnabled: true, + maxToolCalls: 5, + description: 'Find possible targets (using web search)' + }, + webSearch: { + toolsEnabled: true, + maxToolCalls: 3, + description: 'Research the target (using web search)' + }, + research: { + toolsEnabled: false, + maxToolCalls: 2, + description: 'Auto-fill missing user inputs' + }, + firstDraft: { toolsEnabled: false }, + firstCut: { toolsEnabled: false }, + firstEdit: { toolsEnabled: false }, + toneEdit: { toolsEnabled: false }, + finalEdit: { toolsEnabled: false } +} + +// Enhanced callClaude function with tool support +export async function callClaude( + stepName: string, + promptNames: string[], + userContent: string, + toolsEnabled: boolean = false +): Promise<{ text: string; durationSec: number }> { + const pencil = '✏️' + const search = '🔍' + const logPrefix = `${pencil} write:${stepName}` + const startTime = Date.now() + + console.time(`${logPrefix}`) + + try { + // Check if the API client is available + if (!anthropic) { + throw new Error('Anthropic API client is not initialized. API key is missing.') + } + + // Combine all the specified prompts + const systemPrompt = promptNames.map((name) => System_Prompts[name]).join('') + + // Determine if tools should be included in this call + const shouldUseTools = toolsEnabled && ENABLE_WEB_SEARCH && IS_API_AVAILABLE + + // Log tool usage status + if (shouldUseTools) { + console.log(`${search} ${logPrefix}: Tools enabled for this step`) + } + + // Use correct web search tool definition matching API documentation + const tools = shouldUseTools + ? [ + { + type: 'web_search_20250305', + name: 'web_search', + max_uses: 3 + } + ] + : undefined + + // Create API request with conditional tool support + const requestParams: any = { + model: 'claude-3-7-sonnet-20250219', + max_tokens: 4096, + system: systemPrompt, + messages: [{ role: 'user', content: userContent }] + } + + // Add tools to request if enabled + if (tools) { + requestParams.tools = tools + } + + // Implement proper tool execution loop + let currentMessages = [...requestParams.messages] + let finalText = '' + let toolCallCount = 0 + const maxCalls = Math.min( + MAX_TOOL_CALLS_PER_STEP, + stepConfigs[stepName as StepName]?.maxToolCalls || MAX_TOOL_CALLS_PER_STEP + ) + + while (toolCallCount < maxCalls) { + // Create request with current message history + const currentRequest = { + ...requestParams, + messages: currentMessages + } + + const response = await anthropic.messages.create(currentRequest) + + // Log the request ID at debug level + console.debug(`${logPrefix} requestId: ${response.id}`) + + // Process response content properly + let hasToolUse = false + let textContent = '' + + for (const content of response.content) { + if (content.type === 'text') { + textContent += content.text + } else if (content.type === 'server_tool_use' && shouldUseTools) { + // Handle server-side tool use (web search is executed automatically) + hasToolUse = true + toolCallCount++ + console.log(`${search} ${logPrefix}: Web search executed - ${content.name}`) + } else if (content.type === 'web_search_tool_result') { + // Handle web search results (automatically provided by API) + console.log(`${search} ${logPrefix}: Received web search results`) + } + } + + // Add assistant's response to conversation history + currentMessages.push({ + role: 'assistant', + content: response.content + }) + + // Accumulate text content + finalText += textContent + + // Break if no tool use or if we've hit limits + if (!hasToolUse || toolCallCount >= maxCalls) { + break + } + + // If there was tool use, Claude might continue in the same turn + // Check if response has pause_turn stop reason + if (response.stop_reason === 'pause_turn') { + // Continue the conversation to let Claude finish its turn + continue + } else { + // Tool use complete, break the loop + break + } + } + + // Ensure we have text content + if (!finalText) { + throw new Error('No text content received from Claude') + } + + const elapsed = (Date.now() - startTime) / 1000 // seconds + + // Log tool usage statistics + if (shouldUseTools && toolCallCount > 0) { + console.log(`${search} ${logPrefix}: Used ${toolCallCount} web searches`) + } + + // Log the full response text at debug level + console.debug(`${logPrefix} full response:\n---\n${finalText}\n---`) + return { text: finalText, durationSec: elapsed } + } catch (error) { + // Better error handling for tool-related failures + if (toolsEnabled && (error.message?.includes('tool') || error.message?.includes('search'))) { + console.warn( + `${search} ${logPrefix}: Tool error, falling back to text-only mode:`, + error.message + ) + // Retry without tools on tool-related errors + return callClaude(stepName, promptNames, userContent, false) + } + throw error // Re-throw non-tool errors + } finally { + console.timeEnd(`${logPrefix}`) + } +} + +// Define step handlers in a map for easy lookup +const stepHandlers: Record< + StepName, + (state: WriteState) => Promise<{ text: string; durationSec: number }> +> = { + // Enable tools for target finding + findTarget: async (state) => { + System_Prompts['Information'] = state.userInput + + // Check if tools should be enabled for this step + const stepConfig = stepConfigs.findTarget + const toolsEnabled = stepConfig?.toolsEnabled && ENABLE_WEB_SEARCH + + const result = await callClaude( + 'findTarget', + ['Basic', 'Target', 'Information'], + 'Hello! Please help me find a person to contact!', + toolsEnabled + ) + + state.information = result.text + System_Prompts['Information'] = result.text + + return result + }, + + // Enable tools for web search + webSearch: async (state) => { + System_Prompts['Information'] = state.userInput + + // Check if tools should be enabled for this step + const stepConfig = stepConfigs.webSearch + const toolsEnabled = stepConfig?.toolsEnabled && ENABLE_WEB_SEARCH + + const result = await callClaude( + 'webSearch', + ['Basic', 'webSearch', 'Information', 'Results'], + 'Hello! Please research this person!', + toolsEnabled + ) + + state.information = result.text + System_Prompts['Information'] = System_Prompts['Information'] + '\n\n' + result.text + + return result + }, + + // Enable tools for research step + research: async (state) => { + System_Prompts['Information'] = state.userInput + + // Check if tools should be enabled for this step + const stepConfig = stepConfigs.research + const toolsEnabled = stepConfig?.toolsEnabled && ENABLE_WEB_SEARCH + + const result = await callClaude( + 'research', + ['Basic', 'Mail', 'Information', 'Research'], + "Hello! Please update the list of information by replacing all instances of 'undefined' with something that belongs under their respective header based on the rest of the information provided. Thank you!", + toolsEnabled + ) + + state.information = result.text + System_Prompts['Information'] = result.text + + return result + }, + + // Text processing steps remain without tools for performance + firstDraft: async (state) => { + return await callClaude( + 'firstDraft', + ['Basic', 'Mail', 'First_Draft', 'Results'], + 'Hello! Please write an email draft using the following information. \n' + state.userInput + ) + }, + + firstCut: async (state) => { + return await callClaude( + 'firstCut', + ['Basic', 'Mail', 'Information', 'First_Cut', 'Results'], + 'Hello! Please cut the following email draft. \n \n' + state.email + ) + }, + + firstEdit: async (state) => { + return await callClaude( + 'firstEdit', + ['Basic', 'Mail', 'Information', 'First_Edit', 'Results'], + 'Hello! Please edit the following email draft. \n \n' + state.email + ) + }, + + toneEdit: async (state) => { + return await callClaude( + 'toneEdit', + ['Basic', 'Mail', 'Information', 'Tone_Edit', 'Results'], + 'Hello! Please edit the tone of the following email draft. \n \n' + state.email + ) + }, + + finalEdit: async (state) => { + return await callClaude( + 'finalEdit', + ['Basic', 'Mail', 'Information', 'Final_Edit', 'Checklist', 'Results'], + 'Hello! Please edit the following email draft. \n \n' + state.email + ) + } +} + +// Process a specific step with storage integration +export async function processStep(jobId: string): Promise { + const pencil = '✏️' + + try { + // Load state from storage + const jobData = await JobStorage.getJob(jobId) + if (!jobData) { + throw new Error(`Job ${jobId} not found`) + } + + let state = jobData.writeState + console.log(`${pencil} processStep: Processing job ${jobId}, step ${state.nextStep}`) + + // Check if job is already complete or has no next step + if (!state.nextStep || state.step === 'complete') { + console.log(`${pencil} processStep: Job ${jobId} already complete`) + return state + } + + const currentStep = state.nextStep as StepName + state.currentStep = currentStep + + // Update remaining steps - remove current step from remaining + state.remainingSteps = state.remainingSteps.filter((step) => step !== currentStep) + + // Update job status to processing + await JobStorage.updateJobStatus(jobId, 'processing') + + // Execute the step using the step handler from the map + const stepHandler = stepHandlers[currentStep] + if (!stepHandler) { + throw new Error(`Unknown step: ${currentStep}`) + } + + console.log(`${pencil} processStep: Executing step ${currentStep} for job ${jobId}`) + const result = await stepHandler(state) + + // Update email content (except for research-like steps which update information) + if (!['research'].includes(currentStep)) { + state.email = result.text + } + + // Update state with results - current step is now completed + state.step = currentStep + state.completedSteps.push({ + name: currentStep, + durationSec: result.durationSec + }) + + // Set next step based on workflow configuration + const workflowSteps = getWorkflowSteps(state.workflowType) + const currentIndex = workflowSteps.indexOf(currentStep) + + if (currentIndex !== -1 && currentIndex < workflowSteps.length - 1) { + state.nextStep = workflowSteps[currentIndex + 1] + state.currentStep = workflowSteps[currentIndex + 1] + } else { + // Last step completed, mark as complete + state.nextStep = null + state.currentStep = null + state.step = 'complete' + } + + // Update storage with new state + await JobStorage.updateWriteState(jobId, state) + + // If there are more steps, continue processing + if (state.nextStep && state.step !== 'complete') { + console.log( + `${pencil} processStep: Continuing to next step ${state.nextStep} for job ${jobId}` + ) + // Recursively process the next step + return await processStep(jobId) + } else { + // Mark job as completed + console.log(`${pencil} processStep: Job ${jobId} completed`) + await JobStorage.completeJob(jobId, state) + } + + return state + } catch (error) { + console.error(`${pencil} processStep: Error processing job ${jobId}:`, error) + + // Mark job as failed + await JobStorage.failJob(jobId, error.message || 'Unknown error occurred') + + // Re-throw the error for the caller to handle + throw error + } +} + +// Helper function to get workflow steps +function getWorkflowSteps(workflowType: string): StepName[] { + return workflowConfigs[workflowType]?.steps || [] +} + +// SVELTEKIT SUBSTITUTE: Converted to Netlify Function handler +export const handler = async (event: any, context: any) => { + const pencil = '✏️' + + // SVELTEKIT SUBSTITUTE: Handle CORS for browser requests + const headers = { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS' + } + + // Handle preflight requests + if (event.httpMethod === 'OPTIONS') { + return { + statusCode: 200, + headers, + body: '' + } + } + + // Handle GET requests (status check) + if (event.httpMethod === 'GET') { + return { + statusCode: 200, + headers, + body: JSON.stringify({ + status: 'Background processing service is running', + apiAvailable: IS_API_AVAILABLE + }) + } + } + + // Handle POST requests (process job) + if (event.httpMethod === 'POST') { + try { + const { jobId } = JSON.parse(event.body || '{}') + + if (!jobId) { + return { + statusCode: 400, + headers, + body: JSON.stringify({ error: 'Job ID is required' }) + } + } + + console.log(`${pencil} background: Starting processing for job ${jobId}`) + + // Check if API is available + if (!IS_API_AVAILABLE) { + await JobStorage.failJob(jobId, 'Anthropic API key is not available') + return { + statusCode: 500, + headers, + body: JSON.stringify({ error: 'API not available' }) + } + } + + // Process the job + const finalState = await processStep(jobId) + + console.log(`${pencil} background: Job ${jobId} processing completed`) + + return { + statusCode: 200, + headers, + body: JSON.stringify({ + success: true, + jobId, + complete: finalState.step === 'complete' + }) + } + } catch (error) { + console.error(`${pencil} background: Error in background processing:`, error) + + // Try to update job status if we have a jobId + try { + const requestData = JSON.parse(event.body || '{}') + if (requestData.jobId) { + await JobStorage.failJob( + requestData.jobId, + error.message || 'Background processing failed' + ) + } + } catch (parseError) { + // Ignore parse errors when trying to extract jobId for error handling + } + + return { + statusCode: 500, + headers, + body: JSON.stringify({ + error: 'Background processing failed', + message: error.message + }) + } + } + } + + // Handle unsupported methods + return { + statusCode: 405, + headers, + body: JSON.stringify({ error: 'Method not allowed' }) + } +} diff --git a/netlify/functions/status.ts b/netlify/functions/status.ts new file mode 100644 index 00000000..9b73e391 --- /dev/null +++ b/netlify/functions/status.ts @@ -0,0 +1,127 @@ +// SVELTEKIT SUBSTITUTE: Removed SvelteKit imports and replaced with Netlify Function structure +// SVELTEKIT SUBSTITUTE: Changed from '@sveltejs/kit' to standard Response objects +// SVELTEKIT SUBSTITUTE: Changed import paths to netlify/functions + +import { JobStorage } from './storage' +import { prepareResponse } from './write' + +// SVELTEKIT SUBSTITUTE: Converted to Netlify Function handler +export const handler = async (event: any, context: any) => { + // SVELTEKIT SUBSTITUTE: Handle CORS for browser requests + const headers = { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS' + } + + // Handle preflight requests + if (event.httpMethod === 'OPTIONS') { + return { + statusCode: 200, + headers, + body: '' + } + } + + // Handle GET requests + if (event.httpMethod === 'GET') { + // SVELTEKIT SUBSTITUTE: Parse query parameters from event instead of url.searchParams + const jobId = event.queryStringParameters?.jobId + + if (!jobId) { + return { + statusCode: 400, + headers, + body: JSON.stringify({ error: 'Job ID is required' }) + } + } + + try { + const jobData = await JobStorage.getJob(jobId) + + if (!jobData) { + return { + statusCode: 404, + headers, + body: JSON.stringify({ error: 'Job not found' }) + } + } + + // Prepare response using your existing function + const response = prepareResponse(jobData.writeState) + + return { + statusCode: 200, + headers, + body: JSON.stringify({ + ...response, + jobId, + status: jobData.status, + error: jobData.error, + createdAt: jobData.createdAt, + completedAt: jobData.completedAt + }) + } + } catch (error) { + console.error('Error checking job status:', error) + return { + statusCode: 500, + headers, + body: JSON.stringify({ error: 'Internal server error' }) + } + } + } + + // Handle POST requests (for compatibility) + if (event.httpMethod === 'POST') { + try { + const { jobId } = JSON.parse(event.body || '{}') + + if (!jobId) { + return { + statusCode: 400, + headers, + body: JSON.stringify({ error: 'Job ID is required' }) + } + } + + const jobData = await JobStorage.getJob(jobId) + + if (!jobData) { + return { + statusCode: 404, + headers, + body: JSON.stringify({ error: 'Job not found' }) + } + } + + const response = prepareResponse(jobData.writeState) + + return { + statusCode: 200, + headers, + body: JSON.stringify({ + ...response, + jobId, + status: jobData.status, + error: jobData.error + }) + } + } catch (error) { + console.error('Error checking job status:', error) + return { + statusCode: 500, + headers, + body: JSON.stringify({ error: 'Internal server error' }) + } + } + } + + // Handle unsupported methods + return { + statusCode: 405, + headers, + body: JSON.stringify({ error: 'Method not allowed' }) + } +} diff --git a/netlify/functions/storage.ts b/netlify/functions/storage.ts new file mode 100644 index 00000000..da64b433 --- /dev/null +++ b/netlify/functions/storage.ts @@ -0,0 +1,111 @@ +// SVELTEKIT SUBSTITUTE: This file already uses Netlify Blobs correctly, minimal changes needed +// SVELTEKIT SUBSTITUTE: Fixed initialization to properly handle Netlify Functions environment + +let jobStore: any = null + +async function getJobStore() { + if (!jobStore) { + const { getStore } = await import('@netlify/blobs') + jobStore = getStore('email-jobs') + } + return jobStore +} + +// Storage interface functions +export const JobStorage = { + // Create new job entry + async createJob(jobId: string, initialWriteState: any) { + const store = await getJobStore() // SVELTEKIT SUBSTITUTE: Ensure store is initialized + const jobData = { + jobId, + status: 'pending', + writeState: initialWriteState, + createdAt: new Date().toISOString(), + error: null, + completedAt: null + } + + await store.set(jobId, JSON.stringify(jobData)) + return jobData + }, + + // Get job by ID + async getJob(jobId: string) { + try { + const store = await getJobStore() // SVELTEKIT SUBSTITUTE: Ensure store is initialized + const data = await store.get(jobId) + if (!data) return null + return JSON.parse(data) + } catch (error) { + console.error('Error retrieving job:', error) + return null + } + }, + + // Update job status + async updateJobStatus(jobId: string, status: string, error: string | null = null) { + const jobData = await this.getJob(jobId) + if (!jobData) throw new Error(`Job ${jobId} not found`) + + jobData.status = status + jobData.error = error + + if (status === 'completed' || status === 'failed') { + jobData.completedAt = new Date().toISOString() + } + + const store = await getJobStore() // SVELTEKIT SUBSTITUTE: Ensure store is initialized + await store.set(jobId, JSON.stringify(jobData)) + return jobData + }, + + // Update WriteState for job + async updateWriteState(jobId: string, newWriteState: any) { + const jobData = await this.getJob(jobId) + if (!jobData) throw new Error(`Job ${jobId} not found`) + + jobData.writeState = newWriteState + jobData.status = 'processing' + + const store = await getJobStore() // SVELTEKIT SUBSTITUTE: Ensure store is initialized + await store.set(jobId, JSON.stringify(jobData)) + return jobData + }, + + // Complete job with final results + async completeJob(jobId: string, finalWriteState: any) { + const jobData = await this.getJob(jobId) + if (!jobData) throw new Error(`Job ${jobId} not found`) + + jobData.writeState = finalWriteState + jobData.status = 'completed' + jobData.completedAt = new Date().toISOString() + + const store = await getJobStore() // SVELTEKIT SUBSTITUTE: Ensure store is initialized + await store.set(jobId, JSON.stringify(jobData)) + return jobData + }, + + // Mark job as failed + async failJob(jobId: string, errorMessage: string) { + return await this.updateJobStatus(jobId, 'failed', errorMessage) + }, + + // Delete job (cleanup) + async deleteJob(jobId: string) { + const store = await getJobStore() // SVELTEKIT SUBSTITUTE: Ensure store is initialized + await store.delete(jobId) + }, + + // List all jobs (for debugging/admin) + async listJobs() { + const store = await getJobStore() // SVELTEKIT SUBSTITUTE: Ensure store is initialized + const { blobs } = await store.list() + return blobs.map((blob: any) => blob.key) + } +} + +// Utility function to generate unique job IDs +export function generateJobId(): string { + return `job_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` +} diff --git a/netlify/functions/write.ts b/netlify/functions/write.ts new file mode 100644 index 00000000..ee6cc95d --- /dev/null +++ b/netlify/functions/write.ts @@ -0,0 +1,487 @@ +// SVELTEKIT SUBSTITUTE: Removed SvelteKit imports and replaced with Netlify Function structure +// SVELTEKIT SUBSTITUTE: Changed from '@sveltejs/kit' to standard Response objects +// SVELTEKIT SUBSTITUTE: Changed env import to process.env +// SVELTEKIT SUBSTITUTE: Changed import paths to netlify/functions + +import { JobStorage, generateJobId } from './storage' + +// Type definitions +export type StepName = + | 'findTarget' + | 'webSearch' + | 'research' + | 'firstDraft' + | 'firstCut' + | 'firstEdit' + | 'toneEdit' + | 'finalEdit' + +export type WorkflowType = '1' | '2' | '3' | '4' + +export interface StepConfig { + toolsEnabled?: boolean + maxToolCalls?: number + description?: string +} + +export type WorkflowConfig = { + steps: StepName[] + description: string + stepConfigs?: Record +} + +export interface WriteState { + step: StepName | 'complete' | 'start' + workflowType: WorkflowType + userInput: string + email: string + information?: string + completedSteps: Array<{ + name: StepName + durationSec: number + }> + currentStep: StepName | null + remainingSteps: StepName[] + nextStep: StepName | null +} + +export type ChatResponse = { + response: string + apiAvailable?: boolean + stateToken?: string + progressString?: string + complete?: boolean + information?: string + jobId?: string + status?: string +} + +export type Message = { + role: 'user' | 'assistant' | 'system' + content: string +} + +export interface JobStorage { + jobId: string + status: 'pending' | 'processing' | 'completed' | 'failed' + writeState: WriteState + error?: string + createdAt: Date + completedAt?: Date +} + +// SVELTEKIT SUBSTITUTE: Changed from env imports to process.env +const ANTHROPIC_API_KEY_FOR_WRITE = process.env.ANTHROPIC_API_KEY_FOR_WRITE || undefined +const ENABLE_WEB_SEARCH = process.env.ENABLE_WEB_SEARCH !== 'false' +const IS_API_AVAILABLE = !!ANTHROPIC_API_KEY_FOR_WRITE +const NETLIFY_BACKGROUND_FUNCTION_URL = + process.env.NETLIFY_BACKGROUND_FUNCTION_URL || '/.netlify/functions/process-step-background' + +// Step configurations (imported from background file) +const stepConfigs: Record = { + findTarget: { + toolsEnabled: true, + maxToolCalls: 5, + description: 'Find possible targets (using web search)' + }, + webSearch: { + toolsEnabled: true, + maxToolCalls: 3, + description: 'Research the target (using web search)' + }, + research: { + toolsEnabled: false, + maxToolCalls: 2, + description: 'Auto-fill missing user inputs' + }, + firstDraft: { toolsEnabled: false }, + firstCut: { toolsEnabled: false }, + firstEdit: { toolsEnabled: false }, + toneEdit: { toolsEnabled: false }, + finalEdit: { toolsEnabled: false } +} + +// Workflow configurations +const workflowConfigs: Record = { + '1': { + steps: ['findTarget'], + description: 'Find Target Only', + stepConfigs + }, + '2': { + steps: ['webSearch', 'research'], + description: 'Web Search + Autofill', + stepConfigs + }, + '3': { + steps: ['research'], + description: 'Autofill only', + stepConfigs + }, + '4': { + steps: ['firstDraft', 'firstCut', 'firstEdit', 'toneEdit', 'finalEdit'], + description: 'Full Email Generation', + stepConfigs + } +} + +// Log warning during build if API key is missing +if (!IS_API_AVAILABLE) { + console.warn( + '⚠️ ANTHROPIC_API_KEY_FOR_WRITE is not set. The /write page will operate in limited mode.' + ) +} + +// Helper function to parse workflow type from user input +function parseWorkflowType(userInput: string): { + workflowType: WorkflowType + cleanedInput: string +} { + const workflowMatch = userInput.match(/^\[([1-4])\](.*)$/s) + + if (workflowMatch) { + const workflowType = workflowMatch[1] as WorkflowType + const cleanedInput = workflowMatch[2].trim() + return { workflowType, cleanedInput } + } + + // Default to workflow 4 if no prefix is found + return { workflowType: '4', cleanedInput: userInput } +} + +// Function to get step description with tool awareness +function getStepDescription(stepName: StepName): string { + const stepConfig = stepConfigs[stepName] + const toolsWillBeUsed = stepConfig?.toolsEnabled && ENABLE_WEB_SEARCH && IS_API_AVAILABLE + + // Return enhanced description if tools are enabled and available + if (toolsWillBeUsed && stepConfig?.description) { + return stepConfig.description + } + + // Fallback to standard descriptions + const stepDescriptions: Record = { + findTarget: 'Find possible targets', + webSearch: 'Research the target', + research: 'Auto-fill missing user inputs', + firstDraft: 'Create initial draft', + firstCut: 'Remove unnecessary content', + firstEdit: 'Improve text flow', + toneEdit: 'Adjust tone and style', + finalEdit: 'Final polish' + } + + return stepDescriptions[stepName] +} + +// Function to generate a progress string from the state +export function generateProgressString(state: WriteState): string { + const pencil = '✏️' + const checkmark = '✓' + const search = '🔍' + + // Get workflow description + const workflowDescription = workflowConfigs[state.workflowType].description + + // Generate the progress string + let lis = [] + + // Completed steps with tool usage indicators + for (const step of state.completedSteps) { + const stepConfig = stepConfigs[step.name] + const usedTools = stepConfig?.toolsEnabled && ENABLE_WEB_SEARCH && IS_API_AVAILABLE + const icon = usedTools ? `${search}${checkmark}` : checkmark + + lis.push( + `
  • ${getStepDescription(step.name)} (${step.durationSec.toFixed(1)}s) ${icon}
  • ` + ) + } + + // Current step with tool usage indicator + if ( + state.currentStep && + state.step !== 'complete' && + !state.completedSteps.some((s) => s.name === state.currentStep) + ) { + const stepConfig = stepConfigs[state.currentStep] + const willUseTools = stepConfig?.toolsEnabled && ENABLE_WEB_SEARCH && IS_API_AVAILABLE + const icon = willUseTools ? `${search}${pencil}` : pencil + + lis.push(`
  • ${getStepDescription(state.currentStep)} ${icon}
  • `) + } + + // Remaining steps with tool usage preview + const completedAndCurrentSteps = [...state.completedSteps.map((s) => s.name), state.currentStep] + const filteredRemainingSteps = state.remainingSteps.filter( + (step) => !completedAndCurrentSteps.includes(step) + ) + + lis = lis.concat( + filteredRemainingSteps.map((step) => { + const stepConfig = stepConfigs[step] + const willUseTools = stepConfig?.toolsEnabled && ENABLE_WEB_SEARCH && IS_API_AVAILABLE + const description = getStepDescription(step) + const indicator = willUseTools ? ` ${search}` : '' + + return `
  • ${description}${indicator}
  • ` + }) + ) + + const listItems = lis.join('') + const totalTime = state.completedSteps.reduce((sum, step) => sum + step.durationSec, 0) + + if (state.nextStep === null) { + // Process is complete + return `${workflowDescription} - Done (${totalTime.toFixed(1)}s):
      ${listItems}
    ` + } else { + return `${workflowDescription} - Progress:
      ${listItems}
    ` + } +} + +// Initialize a new state for the step-by-step process +function initializeState(userInput: string): WriteState { + const { workflowType, cleanedInput } = parseWorkflowType(userInput) + const workflowSteps = workflowConfigs[workflowType].steps + + const firstStep = workflowSteps[0] + const remainingSteps = workflowSteps.slice(1) + + return { + step: 'start', + workflowType, + userInput: cleanedInput, + email: '', + completedSteps: [], + currentStep: firstStep, + remainingSteps, + nextStep: firstStep + } +} + +// Helper function to prepare consistent responses +export function prepareResponse(state: WriteState): ChatResponse { + // Generate progress string + const progressString = generateProgressString(state) + + // Prepare response + const isComplete = state.nextStep === null + return { + response: state.email || '', + apiAvailable: true, + stateToken: JSON.stringify(state), + progressString, + complete: isComplete, + information: state.information || state.userInput + } +} + +// Trigger background function processing +async function triggerBackgroundProcessing(jobId: string): Promise { + try { + // Call the background function endpoint + await fetch(NETLIFY_BACKGROUND_FUNCTION_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ jobId }) + }) + } catch (error) { + console.error('Error triggering background processing:', error) + // Mark job as failed if we can't trigger the background function + await JobStorage.failJob(jobId, 'Failed to trigger background processing') + } +} + +// SVELTEKIT SUBSTITUTE: Converted to Netlify Function handler +export const handler = async (event: any, context: any) => { + // SVELTEKIT SUBSTITUTE: Handle CORS for browser requests + const headers = { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS' + } + + // Handle preflight requests + if (event.httpMethod === 'OPTIONS') { + return { + statusCode: 200, + headers, + body: '' + } + } + + // Handle GET requests + if (event.httpMethod === 'GET') { + return { + statusCode: 200, + headers, + body: JSON.stringify({ apiAvailable: IS_API_AVAILABLE }) + } + } + + // Handle POST requests + if (event.httpMethod === 'POST') { + // Check if API is available + if (!IS_API_AVAILABLE) { + return { + statusCode: 200, + headers, + body: JSON.stringify({ + response: + '⚠️ This feature requires an Anthropic API key. Please add the ANTHROPIC_API_KEY_FOR_WRITE environment variable to enable email generation functionality. Contact the site administrator for help.', + apiAvailable: false + } as ChatResponse) + } + } + + try { + const pencil = '✏️' + const requestData = JSON.parse(event.body || '{}') + + // Check if this is a continuation of an existing process + let state: WriteState + let jobId: string + + if (requestData.stateToken) { + // Continue an existing process - extract jobId from stateToken or request + try { + const parsedState = JSON.parse(requestData.stateToken) as WriteState + + // If jobId is provided in the request, use it; otherwise we need to handle legacy stateTokens + if (requestData.jobId) { + jobId = requestData.jobId + // Get the latest state from storage + const jobData = await JobStorage.getJob(jobId) + if (!jobData) { + return { + statusCode: 200, + headers, + body: JSON.stringify({ + response: 'Job not found. Please try starting over.', + apiAvailable: true + } as ChatResponse) + } + } + state = jobData.writeState + } else { + // Legacy support - use the state from stateToken directly + state = parsedState + // For legacy requests, we still need to create a job for future processing + jobId = generateJobId() + await JobStorage.createJob(jobId, state) + } + + console.log( + `${pencil} write: Continuing from step ${state.step} (workflow ${state.workflowType}) - Job ID: ${jobId}` + ) + } catch (error) { + console.error('Error parsing state token:', error) + return { + statusCode: 200, + headers, + body: JSON.stringify({ + response: 'Invalid state token. Please try starting over.', + apiAvailable: true + } as ChatResponse) + } + } + } else { + // Start a new process + console.log(`${pencil} write: Starting new email generation`) + + // Get the user input from the first message + const messages = requestData + const info = messages[0]?.content + + if (!info || info === '') { + return { + statusCode: 200, + headers, + body: JSON.stringify({ + response: + 'No information provided. Please fill in some of the fields to generate an email.' + } as ChatResponse) + } + } + + // Initialize new state with workflow parsing + state = initializeState(info) + console.log( + `${pencil} write: Detected workflow type ${state.workflowType}: ${workflowConfigs[state.workflowType].description}` + ) + + // Create a new job in storage + jobId = generateJobId() + await JobStorage.createJob(jobId, state) + console.log(`${pencil} write: Created job ${jobId}`) + + // For initial calls (no stateToken), return progress string without processing + if (requestData.stateToken === undefined) { + const response = prepareResponse(state) + return { + statusCode: 200, + headers, + body: JSON.stringify({ + ...response, + jobId, + status: 'pending' + }) + } + } + } + + // For subsequent calls or continuation, trigger background processing + if (state.nextStep && state.step !== 'complete') { + // Trigger background processing (non-blocking) + triggerBackgroundProcessing(jobId) + + // Update job status to processing + await JobStorage.updateJobStatus(jobId, 'processing') + + // Return current state with job info + const response = prepareResponse(state) + return { + statusCode: 200, + headers, + body: JSON.stringify({ + ...response, + jobId, + status: 'processing' + }) + } + } else { + // Job is complete + const response = prepareResponse(state) + return { + statusCode: 200, + headers, + body: JSON.stringify({ + ...response, + jobId, + status: 'completed' + }) + } + } + } catch (err) { + console.error('Error in email generation:', err) + return { + statusCode: 500, + headers, + body: JSON.stringify({ + response: + 'An error occurred while generating your email. Please try again later or contact support.', + apiAvailable: false + } as ChatResponse) + } + } + } + + // Handle unsupported methods + return { + statusCode: 405, + headers, + body: JSON.stringify({ error: 'Method not allowed' }) + } +} diff --git a/package.json b/package.json index e5a78d2b..43666647 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "@fontsource/roboto-slab": "^5.2.6", "@fontsource/saira-condensed": "^5.2.6", "@glidejs/glide": "~3.6.2", + "@netlify/blobs": "^10.0.1", "@pagefind/default-ui": "^1.3.0", "@prgm/sveltekit-progress-bar": "^2.0.0", "@sveltejs/enhanced-img": "~0.3.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edb90b9a..90aa2310 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@glidejs/glide': specifier: ~3.6.2 version: 3.6.2 + '@netlify/blobs': + specifier: ^10.0.1 + version: 10.0.1 '@pagefind/default-ui': specifier: ^1.3.0 version: 1.3.0 @@ -235,6 +238,10 @@ packages: '@emnapi/runtime@1.4.3': resolution: {integrity: sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==} + '@envelop/instrumentation@1.0.0': + resolution: {integrity: sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw==} + engines: {node: '>=18.0.0'} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -541,6 +548,9 @@ packages: resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@fastify/busboy@3.1.1': + resolution: {integrity: sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==} + '@fontsource/roboto-slab@5.2.6': resolution: {integrity: sha512-srUROPqdczZx5OBlCKojA3C9eNeV3iIAT+nb0YLGb21ZNv58PUf5mom5T5+x6BMaaH1ZuXDi0sT1NaKWuoagYg==} @@ -752,6 +762,18 @@ packages: resolution: {integrity: sha512-IGJtuBbaGzOUgODdBRg66p8stnwj9iDXkgbYKoYcNiiQmaez5WVRfXm4b03MCDwmZyX93csbfHFWEJJYHnn5oA==} hasBin: true + '@netlify/blobs@10.0.1': + resolution: {integrity: sha512-Mbf5WkJlbR5nWA8LgA9CH+dVg7yKxoRXr1jfl1CdzEsRAVIJROPCTXGUYI5N7Q6vk/py0fVLbEie+N9d7eYVdw==} + engines: {node: ^14.16.0 || >=16.0.0} + + '@netlify/dev-utils@3.2.1': + resolution: {integrity: sha512-a96wZheD3duD20aEJXBIui73GewRIcKwsXyzyFyerrsDffQjaWFuWxU9fnVSiunl6UVrvpBjWMJRGkCv4zf2KQ==} + engines: {node: ^18.14.0 || >=20} + + '@netlify/runtime-utils@2.1.0': + resolution: {integrity: sha512-z1h+wjB7IVYUsFZsuIYyNxiw5WWuylseY+eXaUDHBxNeLTlqziy+lz03QkR67CUR4Y790xGIhaHV00aOR2KAtw==} + engines: {node: ^18.14.0 || >=20} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1150,6 +1172,26 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@whatwg-node/disposablestack@0.0.6': + resolution: {integrity: sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==} + engines: {node: '>=18.0.0'} + + '@whatwg-node/fetch@0.10.8': + resolution: {integrity: sha512-Rw9z3ctmeEj8QIB9MavkNJqekiu9usBCSMZa+uuAvM0lF3v70oQVCXNppMIqaV6OTZbdaHF1M2HLow58DEw+wg==} + engines: {node: '>=18.0.0'} + + '@whatwg-node/node-fetch@0.7.21': + resolution: {integrity: sha512-QC16IdsEyIW7kZd77aodrMO7zAoDyyqRCTLg+qG4wqtP4JV9AA+p7/lgqMdD29XyiYdVvIdFrfI9yh7B1QvRvw==} + engines: {node: '>=18.0.0'} + + '@whatwg-node/promise-helpers@1.3.2': + resolution: {integrity: sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==} + engines: {node: '>=16.0.0'} + + '@whatwg-node/server@0.10.10': + resolution: {integrity: sha512-GwpdMgUmwIp0jGjP535YtViP/nnmETAyHpGPWPZKdX++Qht/tSLbGXgFUMSsQvEACmZAR1lAPNu2CnYL1HpBgg==} + engines: {node: '>=18.0.0'} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -1201,6 +1243,10 @@ packages: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} + ansis@4.1.0: + resolution: {integrity: sha512-BGcItUBWSMRgOCe+SVZJ+S7yTRG0eGt9cXAHev72yuGcY23hnLA7Bky5L/xLyPINoSN95geovfBkqoTlNZYa7w==} + engines: {node: '>=14'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1258,6 +1304,9 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + callsite@1.0.0: + resolution: {integrity: sha512-0vdNRFXn5q+dtOqjfFtmtlI9N2eVZ7LMyEV2iKC5mEEFvSg/69Ml6b/WU2qF8W1nLRa0wiSrDT3Y5jOHZCwKPQ==} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -1370,6 +1419,9 @@ packages: supports-color: optional: true + decache@4.6.2: + resolution: {integrity: sha512-2LPqkLeu8XWHU8qNCS3kcF6sCcb5zIzvWaAHYSvPfwhdd7mHuah29NssMzrTYyHN4F5oFy2ko9OBYxegtU0FEw==} + dedent@1.5.1: resolution: {integrity: sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==} peerDependencies: @@ -1418,6 +1470,10 @@ packages: dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + dot-prop@9.0.0: + resolution: {integrity: sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==} + engines: {node: '>=18'} + dotenv@16.5.0: resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} engines: {node: '>=12'} @@ -1440,6 +1496,10 @@ packages: emoji-regex@10.4.0: resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -1616,6 +1676,10 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + find-up@7.0.0: + resolution: {integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==} + engines: {node: '>=18'} + flat-cache@3.2.0: resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} engines: {node: ^10.12.0 || >=12.0.0} @@ -1775,6 +1839,11 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + image-size@2.0.2: + resolution: {integrity: sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==} + engines: {node: '>=16.x'} + hasBin: true + imagetools-core@7.1.0: resolution: {integrity: sha512-8Aa4NecBBGmTkaAUjcuRYgTPKHCsBEWYmCnvKCL6/bxedehtVVFyZPdXe8DD0Nevd6UWBq85ifUaJ8498lgqNQ==} engines: {node: '>=18.0.0'} @@ -1851,6 +1920,12 @@ packages: resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} engines: {node: '>=16'} + jpeg-js@0.4.4: + resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} + + js-image-generator@1.0.4: + resolution: {integrity: sha512-ckb7kyVojGAnArouVR+5lBIuwU1fcrn7E/YYSd0FK7oIngAkMmRvHASLro9Zt5SQdWToaI66NybG+OGxPw/HlQ==} + js-sha256@0.11.1: resolution: {integrity: sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==} @@ -1931,6 +2006,13 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + locate-path@7.2.0: + resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -2115,10 +2197,18 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-queue@8.1.0: resolution: {integrity: sha512-mxLDbbGIBEXTJL0zEx8JIylaj3xQ7Z/7eEVjcF9fJX4DBiH9oqe+oahYnlKKxm0Ci9TlWTyhSHgygxMxjIB2jw==} engines: {node: '>=18'} @@ -2135,10 +2225,18 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-gitignore@2.0.0: + resolution: {integrity: sha512-RmVuCHWsfu0QPNW+mraxh/xjQVw/lhUCUru8Zni3Ctq3AoMhpDTq0OVdKS6iesd6Kqb7viCV3isAL43dciOSog==} + engines: {node: '>=14'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-exists@5.0.0: + resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -2579,6 +2677,10 @@ packages: undici-types@7.8.0: resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} + unicorn-magic@0.1.0: + resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} + engines: {node: '>=18'} + unified@10.1.2: resolution: {integrity: sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==} @@ -2638,6 +2740,10 @@ packages: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} hasBin: true + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + vfile-message@2.0.4: resolution: {integrity: sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==} @@ -2779,6 +2885,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + yaml@1.10.2: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} @@ -2792,6 +2902,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yocto-queue@1.2.1: + resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==} + engines: {node: '>=12.20'} + snapshots: '@ampproject/remapping@2.3.0': @@ -2816,6 +2930,11 @@ snapshots: tslib: 2.8.1 optional: true + '@envelop/instrumentation@1.0.0': + dependencies: + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -2983,6 +3102,8 @@ snapshots: '@eslint/js@8.57.1': {} + '@fastify/busboy@3.1.1': {} + '@fontsource/roboto-slab@5.2.6': {} '@fontsource/saira-condensed@5.2.6': {} @@ -3178,6 +3299,30 @@ snapshots: rw: 1.3.3 tinyqueue: 3.0.0 + '@netlify/blobs@10.0.1': + dependencies: + '@netlify/dev-utils': 3.2.1 + '@netlify/runtime-utils': 2.1.0 + + '@netlify/dev-utils@3.2.1': + dependencies: + '@whatwg-node/server': 0.10.10 + ansis: 4.1.0 + chokidar: 4.0.3 + decache: 4.6.2 + dot-prop: 9.0.0 + env-paths: 3.0.0 + find-up: 7.0.0 + image-size: 2.0.2 + js-image-generator: 1.0.4 + lodash.debounce: 4.0.8 + parse-gitignore: 2.0.0 + semver: 7.7.2 + uuid: 11.1.0 + write-file-atomic: 5.0.1 + + '@netlify/runtime-utils@2.1.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3581,6 +3726,35 @@ snapshots: loupe: 3.1.3 tinyrainbow: 1.2.0 + '@whatwg-node/disposablestack@0.0.6': + dependencies: + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + + '@whatwg-node/fetch@0.10.8': + dependencies: + '@whatwg-node/node-fetch': 0.7.21 + urlpattern-polyfill: 10.1.0 + + '@whatwg-node/node-fetch@0.7.21': + dependencies: + '@fastify/busboy': 3.1.1 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + + '@whatwg-node/promise-helpers@1.3.2': + dependencies: + tslib: 2.8.1 + + '@whatwg-node/server@0.10.10': + dependencies: + '@envelop/instrumentation': 1.0.0 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/fetch': 0.10.8 + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -3630,6 +3804,8 @@ snapshots: ansi-styles@6.2.1: {} + ansis@4.1.0: {} + argparse@2.0.1: {} aria-query@5.3.2: {} @@ -3681,6 +3857,8 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + callsite@1.0.0: {} + callsites@3.1.0: {} chai@5.2.0: @@ -3786,6 +3964,10 @@ snapshots: dependencies: ms: 2.1.3 + decache@4.6.2: + dependencies: + callsite: 1.0.0 + dedent@1.5.1: {} deep-eql@5.0.2: {} @@ -3819,6 +4001,10 @@ snapshots: no-case: 3.0.4 tslib: 2.8.1 + dot-prop@9.0.0: + dependencies: + type-fest: 4.41.0 + dotenv@16.5.0: {} dunder-proto@1.0.1: @@ -3837,6 +4023,8 @@ snapshots: emoji-regex@10.4.0: {} + env-paths@3.0.0: {} + environment@1.1.0: {} es-define-property@1.0.1: {} @@ -4086,6 +4274,12 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + find-up@7.0.0: + dependencies: + locate-path: 7.2.0 + path-exists: 5.0.0 + unicorn-magic: 0.1.0 + flat-cache@3.2.0: dependencies: flatted: 3.3.3 @@ -4234,6 +4428,8 @@ snapshots: ignore@5.3.2: {} + image-size@2.0.2: {} + imagetools-core@7.1.0: {} import-fresh@3.3.1: @@ -4286,6 +4482,12 @@ snapshots: isexe@3.1.1: {} + jpeg-js@0.4.4: {} + + js-image-generator@1.0.4: + dependencies: + jpeg-js: 0.4.4 + js-sha256@0.11.1: {} js-yaml@4.1.0: @@ -4359,6 +4561,12 @@ snapshots: dependencies: p-locate: 5.0.0 + locate-path@7.2.0: + dependencies: + p-locate: 6.0.0 + + lodash.debounce@4.0.8: {} + lodash.merge@4.6.2: {} lodash@4.17.21: {} @@ -4556,10 +4764,18 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-limit@4.0.0: + dependencies: + yocto-queue: 1.2.1 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-locate@6.0.0: + dependencies: + p-limit: 4.0.0 + p-queue@8.1.0: dependencies: eventemitter3: 5.0.1 @@ -4579,8 +4795,12 @@ snapshots: dependencies: callsites: 3.1.0 + parse-gitignore@2.0.0: {} + path-exists@4.0.0: {} + path-exists@5.0.0: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -5020,6 +5240,8 @@ snapshots: undici-types@7.8.0: {} + unicorn-magic@0.1.0: {} + unified@10.1.2: dependencies: '@types/unist': 2.0.11 @@ -5108,6 +5330,8 @@ snapshots: uuid@10.0.0: {} + uuid@11.1.0: {} + vfile-message@2.0.4: dependencies: '@types/unist': 2.0.11 @@ -5257,8 +5481,15 @@ snapshots: wrappy@1.0.2: {} + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + yaml@1.10.2: {} yaml@2.8.0: {} yocto-queue@0.1.0: {} + + yocto-queue@1.2.1: {} diff --git a/src/routes/api/write/+server.ts b/src/routes/api/write/+server.ts deleted file mode 100644 index 1fd2f9c9..00000000 --- a/src/routes/api/write/+server.ts +++ /dev/null @@ -1,522 +0,0 @@ -import { error, json } from '@sveltejs/kit' -import { env } from '$env/dynamic/private' -import Anthropic from '@anthropic-ai/sdk' - -// Safely access the API key, will be undefined if not set -const ANTHROPIC_API_KEY_FOR_WRITE = env.ANTHROPIC_API_KEY_FOR_WRITE || undefined - -// Flag to track if API is available -const IS_API_AVAILABLE = !!ANTHROPIC_API_KEY_FOR_WRITE - -// Log warning during build if API key is missing -if (!IS_API_AVAILABLE) { - console.warn( - '⚠️ ANTHROPIC_API_KEY_FOR_WRITE is not set. The /write page will operate in limited mode.' - ) -} - -// Define step types for server-side use -type StepName = 'research' | 'firstDraft' | 'firstCut' | 'firstEdit' | 'toneEdit' | 'finalEdit' - -// Server-side state management interface (not exposed to client) -interface WriteState { - step: StepName | 'complete' | 'start' // Current/completed step - userInput: string // Original input from form - email: string // Current email content - information?: string // Processed information after research - completedSteps: Array<{ - name: StepName - durationSec: number - }> - currentStep: StepName | null // Step being processed - remainingSteps: StepName[] // Steps still to do - nextStep: StepName | null // Next step to run (null if complete) -} - -// Client-facing response type -export type ChatResponse = { - response: string // Email content to display - apiAvailable?: boolean // Is API available - stateToken?: string // Opaque state token to pass to next request - progressString?: string // Human-readable progress string - complete?: boolean // Is the process complete - information?: string // Processed information for form fields -} - -export type Message = { - role: 'user' | 'assistant' | 'system' - content: string -} - -const System_Prompts: { [id: string]: string } = {} -System_Prompts['Basic'] = `You are a helpful AI assistant. - -Note: Some fields in the information may begin with a robot emoji (🤖). This indicates the field was automatically generated during research. You can use this information normally, just ignore the emoji marker.` -System_Prompts['Mail'] = ` -What follows is the anatomy of a good email, a set of guidelines and -criteria for writing a good mail. Each paragraph, except the last represents -a distinct part of the email. - -Subject line: -The subject line should be short, informative and clearly communicate -the goal of the mail. It must grab the attention and capture the -interest of the recipient. Avoid cliché language. - -Greeting: -The greeting must match the tone of the mail. If possible, address the -recipient by the appropriate title. Keep it short, and mention the reason -for the mail. Establish a strong connection with the recipient: Are they -a politician meant to represent you? Is it regarding something they've -recently done? Make the recipient feel like they owe you an answer. - -First paragraph: -Explain what the purpose of the email is. It must be concise and captivating, -most people who receive many emails learn to quickly dismiss many. Make -sure the relation is established and they have a reason to read on. - -Body paragraph: -The main body of the email should be informative and contain the information -of the mail. Take great care not to overwhelm the reader: it must be -logically structured and not too full of facts. The message should remain -clear and the relation to the greeting and first paragraph must remain clear. -It should not be too long, otherwise it might get skimmed. Links to further -information can be provided. - -Conclusion: -Keep this short and sweet. Make sure it has a CLEAR CALL TO ACTION! -Restate the reason the recipient should feel the need to act. Thank them -for their time and/or your ask. - -General: -Make sure the formatting isn't too boring. Write in a manner the recipient -would respond well to: Do not argue with them, do not mention views they -probably won't share. Try to play to things they said before and that fit -their persona. Keep the tone consistent and not too emotional. Do not sound -crazy. -` -System_Prompts['Checklist'] = ` -Checklist Before Sending -Message Verification - Is the purpose crystal clear? - Have I provided necessary context? - Is there a specific, achievable call to action? - Have I proofread for tone and clarity? -` - -System_Prompts['First_Draft'] = ` -Using the information that will be provided by the user, write the mail -according to the criteria. Get all the information into the mail. -Don't worry about it being too long. Keep the message powerful. -` - -System_Prompts['First_Cut'] = ` -You will be provided with an email by the user. -Remove redundant information and clean up the structure. The point of this pass is -to have the structure clear and the mail slightly longer than needed. The message -should be clear, the information still mostly present, with only what is -absolutely necessary being removed. -` - -System_Prompts['First_Edit'] = ` -You will be provided with an email by the user. The following points are paramount: -Make sure the flow of information is natural. All paragraphs should be -connected in a sensical manner. Remove odd, unfitting or overly emotional -language. Make sure the paragraphs fulfill their roles. -` - -System_Prompts['Tone_Edit'] = ` -You will be provided with an email by the user. The following points are paramount: -Adjust the language to match recipient's communication style. Remove potentially -offensive or dismissive language. Ensure the tone matches the relationship and -purpose. Make sure the points and information is relevant for the recipient. -Assume the recipient's position: How would they react to the mail? What information -would resonate with them? What wouldn't? Do not compromise on the message. -` - -System_Prompts['Final_Edit'] = ` -You will be provided with an email by the user. Make sure the email matches the -criteria initially described. Check spelling, grammar and tone. -` - -System_Prompts['Making_Template'] = ` -Making a template out of an email requires a good email as a base, then punching massive -holes into the email to allow for the fitting of new information, specifically in tone -and style as well as personal connection. The information should be kept, as well as the -structural flow of the email and especially between the paragraphs. Provide clearly -denoted comments on what was removed and by what it should be replaced. - -The user will provide an email for you to turn into a template using the method described before. -` - -System_Prompts['Improving_Template'] = ` -Assume the role of someone filling in the email template. How much do you have to -rewrite text to make you contributions fit? Can you keep the email brief? Are you restricted -by any word choices and sentence structures? Can you instert your own personality into the -template without too much effort? With these considerations, improve the template. - -The user will provide an email template for you to improve using the method described before. -` - -System_Prompts['Explain'] = ` -When making choices, provide a clearly labeled rationale for why you chose as you did -and what informed those decisions. -` - -System_Prompts['Results'] = ` -Only reply with the final results, a.k.a. the final email, and absolutely nothing else. -` - -System_Prompts['Research'] = ` -Please replace all mentions of 'undefined' with the apropriate information that should -go in that space, derived from the rest of the information. - -Important: For any field you fill in that was originally 'undefined' or empty, prefix -your answer with a robot emoji (🤖) to indicate it was automatically generated. - -Example: -Original: "Preferred communication style: undefined" -Your output: "Preferred communication style: 🤖 Formal but approachable" - -Output the full information, including your edits. Output nothing else. -` - -// Only initialize the client if we have an API key -const anthropic = IS_API_AVAILABLE - ? new Anthropic({ - apiKey: ANTHROPIC_API_KEY_FOR_WRITE - }) - : null - -export async function GET() { - return json({ apiAvailable: IS_API_AVAILABLE }) -} - -// Helper function to call Claude API with timing -async function callClaude( - stepName: string, - promptNames: string[], - userContent: string -): Promise<{ text: string; durationSec: number }> { - const pencil = '✏️' - const logPrefix = `${pencil} write:${stepName}` - const startTime = Date.now() - - console.time(`${logPrefix}`) - - try { - // Check if the API client is available - if (!anthropic) { - throw new Error('Anthropic API client is not initialized. API key is missing.') - } - - // Combine all the specified prompts - const systemPrompt = promptNames.map((name) => System_Prompts[name]).join('') - - const response = await anthropic.messages.create({ - model: 'claude-3-7-sonnet-20250219', - max_tokens: 4096, // Increased to handle all fields - system: systemPrompt, - messages: [{ role: 'user', content: userContent }] - }) - - // Log the request ID at debug level - console.debug(`${logPrefix} requestId: ${response.id}`) - - // Ensure the response content is text - if (response.content[0].type !== 'text') { - throw new Error(`Unexpected content type from API: ${response.content[0].type}`) - } - - const result = response.content[0].text - const elapsed = (Date.now() - startTime) / 1000 // seconds - - // Log the full response text at debug level - console.debug(`${logPrefix} full response:\n---\n${result}\n---`) - return { text: result, durationSec: elapsed } - } finally { - console.timeEnd(`${logPrefix}`) - } -} - -// Define user-friendly step descriptions -const stepDescriptions: Record = { - research: 'Auto-fill missing user inputs', - firstDraft: 'Create initial draft', - firstCut: 'Remove unnecessary content', - firstEdit: 'Improve text flow', - toneEdit: 'Adjust tone and style', - finalEdit: 'Final polish' -} - -// Function to generate a progress string from the state -function generateProgressString(state: WriteState): string { - const pencil = '✏️' - const checkmark = '✓' - - // Generate the progress string - let lis = [] - - // Completed steps - use descriptions instead of raw step names - for (const step of state.completedSteps) { - lis.push( - `
  • ${stepDescriptions[step.name]} (${step.durationSec.toFixed(1)}s) ${checkmark}
  • ` - ) - } - - // Current step - only add if not already completed and state isn't complete - if ( - state.currentStep && - state.step !== 'complete' && - !state.completedSteps.some((s) => s.name === state.currentStep) - ) { - lis.push(`
  • ${stepDescriptions[state.currentStep]} ${pencil}
  • `) - } - - // Remaining steps - filter out any that are in completed steps or current step - const completedAndCurrentSteps = [...state.completedSteps.map((s) => s.name), state.currentStep] - const filteredRemainingSteps = state.remainingSteps.filter( - (step) => !completedAndCurrentSteps.includes(step) - ) - lis = lis.concat( - filteredRemainingSteps.map((step) => `
  • ${stepDescriptions[step]}
  • `) - ) - - const listItems = lis.join('') - const totalTime = state.completedSteps.reduce((sum, step) => sum + step.durationSec, 0) - - if (state.nextStep === null) { - // Process is complete - return `Done (${totalTime.toFixed(1)}s):
      ${listItems}
    ` - } else { - return `Progress:
      ${listItems}
    ` - } -} - -// Initialize a new state for the step-by-step process -function initializeState(userInput: string): WriteState { - const allSteps: StepName[] = [ - 'research', - 'firstDraft', - 'firstCut', - 'firstEdit', - 'toneEdit', - 'finalEdit' - ] - - return { - step: 'start', - userInput, - email: '', - completedSteps: [], - currentStep: 'research', // First step - remainingSteps: allSteps.slice(1), // All steps except research (which is current) - nextStep: 'research' - } -} - -// Helper function to prepare consistent responses -function prepareResponse(state: WriteState): ChatResponse { - // Generate progress string - const progressString = generateProgressString(state) - - // Prepare response - const isComplete = state.nextStep === null - return { - response: state.email || '', - apiAvailable: true, - stateToken: JSON.stringify(state), - progressString, - complete: isComplete, - information: state.information || state.userInput - } -} - -// Define step handlers in a map for easy lookup -const stepHandlers: Record< - StepName, - (state: WriteState) => Promise<{ text: string; durationSec: number }> -> = { - research: async (state) => { - System_Prompts['Information'] = state.userInput - - const result = await callClaude( - 'research', - ['Basic', 'Mail', 'Information', 'Research'], - "Hello! Please update the list of information by replacing all instances of 'undefined' with something that belongs under their respective header based on the rest of the information provided. Thank you!" - ) - - state.information = result.text - System_Prompts['Information'] = result.text - - return result - }, - - firstDraft: async (state) => { - return await callClaude( - 'firstDraft', - ['Basic', 'Mail', 'First_Draft', 'Results'], - 'Hello! Please write an email draft using the following information. \n' + state.information - ) - }, - - firstCut: async (state) => { - return await callClaude( - 'firstCut', - ['Basic', 'Mail', 'Information', 'First_Cut', 'Results'], - 'Hello! Please cut the following email draft. \n \n' + state.email - ) - }, - - firstEdit: async (state) => { - return await callClaude( - 'firstEdit', - ['Basic', 'Mail', 'Information', 'First_Edit', 'Results'], - 'Hello! Please edit the following email draft. \n \n' + state.email - ) - }, - - toneEdit: async (state) => { - return await callClaude( - 'toneEdit', - ['Basic', 'Mail', 'Information', 'Tone_Edit', 'Results'], - 'Hello! Please edit the tone of the following email draft. \n \n' + state.email - ) - }, - - finalEdit: async (state) => { - return await callClaude( - 'finalEdit', - ['Basic', 'Mail', 'Information', 'Final_Edit', 'Checklist', 'Results'], - 'Hello! Please edit the following email draft. \n \n' + state.email - ) - } -} - -// Process a specific step -async function processStep(state: WriteState): Promise { - if (!state.nextStep) { - return { - ...state, - step: 'complete', - currentStep: null - } - } - - const currentStep = state.nextStep as StepName - state.currentStep = currentStep - - // Update remaining steps - remove current step from remaining - state.remainingSteps = state.remainingSteps.filter((step) => step !== currentStep) - - // Execute the step using the step handler from the map - const stepHandler = stepHandlers[currentStep] - if (!stepHandler) { - throw new Error(`Unknown step: ${currentStep}`) - } - - const result = await stepHandler(state) - - // Update email content (except for research step which updates information) - if (currentStep !== 'research') { - state.email = result.text - } - - // Update state with results - current step is now completed - state.step = currentStep - state.completedSteps.push({ - name: currentStep, - durationSec: result.durationSec - }) - - // Set next step - const allSteps: StepName[] = [ - 'research', - 'firstDraft', - 'firstCut', - 'firstEdit', - 'toneEdit', - 'finalEdit' - ] - const currentIndex = allSteps.indexOf(currentStep) - - if (currentIndex !== -1 && currentIndex < allSteps.length - 1) { - state.nextStep = allSteps[currentIndex + 1] - state.currentStep = allSteps[currentIndex + 1] - } else { - // Last step completed, mark as complete - state.nextStep = null - state.currentStep = null - state.step = 'complete' - } - - return state -} - -export async function POST({ fetch, request }) { - // Check if API is available - if (!IS_API_AVAILABLE) { - return json({ - response: - '⚠️ This feature requires an Anthropic API key. Please add the ANTHROPIC_API_KEY_FOR_WRITE environment variable to enable email generation functionality. Contact the site administrator for help.', - apiAvailable: false - } as ChatResponse) - } - - try { - const pencil = '✏️' - const requestData = await request.json() - - // Check if this is a continuation of an existing process - let state: WriteState - let stateToken = null - - if (requestData.stateToken) { - // Continue an existing process - try { - state = JSON.parse(requestData.stateToken) as WriteState - console.log(`${pencil} write: Continuing from step ${state.step}`) - } catch (error) { - console.error('Error parsing state token:', error) - return json({ - response: 'Invalid state token. Please try starting over.', - apiAvailable: true - } as ChatResponse) - } - } else { - // Start a new process - console.log(`${pencil} write: Starting new email generation`) - - // Get the user input from the first message - const messages = requestData - const info = messages[0]?.content - - if (!info || info === '') { - return json({ - response: - 'No information provided. Please fill in some of the fields to generate an email.' - } as ChatResponse) - } - - // Initialize new state - state = initializeState(info) - - // For initial calls (no stateToken), return progress string without processing - if (requestData.stateToken === undefined) { - return json(prepareResponse(state)) - } - } - - // For subsequent calls, process the step - state = await processStep(state) - - // Return response using helper function - return json(prepareResponse(state)) - } catch (err) { - console.error('Error in email generation:', err) - return json({ - response: - 'An error occurred while generating your email. Please try again later or contact support.', - apiAvailable: false - } as ChatResponse) - } -} diff --git a/src/routes/write/+page.svelte b/src/routes/write/+page.svelte index a864d3f5..4d6c8509 100644 --- a/src/routes/write/+page.svelte +++ b/src/routes/write/+page.svelte @@ -1,7 +1,18 @@