diff --git a/leanring-buddy/CompanionManager.swift b/leanring-buddy/CompanionManager.swift index 0234cf19..75e7c4ca 100644 --- a/leanring-buddy/CompanionManager.swift +++ b/leanring-buddy/CompanionManager.swift @@ -23,6 +23,10 @@ enum CompanionVoiceState { @MainActor final class CompanionManager: ObservableObject { + /// Maximum number of conversation exchanges persisted to disk. + /// Oldest exchanges are dropped when this limit is exceeded. + static let maximumStoredConversationMessageCount = 50 + @Published private(set) var voiceState: CompanionVoiceState = .idle @Published private(set) var lastTranscript: String? @Published private(set) var currentAudioPowerLevel: CGFloat = 0 @@ -80,9 +84,16 @@ final class CompanionManager: ObservableObject { return ElevenLabsTTSClient(proxyURL: "\(Self.workerBaseURL)/tts") }() - /// Conversation history so Claude remembers prior exchanges within a session. - /// Each entry is the user's transcript and Claude's response. - private var conversationHistory: [(userTranscript: String, assistantResponse: String)] = [] + /// A single user–assistant exchange, stored as a Codable struct so + /// conversation history can be serialized to JSON on disk. + struct ConversationExchange: Codable { + let userTranscript: String + let assistantResponse: String + } + + /// Conversation history so Claude remembers prior exchanges across sessions. + /// Persisted to Application Support/Clicky/conversation_history.json. + private var conversationHistory: [ConversationExchange] = [] /// The currently running AI response task, if any. Cancelled when the user /// speaks again so a new response can begin immediately. @@ -173,6 +184,7 @@ final class CompanionManager: ObservableObject { } func start() { + loadConversationHistoryFromDisk() refreshAllPermissions() print("🔑 Clicky start — accessibility: \(hasAccessibilityPermission), screen: \(hasScreenRecordingPermission), mic: \(hasMicrophonePermission), screenContent: \(hasScreenContentPermission), onboarded: \(hasCompletedOnboarding)") startPermissionPolling() @@ -606,8 +618,8 @@ final class CompanionManager: ObservableObject { } // Pass conversation history so Claude remembers prior exchanges - let historyForAPI = conversationHistory.map { entry in - (userPlaceholder: entry.userTranscript, assistantResponse: entry.assistantResponse) + let historyForAPI = conversationHistory.map { exchange in + (userPlaceholder: exchange.userTranscript, assistantResponse: exchange.assistantResponse) } let (fullResponseText, _) = try await claudeAPI.analyzeImageStreaming( @@ -683,16 +695,17 @@ final class CompanionManager: ObservableObject { // Save this exchange to conversation history (with the point tag // stripped so it doesn't confuse future context) - conversationHistory.append(( + conversationHistory.append(ConversationExchange( userTranscript: transcript, assistantResponse: spokenText )) - // Keep only the last 10 exchanges to avoid unbounded context growth - if conversationHistory.count > 10 { - conversationHistory.removeFirst(conversationHistory.count - 10) + // Drop oldest exchanges when the stored limit is exceeded + if conversationHistory.count > Self.maximumStoredConversationMessageCount { + conversationHistory.removeFirst(conversationHistory.count - Self.maximumStoredConversationMessageCount) } + saveConversationHistoryToDisk() print("🧠 Conversation history: \(conversationHistory.count) exchanges") ClickyAnalytics.trackAIResponseReceived(response: spokenText) @@ -765,6 +778,73 @@ final class CompanionManager: ObservableObject { voiceState = .responding } + // MARK: - Conversation History Persistence + + /// Returns the URL for the conversation history JSON file inside + /// ~/Library/Application Support/Clicky/. Creates the directory + /// if it doesn't exist yet. + private static var conversationHistoryFileURL: URL? { + guard let applicationSupportDirectory = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first else { + return nil + } + let clickyApplicationSupportDirectory = applicationSupportDirectory.appendingPathComponent("Clicky") + // Ensure the Clicky directory exists so writes don't fail + try? FileManager.default.createDirectory( + at: clickyApplicationSupportDirectory, + withIntermediateDirectories: true + ) + return clickyApplicationSupportDirectory.appendingPathComponent("conversation_history.json") + } + + /// Writes the current conversation history to disk as JSON. + /// Called after each new exchange is appended so history survives app restarts. + private func saveConversationHistoryToDisk() { + guard let fileURL = Self.conversationHistoryFileURL else { + print("⚠️ Clicky: Could not resolve Application Support path for conversation history") + return + } + do { + let encodedData = try JSONEncoder().encode(conversationHistory) + try encodedData.write(to: fileURL, options: .atomic) + } catch { + print("⚠️ Clicky: Failed to save conversation history: \(error)") + } + } + + /// Reads conversation history from the JSON file on disk. + /// Called once during start() so prior exchanges are available immediately. + private func loadConversationHistoryFromDisk() { + guard let fileURL = Self.conversationHistoryFileURL, + FileManager.default.fileExists(atPath: fileURL.path) else { + return + } + do { + let savedData = try Data(contentsOf: fileURL) + let decodedExchanges = try JSONDecoder().decode([ConversationExchange].self, from: savedData) + // Apply the stored limit in case it was lowered since the last save + if decodedExchanges.count > Self.maximumStoredConversationMessageCount { + conversationHistory = Array(decodedExchanges.suffix(Self.maximumStoredConversationMessageCount)) + } else { + conversationHistory = decodedExchanges + } + print("🧠 Loaded \(conversationHistory.count) conversation exchanges from disk") + } catch { + print("⚠️ Clicky: Failed to load conversation history: \(error)") + } + } + + /// Clears all conversation history from memory and deletes the on-disk + /// JSON file. Called from the "Clear History" button in the panel. + func clearConversationHistory() { + conversationHistory.removeAll() + guard let fileURL = Self.conversationHistoryFileURL else { return } + try? FileManager.default.removeItem(at: fileURL) + print("🧠 Conversation history cleared") + } + // MARK: - Point Tag Parsing /// Result of parsing a [POINT:...] tag from Claude's response. diff --git a/leanring-buddy/CompanionPanelView.swift b/leanring-buddy/CompanionPanelView.swift index 76789b4c..57eea278 100644 --- a/leanring-buddy/CompanionPanelView.swift +++ b/leanring-buddy/CompanionPanelView.swift @@ -64,6 +64,12 @@ struct CompanionPanelView: View { dmFarzaButton .padding(.horizontal, 16) + + Spacer() + .frame(height: 8) + + clearConversationHistoryButton + .padding(.horizontal, 16) } Spacer() @@ -678,6 +684,36 @@ struct CompanionPanelView: View { .pointerCursor() } + // MARK: - Clear Conversation History + + private var clearConversationHistoryButton: some View { + Button(action: { + companionManager.clearConversationHistory() + }) { + HStack(spacing: 8) { + Image(systemName: "trash") + .font(.system(size: 12, weight: .medium)) + + Text("Clear History") + .font(.system(size: 12, weight: .semibold)) + } + .foregroundColor(DS.Colors.textTertiary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: DS.CornerRadius.medium, style: .continuous) + .fill(Color.white.opacity(0.06)) + ) + .overlay( + RoundedRectangle(cornerRadius: DS.CornerRadius.medium, style: .continuous) + .stroke(DS.Colors.borderSubtle, lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + .pointerCursor() + } + // MARK: - Footer private var footerSection: some View { diff --git a/worker/src/index.ts b/worker/src/index.ts index 2e3e9345..ca3bda87 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -5,8 +5,14 @@ * ships with raw API keys. Keys are stored as Cloudflare secrets. * * Routes: - * POST /chat → Anthropic Messages API (streaming) - * POST /tts → ElevenLabs TTS API + * POST /chat → Anthropic Messages API (streaming) + * POST /tts → ElevenLabs TTS API + * POST /transcribe-token → AssemblyAI temporary websocket token + * + * Hardening: + * - Per-IP sliding-window rate limiting on /chat and /tts + * - Structured JSON error responses with error codes + * - Request logging (method, route, IP, status) for wrangler tail */ interface Env { @@ -16,42 +22,203 @@ interface Env { ASSEMBLYAI_API_KEY: string; } +// --- Structured error codes returned in all error responses --- +type ErrorCode = "RATE_LIMITED" | "UPSTREAM_ERROR" | "BAD_REQUEST" | "INTERNAL_ERROR"; + +interface StructuredErrorBody { + error: string; + code: ErrorCode; +} + +const JSON_CONTENT_TYPE_HEADER = { "content-type": "application/json" }; + +/** + * Build a structured JSON error Response. + * Every error path in the worker funnels through here so the client always + * receives a consistent `{ error, code }` shape. + */ +function buildStructuredErrorResponse( + httpStatus: number, + errorMessage: string, + errorCode: ErrorCode, +): Response { + const body: StructuredErrorBody = { error: errorMessage, code: errorCode }; + return new Response(JSON.stringify(body), { + status: httpStatus, + headers: JSON_CONTENT_TYPE_HEADER, + }); +} + +// --- Per-IP sliding-window rate limiter --- + +/** + * Each entry stores the timestamps (in ms) of recent requests from a single IP + * for a single route. Timestamps older than the window are pruned on every check. + * + * We use an in-memory Map because Cloudflare Workers keep the module-level + * scope alive across requests on the same isolate. This gives us lightweight, + * per-isolate rate limiting without external storage. The trade-off is that + * limits reset when the isolate is evicted, but that is acceptable for + * abuse-prevention (not billing-grade) rate limiting. + */ +interface SlidingWindowEntry { + requestTimestamps: number[]; +} + +interface RateLimitConfiguration { + maxRequestsPerWindow: number; + windowDurationMilliseconds: number; +} + +// Separate rate-limit buckets per route so /chat and /tts limits are independent. +const rateLimitBucketsByRoute = new Map>(); + +const RATE_LIMIT_CONFIG_BY_ROUTE: Record = { + "/chat": { + maxRequestsPerWindow: 20, + // 60 seconds (1 minute) + windowDurationMilliseconds: 60_000, + }, + "/tts": { + maxRequestsPerWindow: 30, + windowDurationMilliseconds: 60_000, + }, +}; + +/** + * Returns `true` if the request should be allowed, `false` if the IP has + * exceeded the rate limit for the given route. + * + * Side-effect: records the current timestamp into the sliding window when the + * request is allowed. + */ +function isRequestAllowedByRateLimit(clientIpAddress: string, routePath: string): boolean { + const routeConfiguration = RATE_LIMIT_CONFIG_BY_ROUTE[routePath]; + if (!routeConfiguration) { + // No rate-limit configured for this route — always allow. + return true; + } + + // Lazily create the per-route bucket map. + if (!rateLimitBucketsByRoute.has(routePath)) { + rateLimitBucketsByRoute.set(routePath, new Map()); + } + const routeBuckets = rateLimitBucketsByRoute.get(routePath)!; + + const currentTimestamp = Date.now(); + const windowStartTimestamp = currentTimestamp - routeConfiguration.windowDurationMilliseconds; + + // Lazily create the entry for this IP. + if (!routeBuckets.has(clientIpAddress)) { + routeBuckets.set(clientIpAddress, { requestTimestamps: [] }); + } + const ipEntry = routeBuckets.get(clientIpAddress)!; + + // Prune timestamps that have fallen outside the sliding window. + ipEntry.requestTimestamps = ipEntry.requestTimestamps.filter( + (timestamp) => timestamp > windowStartTimestamp, + ); + + if (ipEntry.requestTimestamps.length >= routeConfiguration.maxRequestsPerWindow) { + return false; + } + + // Record this request's timestamp so it counts toward the window. + ipEntry.requestTimestamps.push(currentTimestamp); + return true; +} + +// --- Request logging --- + +/** + * Logs a single-line summary of each request for observability via `wrangler tail`. + * Format: "[POST /chat] ip=203.0.113.42 → 200" + */ +function logRequestSummary( + httpMethod: string, + routePath: string, + clientIpAddress: string, + responseStatus: number, +): void { + console.log( + `[${httpMethod} ${routePath}] ip=${clientIpAddress} → ${responseStatus}`, + ); +} + +// --- Main fetch handler --- + export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); + const routePath = url.pathname; + const clientIpAddress = request.headers.get("CF-Connecting-IP") ?? "unknown"; + const httpMethod = request.method; - if (request.method !== "POST") { - return new Response("Method not allowed", { status: 405 }); + // --- Method check --- + if (httpMethod !== "POST") { + const response = buildStructuredErrorResponse( + 405, + "Method not allowed. Only POST requests are accepted.", + "BAD_REQUEST", + ); + logRequestSummary(httpMethod, routePath, clientIpAddress, 405); + return response; } + // --- Rate limiting (applied before any upstream work) --- + if (!isRequestAllowedByRateLimit(clientIpAddress, routePath)) { + const rateLimitConfig = RATE_LIMIT_CONFIG_BY_ROUTE[routePath]; + const windowSeconds = rateLimitConfig + ? rateLimitConfig.windowDurationMilliseconds / 1000 + : 60; + const response = buildStructuredErrorResponse( + 429, + `Rate limit exceeded. Try again in ${windowSeconds} seconds.`, + "RATE_LIMITED", + ); + logRequestSummary(httpMethod, routePath, clientIpAddress, 429); + return response; + } + + // --- Route dispatch --- try { - if (url.pathname === "/chat") { - return await handleChat(request, env); - } + let response: Response; - if (url.pathname === "/tts") { - return await handleTTS(request, env); + if (routePath === "/chat") { + response = await handleChat(request, env); + } else if (routePath === "/tts") { + response = await handleTTS(request, env); + } else if (routePath === "/transcribe-token") { + response = await handleTranscribeToken(env); + } else { + response = buildStructuredErrorResponse( + 404, + `Route not found: ${routePath}`, + "BAD_REQUEST", + ); } - if (url.pathname === "/transcribe-token") { - return await handleTranscribeToken(env); - } + logRequestSummary(httpMethod, routePath, clientIpAddress, response.status); + return response; } catch (error) { - console.error(`[${url.pathname}] Unhandled error:`, error); - return new Response( - JSON.stringify({ error: String(error) }), - { status: 500, headers: { "content-type": "application/json" } } + console.error(`[${routePath}] Unhandled error:`, error); + const response = buildStructuredErrorResponse( + 500, + "An internal error occurred. Please try again later.", + "INTERNAL_ERROR", ); + logRequestSummary(httpMethod, routePath, clientIpAddress, 500); + return response; } - - return new Response("Not found", { status: 404 }); }, }; +// --- Route handlers --- + async function handleChat(request: Request, env: Env): Promise { const body = await request.text(); - const response = await fetch("https://api.anthropic.com/v1/messages", { + const anthropicResponse = await fetch("https://api.anthropic.com/v1/messages", { method: "POST", headers: { "x-api-key": env.ANTHROPIC_API_KEY, @@ -61,48 +228,55 @@ async function handleChat(request: Request, env: Env): Promise { body, }); - if (!response.ok) { - const errorBody = await response.text(); - console.error(`[/chat] Anthropic API error ${response.status}: ${errorBody}`); - return new Response(errorBody, { - status: response.status, - headers: { "content-type": "application/json" }, - }); + if (!anthropicResponse.ok) { + const upstreamErrorBody = await anthropicResponse.text(); + console.error( + `[/chat] Anthropic API error ${anthropicResponse.status}: ${upstreamErrorBody}`, + ); + return buildStructuredErrorResponse( + anthropicResponse.status, + `Anthropic API error: ${upstreamErrorBody}`, + "UPSTREAM_ERROR", + ); } - return new Response(response.body, { - status: response.status, + return new Response(anthropicResponse.body, { + status: anthropicResponse.status, headers: { - "content-type": response.headers.get("content-type") || "text/event-stream", + "content-type": + anthropicResponse.headers.get("content-type") || "text/event-stream", "cache-control": "no-cache", }, }); } async function handleTranscribeToken(env: Env): Promise { - const response = await fetch( + const assemblyAiResponse = await fetch( "https://streaming.assemblyai.com/v3/token?expires_in_seconds=480", { method: "GET", headers: { authorization: env.ASSEMBLYAI_API_KEY, }, - } + }, ); - if (!response.ok) { - const errorBody = await response.text(); - console.error(`[/transcribe-token] AssemblyAI token error ${response.status}: ${errorBody}`); - return new Response(errorBody, { - status: response.status, - headers: { "content-type": "application/json" }, - }); + if (!assemblyAiResponse.ok) { + const upstreamErrorBody = await assemblyAiResponse.text(); + console.error( + `[/transcribe-token] AssemblyAI token error ${assemblyAiResponse.status}: ${upstreamErrorBody}`, + ); + return buildStructuredErrorResponse( + assemblyAiResponse.status, + `AssemblyAI token error: ${upstreamErrorBody}`, + "UPSTREAM_ERROR", + ); } - const data = await response.text(); - return new Response(data, { + const tokenResponseData = await assemblyAiResponse.text(); + return new Response(tokenResponseData, { status: 200, - headers: { "content-type": "application/json" }, + headers: JSON_CONTENT_TYPE_HEADER, }); } @@ -110,7 +284,7 @@ async function handleTTS(request: Request, env: Env): Promise { const body = await request.text(); const voiceId = env.ELEVENLABS_VOICE_ID; - const response = await fetch( + const elevenLabsResponse = await fetch( `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`, { method: "POST", @@ -120,22 +294,26 @@ async function handleTTS(request: Request, env: Env): Promise { accept: "audio/mpeg", }, body, - } + }, ); - if (!response.ok) { - const errorBody = await response.text(); - console.error(`[/tts] ElevenLabs API error ${response.status}: ${errorBody}`); - return new Response(errorBody, { - status: response.status, - headers: { "content-type": "application/json" }, - }); + if (!elevenLabsResponse.ok) { + const upstreamErrorBody = await elevenLabsResponse.text(); + console.error( + `[/tts] ElevenLabs API error ${elevenLabsResponse.status}: ${upstreamErrorBody}`, + ); + return buildStructuredErrorResponse( + elevenLabsResponse.status, + `ElevenLabs API error: ${upstreamErrorBody}`, + "UPSTREAM_ERROR", + ); } - return new Response(response.body, { - status: response.status, + return new Response(elevenLabsResponse.body, { + status: elevenLabsResponse.status, headers: { - "content-type": response.headers.get("content-type") || "audio/mpeg", + "content-type": + elevenLabsResponse.headers.get("content-type") || "audio/mpeg", }, }); }