Skip to content

Commit a510953

Browse files
committed
feat: LLM setup UI, AutoLog rename, onboarding wizard, and bug fixes
- Add LLM provider picker to Settings (proxy vs OpenRouter) with connection test and explicit apply buttons - Rename all UI-facing strings from ContextD to AutoLog (windows, API docs, onboarding) - Fix activity_sessions composite PK insert failures with onConflict ignore semantics - Add 2-step onboarding wizard: permissions then LLM provider setup with error surfacing for keychain failures - Fix isUsingProxy false positive when endpoint URL is empty string
1 parent 952e01c commit a510953

7 files changed

Lines changed: 381 additions & 49 deletions

File tree

ContextD/App/AppDelegate.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
4444
)
4545

4646
let window = NSWindow(
47-
contentRect: NSRect(x: 0, y: 0, width: 520, height: 420),
47+
contentRect: NSRect(x: 0, y: 0, width: 520, height: 500),
4848
styleMask: [.titled, .closable],
4949
backing: .buffered,
5050
defer: false
5151
)
52-
window.title = "Welcome to ContextD"
52+
window.title = "Welcome to AutoLog"
5353
window.contentView = NSHostingView(rootView: onboardingView)
5454
window.center()
5555
window.isReleasedWhenClosed = false
@@ -106,7 +106,7 @@ final class DebugWindowController {
106106
defer: false
107107
)
108108

109-
window.title = "ContextD - Database Debug"
109+
window.title = "AutoLog - Database Debug"
110110
window.contentView = NSHostingView(rootView: contentView)
111111
window.center()
112112
window.isReleasedWhenClosed = false
@@ -160,7 +160,7 @@ final class EnrichmentPanelController {
160160
defer: false
161161
)
162162

163-
panel.title = "ContextD - Enrich Prompt"
163+
panel.title = "AutoLog - Enrich Prompt"
164164
panel.level = .floating
165165
panel.isFloatingPanel = true
166166
panel.hidesOnDeactivate = false

ContextD/LLMClient/OpenRouterClient.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ final class OpenRouterClient: LLMClient, Sendable {
1515

1616
/// True when using a local proxy (no API key needed).
1717
static var isUsingProxy: Bool {
18-
UserDefaults.standard.string(forKey: "llmEndpointURL") != nil
18+
if let custom = UserDefaults.standard.string(forKey: "llmEndpointURL"), !custom.isEmpty {
19+
return true
20+
}
21+
return false
1922
}
2023

2124
private let logger = DualLogger(category: "OpenRouterClient")

ContextD/Permissions/OnboardingView.swift

Lines changed: 216 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,42 @@
11
import SwiftUI
22

33
/// First-run onboarding view that guides users through granting
4-
/// Screen Recording and Accessibility permissions.
4+
/// permissions and configuring their LLM provider.
55
struct OnboardingView: View {
66
@ObservedObject var permissionManager: PermissionManager
77
var onComplete: () -> Void
88

9+
enum Step { case permissions, llmSetup }
10+
11+
@State private var step: Step = .permissions
12+
913
var body: some View {
1014
VStack(spacing: 24) {
11-
// Header
15+
switch step {
16+
case .permissions:
17+
permissionsStep
18+
case .llmSetup:
19+
llmSetupStep
20+
}
21+
}
22+
.padding(32)
23+
.frame(width: 520)
24+
.animation(.easeInOut(duration: 0.2), value: step)
25+
}
26+
27+
// MARK: - Step 1: Permissions
28+
29+
private var permissionsStep: some View {
30+
VStack(spacing: 24) {
1231
VStack(spacing: 8) {
1332
Image(systemName: "eye.circle.fill")
1433
.font(.system(size: 48))
1534
.foregroundStyle(.blue)
1635

17-
Text("Welcome to ContextD")
36+
Text("Welcome to AutoLog")
1837
.font(.title.bold())
1938

20-
Text("ContextD needs a few permissions to capture your screen activity and enrich your AI prompts with context.")
39+
Text("AutoLog needs a few permissions to capture your screen activity and enrich your AI prompts with context.")
2140
.font(.body)
2241
.foregroundStyle(.secondary)
2342
.multilineTextAlignment(.center)
@@ -26,7 +45,6 @@ struct OnboardingView: View {
2645

2746
Divider()
2847

29-
// Permissions
3048
VStack(spacing: 16) {
3149
PermissionRow(
3250
icon: "rectangle.dashed.badge.record",
@@ -49,15 +67,14 @@ struct OnboardingView: View {
4967

5068
Divider()
5169

52-
// Actions
5370
HStack(spacing: 12) {
5471
Button("Refresh Status") {
5572
permissionManager.refreshStatus()
5673
}
5774
.buttonStyle(.bordered)
5875

59-
Button("Continue") {
60-
onComplete()
76+
Button("Next") {
77+
step = .llmSetup
6178
}
6279
.buttonStyle(.borderedProminent)
6380
.disabled(!permissionManager.allPermissionsGranted)
@@ -70,8 +87,197 @@ struct OnboardingView: View {
7087
.multilineTextAlignment(.center)
7188
}
7289
}
73-
.padding(32)
74-
.frame(width: 520)
90+
}
91+
92+
// MARK: - Step 2: LLM Setup
93+
94+
@State private var selectedProvider: SettingsView.LLMProvider = .proxy
95+
@AppStorage("llmEndpointURL") private var customEndpointURL: String = ""
96+
@State private var proxyURL: String = "http://127.0.0.1:11434/v1/chat/completions"
97+
@State private var apiKey: String = ""
98+
@State private var proxyTestResult: String?
99+
@State private var proxyTesting: Bool = false
100+
@State private var showApiKeySaved: Bool = false
101+
@State private var hasApiKey: Bool = false
102+
@State private var saveError: String?
103+
104+
private var llmConfigured: Bool {
105+
switch selectedProvider {
106+
case .proxy:
107+
return !proxyURL.isEmpty
108+
case .openrouter:
109+
return hasApiKey
110+
}
111+
}
112+
113+
private var llmSetupStep: some View {
114+
VStack(spacing: 24) {
115+
VStack(spacing: 8) {
116+
Image(systemName: "brain.head.profile")
117+
.font(.system(size: 48))
118+
.foregroundStyle(.purple)
119+
120+
Text("LLM Provider")
121+
.font(.title.bold())
122+
123+
Text("AutoLog uses an LLM to summarize your screen activity. Choose how to connect.")
124+
.font(.body)
125+
.foregroundStyle(.secondary)
126+
.multilineTextAlignment(.center)
127+
.frame(maxWidth: 400)
128+
}
129+
130+
Divider()
131+
132+
Picker("Provider:", selection: $selectedProvider) {
133+
ForEach(SettingsView.LLMProvider.allCases) { provider in
134+
Text(provider.rawValue).tag(provider)
135+
}
136+
}
137+
.pickerStyle(.segmented)
138+
139+
if selectedProvider == .proxy {
140+
VStack(alignment: .leading, spacing: 8) {
141+
HStack {
142+
TextField("Proxy URL", text: $proxyURL)
143+
.textFieldStyle(.roundedBorder)
144+
145+
Button(proxyTesting ? "Testing..." : "Test") {
146+
testProxy()
147+
}
148+
.disabled(proxyURL.isEmpty || proxyTesting)
149+
}
150+
151+
Text("Run `claude -p` in a terminal to start the proxy.")
152+
.font(.caption)
153+
.foregroundStyle(.secondary)
154+
155+
if let result = proxyTestResult {
156+
HStack {
157+
Image(systemName: result.starts(with: "OK") ? "checkmark.circle.fill" : "xmark.circle.fill")
158+
.foregroundStyle(result.starts(with: "OK") ? .green : .red)
159+
Text(result)
160+
.font(.caption)
161+
.foregroundStyle(result.starts(with: "OK") ? .green : .red)
162+
}
163+
}
164+
}
165+
} else {
166+
VStack(alignment: .leading, spacing: 8) {
167+
HStack {
168+
SecureField("OpenRouter API Key", text: $apiKey)
169+
.textFieldStyle(.roundedBorder)
170+
171+
Button(showApiKeySaved ? "Saved!" : "Save") {
172+
saveKey()
173+
}
174+
.disabled(apiKey.isEmpty)
175+
}
176+
177+
if let error = saveError {
178+
HStack {
179+
Image(systemName: "exclamationmark.triangle.fill")
180+
.foregroundStyle(.red)
181+
Text(error)
182+
.font(.caption)
183+
.foregroundStyle(.red)
184+
}
185+
}
186+
187+
if hasApiKey {
188+
HStack {
189+
Image(systemName: "checkmark.circle.fill")
190+
.foregroundStyle(.green)
191+
Text("API key is configured")
192+
.font(.caption)
193+
.foregroundStyle(.secondary)
194+
}
195+
}
196+
}
197+
}
198+
199+
Divider()
200+
201+
HStack(spacing: 12) {
202+
Button("Back") {
203+
step = .permissions
204+
}
205+
.buttonStyle(.bordered)
206+
207+
Button("Finish Setup") {
208+
applyProvider()
209+
onComplete()
210+
}
211+
.buttonStyle(.borderedProminent)
212+
.disabled(!llmConfigured)
213+
}
214+
215+
Text("You can change this later in Settings.")
216+
.font(.caption)
217+
.foregroundStyle(.secondary)
218+
}
219+
.onAppear {
220+
hasApiKey = OpenRouterClient.hasAPIKey()
221+
}
222+
}
223+
224+
private func applyProvider() {
225+
switch selectedProvider {
226+
case .proxy:
227+
customEndpointURL = proxyURL
228+
case .openrouter:
229+
customEndpointURL = ""
230+
}
231+
}
232+
233+
private func testProxy() {
234+
guard let url = URL(string: proxyURL) else {
235+
proxyTestResult = "Invalid URL"
236+
return
237+
}
238+
proxyTesting = true
239+
proxyTestResult = nil
240+
241+
Task {
242+
var request = URLRequest(url: url)
243+
request.httpMethod = "POST"
244+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
245+
request.timeoutInterval = 5
246+
let body: [String: Any] = [
247+
"model": "anthropic/claude-haiku-4-5",
248+
"max_tokens": 1,
249+
"messages": [["role": "user", "content": "ping"]],
250+
]
251+
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
252+
253+
do {
254+
let (_, response) = try await URLSession.shared.data(for: request)
255+
if let http = response as? HTTPURLResponse, (200...499).contains(http.statusCode) {
256+
proxyTestResult = "OK - proxy is reachable (HTTP \(http.statusCode))"
257+
} else {
258+
proxyTestResult = "Unexpected response"
259+
}
260+
} catch {
261+
proxyTestResult = "Connection failed: \(error.localizedDescription)"
262+
}
263+
proxyTesting = false
264+
}
265+
}
266+
267+
private func saveKey() {
268+
do {
269+
try OpenRouterClient.saveAPIKey(apiKey)
270+
hasApiKey = true
271+
showApiKeySaved = true
272+
saveError = nil
273+
apiKey = ""
274+
Task {
275+
try? await Task.sleep(for: .seconds(2))
276+
showApiKeySaved = false
277+
}
278+
} catch {
279+
saveError = "Failed to save API key: \(error.localizedDescription)"
280+
}
75281
}
76282
}
77283

ContextD/Server/OpenAPISpec.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ enum OpenAPISpec {
77
{
88
"openapi": "3.1.0",
99
"info": {
10-
"title": "ContextD API",
11-
"description": "Search and browse your recent screen activity. ContextD continuously captures and summarizes what you see on screen, and this API lets you search summaries, list activity, and retrieve structured data from your captures.",
10+
"title": "AutoLog API",
11+
"description": "Search and browse your recent screen activity. AutoLog continuously captures and summarizes what you see on screen, and this API lets you search summaries, list activity, and retrieve structured data from your captures.",
1212
"version": "0.1.0",
1313
"contact": {
14-
"name": "ContextD"
14+
"name": "AutoLog"
1515
},
1616
"license": {
1717
"name": "MIT"
@@ -20,7 +20,7 @@ enum OpenAPISpec {
2020
"servers": [
2121
{
2222
"url": "http://127.0.0.1:21890",
23-
"description": "Local ContextD instance"
23+
"description": "Local AutoLog instance"
2424
}
2525
],
2626
"paths": {
@@ -300,7 +300,7 @@ enum OpenAPISpec {
300300
"get": {
301301
"operationId": "healthCheck",
302302
"summary": "Health check",
303-
"description": "Returns the current status of the ContextD API server and basic database statistics.",
303+
"description": "Returns the current status of the AutoLog API server and basic database statistics.",
304304
"responses": {
305305
"200": {
306306
"description": "Server is healthy",

ContextD/Server/ScalarDocsPage.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ enum ScalarDocsPage {
88
<!DOCTYPE html>
99
<html>
1010
<head>
11-
<title>ContextD API Docs</title>
11+
<title>AutoLog API Docs</title>
1212
<meta charset="utf-8" />
1313
<meta name="viewport" content="width=device-width, initial-scale=1" />
1414
<style>

ContextD/Storage/StorageManager+Activities.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@ extension StorageManager {
2121
return record
2222
}
2323

24-
/// Insert an activity-session join record.
24+
/// Insert an activity-session join record, ignoring duplicates.
2525
func insertActivitySession(_ record: ActivitySessionRecord) throws {
2626
var rec = record
2727
try database.dbPool.write { db in
28-
try rec.insert(db)
28+
try rec.insert(db, onConflict: .ignore)
2929
}
3030
}
3131

0 commit comments

Comments
 (0)