From 107aba5efafa6e2a4d394b2aa5e2121733810f80 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Mon, 27 Apr 2026 12:26:32 +0300 Subject: [PATCH 1/2] dev: add DEV_UNLIMITED mock worker and UI toggle for local dev proxy --- leanring-buddy/CompanionManager.swift | 31 +++++++- leanring-buddy/CompanionPanelView.swift | 100 ++++++++++++++++++++++++ worker/DEV.md | 45 +++++++++++ worker/dev_server.js | 38 +++++++++ worker/src/index.ts | 40 ++++++++++ 5 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 worker/DEV.md create mode 100644 worker/dev_server.js diff --git a/leanring-buddy/CompanionManager.swift b/leanring-buddy/CompanionManager.swift index 0234cf19..b294e6b4 100644 --- a/leanring-buddy/CompanionManager.swift +++ b/leanring-buddy/CompanionManager.swift @@ -70,7 +70,16 @@ final class CompanionManager: ObservableObject { /// Base URL for the Cloudflare Worker proxy. All API requests route /// through this so keys never ship in the app binary. - private static let workerBaseURL = "https://your-worker-name.your-subdomain.workers.dev" + /// + /// For local development you can override this by setting the + /// `devWorkerBaseURL` UserDefaults string (e.g. via `defaults write`). + /// Example: `defaults write com.your.bundle.identifier devWorkerBaseURL "http://127.0.0.1:8787"` + private static var workerBaseURL: String { + if let dev = UserDefaults.standard.string(forKey: "devWorkerBaseURL"), !dev.isEmpty { + return dev + } + return "https://your-worker-name.your-subdomain.workers.dev" + } private lazy var claudeAPI: ClaudeAPI = { return ClaudeAPI(proxyURL: "\(Self.workerBaseURL)/chat", model: selectedModel) @@ -110,6 +119,26 @@ final class CompanionManager: ObservableObject { /// The Claude model used for voice responses. Persisted to UserDefaults. @Published var selectedModel: String = UserDefaults.standard.string(forKey: "selectedClaudeModel") ?? "claude-sonnet-4-6" + /// Optional developer override for the worker base URL used for API proxying. + /// Stored in UserDefaults under `devWorkerBaseURL` so it persists across runs. + @Published var devWorkerBaseURLText: String = UserDefaults.standard.string(forKey: "devWorkerBaseURL") ?? "" + + func setDevWorkerBaseURL(_ url: String) { + let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + UserDefaults.standard.removeObject(forKey: "devWorkerBaseURL") + } else { + UserDefaults.standard.set(trimmed, forKey: "devWorkerBaseURL") + } + devWorkerBaseURLText = trimmed + + // Recreate API clients so they pick up the new proxy URL immediately. + claudeAPI = ClaudeAPI(proxyURL: "\(Self.workerBaseURL)/chat", model: selectedModel) + elevenLabsTTSClient = ElevenLabsTTSClient(proxyURL: "\(Self.workerBaseURL)/tts") + // Trigger TLS warmup on new client instance + _ = claudeAPI + } + func setSelectedModel(_ model: String) { selectedModel = model UserDefaults.standard.set(model, forKey: "selectedClaudeModel") diff --git a/leanring-buddy/CompanionPanelView.swift b/leanring-buddy/CompanionPanelView.swift index 76789b4c..65d71d86 100644 --- a/leanring-buddy/CompanionPanelView.swift +++ b/leanring-buddy/CompanionPanelView.swift @@ -13,6 +13,8 @@ import SwiftUI struct CompanionPanelView: View { @ObservedObject var companionManager: CompanionManager @State private var emailInput: String = "" + @State private var devURLInput: String = UserDefaults.standard.string(forKey: "devWorkerBaseURL") ?? "" + @State private var devEnabled: Bool = !(UserDefaults.standard.string(forKey: "devWorkerBaseURL") ?? "").isEmpty var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -31,6 +33,12 @@ struct CompanionPanelView: View { modelPickerRow .padding(.horizontal, 16) + + Spacer() + .frame(height: 12) + + devProxyRow + .padding(.horizontal, 16) } if !companionManager.allPermissionsGranted { @@ -574,6 +582,98 @@ struct CompanionPanelView: View { .padding(.vertical, 4) } + private var devProxyRow: some View { + VStack(spacing: 6) { + Text("DEVELOPER") + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .foregroundColor(DS.Colors.textTertiary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 4) + + HStack { + HStack(spacing: 8) { + Image(systemName: "wrench.and.screwdriver") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(DS.Colors.textTertiary) + .frame(width: 16) + + Text("Dev proxy") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(DS.Colors.textSecondary) + } + + Spacer() + + Toggle("", isOn: Binding(get: { devEnabled }, set: { newVal in + devEnabled = newVal + if newVal { + if devURLInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + devURLInput = "http://127.0.0.1:8787" + } + companionManager.setDevWorkerBaseURL(devURLInput) + } else { + devURLInput = "" + companionManager.setDevWorkerBaseURL("") + } + })) + .toggleStyle(.switch) + .labelsHidden() + .tint(DS.Colors.accent) + .scaleEffect(0.8) + } + + if devEnabled { + HStack(spacing: 8) { + TextField("Dev worker URL", text: $devURLInput) + .textFieldStyle(.plain) + .font(.system(size: 12)) + .foregroundColor(DS.Colors.textPrimary) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: DS.CornerRadius.medium, style: .continuous) + .fill(Color.white.opacity(0.03)) + ) + + Button(action: { + companionManager.setDevWorkerBaseURL(devURLInput) + }) { + Text("Save") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(DS.Colors.textOnAccent) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + Capsule() + .fill(DS.Colors.accent) + ) + } + .buttonStyle(.plain) + .pointerCursor() + + Button(action: { + devURLInput = "" + devEnabled = false + companionManager.setDevWorkerBaseURL("") + }) { + Text("Reset") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(DS.Colors.textSecondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + Capsule() + .stroke(DS.Colors.borderSubtle, lineWidth: 0.8) + ) + } + .buttonStyle(.plain) + .pointerCursor() + } + } + } + .padding(.vertical, 4) + } + private var speechToTextProviderRow: some View { HStack { HStack(spacing: 8) { diff --git a/worker/DEV.md b/worker/DEV.md new file mode 100644 index 00000000..9c815f16 --- /dev/null +++ b/worker/DEV.md @@ -0,0 +1,45 @@ +Dev mode: unlimited (local testing only) +===================================== + +This worker supports a development mock mode that returns fake streaming +responses for `/chat`. This is useful for local testing and for trying the +app without consuming real Anthropic / ElevenLabs credits. + +How to enable +-------------- + +- Locally with `wrangler dev` (example): + +```bash +cd worker +DEV_UNLIMITED=true npx wrangler dev +``` + +- Or set the `DEV_UNLIMITED` variable in your Cloudflare Worker environment. + +Client (macOS app) configuration +-------------------------------- + +The macOS app can be pointed at a local dev worker by setting a UserDefaults +string `devWorkerBaseURL`. For example, to use `http://127.0.0.1:8787` run: + +```bash +defaults write com.your.bundle.identifier devWorkerBaseURL "http://127.0.0.1:8787" +``` + +Then start the app — it will pick up the override and send `/chat` requests to +the local worker. + +What it does +------------ + +- When `DEV_UNLIMITED=true`, the `/chat` route returns a small Server-Sent + Events (SSE) stream that mimics Anthropic `content_block_delta` text chunks. +- This lets the macOS app render progressive responses without calling the + real Anthropic API. + +Security & Ethics +----------------- + +This mode is only intended for local development and testing. Do not use it to +bypass paid subscriptions, nor enable it in production or shared deployments. diff --git a/worker/dev_server.js b/worker/dev_server.js new file mode 100644 index 00000000..0caa9e8d --- /dev/null +++ b/worker/dev_server.js @@ -0,0 +1,38 @@ +const http = require('http'); +const port = process.env.PORT || 8787; + +const server = http.createServer((req, res) => { + if (req.method === 'POST' && req.url === '/chat') { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + + const chunks = [ + JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: 'Hello from Clicky (dev mode).' } }), + JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: ' This is a mock unlimited-response stream.' } }), + ]; + + let i = 0; + function pushNext() { + if (i >= chunks.length) { + res.write('data: [DONE]\n\n'); + res.end(); + return; + } + res.write('data: ' + chunks[i] + '\n\n'); + i++; + setTimeout(pushNext, 120); + } + + pushNext(); + } else { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not found'); + } +}); + +server.listen(port, () => { + console.log(`Dev SSE server listening on http://127.0.0.1:${port}`); +}); diff --git a/worker/src/index.ts b/worker/src/index.ts index 2e3e9345..b434bfe7 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -14,6 +14,9 @@ interface Env { ELEVENLABS_API_KEY: string; ELEVENLABS_VOICE_ID: string; ASSEMBLYAI_API_KEY: string; + // When set to "true", the worker returns mock responses for /chat + // (useful for local development / unlimited interactions testing). + DEV_UNLIMITED?: string; } export default { @@ -51,6 +54,43 @@ export default { async function handleChat(request: Request, env: Env): Promise { const body = await request.text(); + // Dev/mock mode: if DEV_UNLIMITED is enabled, return a fake + // server-sent-events (SSE) stream that mimics Anthropic's + // content_block_delta events. This allows local testing without + // consuming real API credits. + if (env.DEV_UNLIMITED === "true") { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + const chunks = [ + JSON.stringify({ type: "content_block_delta", delta: { type: "text_delta", text: "Hello from Clicky (dev mode)." } }), + JSON.stringify({ type: "content_block_delta", delta: { type: "text_delta", text: " This is a mock unlimited-response stream." } }), + ]; + let i = 0; + function pushNext() { + if (i >= chunks.length) { + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + return; + } + controller.enqueue(encoder.encode(`data: ${chunks[i]}\n\n`)); + i++; + // Small stagger so clients can render progressively + setTimeout(pushNext, 120); + } + pushNext(); + } + }); + + return new Response(stream, { + status: 200, + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache", + }, + }); + } + const response = await fetch("https://api.anthropic.com/v1/messages", { method: "POST", headers: { From 1025372c70dca6e53bf3ca0b236ca0f78a217c8d Mon Sep 17 00:00:00 2001 From: Dmitry Date: Mon, 27 Apr 2026 12:37:54 +0300 Subject: [PATCH 2/2] dev: local unlimited mock for Claude + local TTS + UI toggle --- leanring-buddy/ClaudeAPI.swift | 27 ++++++++++++++++++++++++ leanring-buddy/CompanionManager.swift | 14 ++++++++++++ leanring-buddy/CompanionPanelView.swift | 26 +++++++++++++++++++++++ leanring-buddy/ElevenLabsTTSClient.swift | 10 +++++++++ 4 files changed, 77 insertions(+) diff --git a/leanring-buddy/ClaudeAPI.swift b/leanring-buddy/ClaudeAPI.swift index 0c7070b5..3d1cc04e 100644 --- a/leanring-buddy/ClaudeAPI.swift +++ b/leanring-buddy/ClaudeAPI.swift @@ -107,6 +107,26 @@ class ClaudeAPI { ) async throws -> (text: String, duration: TimeInterval) { let startTime = Date() + // Dev/local mock: return a synthesized progressive response when + // `devUnlimitedMode` is enabled in UserDefaults. This lets the app + // run without calling the remote Claude API for unlimited testing. + if UserDefaults.standard.bool(forKey: "devUnlimitedMode") { + var accumulatedResponseText = "" + let chunks = [ + "Hello from Clicky (local unlimited).", + " This is a mock response for development.", + " Interact as much as you like." + ] + for chunk in chunks { + accumulatedResponseText += chunk + await onTextChunk(accumulatedResponseText) + try? await Task.sleep(nanoseconds: 150_000_000) + guard !Task.isCancelled else { break } + } + let duration = Date().timeIntervalSince(startTime) + return (text: accumulatedResponseText, duration: duration) + } + var request = makeAPIRequest() // Build messages array @@ -220,6 +240,13 @@ class ClaudeAPI { ) async throws -> (text: String, duration: TimeInterval) { let startTime = Date() + // Dev/local mock for non-streaming requests. + if UserDefaults.standard.bool(forKey: "devUnlimitedMode") { + let text = "Hello from Clicky (local unlimited). This is a mock non-streaming response." + let duration = Date().timeIntervalSince(startTime) + return (text: text, duration: duration) + } + var request = makeAPIRequest() var messages: [[String: Any]] = [] diff --git a/leanring-buddy/CompanionManager.swift b/leanring-buddy/CompanionManager.swift index b294e6b4..c9d55056 100644 --- a/leanring-buddy/CompanionManager.swift +++ b/leanring-buddy/CompanionManager.swift @@ -123,6 +123,20 @@ final class CompanionManager: ObservableObject { /// Stored in UserDefaults under `devWorkerBaseURL` so it persists across runs. @Published var devWorkerBaseURLText: String = UserDefaults.standard.string(forKey: "devWorkerBaseURL") ?? "" + /// Whether the app should run in a local 'unlimited interactions' dev mode. + /// Persisted in UserDefaults under `devUnlimitedMode`. + @Published var devUnlimitedMode: Bool = UserDefaults.standard.object(forKey: "devUnlimitedMode") == nil ? true : UserDefaults.standard.bool(forKey: "devUnlimitedMode") + + func setDevUnlimitedMode(_ enabled: Bool) { + UserDefaults.standard.set(enabled, forKey: "devUnlimitedMode") + devUnlimitedMode = enabled + + // Recreate API clients so they pick up the new behavior immediately. + claudeAPI = ClaudeAPI(proxyURL: "\(Self.workerBaseURL)/chat", model: selectedModel) + elevenLabsTTSClient = ElevenLabsTTSClient(proxyURL: "\(Self.workerBaseURL)/tts") + _ = claudeAPI + } + func setDevWorkerBaseURL(_ url: String) { let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { diff --git a/leanring-buddy/CompanionPanelView.swift b/leanring-buddy/CompanionPanelView.swift index 65d71d86..e2b8c400 100644 --- a/leanring-buddy/CompanionPanelView.swift +++ b/leanring-buddy/CompanionPanelView.swift @@ -15,6 +15,7 @@ struct CompanionPanelView: View { @State private var emailInput: String = "" @State private var devURLInput: String = UserDefaults.standard.string(forKey: "devWorkerBaseURL") ?? "" @State private var devEnabled: Bool = !(UserDefaults.standard.string(forKey: "devWorkerBaseURL") ?? "").isEmpty + @State private var devUnlimitedEnabled: Bool = UserDefaults.standard.object(forKey: "devUnlimitedMode") == nil ? true : UserDefaults.standard.bool(forKey: "devUnlimitedMode") var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -622,6 +623,31 @@ struct CompanionPanelView: View { .scaleEffect(0.8) } + // Unlimited interactions toggle + HStack { + HStack(spacing: 8) { + Image(systemName: "infinity") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(DS.Colors.textTertiary) + .frame(width: 16) + + Text("Unlimited interactions") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(DS.Colors.textSecondary) + } + + Spacer() + + Toggle("", isOn: Binding(get: { devUnlimitedEnabled }, set: { newVal in + devUnlimitedEnabled = newVal + companionManager.setDevUnlimitedMode(newVal) + })) + .toggleStyle(.switch) + .labelsHidden() + .tint(DS.Colors.accent) + .scaleEffect(0.8) + } + if devEnabled { HStack(spacing: 8) { TextField("Dev worker URL", text: $devURLInput) diff --git a/leanring-buddy/ElevenLabsTTSClient.swift b/leanring-buddy/ElevenLabsTTSClient.swift index 35545c9d..a31f8146 100644 --- a/leanring-buddy/ElevenLabsTTSClient.swift +++ b/leanring-buddy/ElevenLabsTTSClient.swift @@ -9,6 +9,7 @@ import AVFoundation import Foundation +import AppKit @MainActor final class ElevenLabsTTSClient { @@ -31,6 +32,15 @@ final class ElevenLabsTTSClient { /// Sends `text` to ElevenLabs TTS and plays the resulting audio. /// Throws on network or decoding errors. Cancellation-safe. func speakText(_ text: String) async throws { + // If dev unlimited mode is enabled, use the system TTS to avoid + // calling external ElevenLabs APIs (keeps interactions local). + if UserDefaults.standard.bool(forKey: "devUnlimitedMode") { + let synthesizer = NSSpeechSynthesizer() + synthesizer.startSpeaking(text) + // Clear any existing audio player reference — we're using system TTS. + audioPlayer = nil + return + } var request = URLRequest(url: proxyURL) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type")