Skip to content

Commit 31e3b4c

Browse files
authored
Merge pull request #187 from straff2002/feat/tavily-search-and-services-settings
Add Tavily web search + make ServicesSettingsView self-contained
2 parents e29df92 + cb145bc commit 31e3b4c

4 files changed

Lines changed: 147 additions & 47 deletions

File tree

OpenGlasses/Sources/App/Views/ServicesSettingsView.swift

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,19 @@ import UniformTypeIdentifiers
77
struct ServicesSettingsView: View {
88
@ObservedObject var appState: AppState
99

10-
// Text-to-Speech
11-
@Binding var elevenLabsKeyInput: String
12-
@Binding var selectedVoice: String
13-
@Binding var emotionAwareTTSEnabled: Bool
10+
// Text-to-Speech — self-contained: seeded from Config, persisted on change.
11+
@State private var elevenLabsKeyInput: String = Config.elevenLabsAPIKey
12+
@State private var selectedVoice: String = Config.elevenLabsVoiceId
13+
@State private var emotionAwareTTSEnabled: Bool = Config.emotionAwareTTSEnabled
1414

1515
// Web Search
16-
@Binding var perplexityKeyInput: String
16+
@State private var perplexityKeyInput: String = Config.perplexityAPIKey
17+
@State private var tavilyKeyInput: String = Config.tavilyAPIKey
1718

1819
// Live Streaming
19-
@Binding var broadcastPlatform: String
20-
@Binding var broadcastRTMPURL: String
21-
@Binding var broadcastStreamKey: String
20+
@State private var broadcastPlatform: String = Config.broadcastPlatform
21+
@State private var broadcastRTMPURL: String = Config.broadcastRTMPURL
22+
@State private var broadcastStreamKey: String = Config.broadcastStreamKey
2223

2324
// Camera
2425
@State private var cameraResolution: String = Config.cameraResolution
@@ -70,6 +71,11 @@ struct ServicesSettingsView: View {
7071
// MARK: Text-to-Speech
7172
Section {
7273
SecretInputField(placeholder: "API Key", text: $elevenLabsKeyInput)
74+
.onChange(of: elevenLabsKeyInput) { _, newValue in
75+
Config.setElevenLabsAPIKey(newValue)
76+
// Reset quota cache in case the user added credits or changed key.
77+
appState.speechService.resetElevenLabsQuota()
78+
}
7379

7480
if elevenLabsKeyInput.isEmpty {
7581
Link(destination: URL(string: "https://elevenlabs.io/app/settings/api-keys")!) {
@@ -128,6 +134,9 @@ struct ServicesSettingsView: View {
128134
isOn: $emotionAwareTTSEnabled,
129135
info: "Detects the emotional tone of responses (happy, calm, concerned, excited) and adjusts the voice to match. ElevenLabs voices change stability and style parameters; iOS voices adjust rate and pitch. Makes the assistant sound more natural and empathetic."
130136
)
137+
.onChange(of: emotionAwareTTSEnabled) { _, newValue in
138+
Config.setEmotionAwareTTSEnabled(newValue)
139+
}
131140
} header: {
132141
Text("Text-to-Speech")
133142
} footer: {
@@ -268,7 +277,10 @@ struct ServicesSettingsView: View {
268277

269278
// MARK: Web Search
270279
Section {
271-
SecretInputField(placeholder: "API Key", text: $perplexityKeyInput)
280+
SecretInputField(placeholder: "Perplexity API Key", text: $perplexityKeyInput)
281+
.onChange(of: perplexityKeyInput) { _, newValue in
282+
Config.setPerplexityAPIKey(newValue)
283+
}
272284

273285
if perplexityKeyInput.isEmpty {
274286
Link(destination: URL(string: "https://www.perplexity.ai/settings/api")!) {
@@ -281,13 +293,32 @@ struct ServicesSettingsView: View {
281293
}
282294
}
283295
}
296+
297+
SecretInputField(placeholder: "Tavily API Key", text: $tavilyKeyInput)
298+
.onChange(of: tavilyKeyInput) { _, newValue in
299+
Config.setTavilyAPIKey(newValue)
300+
}
301+
302+
if tavilyKeyInput.isEmpty {
303+
Link(destination: URL(string: "https://app.tavily.com/home")!) {
304+
HStack {
305+
Label("Get API Key", systemImage: "arrow.up.right.square")
306+
Spacer()
307+
Text("tavily.com")
308+
.font(.caption)
309+
.foregroundStyle(.secondary)
310+
}
311+
}
312+
}
284313
} header: {
285314
Text("Web Search")
286315
} footer: {
287-
if perplexityKeyInput.isEmpty {
288-
Text("Add a Perplexity API key for AI-powered search with cited sources. Without one, DuckDuckGo is used.")
316+
if perplexityKeyInput.isEmpty && tavilyKeyInput.isEmpty {
317+
Text("Add a Perplexity or Tavily API key for AI-powered search with cited sources. Without one, DuckDuckGo is used.")
318+
} else if !perplexityKeyInput.isEmpty {
319+
Text("Web searches use Perplexity AI with cited sources\(tavilyKeyInput.isEmpty ? "" : ", then Tavily").")
289320
} else {
290-
Text("Web searches use Perplexity AI with cited sources.")
321+
Text("Web searches use Tavily with cited sources.")
291322
}
292323
}
293324

@@ -368,7 +399,9 @@ struct ServicesSettingsView: View {
368399
Text("Custom RTMP").tag("custom")
369400
}
370401
.onChange(of: broadcastPlatform) { _, platform in
371-
// Pre-fill RTMP ingest URL for known platforms
402+
Config.setBroadcastPlatform(platform)
403+
// Pre-fill RTMP ingest URL for known platforms (persisted via the
404+
// RTMP URL field's own onChange below).
372405
switch platform {
373406
case "youtube": broadcastRTMPURL = "rtmp://a.rtmp.youtube.com/live2"
374407
case "twitch": broadcastRTMPURL = "rtmp://live.twitch.tv/app"
@@ -381,8 +414,14 @@ struct ServicesSettingsView: View {
381414
TextField("RTMP URL", text: $broadcastRTMPURL)
382415
.autocorrectionDisabled()
383416
.textInputAutocapitalization(.never)
417+
.onChange(of: broadcastRTMPURL) { _, newValue in
418+
Config.setBroadcastRTMPURL(newValue)
419+
}
384420

385421
SecretInputField(placeholder: "Stream Key", text: $broadcastStreamKey)
422+
.onChange(of: broadcastStreamKey) { _, newValue in
423+
Config.setBroadcastStreamKey(newValue)
424+
}
386425
} header: {
387426
Text("Live Streaming")
388427
} footer: {

OpenGlasses/Sources/App/Views/SettingsView.swift

Lines changed: 3 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,8 @@ struct SettingsView: View {
2727
@State private var isTogglingEncryption = false
2828

2929
// Service settings (owned here, bound to ServicesSettingsView)
30-
@State private var elevenLabsKeyInput = Config.elevenLabsAPIKey
31-
@State private var selectedVoice = Config.elevenLabsVoiceId
32-
@State private var emotionAwareTTSEnabled = Config.emotionAwareTTSEnabled
33-
@State private var perplexityKeyInput = Config.perplexityAPIKey
34-
@State private var broadcastPlatform = Config.broadcastPlatform
35-
@State private var broadcastRTMPURL = Config.broadcastRTMPURL
36-
@State private var broadcastStreamKey = Config.broadcastStreamKey
30+
// ElevenLabs / TTS voice, web-search keys, and live-streaming config are owned
31+
// by ServicesSettingsView (self-contained, persists to Config on change).
3732

3833
var body: some View {
3934
Form {
@@ -416,16 +411,7 @@ struct SettingsView: View {
416411

417412
Section {
418413
NavigationLink {
419-
ServicesSettingsView(
420-
appState: appState,
421-
elevenLabsKeyInput: $elevenLabsKeyInput,
422-
selectedVoice: $selectedVoice,
423-
emotionAwareTTSEnabled: $emotionAwareTTSEnabled,
424-
perplexityKeyInput: $perplexityKeyInput,
425-
broadcastPlatform: $broadcastPlatform,
426-
broadcastRTMPURL: $broadcastRTMPURL,
427-
broadcastStreamKey: $broadcastStreamKey
428-
)
414+
ServicesSettingsView(appState: appState)
429415
} label: {
430416
Label("Services & Integrations", systemImage: "square.grid.2x2")
431417
}
@@ -695,27 +681,15 @@ struct SettingsView: View {
695681
}
696682
appState.llmService.refreshActiveModel()
697683

698-
Config.setElevenLabsAPIKey(elevenLabsKeyInput)
699-
Config.setElevenLabsVoiceId(selectedVoice)
700-
// Reset quota cache in case user added credits or changed key
701-
appState.speechService.resetElevenLabsQuota()
702-
703-
Config.setPerplexityAPIKey(perplexityKeyInput)
704684
Config.setPrivacyFilterEnabled(privacyFilterEnabled)
705685
appState.privacyFilter.isEnabled = privacyFilterEnabled
706686
Config.setUseGlassesMicForWakeWord(useGlassesMicForWakeWord)
707687

708-
Config.setEmotionAwareTTSEnabled(emotionAwareTTSEnabled)
709-
710688
Config.setIntentClassifierEnabled(intentClassifierEnabled)
711689
Config.setUserMemoryEnabled(userMemoryEnabled)
712690
Config.setMemoryNudgesEnabled(memoryNudgesEnabled)
713691
Config.setConversationPersistenceEnabled(conversationPersistenceEnabled)
714692

715-
Config.setBroadcastPlatform(broadcastPlatform)
716-
Config.setBroadcastRTMPURL(broadcastRTMPURL)
717-
Config.setBroadcastStreamKey(broadcastStreamKey)
718-
719693
if appState.currentMode == .direct {
720694
Task {
721695
appState.wakeWordService.stopListening()

OpenGlasses/Sources/Services/NativeTools/WebSearchTool.swift

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import Foundation
22

3-
/// Searches the web. Uses Perplexity AI when an API key is configured (grounded, cited answers).
4-
/// Falls back to DuckDuckGo Instant Answer API (no API key needed) otherwise.
3+
/// Searches the web. Prefers Perplexity AI, then Tavily, when an API key is configured
4+
/// (grounded, cited answers). Falls back to DuckDuckGo Instant Answer API (no API key
5+
/// needed) otherwise.
56
struct WebSearchTool: NativeTool {
67
let name = "web_search"
7-
let description = "Search the web for information. Uses Perplexity AI (with citations) when configured, otherwise DuckDuckGo. Returns a brief summary."
8+
let description = "Search the web for information. Uses Perplexity AI or Tavily (with citations) when configured, otherwise DuckDuckGo. Returns a brief summary."
89
let parametersSchema: [String: Any] = [
910
"type": "object",
1011
"properties": [
@@ -23,8 +24,15 @@ struct WebSearchTool: NativeTool {
2324

2425
// Try Perplexity first if configured
2526
if Config.isPerplexityConfigured {
26-
let result = await searchPerplexity(query: query)
27-
if let result = result {
27+
if let result = await searchPerplexity(query: query) {
28+
return result
29+
}
30+
// Fall through on failure
31+
}
32+
33+
// Then Tavily if configured
34+
if Config.isTavilyConfigured {
35+
if let result = await searchTavily(query: query) {
2836
return result
2937
}
3038
// Fall through to DuckDuckGo on failure
@@ -89,6 +97,67 @@ struct WebSearchTool: NativeTool {
8997
}
9098
}
9199

100+
// MARK: - Tavily Search
101+
102+
private func searchTavily(query: String) async -> String? {
103+
guard let url = URL(string: "https://api.tavily.com/search") else { return nil }
104+
105+
var request = URLRequest(url: url)
106+
request.httpMethod = "POST"
107+
request.setValue("Bearer \(Config.tavilyAPIKey)", forHTTPHeaderField: "Authorization")
108+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
109+
request.timeoutInterval = 15
110+
111+
let body: [String: Any] = [
112+
"query": query,
113+
"search_depth": "basic",
114+
"include_answer": true,
115+
"max_results": 5
116+
]
117+
118+
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
119+
120+
let config = URLSessionConfiguration.default
121+
config.timeoutIntervalForRequest = 15
122+
let session = URLSession(configuration: config)
123+
124+
do {
125+
let (data, response) = try await session.data(for: request)
126+
127+
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
128+
print("🔍 Tavily failed (HTTP \((response as? HTTPURLResponse)?.statusCode ?? 0)), falling back to DuckDuckGo")
129+
return nil // Fall back to DuckDuckGo
130+
}
131+
132+
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
133+
return nil
134+
}
135+
136+
let results = json["results"] as? [[String: Any]] ?? []
137+
138+
// Prefer Tavily's synthesized answer; otherwise stitch the top result snippets.
139+
var summary = (json["answer"] as? String) ?? ""
140+
if summary.isEmpty {
141+
let snippets = results.prefix(3).compactMap { $0["content"] as? String }
142+
summary = snippets.joined(separator: " ")
143+
}
144+
guard !summary.isEmpty else { return nil }
145+
146+
var result = summary
147+
let urls = results.prefix(3).compactMap { $0["url"] as? String }
148+
if !urls.isEmpty {
149+
let sourceList = urls.enumerated().map { "[\($0.offset + 1)] \($0.element)" }
150+
result += "\n\nSources: \(sourceList.joined(separator: ", "))"
151+
}
152+
153+
return "Search result for \"\(query)\" (via Tavily): \(result)"
154+
155+
} catch {
156+
print("🔍 Tavily error: \(error.localizedDescription), falling back to DuckDuckGo")
157+
return nil
158+
}
159+
}
160+
92161
// MARK: - DuckDuckGo Fallback
93162

94163
private func searchDuckDuckGo(query: String) async -> String {

OpenGlasses/Sources/Utils/Config.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1840,6 +1840,24 @@ struct Config {
18401840
!perplexityAPIKey.isEmpty
18411841
}
18421842

1843+
// MARK: - Tavily Search
1844+
1845+
/// Tavily API key. Stored in the Keychain (see `KeychainService`).
1846+
static var tavilyAPIKey: String {
1847+
if let key = KeychainService.string(for: "tavilyAPIKey"), !key.isEmpty {
1848+
return key
1849+
}
1850+
return ""
1851+
}
1852+
1853+
static func setTavilyAPIKey(_ key: String) {
1854+
KeychainService.setString(key, for: "tavilyAPIKey")
1855+
}
1856+
1857+
static var isTavilyConfigured: Bool {
1858+
!tavilyAPIKey.isEmpty
1859+
}
1860+
18431861
// MARK: - Privacy Filter
18441862

18451863
@UserDefaultsBacked("privacyFilterEnabled", default: false) static var privacyFilterEnabled: Bool

0 commit comments

Comments
 (0)