Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions leanring-buddy/ClaudeAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]] = []
Expand Down
45 changes: 44 additions & 1 deletion leanring-buddy/CompanionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -110,6 +119,40 @@ 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") ?? ""

/// 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 {
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")
Expand Down
126 changes: 126 additions & 0 deletions leanring-buddy/CompanionPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ 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
@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) {
Expand All @@ -31,6 +34,12 @@ struct CompanionPanelView: View {

modelPickerRow
.padding(.horizontal, 16)

Spacer()
.frame(height: 12)

devProxyRow
.padding(.horizontal, 16)
}

if !companionManager.allPermissionsGranted {
Expand Down Expand Up @@ -574,6 +583,123 @@ 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)
}

// 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)
.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) {
Expand Down
10 changes: 10 additions & 0 deletions leanring-buddy/ElevenLabsTTSClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import AVFoundation
import Foundation
import AppKit

@MainActor
final class ElevenLabsTTSClient {
Expand All @@ -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")
Expand Down
45 changes: 45 additions & 0 deletions worker/DEV.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 38 additions & 0 deletions worker/dev_server.js
Original file line number Diff line number Diff line change
@@ -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}`);
});
Loading